From 160dc0bc76e7b3b7ff8424dca427e2db87ec94bc Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 11 Jun 2026 12:21:48 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20the=20coming-soon=20notific?= =?UTF-8?q?ation=20delivery=20channels=20=E2=80=94=20Email=20(SES/SMTP=20t?= =?UTF-8?q?o=20the=20account=20email)=20and=20Slack=20(posts=20to=20the=20?= =?UTF-8?q?org's=20connected=20workspace=20via=20a=20new=20integration=20N?= =?UTF-8?q?otifySlack),=20wired=20in=20both=20backend=20and=20consumer=20w?= =?UTF-8?q?ith=20per-channel=20gating,=20real=20toggles=20replacing=20the?= =?UTF-8?q?=20coming-soon=20labels,=20and=20updated=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/backend/main.go | 1 + cmd/consumer/main.go | 14 +++ docs/content/docs/guides/notifications.mdx | 10 +- internal/app/integration/service.go | 34 ++++++ internal/app/notification/service.go | 108 +++++++++++++++--- internal/models/notification.go | 5 +- .../app/app/settings/notifications/page.tsx | 30 ++++- .../models/app/notifications/Notification.ts | 3 +- 8 files changed, 177 insertions(+), 28 deletions(-) diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 91f5e921..d1347c94 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -870,6 +870,7 @@ func main() { // onto the backend's advanced service (deliverability webhooks can ingest // here too). notificationService = notification.NewService(repository.NewNotificationRepository(primaryDB.Pool), streamingPublisher) + notificationService.WireDelivery(emailNotificationService, integrationServiceForHandler, userRepostory) advancedService.WireNotifier(notificationService) advancedService.WireRealtime(streamingPublisher) emailSender := tasks.NewEmailSender(emailRepostory, eventsPublisher) diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index a4a651b0..08cf97f8 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -29,6 +29,7 @@ import ( "github.com/warmbly/warmbly/internal/infrastructure/kms" "github.com/warmbly/warmbly/internal/infrastructure/pubsub" "github.com/warmbly/warmbly/internal/infrastructure/storage" + "github.com/warmbly/warmbly/internal/notify" "github.com/warmbly/warmbly/internal/observability" "github.com/warmbly/warmbly/internal/repository" ) @@ -228,6 +229,19 @@ func main() { // notifier must be wired here. Missing this = notifications silently never // created. notificationService := notification.NewService(repository.NewNotificationRepository(primaryDB.Pool), streamingPublisher) + // Email + Slack delivery for notifications. Email is best-effort: the + // SES/SMTP service only constructs when email config is present (prod, or + // a dev env that sets it), so a bare dev consumer simply skips the email + // channel. Slack reuses the integration service (token decryption). + var notifEmail notification.EmailSender + if emailCfg, ecErr := cfg.LoadEmailConfig(ctx); ecErr == nil { + if smtpCfg := cfg.LoadSMTPConfig(ctx); smtpCfg != nil { + notifEmail = notify.NewSMTPEmailNotificationService(emailCfg.EmailName, emailCfg.EmailAddress, smtpCfg.Host, smtpCfg.Port) + } else if ses, sErr := notify.NewEmailNotficiationService(ctx, emailCfg.EmailName, emailCfg.EmailAddress); sErr == nil { + notifEmail = ses + } + } + notificationService.WireDelivery(notifEmail, integrationServiceC, repository.NewUserRepostory(primaryDB, kmsClient)) advancedService.WireNotifier(notificationService) // Reply pulses fire in THIS process too (inbox ingest classifies replies). advancedService.WireRealtime(streamingPublisher) diff --git a/docs/content/docs/guides/notifications.mdx b/docs/content/docs/guides/notifications.mdx index 84b3f5f3..a1ee43d8 100644 --- a/docs/content/docs/guides/notifications.mdx +++ b/docs/content/docs/guides/notifications.mdx @@ -74,13 +74,13 @@ Even with the reply notification off, you never miss responses. Every reply stil ## Channels -The settings page also shows where notifications are delivered. +The settings page controls where enabled notifications are delivered. The channel toggles apply across every category above. -- **In-app**: the bell in the dashboard. This is the channel that is live today, and it is controlled by the per-category toggles above. -- **Email**: delivery to your account email. Marked **Coming soon**. -- **Slack**: delivery through a connected Slack integration. Marked **Coming soon**. +- **In-app**: the bell in the dashboard. Always on, controlled by the per-category toggles above. +- **Email**: delivery to your account email. Turn it on to also receive each enabled notification as an email with a link back into the app. +- **Slack**: posts each enabled notification to your workspace's connected Slack, on the channel you chose when connecting. Connect Slack from the [Integrations](/guides/integrations) tab first; until then the toggle saves but nothing is delivered. -For now, the in-app feed is the delivery channel. If you want event-driven Slack or webhook delivery in the meantime, that is what [Automations](/guides/automations) are for: you can route events like replies, bookings, and record changes to outside tools there. +For richer, event-specific routing (custom messages, branching, multiple destinations), use [Automations](/guides/automations) instead: they can route events like replies, bookings, and record changes to outside tools with full control. ## Practical tips diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go index 7dfeb349..846cf2fb 100644 --- a/internal/app/integration/service.go +++ b/internal/app/integration/service.go @@ -139,6 +139,10 @@ type Service interface { // Dispatch; struct payloads are ignored. DispatchAny(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) + // NotifySlack posts a plain message to the org's connected Slack on its + // configured default channel. No-op (nil) when no Slack is connected. + NotifySlack(ctx context.Context, orgID uuid.UUID, title, body string) error + // Repo exposes the underlying repository for the inbound webhook handlers. Repo() repository.IntegrationRepository } @@ -1105,3 +1109,33 @@ func buildDisplayFields(provider models.IntegrationProvider, config map[string]a } return df } + +// NotifySlack posts a one-off message to the org's connected Slack workspace, +// on the default channel chosen at connect time. Used by the notification +// system's Slack delivery channel (distinct from event-subscription actions). +// Best-effort: returns nil when no healthy Slack connection exists. +func (s *service) NotifySlack(ctx context.Context, orgID uuid.UUID, title, body string) error { + conns, err := s.repo.ListConnections(ctx, orgID) + if err != nil { + return err + } + for _, c := range conns { + if c.Provider != models.IntegrationSlack || c.Status != models.IntegrationStatusConnected { + continue + } + channel := configString(c.DisplayFields, "channel") + if channel == "" { + continue + } + sec, serr := s.repo.GetConnectionSecrets(ctx, c.ID) + if serr != nil { + continue + } + token, terr := s.accessTokenFor(ctx, sec) + if terr != nil { + continue + } + return slackPostMessage(ctx, token, channel, eventMessage{Title: title, Detail: body}) + } + return nil +} diff --git a/internal/app/notification/service.go b/internal/app/notification/service.go index b5a8fdc3..90d4ed3c 100644 --- a/internal/app/notification/service.go +++ b/internal/app/notification/service.go @@ -7,6 +7,9 @@ package notification import ( "context" + "fmt" + "strings" + "time" "github.com/google/uuid" @@ -16,6 +19,24 @@ import ( "github.com/warmbly/warmbly/internal/repository" ) +// EmailSender delivers a notification to a user's account email. Satisfied by +// notify.EmailNotificationService. +type EmailSender interface { + Send(ctx context.Context, to, cc, bcc []string, subject, message string) error +} + +// SlackNotifier posts to the org's connected Slack. Satisfied by the +// integration service (NotifySlack). +type SlackNotifier interface { + NotifySlack(ctx context.Context, orgID uuid.UUID, title, body string) error +} + +// UserLookup resolves a user's email + name for email delivery. Satisfied by +// the user repository. +type UserLookup interface { + GetUser(ctx context.Context, id uuid.UUID) (*models.User, error) +} + type Service interface { GetPreferences(ctx context.Context, userID uuid.UUID) (*models.NotificationPreferences, *errx.Error) UpdatePreferences(ctx context.Context, userID uuid.UUID, prefs *models.NotificationPreferences) *errx.Error @@ -26,11 +47,25 @@ type Service interface { // Notify is the gated ingress — best-effort, never errors out the caller. Notify(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any) + + // WireDelivery attaches the email + Slack + user-lookup dependencies for + // the email/Slack channels (wired post-construction in both mains). Any + // may be nil — the matching channel is then skipped. + WireDelivery(email EmailSender, slack SlackNotifier, users UserLookup) } type service struct { repo repository.NotificationRepository publisher *pubsub.StreamingPublisher + email EmailSender + slack SlackNotifier + users UserLookup +} + +func (s *service) WireDelivery(email EmailSender, slack SlackNotifier, users UserLookup) { + s.email = email + s.slack = slack + s.users = users } func NewService(repo repository.NotificationRepository, publisher *pubsub.StreamingPublisher) Service { @@ -93,22 +128,65 @@ func (s *service) Notify(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID return } cat := prefs.CategoryPref(category) - if !cat.Enabled || !cat.Channels.InApp { - return // the gate + if !cat.Enabled { + return // category off — no channel fires } - created, cerr := s.repo.Create(ctx, &models.Notification{ - UserID: userID, - OrganizationID: orgID, - Category: category, - Title: title, - Body: body, - Link: link, - Metadata: meta, - }) - if cerr != nil || created == nil { - return + + // In-app: persist the feed row + push the realtime event. + if cat.Channels.InApp { + created, cerr := s.repo.Create(ctx, &models.Notification{ + UserID: userID, + OrganizationID: orgID, + Category: category, + Title: title, + Body: body, + Link: link, + Metadata: meta, + }) + if cerr == nil && created != nil && s.publisher != nil { + s.publisher.PublishNotificationCreated(ctx, userID.String(), created.ID.String(), string(category), title, link) + } } - if s.publisher != nil { - s.publisher.PublishNotificationCreated(ctx, userID.String(), created.ID.String(), string(category), title, link) + + // Email: deliver to the user's account email (detached, best-effort). + if cat.Channels.Email && s.email != nil && s.users != nil { + go s.deliverEmail(userID, category, title, body, link) + } + + // Slack: post to the org's connected workspace (detached, best-effort). + if cat.Channels.Slack && s.slack != nil && orgID != nil { + org := *orgID + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + _ = s.slack.NotifySlack(ctx, org, title, body) + }() } } + +// deliverEmail renders a minimal HTML notification and emails it to the user. +func (s *service) deliverEmail(userID uuid.UUID, category models.NotificationCategory, title, body, link string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + user, err := s.users.GetUser(ctx, userID) + if err != nil || user == nil || user.Email == "" { + return + } + href := link + if href != "" && len(href) > 0 && href[0] == '/' { + href = "https://app.warmbly.com" + href + } + cta := "" + if href != "" { + cta = fmt.Sprintf(`

Open in Warmbly

`, href) + } + html := fmt.Sprintf(`

%s

%s

%s

You're receiving this because email notifications are on for %s. Manage them in Settings → Notifications.

`, + htmlEscape(title), htmlEscape(body), cta, htmlEscape(string(category))) + _ = s.email.Send(ctx, []string{user.Email}, nil, nil, title, html) +} + +func htmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) + return r.Replace(s) +} diff --git a/internal/models/notification.go b/internal/models/notification.go index e1f3c64a..db88bd5f 100644 --- a/internal/models/notification.go +++ b/internal/models/notification.go @@ -20,10 +20,11 @@ const ( ) // ChannelPrefs is the per-category delivery toggles. Only InApp is delivered -// today; Email/Slack are modeled for forward-compat and rendered "coming soon". +// today across in-app, email, and a connected Slack workspace. type ChannelPrefs struct { InApp bool `json:"in_app"` - Email bool `json:"email"` // reserved; not enforced yet + Email bool `json:"email"` + Slack bool `json:"slack"` } // CategoryPref is the enable flag + channel toggles for one category. diff --git a/web/src/app/app/settings/notifications/page.tsx b/web/src/app/app/settings/notifications/page.tsx index b1c1203f..f4733b93 100644 --- a/web/src/app/app/settings/notifications/page.tsx +++ b/web/src/app/app/settings/notifications/page.tsx @@ -35,6 +35,26 @@ export default function NotificationsSettingsPage() { const setEnabled = (key: NotificationCategoryKey, on: boolean) => setDraft((d) => (d ? { ...d, [key]: { ...d[key], enabled: on } } : d)); + const CATEGORY_KEYS: NotificationCategoryKey[] = [ + "inbound_reply", + "inbound_out_of_office", + "health_bounce", + "health_complaint", + "health_worker_downtime", + ]; + // Channels present globally: "on" when every category carries the channel. + const channelOn = (ch: "email" | "slack") => + !!draft && CATEGORY_KEYS.every((k) => draft[k].channels[ch]); + const setChannel = (ch: "email" | "slack", on: boolean) => + setDraft((d) => { + if (!d) return d; + const next = { ...d }; + for (const k of CATEGORY_KEYS) { + next[k] = { ...d[k], channels: { ...d[k].channels, [ch]: on } }; + } + return next; + }); + const save = async () => { if (!draft || !dirty || update.isPending) return; try { @@ -55,7 +75,7 @@ export default function NotificationsSettingsPage() { return ( @@ -82,15 +102,15 @@ export default function NotificationsSettingsPage() {
{rows(HEALTH)}
-
+
On - Coming soon + setChannel("email", v)} /> - - Coming soon + + setChannel("slack", v)} />
diff --git a/web/src/lib/api/models/app/notifications/Notification.ts b/web/src/lib/api/models/app/notifications/Notification.ts index c09838cb..4b3a8c18 100644 --- a/web/src/lib/api/models/app/notifications/Notification.ts +++ b/web/src/lib/api/models/app/notifications/Notification.ts @@ -2,7 +2,8 @@ export interface ChannelPrefs { in_app: boolean; - email: boolean; // reserved; not delivered yet + email: boolean; + slack: boolean; } export interface CategoryPref {