Files
warmbly/internal/notify/email.go
T
Matt bd6a045751 feat(admin): outreach composer (platform mailer + reply-to + audit log)
Adds a dedicated admin path for sending platform email — distinct from
the campaign emailsend service (which sends through customer mailboxes)
so the two abuse surfaces never share code paths.

Schema (000047) adds admin_outreach_messages: every send is recorded
with sent_by, the resolved to_email, the optional reply_to, subject,
body, and a queued → sent/failed status. Failed sends keep their error
column populated for the audit log.

Extends notify.EmailNotificationService with SendOutreach so both
backends (SES + SMTP) support custom Reply-To: SES via the native
ReplyToAddresses field, SMTP via a forged Reply-To header. The
existing transactional Send() remains unchanged so no other caller is
affected.

Service (internal/app/adminoutreach) resolves recipients three ways:
to_email (raw address), to_user_id (sends to the user's account email),
or to_org_id (sends to the workspace owner). Persist-then-send-then-
mark ensures the audit row exists even if the mailer hangs, and
mark-failed captures the error string verbatim.

Routes:
  POST /admin/outreach            manage_organizations
  GET  /admin/outreach            view_organizations

Admin UI: composer with recipient mode picker (email / user_id / org_id),
configurable Reply-To (defaults to support@warmbly.com so customers can
actually reply), subject + HTML body editor, and an outreach log below
showing the last 50 sends with status badges and error details. Sidebar
entry under Accounts (Send icon).
2026-05-28 12:25:18 +02:00

108 lines
2.6 KiB
Go

package notify
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sesv2"
"github.com/aws/aws-sdk-go-v2/service/sesv2/types"
"github.com/getsentry/sentry-go"
)
type EmailNotificationService interface {
Send(ctx context.Context, to, cc, bcc []string, subject, message string) error
// SendOutreach is the same as Send but lets the caller override
// the Reply-To header. Used by the admin outreach composer so a
// noreply From: address can still funnel replies into a real
// support inbox. Empty replyTo falls back to the From address.
SendOutreach(ctx context.Context, to []string, replyTo, subject, message string) error
}
type emailNotificationService struct {
Name string
Address string
Client *sesv2.Client
From string
}
func NewEmailNotficiationService(ctx context.Context, name, address string) (EmailNotificationService, error) {
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
sentry.CaptureException(err)
return nil, err
}
client := sesv2.NewFromConfig(cfg)
return &emailNotificationService{
Name: name,
Address: address,
Client: client,
}, nil
}
func (s *emailNotificationService) Send(ctx context.Context, to, cc, bcc []string, subject, message string) error {
from := fmt.Sprintf("%s <%s>", s.Name, s.Address)
input := &sesv2.SendEmailInput{
Destination: &types.Destination{
ToAddresses: to,
CcAddresses: cc,
BccAddresses: bcc,
},
Content: &types.EmailContent{
Simple: &types.Message{
Body: &types.Body{
Html: &types.Content{
Data: &message,
},
},
Subject: &types.Content{
Data: &subject,
},
},
},
FromEmailAddress: &from,
}
_, err := s.Client.SendEmail(ctx, input)
if err != nil {
sentry.CaptureException(err)
return err
}
return err
}
// SendOutreach is Send with an explicit Reply-To. SES exposes
// ReplyToAddresses on the SendEmailInput so we don't have to forge MIME
// headers ourselves.
func (s *emailNotificationService) SendOutreach(ctx context.Context, to []string, replyTo, subject, message string) error {
from := fmt.Sprintf("%s <%s>", s.Name, s.Address)
input := &sesv2.SendEmailInput{
Destination: &types.Destination{
ToAddresses: to,
},
Content: &types.EmailContent{
Simple: &types.Message{
Body: &types.Body{
Html: &types.Content{Data: &message},
},
Subject: &types.Content{Data: &subject},
},
},
FromEmailAddress: &from,
}
if replyTo != "" {
input.ReplyToAddresses = []string{replyTo}
}
_, err := s.Client.SendEmail(ctx, input)
if err != nil {
sentry.CaptureException(err)
return err
}
return nil
}