Part 4 of 7 · Shipping notifier series ~5 min read

How a message reaches the customer

The notifier picked a move — shipped, out for delivery, delivered, or delayed. Now the sender Lambda has to figure out who to email, at what time of day, whether they even want the email, and with what context attached. Get any of those wrong and the update is worse than no update: a 2am email, a note to a customer who opted out, a “your order shipped” with no tracking link. Four small guardrails sit between the move and the actual send.

Key takeaways

  • Contact resolution: the order’s own email beats the account default beats the configured fallback.
  • Email is the channel; the sender uses SES outbound with your verified sending domain.
  • Quiet hours defer an update to the next morning so nobody gets a middle-of-the-night email.
  • Every update ships with the order number, the new status, a tracking link, and an unsubscribe link.
  • A delayed move also emails the owner; the customer still gets their warning too.

Four guardrails on every send

Four guardrails between the notifier's chosen move and the sent update A horizontal flow diagram. On the far left, a "Move chosen" box: the notifier emitted an event with the order, the status, and one of four sending moves — shipped, out for delivery, delivered, or delayed. Four guardrail gates sit in a row to the right, each drawn as a vertical bar. Gate 1: Resolve contact — looks up the per-order customer email column first; if blank, falls back to the account-default contact from the rules doc; if still blank, falls back to the configured owner so the update never just vanishes. Gate 2: Quiet hours — checks the local time against the rules-doc quiet-hours window (default 9pm to 8am); if outside sending hours, defers the send to the next morning via an EventBridge Scheduler one-off rule. Gate 3: Unsubscribe check — reads the preferences table; if the customer has unsubscribed from updates, the send is skipped and logged, except a delayed warning still reaches the owner. Gate 4: Final compose — formats the update message from the voice doc template for the chosen move, attaches the order context (order number, new status, carrier, tracking link), and adds an unsubscribe link. After all four gates pass, the update ships via SES outbound to the resolved email. A note at the bottom: every send is logged to DynamoDB so the next check knows the update has gone out. Move chosen shipped, out, delivered, or delayed Gate 1 Resolve contact order email? account default? owner fallback? email is the only channel Gate 2 Quiet hours local time in sending window? if not, defer via Scheduler Gate 3 Unsub- scribe opted out of updates? if yes, skip (owner still warned) Gate 4 Compose + context voice template for the move + order, status, tracking link, unsub link Send — SES outbound email to the resolved customer (owner cc’d on delays) SES SendRawEmail from your verified sending domain every send logged to DDB sn-sends — the next check won’t duplicate Every gate is a deterministic check — no model calls, no surprise behavior on a quiet Tuesday.
Fig 4. Four guardrails between the move and the sent update. Resolve the contact. Honor quiet hours. Check the unsubscribe. Compose with full context. Then send via SES and log it so the next check doesn’t duplicate.

Gate 1: resolve the contact

Three places the sender Lambda looks for the customer’s email, in order. First, the order list’s per-order customer_email column — the email the customer gave at checkout, which is right almost every time. Second, an account-default contact in the rules doc (for shops that keep a separate contacts list keyed by customer name). Third, the configured owner fallback — so an update for an order with a missing email goes to a human instead of vanishing. The fallback should rarely fire; if it does, the weekly digest names every order that hit it so the row can be fixed.

Email is the only channel here, on purpose. Shipping updates are exactly the kind of message customers expect in their inbox, and email gives you a clean unsubscribe and a clear sender identity. The sender uses SES outbound with your verified sending domain so the update comes from you, in your voice — not from an address the customer doesn’t recognize.

Gate 2: quiet hours

The check runs every 30 minutes, around the clock, because carriers deliver and scan parcels at all hours. That means a “delivered” status can land at 11pm, and an “out for delivery” scan can happen at 6am. Without a guard, the customer would get a ping in the middle of the night.

Gate 2 reads the rules doc’s quiet-hours setting (default 9pm to 8am, configurable per business). If the current local time is in the quiet window, the sender creates a one-off EventBridge Scheduler rule that fires at the start of sending hours the next morning and exits without sending. The Scheduler invokes the same sender Lambda with the same payload at the deferred time, where Gate 2 will let it through. A delayed warning is the one exception you can configure to bypass quiet hours, since a customer would usually rather know late than not at all.

Gate 3: unsubscribe check

Every update carries an unsubscribe link. If a customer clicks it, a Function URL Lambda writes an unsubscribed flag to the sn-prefs table for that order. Gate 3 reads that flag before every send and, if the customer opted out, skips the send and logs it.

There’s one carve-out. A delayed warning still reaches the owner even if the customer unsubscribed, because the owner needs to know a delivery is running late regardless of the customer’s email preferences. The customer’s own delayed warning still respects their unsubscribe — if they opted out, they won’t get it. The owner’s alert is internal and isn’t a marketing message, so it isn’t subject to the customer’s opt-out.

Gate 4: compose with full context, then ship

The voice doc has one email template per move: a short, friendly message with placeholders for the order number, the new status, the carrier, and the tracking link. The sender Lambda fills the placeholders, appends an unsubscribe link, wraps it in a small branded HTML email, and ships it via SES SendRawEmail from your verified sending domain. The sending identity and DKIM keys live in SES; nothing sensitive sits in the Lambda.

The tracking link is the most useful thing in the email. Rather than make the customer copy a tracking number into a carrier’s site, the link goes straight to the carrier’s tracking page for that parcel, pre-filled. Checking is one click.

A delayed move adds a second recipient: the owner named in the rules doc. The customer still gets their warning (unless they unsubscribed). But the owner now sees it too, with the order number, the expected date, and how many days late it is, so they have what they need to chase the carrier. The delayed template is calmer than you might expect — it tells the customer what’s known, sets a new expectation, and avoids over-promising.

Every send — any move, customer or owner — writes a row to sn-sends in DynamoDB. The next check reads that row and knows not to send the same update again.

Why the guardrails exist

None of these gates are exotic. They’re the kind of small care a thoughtful person would take if they were sending the updates by hand — check you have the right email, don’t email at 2am, don’t email someone who asked you to stop, include enough context that the customer doesn’t have to ask a follow-up question. Putting them in code as four small sequential gates makes them part of the design, not a feature you’re trusting the writer of any one email to remember.

Next post: how a late delivery gets caught — how the notifier spots an order that’s past its date, warns the customer, alerts the owner, and logs the delay so nothing slips silently.

All posts