Files
notify/service/amazonses/amazon_ses.go
zeekayandGitHub d2c09e545d notify: SES SendRawEmail + List-Unsubscribe injection on marketing class (#8)
* 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.
2026-06-03 21:32:58 -07:00

102 lines
3.0 KiB
Go

package amazonses
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/ses"
"github.com/aws/aws-sdk-go-v2/service/ses/types"
)
//go:generate mockery --name=sesClient --output=. --case=underscore --inpackage
type sesClient interface {
SendEmail(
ctx context.Context,
params *ses.SendEmailInput,
optFns ...func(options *ses.Options),
) (*ses.SendEmailOutput, error)
// SendRawEmail is the raw-MIME path used by SendRaw for marketing
// emails that need to carry RFC 8058 List-Unsubscribe headers.
// SendEmail does not let callers inject custom headers — SendRawEmail
// takes a fully-formed MIME message instead. Both methods coexist on
// the same interface so a single client implementation backs both.
SendRawEmail(
ctx context.Context,
params *ses.SendRawEmailInput,
optFns ...func(options *ses.Options),
) (*ses.SendRawEmailOutput, error)
}
// Compile-time check to ensure that ses.Client implements the sesClient interface.
var _ sesClient = new(ses.Client)
// AmazonSES struct holds necessary data to communicate with the Amazon Simple Email Service API.
type AmazonSES struct {
client sesClient
senderAddress *string
receiverAddresses []string
}
// New returns a new instance of a AmazonSES notification service.
// You will need an Amazon Simple Email Service API access key and secret.
// See https://aws.github.io/aws-sdk-go-v2/docs/getting-started/
func New(accessKeyID, secretKey, region, senderAddress string) (*AmazonSES, error) {
credProvider := credentials.NewStaticCredentialsProvider(accessKeyID, secretKey, "")
cfg, err := config.LoadDefaultConfig(
context.Background(),
config.WithCredentialsProvider(credProvider),
config.WithRegion(region),
)
if err != nil {
return nil, err
}
return &AmazonSES{
client: ses.NewFromConfig(cfg),
senderAddress: aws.String(senderAddress),
receiverAddresses: []string{},
}, nil
}
// AddReceivers takes email addresses and adds them to the internal address list. The Send method will send
// a given message to all those addresses.
func (a *AmazonSES) AddReceivers(addresses ...string) {
a.receiverAddresses = append(a.receiverAddresses, addresses...)
}
// Send takes a message subject and a message body and sends them to all previously set chats. Message body supports
// html as markup language.
func (a AmazonSES) Send(ctx context.Context, subject, message string) error {
input := &ses.SendEmailInput{
Source: a.senderAddress,
Destination: &types.Destination{
ToAddresses: a.receiverAddresses,
},
Message: &types.Message{
Body: &types.Body{
Html: &types.Content{
Data: aws.String(message),
},
// Text: &types.Content{
// Data: aws.String(message),
// },
},
Subject: &types.Content{
Data: aws.String(subject),
},
},
}
_, err := a.client.SendEmail(ctx, input)
if err != nil {
return fmt.Errorf("send mail using Amazon SES service: %w", err)
}
return nil
}