Amazon SES v2 / SNS integration for SquatchMail.
This module owns every call SquatchMail makes to AWS (via the aws package's
AWS.SESv2 and AWS.SNS clients) and the interpretation of their responses.
It does not own persistence of the SquatchMail.Source row — reading and
writing that goes through SquatchMail.Tracker.get_or_create_source/0 and
SquatchMail.Tracker.update_source/1.
It provides four capabilities that back the dashboard's "Connect SES" flow:
- One-click provisioning (
provision/1,provision/3) — idempotently create (or reuse) a configuration set, an SNS topic, an HTTPS subscription to our webhook URL, and a configuration-set event destination pointing at that topic. - Quota sync (
sync_quota/1,ensure_quota_synced/1) — read the SES account sending quota and cache it on the source for 6 hours. - Identity management (
list_identities/1,create_identity/2,recheck_identity/2) — list sending identities with their verification and DKIM status, add a new domain/email identity, and re-query a single identity's live status. - DNS record guidance (
dns_records_for/1) — a pure function turning a normalized identity map into the CNAME/TXT records a user must publish. - Live DNS verification (
check_dns/2) — resolves those CNAME/TXT records against public DNS (via:inet_res, an OTP built-in — no new dependency) and reports pass/warn/missing per record, for a one-click "re-check DNS" action.
Building the AWS client
Every function accepts an optional %AWS.Client{} (see client/0 and
client/1) so callers — including tests — can inject a client with a stubbed
HTTP backend. When omitted, the client is built from the current source row.
Credentials / "ambient" mode
SquatchMail.Source.credentials_mode is either "static" or "ambient":
"static"— the source stores an explicitaccess_key_id/secret_access_keypair; the client is built from those."ambient"— no keys are stored in our database. We readAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKENfrom the environment (the vendoredawspackage'sAWS.Client.create/1behaviour). This deliberately does not perform EC2 IMDSv2 / ECS task role resolution — that is a documented follow-up. A host that runs on an instance/task role and wants those credentials used should either export them into the environment or inject its own%AWS.Client{}via the optional client argument / a configured client factory (seeSquatchMail.SES.client/1).
Whichever path is taken, the client's HTTP backend is wired to the shared
SquatchMail.Finch pool so we never pull in hackney.
Summary
Types
One dns_record/0, annotated with its live verification outcome.
The outcome of checking one DNS record against what's actually published.
A single DNS record the user must publish, from dns_records_for/1.
A normalized identity as returned by list_identities/1.
Functions
Live DNS re-check for a domain identity: resolves every expected DKIM CNAME, SPF TXT, and DMARC TXT record and reports pass/warn/missing per record.
Builds an %AWS.Client{} from the current source row.
Builds an %AWS.Client{} from the given source.
Creates a new SES sending identity (domain or email address).
Creates a new SES sending identity, returning its normalized status.
Maps a normalized identity into the DNS records the user must publish.
Returns the source with a fresh quota, syncing from SES only when stale.
Lists SES sending identities for the current source. See list_identities/1.
Lists SES sending identities as normalized identity/0 maps.
Idempotently provisions SES event publishing for the current source.
Idempotently provisions SES event publishing for source.
Returns true when the source's cached quota is missing or older than 6h.
Re-queries a single identity's live verification/DKIM status from SES.
Re-queries a single identity's live verification/DKIM status from SES.
Syncs the SES sending quota onto the current source, ignoring the cache.
Fetches the SES account sending quota and persists it onto the source.
Types
@type checked_dns_record() :: %{ type: :cname | :txt, name: String.t(), value: String.t(), purpose: :dkim | :spf | :dmarc, status: dns_check_result(), found: [String.t()] }
One dns_record/0, annotated with its live verification outcome.
@type dns_check_result() :: :pass | :warn | :missing
The outcome of checking one DNS record against what's actually published.
:pass— the record resolves and matches the expected value.:warn— the record resolves but doesn't match (e.g. a CNAME pointing somewhere else, or a TXT record present but not the expected value — common when a domain already has an unrelated TXT record at the same name).:missing— nothing resolves for that name/type at all.
@type dns_record() :: %{ type: :cname | :txt, name: String.t(), value: String.t(), purpose: :dkim | :spf | :dmarc }
A single DNS record the user must publish, from dns_records_for/1.
@type identity() :: %{ identity: String.t(), type: :domain | :email, verified?: boolean(), verification_status: String.t() | nil, dkim_status: String.t() | nil, dkim_tokens: [String.t()], dkim_signing_hosted_zone: String.t() | nil, sending_enabled?: boolean() | nil }
A normalized identity as returned by list_identities/1.
Functions
@spec check_dns([dns_record()], (charlist(), atom(), atom() -> list())) :: [ checked_dns_record() ]
Live DNS re-check for a domain identity: resolves every expected DKIM CNAME, SPF TXT, and DMARC TXT record and reports pass/warn/missing per record.
expected_records is normally the output of dns_records_for/1 for the
same identity. The resolver argument defaults to :inet_res.lookup/3
(an OTP built-in — no new dependency) and can be injected for testing or
to point at a specific nameserver; it must accept the same three
positional arguments :inet_res.lookup/3 does: (name_charlist, class, type), returning a list of answers (binaries for :txt — actually a list
of character-list segments per TXT string, hence the flattening below —
and a domain charlist for :cname).
Returns the same list dns_records_for/1 produced, each record augmented
with :status (dns_check_result/0) and :found (the raw values seen
at that name, for display — e.g. showing a user the CNAME they
accidentally pointed elsewhere). This is a live, synchronous check: each
record is one DNS query, so calling this for an identity with several DKIM
tokens makes several queries. Safe to call from a "re-check DNS" button
(SquatchMail.SES.recheck_identity/1,2 covers the SES-side verification
status; this covers whether the records are actually visible in DNS,
which can lag or be misconfigured independently of what SES has cached).
@spec client() :: {:ok, AWS.Client.t()} | {:error, :missing_credentials}
Builds an %AWS.Client{} from the current source row.
Loads the source via SquatchMail.Tracker.get_or_create_source/0. See
client/1 — this returns the same {:ok, client} | {:error, :missing_credentials} shape.
@spec client(SquatchMail.Source.t()) :: {:ok, AWS.Client.t()} | {:error, :missing_credentials}
Builds an %AWS.Client{} from the given source.
For credentials_mode: "static" the source's access_key_id /
secret_access_key are used. For "ambient" the standard AWS environment
variables are read (see the moduledoc "Credentials" section). The client's
HTTP backend is always the shared SquatchMail.Finch pool.
Returns {:error, :missing_credentials} — never raises — when required
credentials aren't available. A fresh, unconfigured SquatchMail.Source is
the normal state before a host has visited Base Camp, so this is an expected
outcome every caller must handle, not an exceptional one: a LiveView calling
sync_quota/1 or list_identities/1 against that source must be able to
render "not connected yet" instead of crashing the handle_event/mount.
Creates a new SES sending identity (domain or email address).
See create_identity/3. Builds the client from the current source.
@spec create_identity(String.t(), AWS.Client.t()) :: {:ok, identity()} | {:error, term()}
Creates a new SES sending identity, returning its normalized status.
For domains, the returned map's :dkim_tokens feed dns_records_for/1 to
produce the DNS records the user must publish. For email addresses, SES sends
a verification email and there are no DKIM tokens. Returns {:ok, identity}
or a wrapped {:error, reason}.
@spec dns_records_for(identity()) :: [dns_record()]
Maps a normalized identity into the DNS records the user must publish.
Pure function — no network calls. For a domain identity it returns:
- one CNAME per DKIM token:
<token>._domainkey.<domain>→<token>.<signing_hosted_zone>(the SES Easy DKIM pattern; the hosted zone comes from the identity's:dkim_signing_hosted_zone, defaulting todkim.amazonses.comwhen absent), - an SPF
TXTrecord on the domain ("v=spf1 include:amazonses.com ~all"), - a starter DMARC
TXTrecord at_dmarc.<domain>.
For an email address identity there are no DNS records (SES verifies via a
confirmation email), so an empty list is returned. Each record is a
dns_record/0 map the dashboard can render as a table row.
@spec ensure_quota_synced(SquatchMail.Source.t() | nil) :: {:ok, SquatchMail.Source.t()} | {:error, :missing_credentials | term()}
Returns the source with a fresh quota, syncing from SES only when stale.
The cache is considered fresh for 6 hours. When quota_checked_at is nil or
older than 6 hours, this calls sync_quota/2; otherwise it returns the
current source unchanged in an {:ok, source} tuple. This is the ticket's
"cache 6h" behaviour.
Lists SES sending identities for the current source. See list_identities/1.
@spec list_identities(AWS.Client.t()) :: {:ok, [identity()]} | {:error, term()}
Lists SES sending identities as normalized identity/0 maps.
Pages through AWS.SESv2.list_email_identities/4 (following NextToken) to
collect the identity list, then fetches per-identity DKIM/verification detail
via AWS.SESv2.get_email_identity/3 (the list response carries the
verification status but not DKIM tokens, which the dashboard needs to render
DNS guidance). Returns {:ok, identities} or a wrapped {:error, reason}.
@spec provision(String.t()) :: {:ok, SquatchMail.Source.t()} | {:error, :missing_credentials | term()}
Idempotently provisions SES event publishing for the current source.
See provision/3. Builds the client from the source itself.
@spec provision(SquatchMail.Source.t(), String.t(), AWS.Client.t()) :: {:ok, SquatchMail.Source.t()} | {:error, term()}
Idempotently provisions SES event publishing for source.
webhook_url must be the full, publicly-reachable HTTPS URL of SquatchMail's
SNS webhook endpoint — typically
https://<host>/<dashboard_path>/webhooks/sns/<webhook_token>.
This module does not compute the host's public base URL (there is no
router/endpoint at this layer); the dashboard/router layer is responsible for
building it and passing it in.
The flow, each step a no-op if already satisfied:
- Create (or reuse) a configuration set named
source.configuration_set(a default derived from the configured prefix is used when blank). - Create (or reuse) an SNS topic. A stored
source.sns_topic_arnis reused only after confirming the topic still exists (GetTopicAttributes); otherwise a new topic is created. - Subscribe
webhook_urlto the topic over HTTPS. - Create the configuration-set event destination pointing at the topic for all relevant SES event types.
On success the resolved configuration_set and sns_topic_arn are persisted
back onto the source and {:ok, source} is returned. On failure a wrapped,
actionable {:error, reason} is returned (never a raw AWS error map).
@spec quota_stale?(SquatchMail.Source.t()) :: boolean()
Returns true when the source's cached quota is missing or older than 6h.
Re-queries a single identity's live verification/DKIM status from SES.
See recheck_identity/2. Builds the client from the current source.
@spec recheck_identity(String.t(), AWS.Client.t()) :: {:ok, identity()} | {:error, term()}
Re-queries a single identity's live verification/DKIM status from SES.
This always hits AWS.SESv2.get_email_identity/3 fresh (no caching) — the
ticket's "live re-check". It re-asks SES for its own verification/DKIM
determination rather than performing a raw DNS lookup: SES is the authority on
whether an identity is usable for sending, and a passing DNS lookup that SES
hasn't yet observed wouldn't let you send. (A supplementary :inet_res-based
DNS resolver could confirm records resolve publicly; that's left as a
dashboard-layer enhancement.) Returns {:ok, identity} or {:error, reason}.
@spec sync_quota() :: {:ok, SquatchMail.Source.t()} | {:error, :missing_credentials | term()}
Syncs the SES sending quota onto the current source, ignoring the cache.
See sync_quota/2.
@spec sync_quota(SquatchMail.Source.t(), AWS.Client.t()) :: {:ok, SquatchMail.Source.t()} | {:error, term()}
Fetches the SES account sending quota and persists it onto the source.
Calls AWS.SESv2.get_account/2, extracts the sending-enabled flag and the
Max24HourSend / MaxSendRate / SentLast24Hours figures into the source's
quota map, and stamps quota_checked_at. Returns {:ok, source} or a
wrapped {:error, reason}.