Implement membership notifications and deep-link handling for Electron desktop app #246

Merged
talksik merged 11 commits from free-monkey into main 2026-06-09 15:04:19 +00:00
5 changed files with 268 additions and 16 deletions
Showing only changes of commit edf493f733 - Show all commits
+1 -1
View File
@@ -103,7 +103,7 @@ func main() {
}
defer firestoreClient.Close()
networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc, livestore.NewMembershipPublisher(firestoreClient))
networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc, livestore.NewMembershipPublisher(firestoreClient), humanSvc)
particleSvc := particle.NewService(db.Pool(), networkSvc)
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
+131
View File
@@ -0,0 +1,131 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ./service.go
//
// Generated by this command:
//
// mockgen -source ./service.go -destination ./mocks/service.go
//
// Package mock_human is a generated GoMock package.
package mock_human
import (
context "context"
reflect "reflect"
time "time"
human "github.com/flowy-live/llink/internal/human"
gomock "go.uber.org/mock/gomock"
)
// MockService is a mock of Service interface.
type MockService struct {
ctrl *gomock.Controller
recorder *MockServiceMockRecorder
isgomock struct{}
}
// MockServiceMockRecorder is the mock recorder for MockService.
type MockServiceMockRecorder struct {
mock *MockService
}
// NewMockService creates a new mock instance.
func NewMockService(ctrl *gomock.Controller) *MockService {
mock := &MockService{ctrl: ctrl}
mock.recorder = &MockServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockService) EXPECT() *MockServiceMockRecorder {
return m.recorder
}
// GetByEmail mocks base method.
func (m *MockService) GetByEmail(ctx context.Context, email string) (*human.Human, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetByEmail", ctx, email)
ret0, _ := ret[0].(*human.Human)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetByEmail indicates an expected call of GetByEmail.
func (mr *MockServiceMockRecorder) GetByEmail(ctx, email any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByEmail", reflect.TypeOf((*MockService)(nil).GetByEmail), ctx, email)
}
// GetByID mocks base method.
func (m *MockService) GetByID(ctx context.Context, id string) (*human.Human, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetByID", ctx, id)
ret0, _ := ret[0].(*human.Human)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetByID indicates an expected call of GetByID.
func (mr *MockServiceMockRecorder) GetByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockService)(nil).GetByID), ctx, id)
}
// GetOrCreateByEmail mocks base method.
func (m *MockService) GetOrCreateByEmail(ctx context.Context, email string) (*human.Human, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOrCreateByEmail", ctx, email)
ret0, _ := ret[0].(*human.Human)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOrCreateByEmail indicates an expected call of GetOrCreateByEmail.
func (mr *MockServiceMockRecorder) GetOrCreateByEmail(ctx, email any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrCreateByEmail", reflect.TypeOf((*MockService)(nil).GetOrCreateByEmail), ctx, email)
}
// ListAll mocks base method.
func (m *MockService) ListAll(ctx context.Context) ([]*human.Human, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAll", ctx)
ret0, _ := ret[0].([]*human.Human)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListAll indicates an expected call of ListAll.
func (mr *MockServiceMockRecorder) ListAll(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAll", reflect.TypeOf((*MockService)(nil).ListAll), ctx)
}
// UpdateEmailNotificationsEnabled mocks base method.
func (m *MockService) UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateEmailNotificationsEnabled", ctx, id, enabled)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateEmailNotificationsEnabled indicates an expected call of UpdateEmailNotificationsEnabled.
func (mr *MockServiceMockRecorder) UpdateEmailNotificationsEnabled(ctx, id, enabled any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEmailNotificationsEnabled", reflect.TypeOf((*MockService)(nil).UpdateEmailNotificationsEnabled), ctx, id, enabled)
}
// UpdateLastEmailNotificationSentAt mocks base method.
func (m *MockService) UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateLastEmailNotificationSentAt", ctx, id, t)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateLastEmailNotificationSentAt indicates an expected call of UpdateLastEmailNotificationSentAt.
func (mr *MockServiceMockRecorder) UpdateLastEmailNotificationSentAt(ctx, id, t any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLastEmailNotificationSentAt", reflect.TypeOf((*MockService)(nil).UpdateLastEmailNotificationSentAt), ctx, id, t)
}
+2
View File
@@ -9,6 +9,8 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
var ErrNotFound = errors.New("human not found")
type Service interface {
+123 -14
View File
@@ -16,6 +16,7 @@ import (
pbaero "github.com/flowy-live/llink/genproto/aero"
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/constants"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/livestore"
"github.com/flowy-live/llink/internal/utils"
)
@@ -27,6 +28,10 @@ var ErrInvalidHumanId = errors.New("invalid humanId")
var ErrCapacityExceeded = errors.New("active stream capacity exceeded")
var ErrInvalidRetentionHours = errors.New("message retention hours must be between 24 and 336")
type humanLookup interface {
GetByID(ctx context.Context, id string) (*human.Human, error)
}
type Service interface {
Reader
@@ -54,17 +59,25 @@ type Service interface {
type serviceImpl struct {
*readerImpl
aeroSvc pbaero.PrimaryClient
billingSvc billing.Service
pub livestore.MembershipPublisher
aeroSvc pbaero.PrimaryClient
billingSvc billing.Service
pub livestore.MembershipPublisher
humanLookup humanLookup
}
func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient, billingSvc billing.Service, pub livestore.MembershipPublisher) Service {
func NewService(
pool *pgxpool.Pool,
aeroSvc pbaero.PrimaryClient,
billingSvc billing.Service,
pub livestore.MembershipPublisher,
humanLookup humanLookup,
) Service {
return &serviceImpl{
readerImpl: newReader(pool),
aeroSvc: aeroSvc,
billingSvc: billingSvc,
pub: pub,
readerImpl: newReader(pool),
aeroSvc: aeroSvc,
billingSvc: billingSvc,
pub: pub,
humanLookup: humanLookup,
}
}
@@ -223,6 +236,14 @@ func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, email
}
func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, humanId string) error {
network, err := s.repo.getByID(ctx, networkID)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
normalized, err := utils.NormalizeEmail(email)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
@@ -231,6 +252,11 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu
return ErrInvalidHumanId
}
prevMembersHumanIds, membersErr := s.repo.getMemberHumanIds(ctx, networkID)
if membersErr != nil {
flog.Warn("unable to get member human ids", "error", membersErr)
}
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
err := s.repo.deleteInvitation(ctx, tx, networkID, normalized)
if err != nil {
@@ -241,6 +267,35 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu
return err
}
if membersErr == nil && len(prevMembersHumanIds) > 0 {
emailRecipients := make([]string, 0, len(prevMembersHumanIds))
for _, memberHumanId := range prevMembersHumanIds {
if memberHumanId != "" {
human, err := s.humanLookup.GetByID(ctx, memberHumanId)
if err != nil {
flog.Warn("unable to find human", "error", err, "humanId", memberHumanId)
continue
}
emailRecipients = append(emailRecipients, human.Email)
}
}
newMemberEmailPrefix := strings.Split(normalized, "@")[0]
_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
ToEmails: emailRecipients,
Subject: fmt.Sprintf("A new member has joined %s", network.Name),
TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
SimpleHtmlData: &pbaero.SimpleHtmlData{
Html: buildNewMemberHTML(network.Name, newMemberEmailPrefix),
},
},
})
if err != nil {
flog.Warn("unable to send email notification", "email", email, "network", network.Name)
}
}
s.mirrorAddMembership(ctx, humanId, networkID)
return nil
}
@@ -253,8 +308,24 @@ func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email str
return s.repo.deleteInvitation(ctx, s.pool, networkID, normalized)
}
const (
emailWebAppURL = "https://llink.flowy.live"
emailDesktopURL = "llink://"
emailDownloadURL = "https://flowylabs.ai/llink/download"
)
// emailDesktopFooter is the shared secondary line offering the desktop app.
// The web app is always the primary CTA (no install required), so desktop is
// kept quiet here and shared across templates so the two can't drift apart.
func emailDesktopFooter() string {
return fmt.Sprintf(`<tr>
<td style="font-size:13px;color:#888888;">
Prefer the desktop app? <a href="%s" style="color:#111111;">Open it</a> or <a href="%s" style="color:#111111;">download here</a>.
</td>
</tr>`, emailDesktopURL, emailDownloadURL)
}
func buildInvitationHTML(networkName string) string {
const downloadURL = "https://flowylabs.ai/llink/download"
safeName := html.EscapeString(networkName)
return fmt.Sprintf(`<!DOCTYPE html>
<html>
@@ -265,25 +336,63 @@ func buildInvitationHTML(networkName string) string {
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
<tr>
<td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
You've been invited to join %s on Flowy.llink
You&#39;ve been invited to join %s on Flowy.llink
</td>
</tr>
<tr>
<td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
Launch (or download) the app to accept your invitation and connect with your team.
Open the app to accept your invitation. If you&#39;re new, you&#39;ll be prompted to create a free account first.
</td>
</tr>
<tr>
<td>
<td style="padding-bottom:24px;">
<a href="%s" style="display:inline-block;background-color:#111111;color:#ffffff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:8px;">
Launch Flowy.llink
Open Flowy.llink (web)
</a>
</td>
</tr>
%s
</table>
</td>
</tr>
</table>
</body>
</html>`, safeName, downloadURL)
</html>`, safeName, emailWebAppURL, emailDesktopFooter())
}
// buildNewMemberHTML is an email template to notify other members that a new member has joined
func buildNewMemberHTML(networkName, newMemberEmailPrefix string) string {
safeName := html.EscapeString(networkName)
safeEmail := html.EscapeString(newMemberEmailPrefix)
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background-color:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f5f5f7;padding:48px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
<tr>
<td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
A new member joined %s
</td>
</tr>
<tr>
<td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
<strong style="color:#111111;">%s</strong> just joined your network on Flowy.llink. Say hello and bring them up to speed.
coderabbitai[bot] commented 2026-06-09 00:34:59 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue | 🟠 Major | Quick win

Honor notification preferences before sending join emails.

Line 280 appends every resolved member email without checking EmailNotificationsEnabled (and without guarding blank emails). That can notify opted-out users and trigger ShootEmail with no valid recipients.

[sraise_placeholder]

Proposed fix
 	if membersErr == nil && len(prevMembersHumanIds) > 0 {
 		emailRecipients := make([]string, 0, len(prevMembersHumanIds))
 		for _, memberHumanId := range prevMembersHumanIds {
 			if memberHumanId != "" {
 				human, err := s.humanLookup.GetByID(ctx, memberHumanId)
 				if err != nil {
 					flog.Warn("unable to find human", "error", err, "humanId", memberHumanId)
 					continue
 				}
-
-				emailRecipients = append(emailRecipients, human.Email)
+				if !human.EmailNotificationsEnabled || strings.TrimSpace(human.Email) == "" {
+					continue
+				}
+				emailRecipients = append(emailRecipients, human.Email)
 			}
 		}
 
-		newMemberEmailPrefix := strings.Split(normalized, "@")[0]
-		_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
-			ToEmails: emailRecipients,
-			Subject:  fmt.Sprintf("A new member has joined %s", network.Name),
-			TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
-				SimpleHtmlData: &pbaero.SimpleHtmlData{
-					Html: buildNewMemberHTML(network.Name, newMemberEmailPrefix),
-				},
-			},
-		})
-		if err != nil {
-			flog.Warn("unable to send email notification", "email", email, "network", network.Name)
+		if len(emailRecipients) > 0 {
+			newMemberEmailPrefix := strings.Split(normalized, "@")[0]
+			_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
+				ToEmails: emailRecipients,
+				Subject:  fmt.Sprintf("A new member has joined %s", network.Name),
+				TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
+					SimpleHtmlData: &pbaero.SimpleHtmlData{
+						Html: buildNewMemberHTML(network.Name, newMemberEmailPrefix),
+					},
+				},
+			})
+			if err != nil {
+				flog.Warn("unable to send email notification", "email", email, "network", network.Name)
+			}
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go/internal/network/service.go` around lines 270 - 296, When building
emailRecipients in the block that iterates prevMembersHumanIds (using
s.humanLookup.GetByID and appending to emailRecipients), only append human.Email
if it is non-empty and the human has EmailNotificationsEnabled == true (or
equivalent opt-in flag); after the loop, skip calling s.aeroSvc.ShootEmail (and
avoid calling buildNewMemberHTML) if emailRecipients is empty to prevent sending
with no recipients, and update the warning log in the error branch to include
network/name and recipient count for context.

Addressed in commit 60b1c3e

_⚠️ Potential issue_ | _🟠 Major_ | _⚡ Quick win_ **Honor notification preferences before sending join emails.** Line 280 appends every resolved member email without checking `EmailNotificationsEnabled` (and without guarding blank emails). That can notify opted-out users and trigger `ShootEmail` with no valid recipients. [sraise_placeholder] <details> <summary>Proposed fix</summary> ```diff if membersErr == nil && len(prevMembersHumanIds) > 0 { emailRecipients := make([]string, 0, len(prevMembersHumanIds)) for _, memberHumanId := range prevMembersHumanIds { if memberHumanId != "" { human, err := s.humanLookup.GetByID(ctx, memberHumanId) if err != nil { flog.Warn("unable to find human", "error", err, "humanId", memberHumanId) continue } - - emailRecipients = append(emailRecipients, human.Email) + if !human.EmailNotificationsEnabled || strings.TrimSpace(human.Email) == "" { + continue + } + emailRecipients = append(emailRecipients, human.Email) } } - newMemberEmailPrefix := strings.Split(normalized, "@")[0] - _, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{ - ToEmails: emailRecipients, - Subject: fmt.Sprintf("A new member has joined %s", network.Name), - TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{ - SimpleHtmlData: &pbaero.SimpleHtmlData{ - Html: buildNewMemberHTML(network.Name, newMemberEmailPrefix), - }, - }, - }) - if err != nil { - flog.Warn("unable to send email notification", "email", email, "network", network.Name) + if len(emailRecipients) > 0 { + newMemberEmailPrefix := strings.Split(normalized, "@")[0] + _, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{ + ToEmails: emailRecipients, + Subject: fmt.Sprintf("A new member has joined %s", network.Name), + TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{ + SimpleHtmlData: &pbaero.SimpleHtmlData{ + Html: buildNewMemberHTML(network.Name, newMemberEmailPrefix), + }, + }, + }) + if err != nil { + flog.Warn("unable to send email notification", "email", email, "network", network.Name) + } } } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@go/internal/network/service.go` around lines 270 - 296, When building emailRecipients in the block that iterates prevMembersHumanIds (using s.humanLookup.GetByID and appending to emailRecipients), only append human.Email if it is non-empty and the human has EmailNotificationsEnabled == true (or equivalent opt-in flag); after the loop, skip calling s.aeroSvc.ShootEmail (and avoid calling buildNewMemberHTML) if emailRecipients is empty to prevent sending with no recipients, and update the warning log in the error branch to include network/name and recipient count for context. ``` </details> <!-- fingerprinting:phantom:poseidon:hawk --> <!-- cr-comment:v1:179253e249b046480879cce2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commit 60b1c3e
</td>
</tr>
<tr>
<td style="padding-bottom:24px;">
<a href="%s" style="display:inline-block;background-color:#111111;color:#ffffff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:8px;">
Open Flowy.llink (web)
</a>
</td>
</tr>
%s
</table>
</td>
</tr>
</table>
</body>
</html>`, safeName, safeEmail, emailWebAppURL, emailDesktopFooter())
}
+11 -1
View File
@@ -7,6 +7,8 @@ import (
pbaero "github.com/flowy-live/llink/genproto/aero"
mock_billing "github.com/flowy-live/llink/internal/billing/mocks"
"github.com/flowy-live/llink/internal/human"
mock_human "github.com/flowy-live/llink/internal/human/mocks"
mock_livestore "github.com/flowy-live/llink/internal/livestore/mocks"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/testhelper"
@@ -41,7 +43,15 @@ func newTestService(t *testing.T) network.Service {
mockPub.EXPECT().Add(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockPub.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
return network.NewService(dbPool, mockAero, mockBilling, mockPub)
mockHuman := mock_human.NewMockService(ctrl)
mockHuman.EXPECT().GetByID(gomock.Any(), gomock.Any()).Return(&human.Human{
ID: "test_human",
Email: "[email protected]",
EmailPrefix: "test",
EmailNotificationsEnabled: false,
}, nil).AnyTimes()
return network.NewService(dbPool, mockAero, mockBilling, mockPub, mockHuman)
}
func TestNetworkService(t *testing.T) {