SquatchMail.Guard (SquatchMail v0.1.0)

Copy Markdown View Source

Pre-send guardrails: suppression enforcement and the complaint-rate auto-pause.

Every send SquatchMail observes or proxies should be checked against check/1 first. Two independent conditions can block a send:

  • Suppression - any recipient address with an active suppression row (hard bounce, soft bounce not yet expired, complaint, or manual). See SquatchMail.Tracker.suppressed_addresses/1 / list_suppressions/1 for how "active" is defined (expires_at IS NULL OR expires_at > now()).
  • Complaint-rate auto-pause - if the fraction of sent emails complained about, over the trailing SquatchMail.Config.complaint_rate_window_days/0 days, is at or above SquatchMail.Config.complaint_rate_threshold/0 (0.1% by default — SES's own account-suspension threshold), sending is paused entirely regardless of recipient. Below SquatchMail.Config.complaint_rate_min_volume/0 sent emails in the window the breaker never trips, even at 100% complaints, so a handful of early sends can't produce a false positive (1 complaint out of 5 sends is not a 20% complaint rate in any meaningful sense). Disable the breaker entirely with complaint_rate_pause: false.

This module never sends anything itself, nor does it touch the network — it only reads from SquatchMail.Tracker. See SquatchMail.Adapters.Watchtower for a Swoosh adapter that enforces this automatically ahead of a wrapped real adapter, and resend/2 for re-sending a previously-captured email through the host's own mailer.

Performance note

check/1 runs one query for suppressions (Tracker.suppressed_addresses/1, WHERE address IN (...) AND (expires_at IS NULL OR expires_at > now()) — a single round trip regardless of recipient count) and, when the breaker is enabled, one aggregate query for the complaint rate — every call, no caching. For a proxy adapter sitting in the hot send path this means two extra round trips per send (one, if the address list is empty and the rate check is disabled). That's an accepted cost for correctness today; if it shows up in profiling, the complaint rate is the obvious candidate to cache (e.g. recomputed on a timer rather than per-send) since it only needs to change a few times a day, not per-request.

Summary

Types

Why a send was blocked by check/1.

Anything check/1 can be called with.

Functions

Checks recipients against SquatchMail's guardrails ahead of a send.

Returns the current trailing-window complaint rate (a fraction, 0.0..1.0), regardless of whether it exceeds the configured threshold or how many emails have been sent. Useful for a dashboard health panel.

Returns true if sending is currently auto-paused due to the complaint rate exceeding its threshold at or above the configured minimum volume.

Re-sends a previously-captured email through the host's own configured Swoosh mailer.

Types

block_reason()

@type block_reason() :: {:suppressed, [String.t()]} | :complaint_rate_paused

Why a send was blocked by check/1.

recipients_input()

@type recipients_input() :: Swoosh.Email.t() | String.t() | [String.t()]

Anything check/1 can be called with.

Functions

check(email)

@spec check(recipients_input()) :: :ok | {:error, block_reason()}

Checks recipients against SquatchMail's guardrails ahead of a send.

Accepts a %Swoosh.Email{} (its :to/:cc/:bcc addresses are extracted), a single address, or a list of addresses.

Returns :ok if the send should proceed, or {:error, reason} if it should be blocked:

  • {:error, {:suppressed, addresses}} - one or more recipients are actively suppressed; addresses lists every suppressed recipient found, not just the first.
  • {:error, :complaint_rate_paused} - the account-wide complaint rate is at or above threshold; sending is paused regardless of recipient.

The complaint-rate check runs first — it's a single aggregate query regardless of recipient count and blocks the entire send outright before bothering to look at individual addresses.

complaint_rate()

@spec complaint_rate() :: float()

Returns the current trailing-window complaint rate (a fraction, 0.0..1.0), regardless of whether it exceeds the configured threshold or how many emails have been sent. Useful for a dashboard health panel.

Returns 0.0 when there have been no sends in the window (nothing to divide by, and nothing to complain about).

paused?()

@spec paused?() :: boolean()

Returns true if sending is currently auto-paused due to the complaint rate exceeding its threshold at or above the configured minimum volume.

Always false when complaint_rate_pause: false is configured.

resend(email, mailer)

@spec resend(SquatchMail.Email.t(), module()) ::
  {:ok, term()} | {:error, block_reason() | term()}

Re-sends a previously-captured email through the host's own configured Swoosh mailer.

mailer is the host's Swoosh.Mailer module (the same one the original send went through, or any other). Builds a fresh %Swoosh.Email{} from the stored subject/bodies/recipients and delivers it — this is a genuine new send (and will itself be captured again if telemetry capture is enabled), not a replay of the original SES request. Runs through check/1 first, same as any other send, so resending to a suppressed address or while paused is rejected the same way.