The least-complex way to send welcome email after a property-management import is a durable queue with a small, rate-limited worker. Keep password resets out of that queue. A reset has a short useful life; a welcome message can wait behind a controlled backlog.
TL;DR: create one idempotent welcome job for each eligible imported resident, send jobs at a configured pace, and retain only enough delivery evidence to explain an outcome. Put password resets on a separate, higher-priority path with their own expiry check.
The bill starts with recipients, retries, and retained evidence
The dominant operational term is the number of delivery attempts, not the queue technology. An import of 12,000 eligible residents creates 12,000 welcome attempts before temporary failures are retried. The useful change is upstream: reject malformed addresses, suppress recipients who should not receive the message, and deduplicate by the event being sent before a worker ever claims a job.
| Operational term | What makes it grow | Change that moves it |
|---|---|---|
| Delivery attempts | Every eligible imported recipient and every retry | Create one idempotent welcome job per resident and import |
| Backlog time | A send pace above the downstream allowance | Pace workers from a configuration value and observe queue age |
| Retained data | Full message bodies and unbounded event history | Keep compact delivery facts under a documented retention policy |
At an intentionally conservative example pace of 5 messages per second, 12,000 first attempts take 2,400 seconds before retries, pauses, or downstream throttling. That is planning arithmetic, not a provider limit.
Google's sender guidelines cover sender authentication, spam rates, and unsubscribe behavior. A bulk welcome flow needs those controls before throughput tuning, because a queue that drains quickly can still create a sender-quality problem.
The retention trade-off is real. Keep a job identifier, import identifier, timestamps, a recipient reference with restricted access, template revision, attempt count, and a normalized outcome. Deliberately stop keeping full MIME bodies and raw delivery responses forever. When a resident reports odd rendering weeks later, the team may be able to prove which template revision was selected without reconstructing every byte delivered. The price of smaller retention is that some late rendering disputes cannot be reproduced exactly.
How should bulk welcome email work after a user import?
First, the import transaction records residents. A separate outbox record represents the intent to send a welcome message. The worker claims that record later, so a process restart cannot silently discard work. An idempotency key made from the import, resident, and message purpose prevents the familiar duplicate: an import is retried after a timeout, and each resident receives a second welcome email.
A small job planner makes that rule explicit. The database uniqueness constraint still matters; a pre-check alone is not concurrency control.
from dataclasses import dataclass
from typing import Iterable
@dataclass(frozen=True)
class ImportedResident:
resident_id: str
email: str
may_receive_welcome_email: bool
def make_welcome_jobs(
import_id: str,
residents: Iterable[ImportedResident],
existing_keys: set[str],
) -> list[dict[str, str]]:
jobs: list[dict[str, str]] = []
for resident in residents:
email = resident.email.strip().lower()
key = f"welcome:{import_id}:{resident.resident_id}"
if not resident.may_receive_welcome_email or "@" not in email:
continue
if key in existing_keys:
continue
jobs.append(
{
"idempotency_key": key,
"recipient": email,
"template_revision": "welcome-2026-01",
}
)
existing_keys.add(key)
return jobs
welcome-2026-01 is an example identifier, not a claim that names make templates immutable. Store the revision with the job, or snapshot rendered content under the selected retention policy. Do not reread a mutable template after a retry and assume the second attempt says the same thing as the first.
A worker needs a configured rate, a maximum attempt count, and a way to classify results. Do not turn every error into a retry. An address rejection, an expired reset token, and a temporary transport failure need different states.
from collections.abc import Callable
from time import monotonic, sleep
class TemporaryDeliveryFailure(Exception):
pass
def deliver_batch(
jobs: list[dict[str, str]],
send: Callable[[dict[str, str]], None],
messages_per_second: float,
max_attempts: int = 3,
) -> list[tuple[str, str]]:
interval = 1 / messages_per_second
next_send_at = monotonic()
outcomes: list[tuple[str, str]] = []
for job in jobs:
for attempt in range(1, max_attempts + 1):
now = monotonic()
if now < next_send_at:
sleep(next_send_at - now)
try:
send(job)
outcomes.append((job["idempotency_key"], "accepted"))
break
except TemporaryDeliveryFailure:
if attempt == max_attempts:
outcomes.append((job["idempotency_key"], "retry_exhausted"))
else:
sleep(2 ** (attempt - 1))
next_send_at = monotonic() + interval
return outcomes
The messages_per_second value belongs in deployment configuration, not source code. Lower it when the delivery path indicates throttling. For a larger import, replace the in-process loop with leased jobs and a shared limiter; otherwise several worker instances can each believe they own the full allowance.
Why does a password reset need its own path?
A welcome email is part of onboarding. A password-reset message is an account-recovery step, so expiry must be checked at send time as well as verification time. If a reset created under a 10-minute policy waits eleven minutes behind an import, sending it is worse than marking it expired: the resident receives a link or code that cannot work.
Keep the paths separate.
Use a separate queue or priority class, a short job expiry, and a narrowly scoped audit record. The verification flow must independently enforce expiry and single use; delivery status does not establish that the intended person received the message. This separation makes diagnosis less ambiguous. A rise in welcome backlog is a capacity question. A rise in expired reset jobs is a recovery-path question.
SMS deserves the same distinction. A short recovery code can fit in one segment, while copied property details or non-GSM characters can change segmentation and delivery behavior. Twilio's SMS character-limit reference documents the GSM-7 and UCS-2 distinction. Treat message encoding and segment count as data to test, not as copywriting trivia.
Make the worker observable without turning logs into a second mailbox
Measure queue age, jobs created, jobs claimed, accepted attempts, permanent rejections, temporary failures, retry exhaustion, and reset jobs discarded because they expired. Tag measurements with a message purpose and template revision, not the resident's full address. A dashboard that counts only successful delivery calls cannot distinguish a drained welcome queue from a reset path that missed its expiry.
Use one small outcome vocabulary across the importer, worker, and support tooling: queued, accepted, permanent_rejection, retry_exhausted, and expired_before_send. Keep provider-specific diagnostic codes separately, with access controls and a retention limit, for cases where they are actually needed.
The testing order matters. Repeat an import to test idempotency. Set the rate configuration deliberately low to test pacing. Force the timeout between a claimed job and a recorded outcome. Then test an expired reset job. Healthy demos usually hide those transitions.
A decision rule that stays small
Start with one durable outbox, one welcome worker, and one reset worker. Add machinery only when observed queue age, recovery expiry rate, or delivery classifications show that the boundary is insufficient. The critical choice is not a mail product. It is refusing to let a low-priority import consume the time budget of a resident who cannot sign in.
For a property-management system, that rule protects the batch workflow and the recovery workflow. It leaves a clear trail when the batch was intentionally slowed, when a recipient was excluded, and when a short-lived reset was correctly discarded.
Further reading
References:
Top comments (0)