Skip to content
← All posts
building product systems

Rebuilding email while it was still sending

How I moved Royal Subz from hardcoded email functions to one observable, editable, budget-aware system without gambling with live customer messages.


Royal Subz had an email system. Signup confirmations arrived. Password resets worked. Admins could send invoices, confirm manual payments, and reply to guest support tickets.

It also had four copies of the same email builder, templates buried inside edge functions, two send paths that logged nothing, one message with no plaintext version, and no reliable way to tell whether a customer email had been delivered or bounced.

The obvious project was to move templates into an admin editor. That was only the visible part.

The real project was replacing a live communication system without losing the behaviors hidden inside it. A broken marketing email is annoying. A broken confirmation email prevents registration. A broken password reset locks somebody out. A broken reply address makes a customer’s support response disappear while both sides assume the other stopped answering.

So I wrote one constraint above everything else: the current email system must keep working unchanged until each replacement is deliberately switched on.

The migration needed three safety nets

I designed the rollout around three mechanisms.

First, every template gets its own use_db_template flag, defaulting to off. A refactored function deployed to production continues using its compiled HTML until that specific template is enabled. There is no global launch where signup, invoices, support, and recovery all change together.

Second, the compiled fallbacks remain inside the functions throughout the migration. If the database is unavailable, the template row is missing, or an admin saves malformed content, the function sends the version that already worked. A payment receipt should not vanish because template configuration had a bad afternoon.

Third, each function runs in shadow mode before cutover. It renders the compiled and database versions, compares them, logs the difference, and still sends the compiled one. Real traffic proves equivalence without turning customers into testers.

The database changes follow the same rule: additive migrations only. New tables, nullable columns, and relaxed constraints can exist beside the old behavior. Cleanup comes after evidence, not before it.

The safest rollback is not another deployment. It is one template flag returning to off.

I had to find what the templates were secretly doing

Reading the current functions exposed two load-bearing behaviors that did not look like template logic.

Guest support replies use a ticket-specific address:

ticket+<reply-token>@mail.royalsubz.com

When the guest responds, the inbound function reads that token, finds the ticket, and appends the message to the thread. Registered customers receive no ticket email at all; the reply appears in their dashboard.

That means reply-to cannot simply come from a sender identity row. It is calculated per message. Replacing it with a generic support@ address would still send successfully, but every reply would be rejected by inbound routing as an unknown recipient. The failure would be silent.

Billing replies have the same shape through a user-specific billing token. Authentication links carry a token_hash that must also remain functional.

I drew a hard boundary: anything that makes an address or link work stays in code. The editable template owns the words around it, not the security or routing mechanism inside it. Sender identities can define a default reply-to, but the shared renderer must accept a caller override for messages that require a dynamic address.

Capacity was a product decision

The Resend free tier allows 100 emails per day and 3,000 per month. At first, 3,000 sounded like the useful number. It is not.

Three thousand divided across thirty days is exactly 100. The limits align only when sends are perfectly flat, and unused daily capacity does not roll forward. A quiet day with 40 messages does not save 60 for launch day. With real order spikes, the usable monthly capacity is closer to 1,700–2,100.

I counted email cost by customer path. A self-serve automated order uses about two messages. An admin-created invoice that converts uses three. Add signup mail, password recovery, abandoned invoices, and guest support replies, and the blended estimate lands near 3.3 emails per order.

That leaves the free tier comfortable around 400 monthly orders, strained around 500–600, and unreliable near 700.

More important than the average was what happens on a busy day. Resend rejects whichever message arrives after the cap, regardless of its value. Forty-three expiry reminders at 6 a.m. can consume the budget that a paying customer needs for a password reset that evening.

I divided messages into three tiers:

  • Signup and password recovery reserve the highest-priority budget because failure blocks access.
  • Payment acknowledgements, subscription-ready mail, invoices, and live ticket replies use the normal budget.
  • Expiry reminders are deferrable and roll into a later batch when capacity is tight.

The shared sender enforces that policy, using actual rows in email_logs rather than an in-memory counter. Reminder batches are capped and spread instead of draining a queue into half the daily allowance at once.

Build the instrument before opening the patient

Delivery observability became Phase 1, before templates or editors.

The system already stored Resend IDs, but that only proved a send request was accepted. I added a resend-events webhook for delivered, delayed, bounced, complained, opened, and clicked events. email_logs gained delivery and failure timestamps, readable reasons, the latest provider event, and its time. A separate email_delivery_events table preserves the full sequence because “delivered, then complained” cannot be represented by one final status alone.

I also made email_logs.user_id nullable. It previously required a profile, and the logging helper skipped recipients without one. Guest ticket emails were structurally impossible to audit. The two populations most likely to need an evidence trail—unregistered visitors and people whose messages failed—were the ones missing from it.

The hardcoded email_type constraint had to go too. It allowed five names and blocked future admin-created templates. Worse, the payment function inserted payment_confirmation while the constraint expected purchase_confirmation. Every log insert failed, the helper swallowed the error, and payment emails quietly stayed absent from the audit trail.

The exit test for this phase was deliberately concrete: a test message must appear as sent and transition to delivered without intervention; a known-bad address must become bounced with a reason an admin can understand.

