SquatchMail.SES (SquatchMail v0.1.0)

Copy Markdown View Source

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 explicit access_key_id / secret_access_key pair; the client is built from those.
  • "ambient" — no keys are stored in our database. We read AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN from the environment (the vendored aws package's AWS.Client.create/1 behaviour). 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 (see SquatchMail.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

checked_dns_record()

@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.

dns_check_result()

@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.

dns_record()

@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.

identity()

@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

check_dns(expected_records, resolver \\ &:inet_res.lookup/3)

@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).

client()

@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.

client(source)

@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.

create_identity(identity)

@spec create_identity(String.t()) ::
  {:ok, identity()} | {:error, :missing_credentials | term()}

Creates a new SES sending identity (domain or email address).

See create_identity/3. Builds the client from the current source.

create_identity(identity, client)

@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}.

dns_records_for(identity)

@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 to dkim.amazonses.com when absent),
  • an SPF TXT record on the domain ("v=spf1 include:amazonses.com ~all"),
  • a starter DMARC TXT record 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.

ensure_quota_synced(source \\ nil)

@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.

list_identities()

@spec list_identities() ::
  {:ok, [identity()]} | {:error, :missing_credentials | term()}

Lists SES sending identities for the current source. See list_identities/1.

list_identities(client)

@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}.

provision(webhook_url)

@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.

provision(source, webhook_url, client)

@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:

  1. Create (or reuse) a configuration set named source.configuration_set (a default derived from the configured prefix is used when blank).
  2. Create (or reuse) an SNS topic. A stored source.sns_topic_arn is reused only after confirming the topic still exists (GetTopicAttributes); otherwise a new topic is created.
  3. Subscribe webhook_url to the topic over HTTPS.
  4. 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).

quota_stale?(source)

@spec quota_stale?(SquatchMail.Source.t()) :: boolean()

Returns true when the source's cached quota is missing or older than 6h.

recheck_identity(identity)

@spec recheck_identity(String.t()) ::
  {:ok, identity()} | {:error, :missing_credentials | term()}

Re-queries a single identity's live verification/DKIM status from SES.

See recheck_identity/2. Builds the client from the current source.

recheck_identity(identity, client)

@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}.

sync_quota()

@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.

sync_quota(source, client)

@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}.