Mailglass.Outbound (Mailglass v0.1.0)

Copy Markdown View Source

Public facade for the mailglass send pipeline (TRANS-04, SEND-01).

All four delivery shapes (sync, async, batch, bang variants) converge on the same %Mailglass.Outbound.Delivery{} return object. Adopter code pattern-matches by struct + status field — never by message strings.

Public verbs

deliver/2 is the canonical public name (matches Swoosh + ActionMailer familiarity). send/2 is the internal implementation verb; deliver/2 is a defdelegate alias (D-13).

Preflight pipeline (SEND-01, D-18)

  1. Mailglass.Tenancy.assert_stamped!/0 — precondition (raises)
  2. Mailglass.Tracking.Guard.assert_safe!/1 — D-38 precondition (raises)
  3. Mailglass.Suppression.check_before_send/1
  4. Mailglass.RateLimiter.check/3 (:transactional bypasses)
  5. Mailglass.Stream.policy_check/1 (no-op seam v0.1)
  6. Mailglass.Renderer.render/1
  7. Persist (two Multis separated by adapter call)

Preconditions (0 + 1) raise on violation. Stages 2-5 return {:error, struct}; the with short-circuits.

Two-Multi sync path (D-20)

Multi#1 (inside Repo.multi/1):

  • Ecto.Multi.insert(:delivery, Delivery.changeset(attrs))
  • Mailglass.Events.append_multi(:event_queued, ...)

Adapter call OUTSIDE any transaction.

Multi#2 (inside Repo.multi/1):

  • Ecto.Multi.update(:delivery, ...) — applies Projector.update_projections/2 with the dispatched event
  • Mailglass.Events.append_multi(:event_dispatched, ...)

After Multi#2 commits → Projector.broadcast_delivery_updated/3.

Adapter-call-in-transaction is a hard no (D-20) — Postgres connection-pool starvation under provider latency. Orphan :queued Delivery rows between Multi#1 and adapter call are reconcilable via Mailglass.Events.Reconciler (Phase 2 D-19) with age ≥5min.

Return shapes

  • {:ok, %Delivery{status: :sent}} — sync success
  • {:error, %Mailglass.Error{}} — preflight short-circuit or Multi failure

deliver_many/2 scope (v0.1)

Async-only. Every message produces an Oban job (or Task.Supervisor spawn when Oban absent). Sync-batch fan-out deferred to v0.5. [ASSUMED — Plan 05 Task 4 decision]

Heterogeneous-tenant batches

deliver_many/2 assumes all messages share the same tenant_id. Mixed-tenant batches corrupt idempotency key derivation (the hash includes tenant_id). Adopters must batch per-tenant. Future enhancement: raise ArgumentError on mixed tenants.

Summary

Functions

Canonical public verb for synchronous delivery (D-13). Delegates to send/2. Matches the naming convention from Swoosh and ActionMailer for adopter familiarity.

Bang variant — raises the error struct directly on failure.

Async delivery. Runs preflight pipeline, persists the Delivery, and enqueues an Oban job (or spawns a Task.Supervisor task when Oban is absent). Always returns {:ok, %Delivery{status: :queued}} on success — never an %Oban.Job{} (D-14 return-shape lock).

Async batch send (TRANS-04, D-15). v0.1 scope: async-only — every message in the batch produces an Oban job (or Task.Supervisor spawn when Oban absent). Sync-batch fan-out deferred to v0.5. [ASSUMED — Plan 05 Task 4 decision]

Bang variant of deliver_many/2. Raises %Mailglass.Error.BatchFailed{} when any Delivery has status: :failed.

Hydrates a Delivery by id, calls the adapter OUTSIDE any transaction, and writes Multi#2. Called by Mailglass.Outbound.Worker.perform/1 and by the Task.Supervisor fallback in enqueue_task_supervisor/2.

Synchronous hot path. Runs the full preflight pipeline, persists the Delivery via two Multis (adapter call between them, OUTSIDE any transaction per D-20), and returns {:ok, %Delivery{status: :sent}} on success.

Functions

deliver(msg, opts \\ [])

(since 0.1.0)

Canonical public verb for synchronous delivery (D-13). Delegates to send/2. Matches the naming convention from Swoosh and ActionMailer for adopter familiarity.

deliver!(msg, opts \\ [])

(since 0.1.0)

Bang variant — raises the error struct directly on failure.

deliver_later(msg, opts \\ [])

(since 0.1.0)
@spec deliver_later(
  Mailglass.Message.t(),
  keyword()
) :: {:ok, Mailglass.Outbound.Delivery.t()} | {:error, Mailglass.Error.t()}

Async delivery. Runs preflight pipeline, persists the Delivery, and enqueues an Oban job (or spawns a Task.Supervisor task when Oban is absent). Always returns {:ok, %Delivery{status: :queued}} on success — never an %Oban.Job{} (D-14 return-shape lock).

deliver_many(messages, opts)

(since 0.1.0)
@spec deliver_many(
  [Mailglass.Message.t()],
  keyword()
) :: {:ok, [Mailglass.Outbound.Delivery.t()]} | {:error, Mailglass.Error.t()}

Async batch send (TRANS-04, D-15). v0.1 scope: async-only — every message in the batch produces an Oban job (or Task.Supervisor spawn when Oban absent). Sync-batch fan-out deferred to v0.5. [ASSUMED — Plan 05 Task 4 decision]

Return shape

{:ok, [%Delivery{}]} always (one row per input message). Each Delivery carries its own :status:

  • :queued — successfully enqueued
  • :failed — preflight rejected; :last_error carries the specific error

Batch-level errors (DB unavailable) return {:error, %Mailglass.Error{}}.

Replay safety

idempotency_key + partial UNIQUE index make re-running the same batch a DB-level no-op. Existing rows are re-fetched via companion SELECT.

deliver_many!(messages, opts \\ [])

(since 0.1.0)
@spec deliver_many!(
  [Mailglass.Message.t()],
  keyword()
) :: [Mailglass.Outbound.Delivery.t()]

Bang variant of deliver_many/2. Raises %Mailglass.Error.BatchFailed{} when any Delivery has status: :failed.

deliver_later!/2 is deliberately NOT provided (enqueue isn't a delivery — nothing delivery-shaped to raise about, per D-16).

dispatch_by_id(delivery_id)

(since 0.1.0)
@spec dispatch_by_id(binary()) ::
  {:ok, Mailglass.Outbound.Delivery.t()} | {:error, Mailglass.Error.t()}

Hydrates a Delivery by id, calls the adapter OUTSIDE any transaction, and writes Multi#2. Called by Mailglass.Outbound.Worker.perform/1 and by the Task.Supervisor fallback in enqueue_task_supervisor/2.

Declared public so the Worker can call it from outside this module.

send(msg, opts \\ [])

(since 0.1.0)

Synchronous hot path. Runs the full preflight pipeline, persists the Delivery via two Multis (adapter call between them, OUTSIDE any transaction per D-20), and returns {:ok, %Delivery{status: :sent}} on success.

deliver/2 is the canonical public alias (see below). send/2 is the internal implementation verb.