50 minute read

TL;DRI was using 3.3% of my Mailchimp send allowance and 90% of my contact allowance, and the next few hundred contacts would have raised the bill 42% for no capability I use. That pricing mismatch started the build; owning the consent history is what justified finishing it. Ten days later I had an email notification system on Azure Communication Services and a collection of failure stories that never raised an error. Composing an email and calling the send API took an afternoon. Everything around it took the rest: double opt-in that cannot be weaponised, suppression that outlives the account, a migration order that cannot mail someone who opted out, open counting that cannot become tracking, a sender that converts at-least-once queues into at-most-once email, and the observability to catch a function that was never invoked, a telemetry pipeline rejecting every item with a silent 401, and a deploy credential that failed on its first real use. This note carries the real code for the load-bearing rules, the configuration surfaces, and the build order with its traps, so a determined reader could rebuild the starter system. It also tells you plainly why you probably should not.
This piece argues
  1. You probably shouldn’t build your own Mailchimp-style system unless your list is the asset, the provider is the commodity, and pricing misaligns with your usage. see why — jump to the section that argues this
  2. Owning your own subscriber, consent, and send history justifies building a custom email system more than cost savings or feature frustrations do. see why — jump to the section that argues this
  3. The real difficulty in building an email system lies not in sending messages but in consent, idempotency, suppression, deliverability, identity, and observability.
What to do
  • Only build your own Mailchimp-style system when your list is the asset, the provider is the commodity, and their pricing bills you for what you barely use.
  • When migrating off a provider, import unsubscribes and cleaned contacts into a permanent suppression store before importing any subscribers, then set activeFrom to the cutover date.
  • Implement a cancellable run table between detection and dispatch so you can hold, cancel, and rerun sends instead of being locked into whatever is queued.

I send email to people who read what I publish. Not much of it: three and a half thousand subscribers, and four or five messages in a month at the absolute most. For many years I have been using Mailchimp to do that for me, and for most of that time I never thought about it. When I did think about it, it was because I was frustrated. Not because Mailchimp lacks features: it has a great many. They are just rarely the ones I want orbuilt in teh way that I want.

I am not new to this. My second job out of university was building a mailer for a web agency that did the mailing for CableTel, which became NTL, which became Virgin Media. I wrote it in VBScript, and it moved about a million emails a run. Roughly two hundred and fifty times what I now send in a month.

So I have been looking for a replacement for a while, and failing to find one. There is a short list of features I want, a shorter list I think I need, and a price point that fits how I actually work. That last part is where every option fell down, because I can go months without sending anything at all when I am not actively writing. The closest fit are self-hoted servcies, and for that I need to run a server, and incur cost when idle.

The Mailchimp bill is £119.88 a month, and it is charged on contacts, not on sends.

  • Contacts: 9,011 of 10,000.
  • Email sends: 3,970 of 120,000.

So I am nearly full on the meter that costs an email provider almost nothing to serve, and using a thirtieth of the one that costs them real money. A few hundred more contacts tip me into the next plan at £2,042.52 a year, a 42% rise arriving without any of the sending load you would expect to be the thing that pushes you up a tier.

That was irritating. On its own it was not a reason to build anything, because there are cheaper providers than Mailchimp and they do what Mailchimp does.

The reason was what else was in there. Who subscribed, to which topics, on what date, what they were sent, who unsubscribed and when. That record is what a regulator asks you for, and it is what my relationship with my audience actually consists of. It existed in exactly one place. It was someone else’s database, shaped to someone else’s idea of lists and groups, and I could get it out only as a CSV.

So I built the replacement, on Azure, with an AI agent doing most of the typing and me supplying the decisions AND architecture. The problem went into writing on 10 August 2026. The first resources went up on the 20th. The first real send was on the 23rd, and it failed in a way worth the section I give it later. As I write on the 27th, the system sends real email in preview, and production is built, wired, and deliberately switched off.

Ten days, then. Composing an email and calling a send API was one afternoon of that. The rest was consent, idempotency, suppression, deliverability, identity and observability, which between them are most of what you are actually renting when you rent an email provider.

Three things broke badly enough to cost me a day each, and not one of them produced an error message. That turned out to be the real subject of this note.

This note is what those ten days cost and what they produced: the data model, the working code for the rules that carry weight, the configuration, and the build order with its traps, so a determined reader could rebuild the starter version. It also says plainly why you probably should not. And it replaces Mailchimp for my uses, not feature for feature. There is no template designer, no segmentation engine, and by deliberate choice no per-contact activity timeline. If your business runs on those, nothing here changes your arithmetic.

You are billed for the axis you do not use

Start with the invoice, because it is the clearest statement of the problem and it is not the argument you expect.

My Mailchimp Standard plan costs £119.88 a month, which is £1,438.56 a year. Here is what that buys, as the dashboard itself reports it:

MeterUsedIncludedUtilisation
Contacts9,01110,00090.1%
Email sends3,970120,0003.3%

Read those two rows together. Sending email is the thing that costs an email provider money: bytes, reputation, deliverability engineering, abuse handling. I use 3.3% of my allowance for it. Storing a contact costs a fraction of a penny a year, and I am at 90% of that.

Those are the only two meters on the plan. One of them is nearly full, and it is the one that costs my provider almost nothing to serve. The other is the actual product, and I am using a thirtieth of it.

I am billed on the axis I do not use, and the axis I do not use is the cheap one.

That would be merely irritating if it were stable. It is not: at 9,011 of 10,000 the next few hundred contacts push me into the recommended plan at £170.21 a month, which is £2,042.52 a year. That is £603.96 more per year, a 42% increase, for no additional capability I would use. My send volume does not change. My features do not change. A counter crosses a line, and the price steps.

Per email actually sent, that is a move from 3.02p to 4.29p. For a plain text message with a link in it.

So the pricing model is not aligned with the cost of the service, and the misalignment compounds with exactly the thing a growing audience does: grow. Every subscriber I earn makes the arrangement worse, which is a strange incentive to accept from a system whose purpose is to help me earn subscribers.

What the same month costs when you own it

The fair question is what the replacement costs, and I am going to answer it with an estimate rather than an invoice, clearly labelled as such, because production has not yet run a full month. These are published list prices for West Europe, read from Azure’s retail price API on 27 August 2026, applied to my actual volumes. It is arithmetic, not a bill. I will publish the bill.

ComponentList priceMy monthly usageCost
Email sent$0.00025 per email3,970 emails$0.99
Email data transferred$0.00012 per MB~199 MB (50 KB average)$0.02
Table storage£0.0339 per GB/monthwell under 0.1 GB£0.003
Table + queue transactions£0.0003 per 10,000~450,000£0.014
Blob storagepennies per GBcontent snapshots, weekly backups<£0.01
Event Grid operationsfree to 100,000/month~8,000£0
Functions executionsfree to 250,000/month~118,000 across both environments£0
Functions execution timefree to 100,000 GB-s/month~64,000 GB-s£0
Telemetry ingestionfree to 5 GB/monthwell under 1 GB£0

