How sending is built: one service with a clear contract, two call modes
and honest handling of delivery failures. The consumer knows nothing
about SMTP — it asks to “send the invitation”, and the transport takes
care of the rest.
Service interface
Inside are two low-level steps: compose (template + data +
language → a ready email: subject, HTML and text version) and
send (delivery of the ready email over SMTP).
-
send_invite (email, link, role, language) — the
invitation email.
-
send_password_reset (email, link, language) — the
password-reset email.
-
send_test (email, language) — a diagnostic email
for the “Test connection” button.
Inline or queue
One core of compose + send, two call paths.
The choice is by the nature of the scenario, not by convenience.
Decision. Diagnostics —
inline (synchronous):
the test email is sent right in the request with a timeout, so the
admin sees the verdict at once. Everything else —
through the
queue: invitations (including bulk), password reset and
alerts
are enqueued as a task in SAQ, and a worker sends them in the
background.
Inline
synchronous
Sending within the request with a short timeout. The outcome is
available at once: the result is written to
last_test_ok / last_test_at and reflected
by a status chip on the settings screen.
Who: the test email only.
Queue · SAQ
asynchronous
A task in SAQ on top of Redis (the broker is already there). Emails
are sent in the background by the
platform's shared worker
— with retries, throttling and durability across restarts. The
consumer replies instantly, without waiting for delivery.
Who: invite · reset · bulk · alerts.
Send rate is governed by the queue worker — not the consumer. It's the
worker's responsibility, in one place: drop the tasks in, and the
worker works them off at the set rate.
-
Why a rate. A volley of emails all at once would hit
timeouts, and the provider would treat it as a mailout and throttle
it or mark it as spam. The worker keeps a safe rate: a send-rate limit
and a reasonable batch at a time.
-
The consumer doesn't batch itself. However many
emails are needed — that's simply that many tasks in the queue. The
consumer drops them in and replies at once, without waiting for
delivery; accumulating and splitting into batches is the worker's job,
in one place.
-
Parameters are config, not hardcode. Concurrency,
send rate and batch size are set by the worker's settings.
-
No duplicates — idempotency. The task is enqueued
with a stable
job_id: a repeat enqueue of the same send
(a request retry, a double click, a repeated enqueue) is dropped while
the task is queued or running. A retry on delivery failure is the same
task with the same id, not a second email.
Delivery errors
SMTP can go down, time out or reject. What happens depends on the mode
and on the type of error.
By mode.
inline
Verdict at once
Failure → a typed error upward; the test screen shows a fail,
and false is written to last_test_ok.
queue
Retries, then not-delivered
A temporary failure → retries with exponential backoff;
exhausted → the task goes to “not delivered”, and the status is
visible to the consumer (for an invite — on the
admission model).
By error type.
5xx
Permanent rejection
Wrong address, mailbox refused — retries are pointless, straight
to “not delivered”.
4xx · network
Temporary failure
Server unavailable, timeout, provider throttle — we retry with
backoff.
-
Password reset is a special case. A delivery failure
is not disclosed outward: the response to “forgot your
password?” is always the same (anti-enumeration —
Auth / Security),
and the failure goes only to the log and audit.