SquatchMail.Tracker (SquatchMail v0.1.0)

Copy Markdown View Source

The persistence context for SquatchMail's observability data.

Everything the capture engine, webhook ingestion, and dashboard need to read and write email records, events, suppressions, and source configuration lives here. All database access goes through SquatchMail.Config.repo/0 so the host application's repo is used, and the configured SquatchMail.Config.prefix/0 schema keeps SquatchMail's tables isolated.

Prefix handling

Every SquatchMail schema declares @schema_prefix "squatch_mail", which Ecto respects automatically for Repo.insert/update/delete/all/get and for queries built from those schemas. As a result the context does not pass prefix: redundantly on schema-based operations. prefix: is only supplied where Ecto can't infer it — namely the raw version-tracking SQL in the migrator (which passes it explicitly) — not in this module.

Summary

Functions

Returns bounce reason details per email, for a list of email ids, in a single query.

Returns complaint details per email, for a list of email ids, in a single query.

Returns open/click counts per email, for a list of email ids, in a single aggregate query.

Fetches an email by its public_id, preloading :recipients, :attachments, and :events (events ordered by occurred_at ascending).

Returns the single source row, inserting a default one (with a generated webhook_token) if none exists yet.

Lists emails with :recipients preloaded, optionally filtered.

Lists suppressions, optionally filtered.

Records an inbound webhook audit entry.

Marks an email as sent: sets message_id, sent_at, and status "sent".

Computes the next email status given the current status and a proposed status.

Prunes data older than the source's retention_days.

Records a captured email together with its recipients and attachments.

Records an email event and, when possible, links it to its email and advances the email's status.

Computes send/engagement counts and rates for a date range, plus deltas versus the immediately-preceding equal-length period.

Inserts or updates a suppression for an address.

Returns true if a non-expired suppression exists for the address.

Returns the subset of addresses that carry an active (non-expired) suppression, in a single query.

Deletes any suppression row(s) for the given address. Returns {:ok, count}.

Updates an email's status. Accepts an %Email{} or its integer id.

Updates the source row with the given attributes.

Functions

bounce_details(email_ids)

@spec bounce_details([integer()]) :: %{
  required(integer()) => %{
    bounce_type: String.t() | nil,
    bounce_subtype: String.t() | nil,
    diagnostic: String.t() | nil
  }
}

Returns bounce reason details per email, for a list of email ids, in a single query.

For each id, the latest "bounce" event's SES payload is summarized as %{bounce_type: _, bounce_subtype: _, diagnostic: _} (any of which may be nil — legacy payloads and manual status changes don't always carry them). :diagnostic is the diagnosticCode of the bounced recipient matching the event's own recipient, falling back to the first bounced recipient. Ids with no bounce event are absent from the result map.

Used by the dashboard's Bounces page for its Reason column, the same way engagement_counts/1 backs the Trail Log's engagement column.

complaint_details(email_ids)

@spec complaint_details([integer()]) :: %{
  required(integer()) => %{feedback_type: String.t() | nil}
}

Returns complaint details per email, for a list of email ids, in a single query.

For each id, the latest "complaint" event's SES payload is summarized as %{feedback_type: _} — the ISP's complaintFeedbackType (e.g. "abuse"), which SES only includes when the reporting ISP provides it, so it is often nil. Ids with no complaint event are absent from the result map.

Used by the dashboard's Complaints page for its Feedback column.

engagement_counts(email_ids)

@spec engagement_counts([integer()]) :: %{
  required(integer()) => %{opens: non_neg_integer(), clicks: non_neg_integer()}
}

Returns open/click counts per email, for a list of email ids, in a single aggregate query.