Total: about £0.82 a month. Call it £10 a year, against £1,438.56 rising to £2,042.52.

Now the part that matters more than the number, because the number on its own is misleading in my favour.

Most of that is free because my workload is trivially small, not because self-hosting is clever. Five of the nine lines are zero, and every one of them is zero because it sits inside a monthly free grant: 250,000 function executions, 100,000 GB-seconds, 100,000 Event Grid operations, 5 GB of telemetry. I am not beating Azure’s pricing; I am below its notice. Those grants are also per subscription, so they are shared with everything else I run, and my headroom is smaller than the table implies.

The one line that is genuinely, irreducibly the cost of sending email is the first one, and it is a pound. That is what moving four thousand messages actually costs at wholesale. Everything my old plan charged above that pound was for storing contacts and for features.

And the shape scales the way the argument predicted. Because the built cost keys on sends and rows, not on contacts, growing the list barely moves it:

ScenarioRoughly what it costs me
Today: 9,011 contacts, 3,970 sends~£0.82/month
Triple the list, same send rate~£0.85/month (a few more table rows)
Same list, 40,000 sends/month~£8/month, and the function grants start to bind
Same list, 400,000 sends/month~£85/month, now genuinely dominated by per-email cost

The crossover is sends, not contacts. Tripling my audience costs me pennies; sending them ten times as much mail costs me an order of magnitude more. That is the correct incentive, and it is precisely the opposite of the one I was paying for, where growing the audience was expensive and mailing them more was free.

I have been deliberately generous to the rented side in that table, and elsewhere. Where I quote Mailchimp beyond ten thousand contacts I hold their price flat at £2,042.52, because the dashboard gave me exactly two prices and I am not going to invent their higher tiers. In reality that number keeps stepping upward and mine does not, so the true gap is wider than I am claiming. Being conservative in a competitor’s favour is the only way a comparison like this is worth printing: if it is still stark when you round in their direction, the argument does not depend on the rounding.

And notice what the shape says rather than what the ratio says. The rented price steps because contacts crossed a threshold. Nothing about my sending changed on either side of that step: same emails, same content, same recipients, £603.96 more. The built cost does not step, because adding rows to a table is not an event.

Two honest deductions before anyone quotes the £10. The preview environment is real infrastructure doing no business work, and I count it above rather than comparing production-only against Mailchimp, because leaving it out would flatter the comparison and because it is the cheapest insurance I own against mailing nine thousand people by accident. And the numbers exclude the only cost that actually hurt: ten days of building, and the ongoing attention of being the person who gets paged when the send queue is silent. £10 a year plus a fortnight of your life is a different proposition from £10 a year, and the rest of this note is mostly about that fortnight.

Here is the whole system, so the rest of the note has somewhere to hang. Read it as layers: everything depends downward, and nothing depends upward.

The notification system as layers: consent binding across the top, then surfaces, endpoints, a domain core with no cloud dependencies, adapters, and data. Readers and the operator sit to the left; the rented Azure services sit to the right.

Four things in that picture are the argument of this whole note.

Everything I rent is the dashed column on the right. One service that moves bytes to inboxes, an event bus because that is the only way delivery outcomes come back, a directory for sign-in, a second mail path used exclusively for telling me the first one broke, and somewhere to put telemetry. Five boxes. No database server, no queue broker, no email platform, and nobody else holding my list.

Consent is a band, not a box. It is drawn across the top because it is not a component you can point at: it constrains the endpoints, the core rules, and the data layout simultaneously. A system where consent is a feature has it in one box. Here it is the ceiling everything else is built under.

The core knows nothing about the cloud. It sits between the endpoints that call it and the adapters that serve it, and the dependency direction is what makes that true: adapters depend on the core, never the reverse. That is why every rule in this note is testable without an Azure account, and why the same logic runs unchanged in a website and in a worker.

One box in the data layer is not duplicated. Everything else exists twice, once per environment. The suppression table deliberately does not, because an unsubscribe is a fact about a person rather than about a deployment, and the only thing worse than losing it is having two of them disagree. It is the only box in that row drawn differently, and that is not decoration.

The subscriber list, the consent history, and the send record all live in the columns I own. That is the entire point of the exercise, and the rest of this note is what it cost to put them there.

The email was never the product. The list was.

Even so, the invoice was not the trigger. If it were, the answer would have been a cheaper provider, and there are several. The trigger was noticing what Mailchimp actually held: the only copy of my consent history.

Who subscribed, to what, since when, what they were sent, who unsubscribed and when. That is the record a regulator asks for, the record an email system is built on, and it lived in someone else’s database, modelled as someone else’s lists and groups, exportable only as CSV. Meanwhile my own content system already modelled the things Mailchimp was duplicating badly: items with stable identities, topics, publication history, canonical URLs.

So the design goal was never “clone Mailchimp”. It was: the subscriber list, the consent record, and the send history belong in my estate, next to the content they refer to, and the only thing worth renting is the pipe that moves bytes to inboxes. Azure Communication Services rents me the pipe. Everything else is mine.

One design decision up front shaped everything downstream. Topics are not campaigns; they are a curated registry of content types. Publishing an item whose type is in the registry is what fires a notification. There is no campaign scheduler, no “blast” concept, and no cron for marketing: cadence is editorial discipline, not configuration. The registry is a YAML file, and adding a topic is adding an entry:

# The topic registry: the curated list of subscribable topics.
# Everything downstream derives from this file: the detector's join, the
# subscribe form's labels, the email shape, the List-ID header.
topics:
  - key: "type:article"
    trigger: { itemTypes: [article] }
    label: "Articles"
    cadence: "usually weekly"        # the PROMISE beside the checkbox. Honest, or
                                     # "irregular": broken rhythm reads as
                                     # disorganisation and converts unsubscribes
                                     # into spam reports.
    shape: publication               # who the reader thinks sent it: `publication`
                                     # (a masthead announcing) or `letter` (a person
                                     # writing). Named for the reader's perception,
                                     # never for the content type.
    listId: "articles.example.com"   # per-topic List-ID, so one-click unsubscribe
                                     # scopes to the topic the email came from
    activeFrom: "2026-08-25"         # never announce items published before the
                                     # topic existed (or before you migrated: an
                                     # item your old provider already announced
                                     # must never be re-sent)

The activeFrom field is the migration story in one line. Each topic’s date is set to its cutover day, so the new system can see the whole back catalogue and still never re-announce anything the old provider already sent.

If your list lives comfortably in your provider and your consent story is “the provider handles it”, stay put. The build is only worth it when the list is the asset and the provider is the commodity, and that was exactly my position.

The afternoon of send code sits on top of about a week of consent code, and the consent code is where every interesting decision lives. This is the part worth stealing even if you never build the rest.

One address normalisation, used everywhere. Subscription identity, suppression matching, and import all share a single normaliser, so the same address can never exist twice in different spellings. The whole thing is small enough to publish:

/// Rules: trim, Unicode NFC, invariant lower-case, and plus-addressing folded
/// to the base (martin+news@ IS martin@). Normalised form is for IDENTITY AND
/// MATCHING ONLY — delivery always uses the address as the subscriber typed it.
public static string? Normalize(string? address)
{
    if (string.IsNullOrWhiteSpace(address)) return null;

    var trimmed = address.Trim().Normalize(NormalizationForm.FormC);
    var at = trimmed.LastIndexOf('@');
    if (at <= 0 || at == trimmed.Length - 1) return null;

    var local = trimmed[..at];
    var domain = trimmed[(at + 1)..];
    if (domain.Contains('@') || domain.Length == 0) return null;

    var plus = local.IndexOf('+');
    if (plus > 0) local = local[..plus];
    if (local.Length == 0) return null;

    return string.Concat(local.ToLowerInvariant(), "@", domain.ToLowerInvariant());
}

The comment carries the rule that matters: normalise for identity, deliver as typed. Fold +news for matching, or one person unsubscribes and their aliases keep receiving mail; but send to the exact string they gave you, because the routing decoration is theirs.

Nothing becomes a subscriber at subscribe. The public endpoint writes rows in a pending state and queues a confirmation email. Only clicking the confirm link makes a row active, and only active rows are ever dispatched to. The worst a stranger can do by typing your address into my form is cause you one confirm email, and rate limits bound even that. Two buckets, because they defend different people: per-source per hour (20) bounds one machine; per-address per day (5) bounds a mailbomb aimed at a third party from many machines.

  
sequenceDiagram
    autonumber
    actor P as Person
    participant W as Website endpoint
    participant S as Subscriptions
    participant Q as Transactional queue
    participant K as Worker
    participant M as Their mailbox

    P->>W: POST subscribe · address + topics
    W->>S: write rows as PENDING
    W->>Q: enqueue a confirm request
    W-->>P: 200, and the same sentence either way
    Note over P,W: a known and an unknown address
answer identically here Q->>K: dequeue Note over K: the provider credentials exist ONLY here,
so deploying a website cannot send mail K->>M: confirm email · signed capability link P->>M: reads it P->>W: GET confirm?token=... W->>S: PENDING becomes ACTIVE W->>Q: enqueue the welcome Note over S: only ACTIVE is ever dispatched to,
so a stranger typing your address
costs you one email and nothing else

Two things in that exchange are worth reading twice. The response at step 4 is identical whether or not the address was already known, which is what stops the endpoint being an address-enumeration oracle. And the website never touches the mail provider: it writes rows and enqueues a request, and a separate process with the only copy of the credentials does the sending. A website deployment cannot begin emailing people, however wrong the deployment is.

One subtlety that would have bitten silently: rows written before the state field existed have no state, and a missing state reads as active, not pending. Default the other way and every existing subscriber’s mail silently stops on the day you deploy the feature.

Suppression is keyed on the address and outlives everything. An unsubscribe is a fact about a person, not about an account or an environment. The suppression table is deliberately the one table with no environment suffix, every environment shares it, and deleting your account writes your addresses into it rather than removing them. The objection outlives the relationship. Topic-level opt-out, by contrast, is a preference change and writes nothing there: the boundary between “I want less” and “leave me alone” is load-bearing, and only the second is permanent. Mail is classed as Content, Account, or Administration, and only Content is suppression-checked: a confirmation link is how somebody leaves suppression, so refusing to send it would trap a person in the state they are trying to exit. Receipts still deliver to unsubscribed people. That is the law working as intended, not a bug.

Every link in an email is a capability URL. Unsubscribe, preferences, confirm, and the open pixel are all authorised by a signed token, not a session: HMAC-SHA256 over a tiny JSON payload, with a kid-based keyring so keys rotate without breaking old emails. The payload is deliberately small; the server resolves everything else:

// token = base64url(payload) + "." + base64url(hmacsha256(payload))
// App setting shape: TOKEN_KEYS = {"2026-08":"<base64 256-bit secret>"}
//                    TOKEN_SIGNING_KID = "2026-08"
{
  "purpose": "unsub",        // "unsub" | "pref" | "open" | "confirm" — a token
                             // for one purpose is never accepted for another
  "subject": "person@example.com",
  "topic": "type:article",   // null = all topics
  "kid": "2026-08",          // which keyring entry signed it
  "expiresUtc": null         // null = never. THE UNSUBSCRIBE RULE: one-click
                             // must work from years-old emails, so unsub tokens
                             // never expire and retired kids stay verifiable.
}

Three rules in that design earned their place. Verification returns null on any failure without distinguishing why, so the anonymous endpoints never leak which part of a token was wrong. Keys below 256 bits are refused at startup, because the keyring used to accept any base64 string, and an operator could have set an eight-byte key and every capability URL would have worked, guessably. And the failure direction is chosen: a keyring that will not parse takes the endpoints down visibly rather than minting weak tokens silently. No unsubscribe is a visible fault; a forgeable one is a silent breach.

The success message must be true. The subscribe endpoint originally returned the same cheerful sentence for every outcome, an anti-enumeration measure so a stranger probing addresses learns nothing. The flaw surfaced in review: an already-subscribed person got “check your inbox” and no email, which is a lie with a countdown attached. The amendment kept the anti-enumeration property and ended the lie: every success a human can reach now queues a real email. A confirm for pending topics, or an “already subscribed” note otherwise. Known and unknown addresses still answer identically at the API; the difference travels only to the mailbox, which is the one place the rightful owner is standing.

Opens are counted without a standing way to track anyone. Each sent email carries a one-pixel image (a 43-byte transparent GIF: the smallest thing that is a valid image in every mail client) whose token contains a 128-bit random nonce, minted per recipient per send and written down nowhere. The open store records (run, nonce) with no address; the freshness store records an address and a date with no run. No query, dashboard, or casual join can answer “did this person open that send”, because the only artifact connecting the two facts is the token in the recipient’s own mailbox, which I never keep. Deriving the nonce from the address would have been simpler and would have destroyed the property entirely: anyone holding an address could recompute the key and ask the forbidden question. Random, or it is not anonymous.

I should be honest about what this buys, because the design record is. With enough effort the join is always reconstructable: timing correlation, provider message ids. Like a UK ballot, whose serial number does match a counterfoil, the anonymity is a governance property, not a mathematical one. What the construction guarantees is that a breach requires deliberate reconstruction, which is visible, exceptional, and unmistakably a decision rather than an accident. And the resulting number is a floor, never a rate: most mail clients block remote images, so I refuse to publish an open percentage that would be a fabricated statistic.

None of this is clever engineering. It is a series of small, boring rules, each of which exists because the easy version quietly betrays someone: the subscriber, the regulator, or the operator reading the dashboard.

Move the objections before you move the subscribers

Nine thousand contacts do not teleport. The migration is where a self-built system is most likely to do something unforgivable, because the two failure modes are mailing someone who told your old provider to stop, and re-announcing your entire back catalogue to everyone at once.