Only once regressions could be seen did I start changing the send path.

The database stores content, not email plumbing

The data model has three main parts.

email_templates stores the key, subject, block content, rendered HTML, plaintext, tier, identity, declared variables, required variables, system flags, and cutover state. email_identities stores the local part, display name, default reply-to, domain, and active state. email_theme contains the shared colors, typography, container width, and logos that had been copied as constants across four functions.

The email-safe skeleton remains code. Table layouts, escaped substitutions, Outlook markup, VML button fallbacks, functional links, and dynamic reply routing are not freeform content.

I seeded the six existing templates and diffed their output against the original strings. Five matched byte for byte. Ticket replies could not: that function had reimplemented the skeleton and omitted the Outlook block. Keeping it as a standalone exception would preserve exact bytes but permanently exclude it from future theme updates. I accepted the controlled markup normalization and planned to cut it over first because it is low volume and admin-triggered.

Shadow rendering revealed another divergence I had missed by inspection. Invoice and payment-link emails used a different skeleton: no dark header, a different background, rounded containers, Arial headings, and no Outlook block. The logo was designed for a dark background and was nearly invisible on the white header customers actually received.

I chose to move those templates onto the shared theme during cutover. Their shadow diff intentionally reports extra lines. That is not unexplained drift; it is the visible bug the migration is buying away.

One renderer became the entire send path

The shared renderer performs the complete sequence:

resolve template
  -> choose database or compiled fallback
  -> validate and escape variables
  -> apply the theme
  -> resolve identity and reply-to
  -> enforce the tier budget
  -> send
  -> log the result

Centralizing this fixed problems that no individual template editor could. Plaintext is generated from block content, so the guest ticket reply no longer sends HTML only. Required variables are validated at save time. Deleting {{confirmation_url}} from a signup template cannot produce a polished email with no working confirmation link.

That kind of mistake is dangerous because it fails by succeeding: the provider accepts the message, delivery looks healthy, and the user receives something useless.

I refactored one function per deployment, starting with guest ticket replies, then admin resends, invoices, payment confirmations, and finally authentication. The order follows blast radius. The Auth Hook comes last because its callers live outside the application and its failure blocks account access.

The editor needed guardrails more than freedom

The Email configuration page is lazy-loaded inside the admin area, so customers download none of the editor library.

Admins can edit headings, paragraphs, buttons, images, dividers, spacers, inline formatting, subjects, and declared variables. They can preview at desktop and mobile sizes in a sandboxed iframe, send a test to themselves, manage sender identities, adjust the shared theme, inspect delivery health, and see daily and monthly budget usage.

They cannot casually edit the structural pieces that keep email clients working. Raw HTML exists behind an advanced control, not as the default authoring surface. Authentication templates are marked as system messages because disabling them breaks signup or recovery without creating an obvious frontend error. Every template has Restore Default because an editor without recovery turns one bad save into a database repair.

Creating an identity also forces a reply-routing choice. Any local part on the verified sending domain can send immediately, but that does not mean inbound replies know where to go. An address like marketing@ defaults replies to support unless I deliberately extend the inbound router. A successful outbound message should not create a dead-end conversation.

Test sends prove rendering and delivery. For signup and recovery, they use clearly labelled dummy links because only Supabase Auth can issue a genuine token. The UI needs to be honest about what the test does not prove.

Cutover is a sequence, not a launch date

The database version is enabled one template at a time: ticket reply, admin resend, invoice, payment acknowledgement, password recovery, then signup confirmation. After each flag flip, I verify the delivery log before continuing.

Authentication needs one extra signal. A perfect test message can succeed while real signups receive nothing if Supabase is not invoking the hook or its secret is wrong. A “hook last fired” indicator by action type separates a rendering problem from a hook that never called the function.

Only after the shared path is proven do I add the missing messages. Automated NOWPayments and Binance confirmations currently send no receipt, even though manual approvals do. That becomes one Payment Acknowledged template with the method supplied as a variable. Subscription Ready fires when credentials are completed. Invoice Expiry Reminder runs through a real scheduler and obeys the deferrable budget.

Four requested emails collapse into two reusable templates. Fewer templates mean less authoring and fewer places for wording, layout, or logic to drift.

The final cleanup is intentionally last: remove duplicated builders and compiled fallbacks, require platform JWT verification where appropriate, move the remaining in-memory inbound limiter to the database, and give configurable security limits hard bounds so an admin cannot turn a protection into 999999 requests by mistake.

Comprehensive means knowing what happened

An email system is not comprehensive because it has a visual editor or more sender addresses.

It is comprehensive when it knows which message should be sent, which ones must survive a quota spike, what functional data cannot be edited, whether the provider delivered it, which version rendered, where a reply will go, and how to recover from a bad change without deploying code.

There is still one boundary the system cannot cross. A provider reporting delivered means the receiving server accepted the message; it does not reveal inbox versus spam. Real placement testing requires seed inboxes or a dedicated service and belongs after cutover, as an on-demand diagnostic rather than another continuous email expense.

I began with six hardcoded templates and a request to make them editable. The work became observability, quota planning, reply routing, variable contracts, sender identity, safe previews, phased deployment, and rollback.

That expansion was not scope drift. It was the difference between managing email designs and managing email as a product system.