Tenancy behaviour + process-dict helpers.
Adopters implement @behaviour Mailglass.Tenancy and configure it via:
config :mailglass, tenancy: MyApp.TenancyThe behaviour exposes ONE callback: scope/2. Non-callback helpers
live on this module for tenant-context plumbing.
Default resolver
Mailglass.Tenancy.SingleTenant is the shipped default — a no-op
scope/2 and a "default" literal tenant_id from current/0 when
no stamping has occurred.
Process-dict convention (D-30)
put_current/1 writes tenant_id :: String.t() under the
namespaced key :mailglass_tenant_id. current/0 reads it. The
with_tenant/2 block form wraps + restores. tenant_id!/0 raises
Mailglass.TenancyError when the key is unset — the fail-loud
variant for callers that assert they hold context.
Phoenix 1.8 %Scope{} interop (D-32)
Core does NOT pattern-match %Phoenix.Scope{}. Adopters write a
two-line Plug / on_mount callback:
def on_mount(_name, _params, _session, socket) do
scope = socket.assigns.current_scope
Mailglass.Tenancy.put_current(scope.organization.id)
{:cont, socket}
endDocumented in guides/multi-tenancy.md (Phase 7 DOCS-02).
Summary
Callbacks
Optional: resolve the tenant from a verified webhook context (D-12).
Optional: return a per-tenant tracking host override (D-32).
Functions
Raises %Mailglass.TenancyError{type: :unstamped} if no tenant is
stamped in the current process. Returns :ok otherwise.
Emits an audit breadcrumb when a call intentionally opts into
scope: :unscoped access.
Clear any tenant scope set in the current process dictionary.
Returns the current tenant_id or the configured resolver's default.
Stamps the current tenant in the process dictionary.
Dispatch to the configured tenancy module's resolve_webhook_tenant/1
callback (Phase 4 D-12 — the optional callback Plan 05 formally declares).
Scopes queryable to the current (or supplied) tenant context via
the configured resolver.
Returns the current tenant_id or raises Mailglass.TenancyError.
Runs fun with tenant_id stamped as the current tenant, then
restores whatever was stamped before (nil if nothing).
Callbacks
@callback resolve_webhook_tenant( context :: %{ provider: atom(), conn: Plug.Conn.t(), raw_body: binary(), headers: [{String.t(), String.t()}], path_params: map(), verified_payload: map() | nil } ) :: {:ok, String.t()} | {:error, term()}
Optional: resolve the tenant from a verified webhook context (D-12).
Called by Mailglass.Webhook.Plug AFTER Provider.verify!/3 returns
:ok — D-13's "verify-first, tenant-second" ordering closes the
Stripe-Connect chicken-and-egg trap (a forged request cannot spoof its
way into a tenant's suppression list because the signature gate runs
on the global key material before any tenant-scoped work).
Adopters returning {:ok, tenant_id} stamp tenant context for the
rest of the ingest pipeline (normalize + persist + broadcast).
{:error, reason} causes Mailglass.Webhook.Plug to raise
%Mailglass.TenancyError{type: :webhook_tenant_unresolved} and
return HTTP 422.
Context map fields
:provider—:postmark | :sendgrid(the atom the router dispatched against):conn— thePlug.Connfor header / IP / path-param introspection:raw_body— verified raw bytes (signature passed before this callback fires):headers—[{name, value}]list as produced by the verifier:path_params— adopter route's path params (e.g.%{"tenant_id" => "..."}):verified_payload—nilat v0.1 (the plug has not yet decoded the JSON payload at callback time). v0.5 may set this toJason.decode!/1of:raw_bodyto support Stripe-Connect-style strategies that inspect the provider event'saccountfield post-verify.
Examples
# SingleTenant default — everything is "default"
def resolve_webhook_tenant(_), do: {:ok, "default"}
# Per-shop Shopify-style header lookup
def resolve_webhook_tenant(%{headers: headers}) do
case List.keyfind(headers, "x-shopify-shop-domain", 0) do
{_, shop_domain} -> {:ok, shop_domain}
nil -> {:error, :missing_shop_domain}
end
endAdopters not implementing this callback get SingleTenant default
behaviour ({:ok, "default"}) via the dispatcher's
function_exported?/3 fallback. Mailglass.Tenancy.ResolveFromPath
ships as opt-in sugar for URL-prefix tenant resolution.
@callback scope(queryable :: Ecto.Queryable.t(), context :: term()) :: Ecto.Queryable.t()
Optional: return a per-tenant tracking host override (D-32).
Default adopter resolution: :default (use the global
config :mailglass, :tracking, host: value). Adopters returning
{:ok, host} get per-tenant subdomains (track.tenant-a.example.com)
for strict cookie/origin isolation.
Functions
@spec assert_stamped!() :: :ok
Raises %Mailglass.TenancyError{type: :unstamped} if no tenant is
stamped in the current process. Returns :ok otherwise.
Unlike current/0, does NOT fall back to the SingleTenant default.
This is the SEND-01 precondition (D-18) — ensures
Events.append_multi/3 auto-capture via Tenancy.current/0 does not
silently default to "default" in a multi-tenant adopter.
Emits an audit breadcrumb when a call intentionally opts into
scope: :unscoped access.
This keeps the bypass path explicit and machine-searchable for TENANT-03 reviews.
@spec clear() :: :ok
Clear any tenant scope set in the current process dictionary.
Returns :ok. Primarily used by test on_exit cleanup — encapsulates
the internal process-dict key so tests don't need to know the exact
atom (:mailglass_tenant_id). Production code should rely on
with_tenant/2 block scoping (Pitfall 7), which auto-restores prior
scope even on raise.
@spec current() :: String.t() | nil
Returns the current tenant_id or the configured resolver's default.
Reads the process-dict first; falls back to the configured resolver's
default when nothing has been stamped. With the default
Mailglass.Tenancy.SingleTenant resolver active and no explicit
stamping, returns the literal "default". With an adopter resolver
configured, returns nil unless the adopter's own fallback is wired
in via put_current/1.
@spec put_current(String.t() | nil) :: :ok
Stamps the current tenant in the process dictionary.
Subsequent current/0 calls return this value (until the process
exits or put_current/1 is called again). Passing nil deletes the
stamp; current/0 then falls back to the resolver's default.
Dispatch to the configured tenancy module's resolve_webhook_tenant/1
callback (Phase 4 D-12 — the optional callback Plan 05 formally declares).
Returns {:ok, tenant_id} on success or {:error, reason} when the
adopter's tenancy module cannot map the verified webhook context to a
known tenant. Mailglass.Webhook.Plug rescues the latter as a 422 via
%Mailglass.TenancyError{type: :webhook_tenant_unresolved}.
The context map shape is documented in CONTEXT D-12:
%{
provider: :postmark | :sendgrid,
conn: Plug.Conn.t(),
raw_body: binary(),
headers: [{String.t(), String.t()}],
path_params: map(),
verified_payload: map() | nil
}Fallback behaviour
Mailglass.Tenancy.SingleTenant ships a concrete
resolve_webhook_tenant/1 impl that returns {:ok, "default"} — the
zero-config single-tenant default. Adopter tenancy modules that do
not implement the optional callback also fall through to
{:ok, "default"} via the dispatcher's function_exported?/3
check; multi-tenant adopters MUST implement the callback to get
meaningful tenant routing.
@spec scope(Ecto.Queryable.t(), term()) :: Ecto.Queryable.t()
Scopes queryable to the current (or supplied) tenant context via
the configured resolver.
With Mailglass.Tenancy.SingleTenant, this is a no-op. With an
adopter resolver, this injects a WHERE tenant_id = ? clause (or
equivalent) into the query.
@spec tenant_id!() :: String.t()
Returns the current tenant_id or raises Mailglass.TenancyError.
Unlike current/0, this does NOT fall back to the SingleTenant
default. Use this when the caller is certain it holds tenant context
(e.g. inside an Oban worker after the middleware has run) and wants
to fail loud on the "forgot to stamp" programmer error.
Runs fun with tenant_id stamped as the current tenant, then
restores whatever was stamped before (nil if nothing).
Useful for tests and for Oban middleware serializing context across
job boundaries (see Mailglass.Oban.TenancyMiddleware). The prior
value is restored even when fun raises.