eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// 🤖 A minimal SES v2 sender for the email anchor's sign-in codes (issue
4// #1091) — one SigV4-signed POST to the SendEmail API, riding the same
5// hand-rolled signer the S3 store uses (sigv4.go). No SDK: the whole mailer
6// is readable in one sitting, and home's dependency set stays boring.
7//
8// Deliverability (SPF/DKIM, sandbox exit, bounce handling) is SES-console
9// and DNS work, deliberately outside this file — see issue #1091's ops list.
10
11import (
12 "bytes"
13 "crypto/sha256"
14 "encoding/hex"
15 "encoding/json"
16 "errors"
17 "fmt"
18 "io"
19 "net/http"
20 "os"
21 "time"
22)
23
24// sesMailer sends through SES v2 in one region from one address. endpoint is
25// overridable for tests (a fake SES httptest server); empty means the real
26// regional endpoint.
27type sesMailer struct {
28 region string
29 from string
30 accessKey string
31 secretKey string
32 endpoint string
33}
34
35func newSESMailer(region, from string) (*sesMailer, error) {
36 access, secret := os.Getenv("AWS_ACCESS_KEY_ID"), os.Getenv("AWS_SECRET_ACCESS_KEY")
37 if access == "" || secret == "" {
38 return nil, errors.New("email anchor: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set")
39 }
40 return &sesMailer{region: region, from: from, accessKey: access, secretKey: secret}, nil
41}
42
43// send delivers one plain-text email. The recipient address is used for this
44// send and then dropped — nothing here persists it (the blind-index rule).
45func (m *sesMailer) send(to, subject, text string) error {
46 body, err := json.Marshal(map[string]any{
47 "FromEmailAddress": m.from,
48 "Destination": map[string]any{"ToAddresses": []string{to}},
49 "Content": map[string]any{
50 "Simple": map[string]any{
51 "Subject": map[string]any{"Data": subject, "Charset": "UTF-8"},
52 "Body": map[string]any{
53 "Text": map[string]any{"Data": text, "Charset": "UTF-8"},
54 },
55 },
56 },
57 })
58 if err != nil {
59 return err
60 }
61 endpoint := m.endpoint
62 if endpoint == "" {
63 endpoint = "https://email." + m.region + ".amazonaws.com"
64 }
65 req, err := http.NewRequest("POST", endpoint+"/v2/email/outbound-emails", bytes.NewReader(body))
66 if err != nil {
67 return err
68 }
69 now := time.Now()
70 digest := sha256.Sum256(body)
71 req.Header.Set("Content-Type", "application/json")
72 req.Header.Set("X-Amz-Date", now.UTC().Format("20060102T150405Z"))
73 req.Header.Set("X-Amz-Content-Sha256", hex.EncodeToString(digest[:]))
74 awsSignV4(req, m.accessKey, m.secretKey, m.region, "ses", now)
75 resp, err := oidcHTTP.Do(req)
76 if err != nil {
77 return err
78 }
79 defer resp.Body.Close()
80 if resp.StatusCode/100 != 2 {
81 b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
82 return fmt.Errorf("ses: %s: %s", resp.Status, b)
83 }
84 return nil
85}
86