carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The DNS record set a sending identity needs (spec §7). Pure: SES's DKIM
2// tokens in, resource record sets out. Who WRITES them is the one difference
3// between a platform domain (CARLOS owns the zone) and a custom domain (the
4// customer publishes the same set by hand) — the set itself is identical,
5// which is what makes the two paths one code path.
6package mail
7
8import (
9 "fmt"
10 "strings"
11
12 "github.com/carlosframework/platform/internal/dnsrec"
13)
14
15// recordTTL is short on purpose: these records change on rotation and on
16// teardown, and a long TTL turns "we removed it" into "it is still being
17// answered for a day".
18const recordTTL = 300
19
20// MailFromDomain is the custom MAIL FROM subdomain for a domain in a region.
21//
22// The region is IN THE NAME, and it is there from the first domain ever
23// verified. SES's MAIL FROM MX target is region-specific, so one subdomain
24// cannot serve two regions without ambiguity — and for a custom domain this
25// record lives in the CUSTOMER's zone, which makes it the one record we
26// cannot republish for them later. The full region name is used rather than
27// an abbreviation so there is no code table to keep in sync.
28func MailFromDomain(domain, region string) string {
29 return "mail-" + region + "." + domain
30}
31
32// RecordSet renders every record a sending identity needs. rua is the DMARC
33// aggregate-report mailto target; empty means publish no rua (spec §7).
34//
35// Note what is NOT here: any record at `domain` itself. The edge already owns
36// an A record at an app's host, so every name below is a distinct label
37// underneath it. That separation is what lets the email pass and the
38// provisioning reconciler write into one zone without knowing about each
39// other, and TestNoRecordSitsAtTheDomainApex exists to keep it true.
40func RecordSet(domain, region string, dkimTokens []string, rua string) []dnsrec.Record {
41 out := make([]dnsrec.Record, 0, len(dkimTokens)+3)
42 for _, tok := range dkimTokens {
43 out = append(out, dnsrec.Record{
44 Name: tok + "._domainkey." + domain,
45 Type: dnsrec.TypeCNAME,
46 Values: []string{tok + ".dkim.amazonses.com"},
47 TTL: recordTTL,
48 })
49 }
50 mailFrom := MailFromDomain(domain, region)
51 out = append(out,
52 dnsrec.Record{
53 Name: mailFrom,
54 Type: dnsrec.TypeMX,
55 Values: []string{fmt.Sprintf("10 feedback-smtp.%s.amazonses.com", region)},
56 TTL: recordTTL,
57 },
58 // SPF is checked against the MAIL FROM domain, so it belongs here
59 // and not at the identity domain.
60 dnsrec.Record{
61 Name: mailFrom,
62 Type: dnsrec.TypeTXT,
63 Values: []string{"v=spf1 include:amazonses.com ~all"},
64 TTL: recordTTL,
65 },
66 dnsrec.Record{
67 Name: "_dmarc." + domain,
68 Type: dnsrec.TypeTXT,
69 Values: []string{dmarcValue(rua)},
70 TTL: recordTTL,
71 },
72 )
73 return out
74}
75
76// dmarcValue builds the DMARC policy.
77//
78// adkim=s and aspf=s are NOT hygiene — they are the tenant boundary. DMARC's
79// default relaxed alignment compares organizational domains, so on a shared
80// registrable domain like oncarlos.com one tenant's DKIM signature would
81// authenticate another tenant's From address. Strict alignment is what makes
82// that impossible.
83//
84// p=none because a tenant whose first send is rejected by its own DMARC
85// record learns nothing useful; tightening it is a follow-up gated on
86// aggregate reports actually arriving.
87func dmarcValue(rua string) string {
88 v := "v=DMARC1; p=none; adkim=s; aspf=s"
89 if strings.TrimSpace(rua) != "" {
90 v += "; rua=mailto:" + strings.TrimSpace(rua)
91 }
92 return v
93}
94