# `SquatchMail.SES`
[🔗](https://github.com/Axio-Intelligence/Squatch-Mail/blob/v0.1.0/lib/squatch_mail/ses.ex#L1)

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.

# `checked_dns_record`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

# `check_dns`

```elixir
@spec check_dns([dns_record()], (charlist(), atom(), atom() -&gt; 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` (`t: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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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
`t:dns_record/0` map the dashboard can render as a table row.

# `ensure_quota_synced`

```elixir
@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`

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

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

# `list_identities`

```elixir
@spec list_identities(AWS.Client.t()) :: {:ok, [identity()]} | {:error, term()}
```

Lists SES sending identities as normalized `t: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`

```elixir
@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`

```elixir
@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?`

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

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

# `recheck_identity`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
