mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 00:01:24 +00:00
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).
108 lines
2.6 KiB
Go
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
|
|
}
|