mirror of
https://github.com/hanzoai/notify.git
synced 2026-10-30 15:04:32 +00:00
* kmsbridge: drop legacy-Infisical workaround framing in doc comments
kmsbridge has been the canonical path to Hanzo KMS since the canonical
migration landed. Update doc comments to reflect that — the package is the
direct line to /v1/kms/orgs/{org}/secrets, not a stopgap around a base/plugins
version pin. The base.KMSClient surface compatibility note stays since callers
do still swap import paths to migrate.
* notify: SES SendRawEmail + List-Unsubscribe injection on marketing class
Per RFC 8058 + Gmail/Outlook bulk-sender policy (2024+), marketing email
must carry both List-Unsubscribe and List-Unsubscribe-Post to surface
the native one-click unsubscribe button. The SES SDK's SendEmail cannot
inject custom headers; SendRawEmail takes raw MIME instead.
Substrates:
- service/amazonses/raw.go: AmazonSES.SendRaw method, builds the MIME
envelope (From / To / Subject / Date / MIME-Version / Content-Type +
sorted extra headers + blank line + body) and ships via SendRawEmail.
Header smuggling (CR/LF in extra header values) is rejected at
compose time. SendEmail path is untouched.
- internal/marketing/category.go: IsMarketing predicate matching the
five §3.2 buckets (promotional, newsletter, product_update,
partner_offer, research) + HTML / plain footer injection helpers.
- internal/tasks/activities.go: marketing branch on Activities.send.
When (a) email channel, (b) marketing category, (c) marketing.Config
wired: mint unsubscribe token, persist row into
notification_unsubscribe_tokens BEFORE send, append visible footer
to the body, route through chain.RunMarketing.
- internal/tenant/chain.go: ChainProvider gains an optional RawSender
extension. RunMarketing prefers SendRaw when implemented (today: SES
API only), falls back to plain Send otherwise. Per-attempt + whole-
chain deadlines and the retry classifier are unchanged.
- pkg/types + internal/tasks/workflow: SendRequest / SendInput gain
Category, UserID, IsHTML fields.
- cmd/notifyd/main.go: wires marketing.Config (NOTIFY_ENV + unsubSigner)
onto Activities at boot. NOTIFY_UNSUBSCRIBE_SECRET also gates this
path (same fail-closed rule that already gates /unsubscribe routes).
Tests (37 added across 4 files):
- service/amazonses/raw_test.go — MIME envelope shape; sorted header
ordering; HTML vs plain Content-Type; multi-recipient To header;
header-smuggling rejection; SDK error propagation.
- internal/marketing/category_test.go — IsMarketing buckets + edges;
HTML + plain footer injection; empty URL no-op.
- internal/tasks/marketing_test.go — token persistence;
three-gate shouldUseMarketing decision matrix; idempotent re-persist;
extractToken URL parsing.
- internal/tenant/chain_marketing_test.go — RunMarketing prefers
RawSender path; falls back to Send when RawSender absent; multi-
provider fallthrough on retryable failure.
Completes the PR #7 follow-up: the header builder shipped in #7 is now
wired into the send path; one-click unsubscribe works end-to-end for
marketing-class email.
135 lines
4.6 KiB
Go
135 lines
4.6 KiB
Go
// raw.go — SendRaw is the SES SendRawEmail path that lets callers
|
|
// inject custom MIME headers the higher-level SendEmail API cannot
|
|
// express. The motivating use case is RFC 8058 List-Unsubscribe +
|
|
// List-Unsubscribe-Post pair, required by Gmail and Outlook bulk-sender
|
|
// policy (2024+) for every marketing-class email.
|
|
//
|
|
// SendEmail and SendRaw coexist: SendEmail stays the simple-path API
|
|
// for transactional sends (the SDK builds the MIME envelope itself);
|
|
// SendRaw is opt-in for marketing where the caller controls every
|
|
// header. There is no shared mutable state — each call constructs its
|
|
// own envelope so concurrent sends on the same AmazonSES instance are
|
|
// safe.
|
|
|
|
package amazonses
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"mime"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/service/ses"
|
|
"github.com/aws/aws-sdk-go-v2/service/ses/types"
|
|
)
|
|
|
|
// SendRaw assembles a minimal RFC 5322 MIME message with the requested
|
|
// extra headers (e.g. List-Unsubscribe) and ships it via SES
|
|
// SendRawEmail. isHTML toggles between text/html and text/plain in the
|
|
// Content-Type header; the body is included verbatim — callers are
|
|
// expected to have already appended any footer they want visible.
|
|
//
|
|
// extraHeaders order is sorted alphabetically for byte-stable output
|
|
// (test assertions and any potential receiver-side DKIM hashing both
|
|
// benefit). Header values must be CRLF-free; we reject any value
|
|
// containing a CR or LF rather than silently letting it inject extra
|
|
// headers ("header smuggling" prevention).
|
|
func (a AmazonSES) SendRaw(ctx context.Context, subject, body string, isHTML bool, extraHeaders map[string]string) error {
|
|
raw, err := a.buildRawMessage(subject, body, isHTML, extraHeaders, time.Now().UTC())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
input := &ses.SendRawEmailInput{
|
|
RawMessage: &types.RawMessage{Data: raw},
|
|
}
|
|
if _, err := a.client.SendRawEmail(ctx, input); err != nil {
|
|
return fmt.Errorf("send raw mail using Amazon SES service: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildRawMessage assembles the MIME bytes. Exposed at package scope so
|
|
// tests can assert envelope shape without paying the SDK round-trip.
|
|
//
|
|
// Envelope shape (CRLF line endings, RFC 5322 / RFC 5321 1000-char hard
|
|
// cap respected by short header values; long bodies are not folded
|
|
// because SES accepts them as-is):
|
|
//
|
|
// From: <sender>
|
|
// To: <r1>, <r2>, …
|
|
// Subject: <subject>
|
|
// Date: <RFC1123Z>
|
|
// MIME-Version: 1.0
|
|
// Content-Type: text/{html,plain}; charset=UTF-8
|
|
// List-Unsubscribe: <https://…>, <mailto:…> ← extraHeaders
|
|
// List-Unsubscribe-Post: List-Unsubscribe=One-Click
|
|
//
|
|
// <body>
|
|
func (a AmazonSES) buildRawMessage(subject, body string, isHTML bool, extraHeaders map[string]string, now time.Time) ([]byte, error) {
|
|
sender := ""
|
|
if a.senderAddress != nil {
|
|
sender = *a.senderAddress
|
|
}
|
|
if strings.TrimSpace(sender) == "" {
|
|
return nil, errors.New("amazonses: sender address is required for SendRaw")
|
|
}
|
|
if len(a.receiverAddresses) == 0 {
|
|
return nil, errors.New("amazonses: at least one receiver address is required for SendRaw")
|
|
}
|
|
|
|
// Reject header values that would smuggle extra headers via CR/LF.
|
|
// Subject is RFC 2047 encoded so a literal newline in the subject is
|
|
// already neutralised; the extras need an explicit check.
|
|
for k, v := range extraHeaders {
|
|
if strings.ContainsAny(k, "\r\n") {
|
|
return nil, fmt.Errorf("amazonses: header name %q contains CR/LF", k)
|
|
}
|
|
if strings.ContainsAny(v, "\r\n") {
|
|
return nil, fmt.Errorf("amazonses: header %q value contains CR/LF", k)
|
|
}
|
|
}
|
|
|
|
contentType := "text/plain; charset=UTF-8"
|
|
if isHTML {
|
|
contentType = "text/html; charset=UTF-8"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
writeHeader := func(name, value string) {
|
|
buf.WriteString(name)
|
|
buf.WriteString(": ")
|
|
buf.WriteString(value)
|
|
buf.WriteString("\r\n")
|
|
}
|
|
|
|
writeHeader("From", sender)
|
|
writeHeader("To", strings.Join(a.receiverAddresses, ", "))
|
|
// Subject may contain non-ASCII; encoded-word it per RFC 2047.
|
|
writeHeader("Subject", mime.QEncoding.Encode("UTF-8", subject))
|
|
writeHeader("Date", now.Format(time.RFC1123Z))
|
|
writeHeader("MIME-Version", "1.0")
|
|
writeHeader("Content-Type", contentType)
|
|
|
|
// Sort extra headers so output is byte-stable for tests and any
|
|
// DKIM-style signing experiments.
|
|
if len(extraHeaders) > 0 {
|
|
names := make([]string, 0, len(extraHeaders))
|
|
for k := range extraHeaders {
|
|
names = append(names, k)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
writeHeader(name, extraHeaders[name])
|
|
}
|
|
}
|
|
|
|
// Blank line between headers and body, then body verbatim.
|
|
buf.WriteString("\r\n")
|
|
buf.WriteString(body)
|
|
return buf.Bytes(), nil
|
|
}
|