From e58edaddd3385d9742465c3d55e99c21c23dfb09 Mon Sep 17 00:00:00 2001 From: talksik Date: Sun, 12 Apr 2026 10:45:24 -0700 Subject: [PATCH] feat: send email notifications for network invites Closes #126 --- go/cmd/emailnotifierjob/main.go | 2 +- go/cmd/orion/main.go | 2 +- go/cmd/pusherservice/main.go | 2 +- go/internal/handler/handler.go | 20 ++-- go/internal/network/mocks/mock_aero.go | 150 +++++++++++++++++++++++++ go/internal/network/service.go | 63 ++++++++++- go/internal/network/service_test.go | 17 ++- go/internal/particle/service_test.go | 16 +-- 8 files changed, 246 insertions(+), 26 deletions(-) create mode 100644 go/internal/network/mocks/mock_aero.go diff --git a/go/cmd/emailnotifierjob/main.go b/go/cmd/emailnotifierjob/main.go index 2a227c8..e536279 100644 --- a/go/cmd/emailnotifierjob/main.go +++ b/go/cmd/emailnotifierjob/main.go @@ -67,7 +67,7 @@ func main() { // Initialize services humanSvc := human.NewService(db.Pool()) - networkSvc := network.NewService(db.Pool()) + networkSvc := network.NewService(db.Pool(), aeroSvc) slog.Info("starting email notification cycle") if err := runNotificationCycle(ctx, firestoreClient, aeroSvc, pusherSvc, humanSvc, networkSvc); err != nil { diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index eb1fc22..9880ad8 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -67,7 +67,7 @@ func main() { // Initialize services authSvc := auth.NewAuthService(redisClient, aeroSvc) humanSvc := human.NewService(db.Pool()) - networkSvc := network.NewService(db.Pool()) + networkSvc := network.NewService(db.Pool(), aeroSvc) particleSvc := particle.NewService(db.Pool(), networkSvc) depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{ GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"), diff --git a/go/cmd/pusherservice/main.go b/go/cmd/pusherservice/main.go index 2d0c253..2e36865 100644 --- a/go/cmd/pusherservice/main.go +++ b/go/cmd/pusherservice/main.go @@ -37,7 +37,7 @@ func main() { // Services authSvc := auth.NewAuthService(authRedis, nil) // nil aeroSvc — pusher only calls GetSession - networkSvc := network.NewService(db.Pool()) + networkSvc := network.NewService(db.Pool(), nil) // nil aeroSvc — pusher never calls InviteByEmail // Pod identity (use hostname in k8s, which is the pod name) podID, err := os.Hostname() diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index 2925942..3db4cee 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -58,11 +58,11 @@ type Human struct { } type Network struct { - Id string `json:"id"` - Name string `json:"name"` - AdminHuman Human `json:"admin_human"` - Humans []Human `json:"humans"` - CreatedAt time.Time `json:"created_at"` + Id string `json:"id"` + Name string `json:"name"` + AdminHuman Human `json:"admin_human"` + Humans []Human `json:"humans"` + CreatedAt time.Time `json:"created_at"` } // Auth Request/Response DTOs @@ -1008,11 +1008,11 @@ func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network } return Network{ - Id: n.ID, - Name: n.Name, - AdminHuman: humanToDTO(adminHuman), - Humans: humans, - CreatedAt: n.CreatedAt, + Id: n.ID, + Name: n.Name, + AdminHuman: humanToDTO(adminHuman), + Humans: humans, + CreatedAt: n.CreatedAt, }, nil } diff --git a/go/internal/network/mocks/mock_aero.go b/go/internal/network/mocks/mock_aero.go new file mode 100644 index 0000000..1b6adcc --- /dev/null +++ b/go/internal/network/mocks/mock_aero.go @@ -0,0 +1,150 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: /Users/talksik/Documents/code/llink/go/genproto/aero/main_grpc.pb.go +// +// Generated by this command: +// +// mockgen -source=/Users/talksik/Documents/code/llink/go/genproto/aero/main_grpc.pb.go -destination=/Users/talksik/Documents/code/llink/go/internal/network/mocks/mock_aero.go -package=mocks +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + pbaero "github.com/flowy-live/llink/genproto/aero" + gomock "go.uber.org/mock/gomock" + grpc "google.golang.org/grpc" +) + +// MockPrimaryClient is a mock of PrimaryClient interface. +type MockPrimaryClient struct { + ctrl *gomock.Controller + recorder *MockPrimaryClientMockRecorder + isgomock struct{} +} + +// MockPrimaryClientMockRecorder is the mock recorder for MockPrimaryClient. +type MockPrimaryClientMockRecorder struct { + mock *MockPrimaryClient +} + +// NewMockPrimaryClient creates a new mock instance. +func NewMockPrimaryClient(ctrl *gomock.Controller) *MockPrimaryClient { + mock := &MockPrimaryClient{ctrl: ctrl} + mock.recorder = &MockPrimaryClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPrimaryClient) EXPECT() *MockPrimaryClientMockRecorder { + return m.recorder +} + +// ShootEmail mocks base method. +func (m *MockPrimaryClient) ShootEmail(ctx context.Context, in *pbaero.ShootEmailRequest, opts ...grpc.CallOption) (*pbaero.ShootEmailResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, in} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ShootEmail", varargs...) + ret0, _ := ret[0].(*pbaero.ShootEmailResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ShootEmail indicates an expected call of ShootEmail. +func (mr *MockPrimaryClientMockRecorder) ShootEmail(ctx, in any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, in}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ShootEmail", reflect.TypeOf((*MockPrimaryClient)(nil).ShootEmail), varargs...) +} + +// MockPrimaryServer is a mock of PrimaryServer interface. +type MockPrimaryServer struct { + ctrl *gomock.Controller + recorder *MockPrimaryServerMockRecorder + isgomock struct{} +} + +// MockPrimaryServerMockRecorder is the mock recorder for MockPrimaryServer. +type MockPrimaryServerMockRecorder struct { + mock *MockPrimaryServer +} + +// NewMockPrimaryServer creates a new mock instance. +func NewMockPrimaryServer(ctrl *gomock.Controller) *MockPrimaryServer { + mock := &MockPrimaryServer{ctrl: ctrl} + mock.recorder = &MockPrimaryServerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPrimaryServer) EXPECT() *MockPrimaryServerMockRecorder { + return m.recorder +} + +// ShootEmail mocks base method. +func (m *MockPrimaryServer) ShootEmail(arg0 context.Context, arg1 *pbaero.ShootEmailRequest) (*pbaero.ShootEmailResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ShootEmail", arg0, arg1) + ret0, _ := ret[0].(*pbaero.ShootEmailResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ShootEmail indicates an expected call of ShootEmail. +func (mr *MockPrimaryServerMockRecorder) ShootEmail(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ShootEmail", reflect.TypeOf((*MockPrimaryServer)(nil).ShootEmail), arg0, arg1) +} + +// mustEmbedUnimplementedPrimaryServer mocks base method. +func (m *MockPrimaryServer) mustEmbedUnimplementedPrimaryServer() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "mustEmbedUnimplementedPrimaryServer") +} + +// mustEmbedUnimplementedPrimaryServer indicates an expected call of mustEmbedUnimplementedPrimaryServer. +func (mr *MockPrimaryServerMockRecorder) mustEmbedUnimplementedPrimaryServer() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "mustEmbedUnimplementedPrimaryServer", reflect.TypeOf((*MockPrimaryServer)(nil).mustEmbedUnimplementedPrimaryServer)) +} + +// MockUnsafePrimaryServer is a mock of UnsafePrimaryServer interface. +type MockUnsafePrimaryServer struct { + ctrl *gomock.Controller + recorder *MockUnsafePrimaryServerMockRecorder + isgomock struct{} +} + +// MockUnsafePrimaryServerMockRecorder is the mock recorder for MockUnsafePrimaryServer. +type MockUnsafePrimaryServerMockRecorder struct { + mock *MockUnsafePrimaryServer +} + +// NewMockUnsafePrimaryServer creates a new mock instance. +func NewMockUnsafePrimaryServer(ctrl *gomock.Controller) *MockUnsafePrimaryServer { + mock := &MockUnsafePrimaryServer{ctrl: ctrl} + mock.recorder = &MockUnsafePrimaryServerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUnsafePrimaryServer) EXPECT() *MockUnsafePrimaryServerMockRecorder { + return m.recorder +} + +// mustEmbedUnimplementedPrimaryServer mocks base method. +func (m *MockUnsafePrimaryServer) mustEmbedUnimplementedPrimaryServer() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "mustEmbedUnimplementedPrimaryServer") +} + +// mustEmbedUnimplementedPrimaryServer indicates an expected call of mustEmbedUnimplementedPrimaryServer. +func (mr *MockUnsafePrimaryServerMockRecorder) mustEmbedUnimplementedPrimaryServer() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "mustEmbedUnimplementedPrimaryServer", reflect.TypeOf((*MockUnsafePrimaryServer)(nil).mustEmbedUnimplementedPrimaryServer)) +} diff --git a/go/internal/network/service.go b/go/internal/network/service.go index b3d841e..738ed21 100644 --- a/go/internal/network/service.go +++ b/go/internal/network/service.go @@ -4,8 +4,11 @@ import ( "context" "errors" "fmt" + "html" + "log/slog" "strings" + pbaero "github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal/utils" "github.com/jackc/pgx/v5/pgxpool" ) @@ -38,11 +41,12 @@ type Service interface { } type serviceImpl struct { - repo repository + repo repository + aeroSvc pbaero.PrimaryClient } -func NewService(pool *pgxpool.Pool) Service { - return &serviceImpl{repo: newRepository(pool)} +func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient) Service { + return &serviceImpl{repo: newRepository(pool), aeroSvc: aeroSvc} } func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*Network, error) { @@ -125,6 +129,11 @@ func (s *serviceImpl) ListAll(ctx context.Context) ([]*Network, error) { // Invitation methods func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error { + network, err := s.repo.getByID(ctx, networkID) + if err != nil { + return ErrNotFound + } + for _, email := range emails { normalized, err := utils.NormalizeEmail(email) if err != nil { @@ -133,6 +142,19 @@ func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, email if err := s.repo.createInvitation(ctx, networkID, normalized); err != nil { return err } + + _, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{ + ToEmails: []string{email}, + Subject: fmt.Sprintf("Invitation to Join %s on Flowy.llink", network.Name), + TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{ + SimpleHtmlData: &pbaero.SimpleHtmlData{ + Html: buildInvitationHTML(network.Name), + }, + }, + }) + if err != nil { + slog.Warn("unable to send email notification", "email", email, "network", network.Name) + } } return nil } @@ -171,3 +193,38 @@ func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email str } return s.repo.deleteInvitation(ctx, networkID, normalized) } + +func buildInvitationHTML(networkName string) string { + const downloadURL = "https://flowylabs.ai/llink" + safeName := html.EscapeString(networkName) + return fmt.Sprintf(` + + + + + + +
+ + + + + + + + + + +
+ You've been invited to join %s on Flowy.llink +
+ Download the app to accept your invitation and connect with your team. +
+ + Download Flowy.llink + +
+
+ +`, safeName, downloadURL) +} diff --git a/go/internal/network/service_test.go b/go/internal/network/service_test.go index eeb6d3e..b2f2032 100644 --- a/go/internal/network/service_test.go +++ b/go/internal/network/service_test.go @@ -5,10 +5,13 @@ import ( "os" "testing" + pbaero "github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal/network" + "github.com/flowy-live/llink/internal/network/mocks" "github.com/flowy-live/llink/internal/testhelper" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" ) var dbPool *pgxpool.Pool @@ -21,9 +24,19 @@ func TestMain(m *testing.M) { os.Exit(ret) } +func newTestService(t *testing.T) network.Service { + ctrl := gomock.NewController(t) + mockAero := mocks.NewMockPrimaryClient(ctrl) + mockAero.EXPECT(). + ShootEmail(gomock.Any(), gomock.Any()). + Return(&pbaero.ShootEmailResponse{}, nil). + AnyTimes() + return network.NewService(dbPool, mockAero) +} + func TestNetworkService(t *testing.T) { ctx := context.Background() - svc := network.NewService(dbPool) + svc := newTestService(t) adminHumanId := "human_admin123" member1HumanId := "human_member1abc" @@ -115,7 +128,7 @@ func TestNetworkService(t *testing.T) { func TestNetworkInvitations(t *testing.T) { ctx := context.Background() - svc := network.NewService(dbPool) + svc := newTestService(t) adminHumanId := "human_invtest_admin" inviteeEmail := "invitee@example.com" diff --git a/go/internal/particle/service_test.go b/go/internal/particle/service_test.go index ec8a2a2..b85ac72 100644 --- a/go/internal/particle/service_test.go +++ b/go/internal/particle/service_test.go @@ -33,7 +33,7 @@ func getStreamStatus(data json.RawMessage) string { func TestParticleService_CreateAndGet(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool) + networkSvc := network.NewService(dbPool, nil) svc := particle.NewService(dbPool, networkSvc) // Create a network first @@ -72,7 +72,7 @@ func TestParticleService_CreateAndGet(t *testing.T) { func TestParticleService_NestedParticles(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool) + networkSvc := network.NewService(dbPool, nil) svc := particle.NewService(dbPool, networkSvc) // Create a network @@ -118,7 +118,7 @@ func TestParticleService_NestedParticles(t *testing.T) { func TestParticleService_CustomVisibility(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool) + networkSvc := network.NewService(dbPool, nil) svc := particle.NewService(dbPool, networkSvc) // Create a network with a member @@ -160,7 +160,7 @@ func TestParticleService_CustomVisibility(t *testing.T) { func TestParticleService_UpdateAndDelete(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool) + networkSvc := network.NewService(dbPool, nil) svc := particle.NewService(dbPool, networkSvc) // Create a network @@ -198,7 +198,7 @@ func TestParticleService_UpdateAndDelete(t *testing.T) { func TestParticleService_ListRootParticles(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool) + networkSvc := network.NewService(dbPool, nil) svc := particle.NewService(dbPool, networkSvc) // Create a network @@ -224,7 +224,7 @@ func TestParticleService_ListRootParticles(t *testing.T) { func TestParticleService_OpenCloseStream(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool) + networkSvc := network.NewService(dbPool, nil) svc := particle.NewService(dbPool, networkSvc) // Create a network @@ -272,7 +272,7 @@ func TestParticleService_OpenCloseStream(t *testing.T) { func TestParticleService_NotAStream(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool) + networkSvc := network.NewService(dbPool, nil) svc := particle.NewService(dbPool, networkSvc) // Create a network @@ -301,7 +301,7 @@ func TestParticleService_NotAStream(t *testing.T) { func TestParticleService_AccessInheritance(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool) + networkSvc := network.NewService(dbPool, nil) svc := particle.NewService(dbPool, networkSvc) // Create a network with members