Delivery
The two ways a message leaves this service, and what is recorded about it. delivery_log
is the only record that an attempt happened, so its contents are as much the subject here
as the send.
REQ-0042 — the web job is unconditional and the email job is not
One notification becomes one job per channel:
- web, always —
channel_webon the preference row is not consulted, so a participant cannot switch the web channel off through this path; - email, when the facts name explicit recipients, or the participant set
channel_emailand left an address.
The exception is an email-only ingest (REQ-0014): a user_id beginning email-ingest:
together with explicit recipients gets no web job. Both halves are required — recipients
under a real participant, or a synthetic id with no recipients, still get one.
The web destination is web:<user> or web:<user>:<community>. The daily cap no longer
reads it: it counts the participant's notifications through their nudges (REQ-0039).
Jobs of one notification share its nudge_id, notification_id and dedup_key and
differ only in channel, destination and their own id.
Only web and email have publishers. Channel also declares telegram and
whatsapp — storable on a preference row — and asking for either raises.
REQ-0043 — explicit recipients are filtered, de-duplicated and kept in order
facts.email_recipients must be a list; entries are trimmed, matched against a simple
address pattern, de-duplicated case-insensitively keeping the first spelling, and kept in
the order given.
An unparseable address is dropped silently. A sender whose list is half typos is told
nothing; a sender whose list is entirely typos gets missing_target (REQ-0013).
Explicit recipients replace the participant's own address rather than adding to it.
REQ-0044 — a push goes to every enabled subscription of that participant
Selected by user id and enabled. A disabled subscription and another participant's are
excluded in SQL rather than by the push service rejecting them.
When the job carries a community, subscriptions for that community and those with no community are included — a browser that subscribed before the participant joined a community keeps working. When the job carries none, the community filter is not applied at all, so a community-scoped subscription receives it. Scoping down is filtered; scoping up is not.
REQ-0045 — a push carries the message, the ids, and a signed click token
{title, body, data: {url, nudge_id, rule_id, notification_id, click_tracking_token}}.
The token is verifiable and names exactly the notification it was minted for; it is
null when the job has no notification id. url defaults to /.
The token is what lets a service worker report a click from a page with no session (REQ-0056).
REQ-0046 — a subscription the push service has forgotten is disabled
404 and 410 mean the endpoint is gone for good, and it is disabled so that it is not
retried for every notification for ever — each retry being a request to somebody else's
server.
Any other failure, including an exception carrying no response, leaves the subscription
enabled: a 503 during an outage is not evidence that a browser has gone.
The same handling applies to an administrator's test push (REQ-0005).
REQ-0084 — a push is sent only to an endpoint that passes the rules at send time
Every send — a delivery and an administrator's test push — checks the stored endpoint against REQ-0083 again, because a row may predate the rules and a name can resolve somewhere else than when it was stored. Then:
- a URL that breaks a rule (scheme, port, credentials, host not allowed) is not sent to and the subscription is disabled — it can never pass;
- a host that does not resolve, or resolves to a non-public address, is not sent to and the subscription stays enabled — resolution can change;
- the request goes through a session that follows no redirect (a
3xxis a failed push, REQ-0046 applies), ignores the environment's proxy settings, and refuses the connection if the peer it reached is not a public address — the name is resolved again on connect; - it has a timeout,
WEBPUSH_TIMEOUT_SECONDS(10 s). A timeout or a refused connection is a failed push and leaves the subscription enabled.
Each of these is recorded on the delivery log as failed with its reason.
REQ-0047 — an absent recipient and an absent configuration are failures, not errors
Recorded and returned rather than raised:
| Situation | Recorded |
|---|---|
| the participant has no enabled subscription | failed, no_subscriptions |
VAPID_PRIVATE_KEY is unset |
failed, Missing VAPID_PRIVATE_KEY |
SMTP_HOST or EMAIL_FROM is unset |
failed, Missing SMTP_HOST / Missing EMAIL_FROM |
The first is the ordinary case for anyone who never allowed notifications in their
browser, so delivery_log reports failures for every such participant and a genuine
outage looks the same.
The other two mean a service deployed without configuration keeps accepting events and silently delivers nothing.
REQ-0048 — one delivery-log row per attempt, whatever the outcome
The row's id is the job's id, and it carries the nudge id, the destination, the status,
the error and sent_at when it was sent.
The web publisher records its channel as webpush while the job's channel is web.
Nothing depends on the agreement, because the cap counts by destination prefix rather
than by channel.
REQ-0049 — email is text/plain, over STARTTLS unless SSL is configured
Subject is the rendered title, body the rendered body (an HTML alternative may ride
along — REQ-0079 — but the text part is always there). SMTP_USE_SSL selects implicit
TLS and makes SMTP_USE_TLS irrelevant; the default is STARTTLS on port 587. With no
SMTP_USERNAME the login step is skipped rather than attempted empty — an
unauthenticated relay is a supported configuration.
Every exception is caught and recorded, so one channel failing never stops the other from being tried.
REQ-0079 — a template's HTML body is sent as a multipart/alternative beside the text
When the template carries html_jinja, the e-mail is multipart/alternative with the
rendered text part first and the rendered HTML part second; the notification stores the
rendered HTML in body_html. Without html_jinja nothing changes: the message is the
text/plain of REQ-0049 and body_html is null. Web push never carries the HTML.