| 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. |
| 6 | package mail |
| 7 | |
| 8 | import ( |
| 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". |
| 18 | const 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. |
| 28 | func 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. |
| 40 | func 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. |
| 87 | func 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 | |