feat: implement the coming-soon notification delivery channels — Email (SES/SMTP to the account email) and Slack (posts to the org's connected workspace via a new integration NotifySlack), wired in both backend and consumer with per-channel gating, real toggles replacing the coming-soon labels, and updated docs

This commit is contained in:
Matthew Meszaros
2026-06-11 12:21:48 +02:00
parent 39a9752d63
commit 160dc0bc76
8 changed files with 177 additions and 28 deletions
+1
View File
@@ -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)
+14
View File
@@ -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)
+5 -5
View File
@@ -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
+34
View File
@@ -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
}
+93 -15
View File
@@ -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(`<p><a href="%s" style="display:inline-block;padding:10px 20px;background:#0284c7;color:white;text-decoration:none;border-radius:6px;">Open in Warmbly</a></p>`, href)
}
html := fmt.Sprintf(`<h2 style="margin:0 0 8px;">%s</h2><p style="color:#475569;">%s</p>%s<p style="color:#94a3b8;font-size:12px;margin-top:24px;">You're receiving this because email notifications are on for %s. Manage them in Settings &rarr; Notifications.</p>`,
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("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return r.Replace(s)
}
+3 -2
View File
@@ -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.
@@ -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 (
<SectionShell
title="Notifications"
description="Which events show up in your in-app feed (the bell). Defaults reflect the recommendation."
description="Which events notify you, and where they are delivered. Defaults reflect the recommendation."
actions={
dirty ? (
<>
@@ -82,15 +102,15 @@ export default function NotificationsSettingsPage() {
<Section eyebrow="Health" description="Deliverability + infrastructure alerts. Recommended on.">
{rows(HEALTH)}
</Section>
<Section eyebrow="Channels" description="Where notifications are delivered.">
<Section eyebrow="Channels" description="Where enabled notifications are delivered. Applies across every category above.">
<Row label="In-app" description="The bell in the dashboard chrome (controlled per category above).">
<span className="text-[11px] font-medium text-emerald-600">On</span>
</Row>
<Row label="Email" description="Delivery to your account email.">
<span className="text-[11px] text-slate-400">Coming soon</span>
<Toggle on={channelOn("email")} onChange={(v) => setChannel("email", v)} />
</Row>
<Row label="Slack" description="Connect via the Integrations tab.">
<span className="text-[11px] text-slate-400">Coming soon</span>
<Row label="Slack" description="Posts to your workspace's connected Slack. Connect it in the Integrations tab first.">
<Toggle on={channelOn("slack")} onChange={(v) => setChannel("slack", v)} />
</Row>
</Section>
</>
@@ -2,7 +2,8 @@
export interface ChannelPrefs {
in_app: boolean;
email: boolean; // reserved; not delivered yet
email: boolean;
slack: boolean;
}
export interface CategoryPref {