The order matters more than the tooling, and it is counter-intuitive: import the unsubscribes first, before a single subscriber row exists.

Mailchimp exports one CSV per segment. The unsubscribed and cleaned segments go into the permanent suppression store as step one of the migration, so that no later step can send to an opted-out address even by accident. Subscribers come after. If you do it the other way around and something goes wrong in between, you have a list of live addresses and no record of who among them said no, and you will find out which is which by being reported as a spammer.

The parser is deliberately dull, and one line of it is the point:

var normalized = AddressNormalizer.Normalize(raw);
if (normalized is null) { invalid++; continue; }
addresses.Add(normalized);

Every imported address goes through the same normaliser as every subscribe, every suppression check, and every send. That is what makes the import join correctly with the rest of the system rather than creating a parallel population of near-duplicates. The rows that fail to normalise are counted and reported for a human to eyeball, never silently dropped: a malformed address in an export is a fact about your old data, and you want to see it, not lose it.

Then the second half, which is the activeFrom field from the topic registry doing its job. At cutover, each topic’s activeFrom is set to its migration date. The detector can now see the entire back catalogue in the candidates feed and will schedule none of it, because everything predates the topic. The system starts from a standing start with full history visible and nothing queued.

Get that wrong and your first act as an independent sender is to mail nine thousand people about forty articles they read months ago. There is no recovering from that, and no apology email that helps, because the apology is another send.

A smaller note on migration honesty: the preview environment runs entirely on fake subscribers by decision. Real addresses are imported into production only. Every rehearsal in a system like this is a rehearsal with real people’s mailboxes unless you deliberately arrange otherwise.

The pipeline is boring on purpose