Used by list views (e.g. the Trail Log's engagement column) that need per-row engagement counts without an N+1 query per row. Ids with no email_events of type "open" or "click" are simply absent from the result map — callers should default to 0 on lookup miss, e.g. Map.get(counts, id, %{opens: 0, clicks: 0}).

get_email!(public_id)

@spec get_email!(String.t()) :: SquatchMail.Email.t()

Fetches an email by its public_id, preloading :recipients, :attachments, and :events (events ordered by occurred_at ascending).

Raises Ecto.NoResultsError when no email matches.

get_or_create_source()

@spec get_or_create_source() :: SquatchMail.Source.t()

Returns the single source row, inserting a default one (with a generated webhook_token) if none exists yet.

list_emails(filters \\ %{})

@spec list_emails(map() | Keyword.t()) :: [SquatchMail.Email.t()]

Lists emails with :recipients preloaded, optionally filtered.

Supported filters:

  • :status - exact status match.
  • :search - case-insensitive match over subject, from_email, and recipient address (joins recipients only when set).
  • :from_date / :to_date - inclusive inserted_at bounds. Alternatively :date_range may be a %{from: _, to: _} map.
  • :limit / :offset - pagination (default limit 50).

list_suppressions(filters \\ %{})

@spec list_suppressions(map() | Keyword.t()) :: [SquatchMail.Suppression.t()]

Lists suppressions, optionally filtered.

Supported filters: :reason, :address, :limit, :offset.

log_webhook(attrs)

@spec log_webhook(map()) :: {:ok, SquatchMail.WebhookLog.t()} | {:error, changeset()}

Records an inbound webhook audit entry.

attrs follows SquatchMail.WebhookLog's castable fields (:provider, :message_type, :status, :payload, :error). Callers are expected to log every inbound webhook payload regardless of outcome, so this always inserts rather than upserting.

mark_email_sent(email, message_id, sent_at \\ DateTime.utc_now())

@spec mark_email_sent(SquatchMail.Email.t(), String.t(), DateTime.t()) ::
  {:ok, SquatchMail.Email.t()} | {:error, changeset()}

Marks an email as sent: sets message_id, sent_at, and status "sent".

next_status(current, proposed)

@spec next_status(String.t(), String.t()) :: String.t()

Computes the next email status given the current status and a proposed status.

Terminal-negative statuses (bounced, complained, rejected, failed) always win. Otherwise the status only advances forward per the engagement rank; a lower-ranked proposal is ignored. Unknown statuses are treated as rank 0.

prune()

@spec prune() :: %{
  emails: non_neg_integer(),
  events: non_neg_integer(),
  webhook_logs: non_neg_integer()
}

Prunes data older than the source's retention_days.

Deletes emails whose inserted_at is older than the cutoff (cascading to recipients/attachments and nilifying events via foreign keys), then deletes now-orphaned email_events (with nil email_id) whose occurred_at is older than the cutoff. Also deletes webhook_logs older than a fixed 30-day window, independent of retention_days.

Returns %{emails: count, events: count, webhook_logs: count}.

record_email(attrs)

@spec record_email(map()) :: {:ok, SquatchMail.Email.t()} | {:error, changeset()}

Records a captured email together with its recipients and attachments.

attrs is a map of email fields plus two optional nested lists:

  • :recipients - list of %{kind, address, name} maps.
  • :attachments - list of %{filename, content_type, size, disposition} maps.

Runs in a single transaction: the email is inserted (generating a public_id), recipients and attachments are inserted, has_attachments/attachments_count are derived from the attachment list, and any orphan email_events that arrived before this email was known (matched by message_id) are back-linked to the new email.

Returns {:ok, email} with :recipients and :attachments preloaded, or {:error, changeset}.

record_event(attrs)

@spec record_event(map()) :: {:ok, SquatchMail.EmailEvent.t()} | {:error, changeset()}

Records an email event and, when possible, links it to its email and advances the email's status.

The event is always inserted. If it carries a message_id and a matching email exists, the event's email_id is set and the email's status is advanced per the event-type mapping using next_status/2 (never regressing).

The matching email (if any) is re-fetched and row-locked (SELECT ... FOR UPDATE) inside the transaction, immediately before computing the status advance. This matters because SNS can deliver more than one notification for the same message_id close together (e.g. a bounce and a complaint arriving within milliseconds of each other, or a plain redelivered retry): without the lock, two concurrent record_event/1 calls could both read the same starting status, both compute their own "next" status from it, and have the second commit silently overwrite the first's advancement. The row lock serializes the two — the second call's FOR UPDATE blocks until the first transaction commits, then reads the already-advanced status, so both events still land correctly relative to each other.

Returns {:ok, event} (with email_id set when linked) or {:error, changeset}.

stats(map)

@spec stats(%{from: DateTime.t(), to: DateTime.t()}) :: map()

Computes send/engagement counts and rates for a date range, plus deltas versus the immediately-preceding equal-length period.

Accepts %{from: DateTime.t(), to: DateTime.t()}. Counts are taken over emails.inserted_at within the range. Returns a map:

%{
  current: %{sent: _, delivered: _, opened: _, clicked: _, bounced: _,
             complained: _, total: _},
  previous: %{...same keys...},
  rates: %{delivered: float, opened: float, clicked: float,
           bounced: float, complained: float},
  deltas: %{sent: float, delivered: float, ...}  # percent change vs previous
}

Rate denominators: delivered is over total; opened/clicked are over delivered (engagement of what landed); bounced/complained are over total. Deltas are percentage change of each count versus the prior period (nil when the prior count is 0 and the current is 0; +100.0-style growth when prior is 0 and current is positive is reported as nil to avoid dividing by zero — callers render "new").

Counts are computed with two aggregate queries (one per period), each using count(*) FILTER (WHERE ...); there is no per-row looping in Elixir.

suppress(attrs)

@spec suppress(map()) :: {:ok, SquatchMail.Suppression.t()} | {:error, changeset()}

Inserts or updates a suppression for an address.

Addresses are unique. If the address is already suppressed, its reason, event_type, expires_at, and notes are updated (upsert) rather than raising a constraint error.

suppressed?(address)

@spec suppressed?(String.t()) :: boolean()

Returns true if a non-expired suppression exists for the address.

A suppression is active when expires_at IS NULL or expires_at > now().

For checking many addresses at once (e.g. every recipient of a batch send), prefer suppressed_addresses/1 — one query for N addresses instead of N queries.

suppressed_addresses(addresses)

@spec suppressed_addresses([String.t()]) :: [String.t()]

Returns the subset of addresses that carry an active (non-expired) suppression, in a single query.

A suppression is active when expires_at IS NULL or expires_at > now(), same as suppressed?/1. Returns [] immediately without querying when addresses is empty.

unsuppress(address)

@spec unsuppress(String.t()) :: {:ok, non_neg_integer()}

Deletes any suppression row(s) for the given address. Returns {:ok, count}.

update_email_status(email, status)

@spec update_email_status(SquatchMail.Email.t() | integer(), String.t()) ::
  {:ok, SquatchMail.Email.t()} | {:error, changeset()}

Updates an email's status. Accepts an %Email{} or its integer id.

update_source(attrs)

@spec update_source(map()) :: {:ok, SquatchMail.Source.t()} | {:error, changeset()}

Updates the source row with the given attributes.