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.
102 lines
3.0 KiB
Go
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
|
|
}
|