The runtime is ten Azure Functions and some storage tables. No database server, no message broker beyond storage queues, no framework. Structurally it is four components and two hosts:

  
flowchart TB
    subgraph H1["HOST 1 · the website · one per channel"]
        direction TB
        HUGO["Presentation
subscribe form · preferences
admin ledger · subscribers
"] FUNCS["Endpoints package
public + admin HTTP"] ADAPT["Estate package"] HUGO -.->|"browser calls"| FUNCS end subgraph H2["HOST 2 · the worker · one per environment"] WRK["Worker app
10 functions · email shells"] end STOR["Storage adapters
Tables · Queues · Blobs"] CORE["Core · no cloud dependencies
normalisation · capability tokens · send keys
attempt state machine · pacing · consent rules

declares every port"] FUNCS --> STOR WRK --> STOR FUNCS --> CORE WRK --> CORE STOR ==>|"implements the store ports"| CORE ADAPT ==>|"implements the identity
and challenge ports"| CORE

There are two hosts because there had to be. Static web apps can only host HTTP-triggered functions, so nothing on the website can ever read a queue, run on a timer, or take a callback. The endpoints that need the signed-in visitor’s identity have to live on the website, because that is where the session exists. Everything driven by a clock or a queue has to live somewhere else. That constraint, not a preference for microservices, is why there is a separate worker at all.

Everything that is a rule lives in the Core, and the Core knows nothing about Azure. It declares ports and the hosts supply implementations: the storage package implements the store ports, and the estate package implements the two ports that reach outside the capability, for identity and for the bot challenge. That is what makes the consent rules above testable without a cloud account, and it is why the same logic runs unchanged in a website process and a worker process.

The arrows point the way they do deliberately. Nothing in the Core points outward, so a change to how storage works cannot reach the rules, and a rule cannot quietly acquire a dependency on a table.

Behaviourally, a send moves through it like this:

  
flowchart TB
    FEED["Candidates feed
published by each website"] DET["Detector
timer, hourly"] RUNS[("Run table
scheduled · held · cancelled")] DIS["Dispatcher
timer, 5 min"] SUBS[("Subscriptions")] SUPP[("Suppression")] DQ(["dispatch queue"]) SND["Sender
queue trigger"] ACS["Communication Services"] HUMAN(["Recipient"]) EG["Event Grid"] RQ(["delivery queue"]) DRP["Delivery reports
queue trigger"] SITE["Subscribe · confirm
on the website"] TQ(["transactional queue"]) TXN["Transactional mail
queue trigger"] FEED -->|"publication is the trigger,
never deployment"| DET DET -->|"minus the send log"| RUNS RUNS -->|"due runs only"| DIS SUBS --> DIS SUPP -.->|"checked before every send"| DIS DIS -->|"one message per recipient"| DQ DQ --> SND SND --> ACS ACS --> HUMAN ACS -->|"outcomes"| EG EG --> RQ RQ --> DRP DRP -.->|"hard bounces only"| SUPP SITE --> SUBS SITE -->|"the site enqueues,
the worker sends"| TQ TQ --> TXN TXN --> ACS

Four things in that picture are decisions rather than plumbing, and each is argued below: publication rather than deployment as the trigger; a cancellable table between deciding and doing; one message per recipient; and the site enqueueing transactional mail rather than sending it. The dotted lines are the consent path, which touches the pipeline at both ends: suppression is read before every dispatch and written by every hard bounce.

The diagram leaves out the observability, deliberately, because drawing it would double the arrows and halve the legibility. Around the edges sit poison-queue reapers for both queues, a health endpoint, a campaign alerter, and a weekly export of the consent-bearing tables. And every function in that picture stamps a heartbeat row as it runs. Remember that detail; it is the hero of the section after next.

Five decisions in that diagram carried more weight than I expected.

The trigger is publication state, never deployment. My sites rebuild constantly, so anything fired from “deploy finished” is a resend hazard with a hair trigger. The detector’s question is: does the feed contain an item whose type is in the registry, published after the topic’s activeFrom, with no send recorded? RSS looks like the obvious feed to poll and fails four concrete ways: no stable item identity (the idempotency key), no item type (which picks audience and template), capped and curated entries (a publish burst pushes items off the feed before the poller reads them, silently), and ambiguity about which feed speaks for the whole corpus. So each site publishes a dedicated candidates feed for this one consumer: complete over a rolling window, identity-keyed, type-carrying. A feed that fails to parse is skipped for the cycle, never read as empty: absence can delay a send, but it can never cause a wrong one.

A table sits between deciding and doing. The detector does not enqueue anything; it writes run rows with a due time, and the dispatcher acts on rows that are due. The reason is operational, not architectural: a queue message in flight cannot be cancelled, but a row can. Cancel, hold, requeue, and the kill window all become row operations on an admin page. The due time is published + hold (default four hours), which exists because a notification system quietly makes publishing irreversible in a new way: an accidental publish must be killable before the send fires. A per-topic gap (default 48 hours) spaces two items published close together, in order, neither lost.

The idempotency key is boring and visible. {itemId}:{topicId}:published, with a version label appended for edition sends. A run is one execution of that key (the first automatic send, or an operator’s rerun), and every recipient in a run gets a deterministic correlation id:

// A redelivered queue message presents the same id, so the provider can
// de-duplicate; a RERUN gets fresh ids by construction (the run number is in
// the hash), which is exactly what makes a rerun deliverable to someone the
// previous run already reached.
public Guid OperationIdFor(string normalizedRecipient)
{
    var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{runKey}|{normalizedRecipient}"));
    return new Guid(bytes.AsSpan(0, 16));
}

One message per recipient. A single send to many recipients kills personalised unsubscribe tokens, per-recipient suppression checks, and the ability to retry one failure without resending to everyone, and it leaks addresses. At my scale the cost difference is nil.

Pacing defers; it never truncates. Your provider grants you a send rate, and a large run has to be spread across it using queue visibility delays. The rule that matters is what happens when a run will not fit: if staggering would push a recipient beyond the queue’s seven-day visibility ceiling, the pacing function refuses to schedule rather than quietly dropping the tail. A run that is held and visible is an operational problem. A run that silently sent to the first eight hundred people and forgot the rest is a trust problem you will discover from a reader asking why they never hear from you.

The sender’s whole intelligence is one switch statement. Storage queues deliver at least once. Email must deliver at most once. The conversion between those two guarantees is a state machine whose transition record is written before the provider call:

public enum AttemptState { None, Queued, Attempting, Sent }

public static AttemptDecision OnDequeue(AttemptState recorded) => recorded switch
{
    AttemptState.None       => AttemptDecision.Proceed,
    AttemptState.Queued     => AttemptDecision.Proceed,
    // An `attempting` row with no outcome means a crash mid-send, or a race.
    // DO NOT resend — flag to the operator. A duplicate flagged for a human
    // beats a duplicate delivered.
    AttemptState.Attempting => AttemptDecision.FlagToOperator,
    AttemptState.Sent       => AttemptDecision.AcknowledgeDuplicate,
};

Drawn out, with the two branches that matter:

  
sequenceDiagram
    autonumber
    participant Q as Dispatch queue
    participant S as Sender
    participant A as Attempt row
    participant E as Communication Services

    Q->>S: message · run + recipient
    S->>A: read the recorded state
    alt no row yet, or Queued
        S->>A: claim as ATTEMPTING, under an ETag
        Note over S,A: written BEFORE the provider call,
so a crash leaves evidence S->>E: send E-->>S: accepted S->>A: record SENT else already ATTEMPTING Note over S,A: a crash mid-send, or two racing deliveries S--xE: do not resend S->>A: flag it for a human else already SENT S--xE: acknowledge the duplicate, silently end

The crossed arrows are the whole point: two of the three paths never reach the provider. The row is claimed under an ETag, so of two racing deliveries exactly one wins. And the claim is written before the send rather than after, which is the ordering that makes the middle branch possible at all: a process that crashes between sending and recording leaves a row saying “I was about to”, and that ambiguity is handed to a human rather than resolved by guessing. A duplicate flagged for an operator beats a duplicate delivered to a reader.

Queued is written at enqueue time, and exists because of a real defect: before it, a run with rate-deferred recipients re-queued its whole head every five minutes and the tail never advanced. The subtraction set for “who still needs queueing” has to be recorded state, not memory.

And one structural rule that is easy to get wrong: the website enqueues; the worker sends. Confirmation and welcome emails are queued by the site but sent by the worker, so the provider configuration exists in exactly one place and production cannot start emailing people merely because a website deployed. Configuration presence is the launch flag. A ring with no sender settings refuses to send, by design, and that is why I can write “production is not sending” as a designed state rather than an apology.

The storage layout, complete, because it is the actual data model of the system. Everything is Azure Tables, queues, and blobs on one storage account:

StoreKindHolds
Subscriptions{Env}tableone row per (address, topic key): state, origin, dates
SendSchedule{Env}tablerun rows (the cancellable table), plus heartbeat rows in a control partition
SendLog{Env}tablewhat was sent, when, per run
attempt rowstableper (run, recipient): the state machine’s record, ACS operation id, delivery verdict
OpenEvents{Env}table(run, nonce) rows: unique opens, no addresses
MergeLog{Env}tableone row per account address-merge that moved something, plus that account’s chosen delivery address (see below)
Suppressiontable, no env suffixthe permanent objections: address, reason, date
dispatch-{env}queueone message per recipient, visibility-staggered
delivery-{env}queueprovider outcomes, written by Event Grid
transactional-{env}queueconfirm/welcome mail, site → worker
*-poisonqueuesthe host’s dead letters; a reaper turns them into recorded failures
notification-bodiesblobper-run content snapshots (a table property caps at 32k characters and truncated a long article mid-read; snapshot written before the run row, so a schedulable run can never reference a missing body)
notification-backupsblobweekly NDJSON of the three consent-bearing tables, with row counts and hashes

One person, several addresses

A person who signs in may hold more than one verified email address. Without a merge, their preferences page can only speak for whichever address they happened to sign in with, and anyone holding two sees half their own subscriptions with no way to tell that is what they are looking at.

Three rules make the merge safe, and all three are a pure function so they can be tested without any storage at all:

  1. A topic moves; it is not copied. On for the delivery address, off at the source. Two rows would mean two emails. Switching the source off is a preference change and writes no suppression: nobody objected, their mail moved.
  2. Suppression always wins. A suppressed address contributes nothing, and a merge never lifts a suppression. What was withheld is reported to the person so they can tick the box themselves, which is an explicit new consent rather than one inferred on their behalf.
  3. It is idempotent, so it can run lazily whenever the account page is read rather than being pushed by an event. A repeated, late, or duplicated pass cannot corrupt anything.

Two implementation details carry more weight than they look. The account identifier comes from the validated sign-in principal, never from the request payload: a browser-supplied list of addresses would let anyone claim anyone’s subscriptions. And the merge is durable, not a read-time union — which is what keeps identity out of the send path entirely. Afterwards the dispatcher reads subscription rows and never needs to know an account exists. A union would have dragged the account system into every send.

The notification system also does not read the identity directory itself. It declares a port and something further up the stack adapts it, because the awkward cases (federated sign-ins, guests, the two shapes a directory produces for what is nominally the same thing) are the account system’s knowledge, and a second reader of that knowledge is a second definition of “your email address” that will diverge from the first, silently, on exactly those awkward cases. A directory that cannot answer returns null, never an empty list: the page then says the merge was unavailable rather than confidently presenting one address as the whole account.

The same shape covers bot protection on the public subscribe form. The system declares a challenge port and implements it nowhere; the site already runs a challenge for its contact forms, and a second verifier here would be a second definition of “passed” that drifts from the first. Absent means open, not closed: a deployment with no challenge configured still subscribes people, bounded by the honeypot and the two rate limiters.

Somebody has to be able to see it

A send pipeline you cannot inspect is a pipeline you cannot trust, and the operator surface is not a nice-to-have you add later. It is where the cancellable run table stops being an architectural idea and becomes a button.

The admin surface is a small set of pages composed from the same API the rest of the system uses:

  • The ledger: every run, its state, when it is due, how many recipients were queued, sent, delivered, bounced, and how many unique opens it recorded. Runs can be held, cancelled, or rerun from here, which is the entire justification for putting a table between the detector and the dispatcher.
  • The subscribers pane, deliberately Mailchimp-lite and read-only: one row per address, showing the worst-of status across their topics (any pending shows pending; all unsubscribed shows that; otherwise active), per-topic chips, suppression state with its reason and date, the soft-failure bounce signal, when they were last seen, and a popup with that address’s sends over the last thirty days.
  • The pipeline view, which renders what the health endpoint reports: queue depths, heartbeat ages, stuck runs, and whether sending is enabled at all.

That subscribers pane is where the privacy construction shows up as a product decision rather than a principle. It can show you that a send had opens, and it can show you when an address was last seen alive. It can never grow Mailchimp’s per-contact activity timeline, because the data to build one does not exist and cannot be reconstructed from a query. I wrote the thinness into its documentation deliberately, so that a future version of me reading the page does not mistake a design for an unfinished feature and helpfully “fix” it.

One more surface worth building early: a registry endpoint on the worker that reports which topics exist. The worker’s own registry is the list its detector joins against, so it alone decides what can fire and what a subscribe request is allowed to write. Websites hold a display copy for rendering labels, and that copy decides nothing. When those two disagree, you want one of them to be unambiguously the truth.

It wasn’t failing. It was never running.

Now the morning I opened with.

The first real send produced nothing, and nothing produced it. The Azure Functions queue extension defaults to base64 message encoding. The Azure.Storage.Queues v12 SDK, which my dispatcher writes with, defaults to plain UTF-8. Two Microsoft defaults, one queue, no overlap. The host could not decode a single message, and it failed at the binding layer, before my function was ever entered: no invocation, no exception, no log, no telemetry, message straight to poison.

The comment I wrote into host.json at the moment of the fix is the rawest record I have:

// THE SEND BUG, root cause (2026-08-23). The Functions queue extension defaults
// to messageEncoding "base64"; the Azure.Storage.Queues v12 SDK defaults to NONE.
// Both of this worker's queue producers are SDK-or-service writers of plain UTF-8
// JSON — the dispatcher (QueueClient.SendMessageAsync) and Event Grid's
// storage-queue destination — so the host could not decode a single message. It
// failed at the BINDING layer, before entering the function, and moved every
// message straight to poison: no invocation, no exception, no heartbeat, nothing
// in Application Insights. Four poisoned sends and zero emails, all from this.
// "none" makes the host read what the producers actually write (and keeps the
// messages human-readable in the portal, which is how this was finally seen).
"messageEncoding": "none",

What found it was not a diagnostic. It was a pair of signals built for other reasons: every worker function stamps a heartbeat row in storage as it runs, and poison queues have depths. From the commit message that shipped the fix:

Found by the pair shipped in 33.51.0: a fresh send pushed the poison queue from 3 to 4 while the sender heartbeat stayed null. Not failing — never invoked. Neither signal alone would have shown it.

The poison queue growing said messages were dying. The heartbeat staying null said my code was never entered. Only together do those two facts say “the failure is in the layer between the queue and you”, which no log could say, because the whole point is that nothing of mine ever ran.

There is a second half, and it forbids the obvious “fix”. Event Grid base64-encodes the bodies it writes to storage queues, so the same queue infrastructure has two producers with two encodings, and messageEncoding is a single global setting. The delivery-report handler therefore decodes base64 itself (JSON passes through; anything else is decoded), and the first real delivery report proved why, five invocations deep:

’e’ is an invalid start of a value. LineNumber: 0 | BytePositionInLine: 0.

'e' being the first character of base64-encoded {". Flip the global setting to make Event Grid happy and you re-break sending. The disagreement between producers is handled in code because it cannot be handled in configuration.

Silence from your telemetry is not evidence that your app is quiet

The queue bug should have been a five-minute diagnosis. It took hours, because the system’s designated reporter had been dead the entire life of the capability, and its death was itself silent.

Both Application Insights components were created with local authentication disabled. That is a defensible security posture: instrumentation keys are bearer secrets, and a leaked one lets anyone write telemetry into your workspace. But it has a sharp edge that nothing warns you about: an app that authenticates with only a connection string has every telemetry item rejected at ingestion with HTTP 401, and nothing in the app can log the rejection, because the rejection is the logging path.

Both workers ran for a week with zero telemetry. Not zero errors: zero rows of every type, including the host-generated request records that do not depend on my code at all. Every configuration setting looked correct. The diagnosis, in the end, was one direct test: post a synthetic event to the component’s own ingestion endpoint and read the answer Azure gives.

POST https://westeurope-5.in.applicationinsights.azure.com/v2/track
{"itemsReceived":1,"itemsAccepted":0,"appId":null,
 "errors":[{"index":0,"statusCode":401,"message":"Authentication required"}]}
HTTP 401

Received: one. Accepted: zero. The host and worker had been sending telemetry all along, and Azure had been throwing every item away. The fix needs all three parts, and each alone looks sufficient:

WhatWhy
Grant the app’s managed identity Monitoring Metrics Publisher on its own App Insights componentthe identity must be allowed to write
Set APPLICATIONINSIGHTS_AUTHENTICATION_STRING = ClientId={identity-client-id};Authorization=AADthe app must be told to use that identity
Delete APPINSIGHTS_INSTRUMENTATIONKEY if presentthe docs require exactly one connection setting, and copy the connection string from the resource rather than hand-assembling it (mine carried an empty ApplicationId=)

After seven days of nothing, the workspace showed 46 rows within 75 seconds of the change.

The transferable rule costs nothing to adopt and I will never build a pipeline without it again: never let telemetry be a system’s only reporter. The storage-backed heartbeats were added as cheap operational garnish. For a week they were the only reporting channel that worked, and when the first real send failed they were the only reason the failure was visible at all. A pipeline whose sole reporter is telemetry has no reporter; it has a hope.

A documented command that has never been run is a guess with good formatting

The third failure is the one I find hardest to forgive, because I wrote it down myself, neatly, in advance.

The deploy pipeline authenticates to Azure with GitHub OIDC federated credentials. The preview credential was minted by the Azure portal’s wizard and worked from day one. The production credential I created by hand from the pattern every tutorial shows: repo:org/repo:environment:production. It sat there for two days looking exactly like infrastructure. Then the first production tag pushed, and:

Federated token details:
 subject claim - repo:nkdAgility@9676770/NKDAContent-Hub@1244462988:environment:production
Attempting Azure CLI login by using OIDC...
##[error]AADSTS700213: No matching federated identity record found for presented
assertion subject 'repo:nkdAgility@9676770/NKDAContent-Hub@1244462988:environment:production'.

My GitHub organisation emits OIDC subjects in the immutable format, with numeric org and repository ids embedded. The classic form matches nothing. The wizard had silently used the immutable form, which is why preview worked; my hand-written credential used the documented form, which is why production failed on its first real use, blocking the release it existed to serve. The subject claim in the failing job’s log is the source of truth: copy it verbatim, never compose it from a pattern.

The working shape, with the two details that matter (the @id segments, and quoting):

az identity federated-credential create --name github-environment-production `
  --identity-name my-worker-deploy --resource-group MyGroup `
  --issuer https://token.actions.githubusercontent.com `
  --subject 'repo:MyOrg@<org-id>/MyRepo@<repo-id>:environment:production' `
  --audiences api://AzureADTokenExchange

And scope the production credential to the GitHub environment, not a branch or tag ref: a tag push presents a ref:refs/tags/v* subject that a ref-scoped credential cannot match (federated subjects take no wildcards), while an environment-scoped credential matches on any ref, and the environment is your future approval gate.

The same day produced the rule I now apply to every runbook I write: a documented command that has never been run is a guess with good formatting. A recorded command reads as verified, and the reader has no way to tell a proven recipe from a plausible one. Either write it after running it, or label it untested, loudly. (The commands in this note follow that rule: everything shown was run against the live estate and then genericised, except where I say otherwise.)

Identity kept teaching all week. Two days later an unrelated tidy-up deleted the portal-minted deploy identity, on the reasonable belief that it was unreferenced, and every worker deploy broke with AADSTS700016: Application not found in the directory. The estate then held three deploy-identity shapes in a single day: the shared portal identity (dead), the runtime identities pressed into deploy duty (reversed the same day, because it put deploy authority on the identity the running app authenticates as, so anything able to act as the app could redeploy it), and finally dedicated per-environment deploy identities holding deploy rights on their own app and nothing else. Deploying and running are now separate identities, and the two environments cannot touch each other.

One thing in this story worked exactly as designed, and it deserves the credit: when the tagged deploy failed, the release job was skipped, so the tag never became a release pointing at a worker that was never deployed. The guard’s first real production test, and it held. Every safety mechanism in this note earned its keep in its first week; that one earned it before the system had sent production email at all.

Gmail thinks your confirmation email is phishing, and Gmail has a point

One deliverability story, because it reversed a decision I was confident about.

A controlled comparison settled it: a full article email and a four-sentence transactional confirmation, same sender, same domain, same pipe, minutes apart. The article inboxed at Gmail. The transactional letter was rejected at the edge with a 550.

The diagnosis reframed how I think about spam filtering. The rejected message was short, nearly content-free, and its only substance was a single opaque tokenised link, which is the structural fingerprint of phishing. Gmail was not wrong about the shape; it was wrong about the intent, and it has no access to intent. The fixes followed the shape: the transactional emails got real content (topic labels, cadence descriptions pulled from the registry), visible bare URLs under every action link, and, reversing my launch-day decision, the RFC 8058 one-click unsubscribe header pair even on transactional mail. The headers are provider-facing legitimacy; the visible letter still offers no unsubscribe link, because there is nothing to unsubscribe from mid-signup. Every topic also carries its own List-ID, so a provider’s native unsubscribe scopes to the topic the email came from rather than nuking everything.

SPF and DKIM are table stakes and took minutes. What deliverability actually demanded was this: making my most humble emails look less like the emails criminals send.

The build, in order

The condensed recipe. Not blow-by-blow: each step names the resource, the one or two decisions inside it, and the trap if there is one. A competent engineer with an Azure subscription can walk this list; the failures above are the annotations.

1. The email service. A Communication Services resource plus an Email Communication Service with a custom sending subdomain (mail.yourdomain.com), linked together. Choose the data location deliberately: it is baked into the endpoint hostname and cannot change later. A dedicated subdomain, so newsletter reputation can never damage transactional deliverability.

2. DNS. Domain verification TXT, SPF, DKIM (two CNAMEs). Minutes of work if your zone is scriptable. Add DMARC to the list; mine is still marked not-started in the estate record, which is how honest infrastructure documentation looks.

3. The manual residue. An Exchange accepted domain and mailbox for the from-address, and a support ticket to raise the provider’s default send quota. Both permanently outside infrastructure-as-code; write them down as manual steps or lose them.

4. The return path. ACS emits delivery outcomes only via Event Grid. Create a system topic on the Communication Services resource and one subscription per environment on EmailDeliveryReportReceived, each with a storage-queue destination pointing at that environment’s delivery queue. Each environment receives a full copy and ignores messages it does not recognise; deleting an unknown message is always safe. Remember from the war story: Event Grid base64-encodes what it writes, and your own SDK writer does not, so the report handler decodes for itself.

5. Storage. The tables, queues, and containers from the layout table above, on one storage account. The worker creates its own queues and tables on first use; the two blob lifecycle rules (90 days on body snapshots, a year on backups) are account configuration that appears in no code and no diff, so they are set by hand and documented, or they are forgotten.

5a. The migration, before anything is allowed to send. Export your provider’s unsubscribed and cleaned segments and load them into the suppression table first, then import subscribers, then set every topic’s activeFrom to the cutover date. In that order. See Move the objections before you move the subscribers above for why each step fails if you reorder it.

6. Compute, per environment. A Flex Consumption plan, a Function App (dotnet-isolated), its own host storage account with a deployment container, and a user-assigned managed identity. Then the role grants, and here is the trap that costs an afternoon: Owner on the subscription grants no table or queue data access. Control plane and data plane are separate worlds; the identity needs explicit data roles (Table Data Contributor, Queue Data Contributor, Blob Data Contributor) on the data account. Blob is the exception that hides this from you, because Blob Data Owner is commonly assigned, so blobs work and you conclude access is fine. Verify with a role-assignment list; never assume.

7. Deploy identity, per environment. Dedicated, separate from the runtime identity, holding deploy rights on its own app only, with the OIDC federated credential from the war story: subject copied from a real token, production scoped to the GitHub environment. Deploying and running are different powers; do not put them on the same principal.

8. Telemetry that can actually ingest. App Insights per environment. If you disable local auth (do), apply all three parts of the fix table above, and then prove ingestion with a synthetic POST before trusting any empty query.

9. Alerting that assumes the pipe is broken. An availability test on the worker’s health endpoint asserting a content match on "critical":false, because the endpoint answers 200 even when degraded (the status board’s liveness rule depends on that), so status-code alerting is blind. And a separate campaign alert path that emails send failures over a different transport than the one that failed: an alert that travels the broken pipe arrives never.

10. The worker configuration. Every setting namespaced and absent-fails-closed. The full surface is small enough to list:

SettingMeaning
RING (environment label)table/queue suffix; required
FEED_URLSthe candidate feeds the detector polls, semicolon-separated
REGISTRY_JSONthe topic registry projection (converted from the YAML at build)
TOKEN_KEYS / TOKEN_SIGNING_KIDthe HMAC keyring and active kid; required
DATA_ACCOUNTdata-side storage, managed identity
ACS_ENDPOINT + SENDERthe provider. Both absent = sending disabled: the launch flag
GRANTED_PER_HOURthe rate the provider actually granted; drives dispatch staggering
HOLD_HOURS / TOPIC_GAP_HOURSthe kill window (4) and per-topic spacing (48)
queue names ×3resolved by the Functions host, so they must be real app settings, not values inside your own config object
AZURE_CLIENT_IDthe identity’s client id; without it the credential chain cannot pick the right identity on an identity-only app

11. The code. A pure domain core with no cloud dependencies (the normaliser, the token service, the registry model, the feed parser, the detector join, the send key, the state machine, pacing, suppression rules, the double-opt-in rules, the rate limiters, the pixel, the account merge, the export parser), storage adapters behind interfaces, the public subscribe surface, the ten worker functions, and the email templates. The core being pure is what makes every rule above testable without Azure, and the test suite is where the consent properties live as assertions rather than prose: the public answer does not vary by whether the address is known, a pending row is not sendable, a repeat confirm sends no second welcome, one address cannot be mailbombed from many sources.

Also build a registry gate into your publishing pipeline: every topic in the registry must name a content type that actually exists in your taxonomy, checked at build time. A topic pointing at a type nothing publishes is a subscription form offering people mail that can never arrive.

11a. The operator surface. The ledger, the subscribers pane, and the pipeline view from Somebody has to be able to see it. Build the ledger early rather than last: it is what turns the run table’s cancel and rerun operations from a database exercise into something you can actually do at 9pm when a send is queued and wrong.

12. Template tests, specifically. A broken email template is invisible to every gate you have: it compiles, it sends, and it only shows up in someone’s inbox. So the shell tests assert the properties that matter: no unreplaced {{TOKEN}} ever reaches a subscriber, an unknown shape throws instead of guessing a voice, an absent author renders no byline rather than “By “, an absent image collapses rather than emitting a broken tag, a 40,000-character body survives whole, and the nameless greeting never reads as a failed mail merge.

And the channel-side routing, which is two lines of static web app configuration: the admin surface role-gated, the subscriber surface anonymous.

{ "route": "/api/notifications/admin/*", "allowedRoles": ["admin", "admin-notifications"] },
{ "route": "/api/notifications/*",       "allowedRoles": ["anonymous"] }

One behaviour rule ties the public surface together: off is 404, broken is 503. An environment whose settings are deliberately absent answers 404 on everything, exactly like a site that never adopted the capability. But a setting that is present and unusable (a keyring that will not parse, a key below the floor, a storage client that throws) answers 503 with a stable reason code. “Not found” for an environment that is meant to be live is a lie, and the day it was told, a corrupt keyring spent an hour indistinguishable from a missing one. The one exception is the open pixel, which returns its 43 bytes with a 200 in every state, because it renders inside somebody’s email, and an error there is a broken-image glyph in a message you sent.

So, should you?

The three big failures in this note share a property worth stating plainly: each was invisible to the system’s own reporting, and each was caught by a signal that existed for a different reason. A poison queue depth and a heartbeat. A synthetic POST to an endpoint nobody thinks to test. A release gate that refused to publish a tag for a deploy that never happened. If there is one transferable lesson in the whole build, it is that: the observability you will actually need is the kind that still works when the failure is in the layer below your code, and you have to build it before you know why.

The honest accounting on time. The problem was written down on 10 August. The first resources were provisioned on 20 August. The first real send, and the two silent failures it exposed, came on 23 August. The identity failures landed on the 24th and 26th. As I write on the 27th, preview sends real email and production is provisioned, gated, and deliberately quiet. Call it ten days of intense building, with an AI agent doing the bulk of the implementation and me supplying decisions, review, and the Azure portal password.

The honest accounting on money has two halves with very different confidence. The rented side is exact, because it is an invoice: £1,438.56 a year today, £2,042.52 once the contact counter tips. The built side is an estimate from list prices at my measured volumes, about £10 a year, and I have shown the working above rather than asking you to take it. I will publish the real invoice in December, and if the estimate is wrong I will say so in this same note rather than quietly leaving it.

But the ratio is not the interesting part, and I would rather you took the shape than the number. The rented cost scales with contacts stored, which is the axis I do not use and cannot stop growing. The built cost scales with emails sent, which is the axis I do use and control directly. Tripling my audience costs me pennies. Sending them ten times as much mail costs me an order of magnitude more. One of those is the correct incentive for a business trying to earn readers, and it is not the one I was paying for.

But the money is still not the argument, and neither is the fun. The argument is the section above it: my subscriber list, consent history, and send record now live in my own estate, next to the content they refer to, under my own retention rules, exportable because they were never captive. The provider I depend on moves bytes; everything that constitutes the relationship with my audience is mine.

That twenty-year-old mailer did not teach me how email works now. What it left me was the shape of what I did not know, which is a different and more useful thing. I still had the lexicon: bounces, suppression, double opt-in, list hygiene, the difference between a soft failure and a hard one. All of it was twenty years out of date, and everything that matters had moved underneath it. The LLM filled exactly that gap. It did not supply the judgement about what a subscriber is owed, and it could not have told me which of these failures I would find unforgivable. It supplied the twenty years of architecture, tooling and capability I had missed, on demand, in a vocabulary I already spoke.

That is the honest precondition on everything above. Ten days works if you arrive knowing the problem well enough to interrogate the answers.

You probably shouldn’t build this. If your list is small, your consent story is comfortable, and your provider’s features earn their fee, the ten days and the failure stories above are the case for staying put; read them as a price list. Notice too that the arithmetic only works because my send volume is tiny relative to my list: at 3.3% utilisation the rented plan is nearly all storage tax. Invert that ratio, send daily to a list a tenth the size, and a provider’s per-send infrastructure starts genuinely earning its fee, and my conclusion flips with it. Build it when the list is the asset, the provider is the commodity, the pricing model bills you for something other than what you use, and you have the observability discipline to run a system whose worst failures make no sound.

The send was an afternoon. The system was the week. The difference between the two is the product.

Enjoyed this? One click, no account.

Comments Subscribe

Questions this answers

Should I build my own alternative to Mailchimp to send my newsletter emails?

In most cases you should not build your own Mailchimp replacement, because recreating the consent rules, suppression handling, observability, deliverability, identity, migration, and safe deployment pipeline is a week or more of serious engineering work whose worst failures are silent. It only becomes attractive when your list itself is the asset, your provider’s pricing charges mainly for unused contact storage, you can keep send volume low relative to list size, and you have the operational discipline to run and observe a production-grade email system. For typical lists and usage, staying with a provider whose features you value is the better trade, even if the pricing feels unfair.

How much does it actually cost to run your own Mailchimp-style email system compared to paying for Mailchimp?

In the described case, Mailchimp costs £1,438.56 per year now and would jump to £2,042.52 per year when crossing 10,000 contacts, while an equivalent self-built system on Azure at the same volumes is estimated at around £10 per year in cloud costs. The big distinction is that Mailchimp’s pricing scales with contacts stored—an axis the author barely uses but can’t stop growing—whereas the self-hosted cost scales with emails actually sent, which the operator directly controls.