Engineering Journal · ARCHITECTURE · September 3, 2026 · 18 min read
A Million Emails a Month Is an Operations Problem
At college volume, mail is not a notification helper. It is a queued product with scheduling, identity, failure isolation, and a reputation you can lose on a Tuesday.
By Golam Sorwar, Tech Lead and Full Stack Engineer in Dublin.
Once you are sending on the order of a million and a half messages a month, it is no longer honest to talk about email as a Laravel notification. That number is not a trophy. It is a description of load: reminders, notices, receipts, operational mail, the unglamorous sentences an institution has to send to keep a week moving.
I have watched teams treat that load as a transport detail. Put the address in a mailable, throw it on the default queue, trust the provider. That works at a volume where failure is a support ticket. At this volume, failure is a reputation event, a backlog that becomes yesterday's news arriving on Friday, or a burst that looks like abuse to a mailbox provider who does not care that your intent was pastoral.
This is the most operational essay I have written about our stack, because that is what the work became. The code is not clever. The design is about isolation, honesty of state, and not letting a campaign, a password reset, and a finance receipt share a fate they did not earn.
Context
The mail is transactional more than marketing, though the line blurs when you send a lot of "please do this" messages on a calendar. Users are students, staff, sometimes agents or parents. The content is ordinary and the timing is not: a reminder that is useful on Monday is noise on Thursday. A receipt that arrives twice is a finance incident. A blast that goes to the wrong segment is a trust incident.
The application is still a Laravel monolith with workers, Redis, and a provider in front of the public internet. We did not need a separate company to send mail. We needed the monolith to stop pretending mail was a side effect of a controller.
I still write code in this path. I also get the conversations when a mailbox provider starts treating us more coldly, or when a scheduled send overlaps a peak login window, or when someone asks why a message is "sent" in the UI and absent in the inbox. Those conversations are the architecture.
The real problem
The apparent problem is delivery. Did it send. Why did it bounce. Can we resend. Those questions are valid and they are not the first ones.
The deeper problem is that email is a distributed product with a hostile downstream. You do not control inbox placement, delay, or whether a student is using an address their institution already trained filters to hate. Your success condition cannot be "the provider accepted the MIME." That is a hop. The product condition is "the right person got the right meaning in a window that still matters, and we can prove we did not spam them."
A second deeper problem is coupling. If password resets, invoice mail, and a scheduled notice share one queue, one provider key, one rate, and one worker pool, then a busy notice day becomes a login incident. I have seen "just email them" take down the thing the email was supposed to support.
There is also a data problem dressed as a feature. People want opens and clicks because they are used to marketing tools. In an operational setting, tracking is evidence and it is also a privacy choice. If you collect it, you now have a store that will be asked to explain itself. If you do not, you will still be asked why someone did not see the message. You have to pick which ignorance you can live with.
Constraints
Volume is lumpy. Monthly totals hide the hour when a term reminder and a payment notice land together. The system has to survive the lump, not the average.
We cannot buy our way out of reputation. A bigger instance does not make Gmail like you. Sending faster can make them like you less. Throughput is a political problem with a technical surface.
Templates change. Legal language changes. A stored body and a live template will disagree if you are sloppy about what you persist at send time. Historical mail is a record. If you re-render history with today's template, you have rewritten the past.
Mobile and web clients will trigger mail from more than one place. Idempotency is not optional. The same user hitting save twice, or a worker retrying, is how you get the "why did I get this three times" thread.
And we cannot pause the institution to rebuild mail. Every improvement ships next to live sends. That is the same constraint as every other essay in this series, applied to a channel people still treat as plumbing.
Options considered
Keep mailables in the request, default queue, one provider. Advantage: the framework already knows how. Disadvantage: you will discover isolation during an incident. Risk: a template bug or a slow provider takes the user-facing request or starves other jobs. Maintenance is easy until it is not. Business impact is a quiet accumulation of "email is flaky."
Move everything to a dedicated ESP product and operate from their UI. Advantage: bounce handling and analytics come in the box. Disadvantage: your application becomes a passenger; scheduled institutional logic still lives with you. Risk: two sources of truth for who was emailed, and a vendor change that your domain cannot describe. Fine as the pipe. Poor as the brain.
Build an internal mail platform with its own service and datastore. Advantage: clean boundary, independent scale. Disadvantage: you now run a product. Risk: the boundary is fake if the monolith still renders every template and the service is only SMTP with extra steps. I would do this when the team and the volume justify a second deployable. I would not do it as a way of avoiding queue design.
Keep mail in the application, but give it a real domain: messages as records, queues by class, schedules as first-class work, provider as a witness, tracking as an explicit choice. Advantage: the organisation can ask the application who was emailed. Disadvantage: you write more than a mailable. Risk: the table grows without retention. This is the option that matched our team and our need to explain ourselves.
Decision
We kept email inside the product and stopped treating it as a notification helper. A message is a row with a purpose, an audience, a template version, a schedule, a provider id, and a state we own. Workers send. The provider reports. We do not collapse those.
I chose that because the questions we get are application questions. Who was in the segment. Why did this person get the payment mail twice. Can we send this at 09:00 local and not at 02:00 because a job ran early. An ESP can help you see opens. It cannot, by itself, know your domain.
Why not a separate service? Because the cost of a second system would have been another neighbour, and this series is already full of neighbours. A clear module, separate queues, and a table we can query got us the isolation we needed without a new on-call shape.
Why not send in the request? Because the user's click is not a good timeout boundary for a provider, and because retries do not belong in a browser refresh.
Implementation / Process
The request writes an intent. It does not talk to SMTP. That sounds small. It is the whole reliability story. The intent has an idempotency key for the obvious cases: the same receipt, the same reset in a short window, the same scheduled notice for the same person on the same day.
Queues are split by class. Fast, small, user-blocking mail — resets, the message that unblocks a login — does not sit behind a term reminder to tens of thousands. Bulk has its own workers, its own rate, and its own permission to be slow. Mixing them is how you invent a false emergency.
Scheduling is a clock you own. We do not enqueue thirty days of rendered mail on the day someone clicks "plan." We enqueue the decision, and we materialise near the send time, with a cap. If you materialise too early, you send stale facts. If you materialise too late, you miss the window. The compromise is boring and written down.
The provider is a witness. We store their message id when we have it. Webhooks update delivered, bounced, complained. Those states do not overwrite our purpose. A bounce is a fact about an address. It is not, by itself, a fact about whether the student is still enrolled.
Tracking is narrow. If we need to know whether an operational mail was at least accepted, we can know that without building a marketing suite. Click tracking in institutional mail should be a decision, not a default. Defaults become surveillance by accident.
Rendering is versioned. The body we sent is the body we sent. Templates can evolve for the next message. Support should not have to guess.
Suppression is part of the domain. Bounces and complaints are not only provider numbers. They are a list you consult before you are a good neighbour. Sending again because a job retried is how you turn a bounce into a reputation problem.
Deployment of workers is part of mail. A release that leaves old workers on an old template, or that restarts everyone in the middle of a bulk window, is an email incident. We treat it that way now.
// Split the work the way the institution already splits urgency.
MailIntent::query()->due()->each(function (MailIntent $intent) {
$queue = $intent->isInteractive() ? 'mail-fast' : 'mail-bulk';
SendMailIntent::dispatch($intent)->onQueue($queue);
});
Problems and failures
We once let a bulk product share a connection pool with interactive mail because the provider account was the same. Account is not isolation. Rate and queue are. The morning felt like "email is down." Email was busy.
Scheduled sends drifted after a timezone assumption that was true for the office and false for the audience. A reminder that is technically on time in UTC can still be rude. We had to make local time a first-class input, not a format.
Retries duplicated mail when the provider accepted a message and we died before recording the id. That is the same partial-success story as payments, wearing a friendlier protocol. Idempotency keys on our side are not enough if we cannot ask, or safely assume, what they already took.
Tracking tempted people. As soon as a number existed, someone wanted to manage humans with it. We had to say no to a few uses. A low open rate on a plain-text operational notice is not a moral fact about a student.
Template includes and shared layouts created a class of bug where a footer change went out in a blast that had been approved in a different season. Versioning the sent body was the fix. Reviewing bulk content like a release was the process.
I under-communicated backpressure. When we slowed a send to protect reputation, it looked like the system was broken. Operations need a sentence: we are sending, on purpose, at a rate the internet will tolerate.
Trade-offs
We chose delay over blast. That is worse for the person who wants the campaign out before lunch. It is better for the next hundred days of inbox placement.
We chose a larger mail module over a smaller mental model. New engineers have to learn that "send email" is not one helper. I think that cost is honest. A helper that hides a million messages is a lie.
We chose less tracking than a marketing stack would consider normal. We lost some charts. We kept a cleaner story about what we collect.
We did not choose a separate service. We accepted that a serious module in a monolith can be the right size. If the team and the failure modes grow, that decision can be reopened. It should be reopened because of an isolation need, not because services are fashionable.
Result
Mail is still a source of tickets. It is a less mystical one. We can usually say whether we accepted the intent, whether a worker sent it, what the provider reported, and whether we suppressed the address. That is enough to have an adult conversation with support.
The interactive path no longer competes as directly with the bulk path. Busy institutional days still hurt. They hurt in the bulk workers, which is where they should hurt.
I will not invent a delivery percentage. Providers already lie with averages, and our audience is not a lab. The result I trust is operational: fewer days when "email" meant "the product is stuck," and a clearer no when someone asked to dump a segment at full speed.
What I would do differently today
I would have split queues before the volume made the split feel dramatic. Isolation is cheaper when it looks slightly premature.
I would have persisted the sent body from the first bulk product, not after the first "what did we actually send" argument. History is cheaper to keep than to reconstruct.
I would treat provider account structure as architecture earlier: separate credentials and streams for mail that must never share a reputation fate. That is a procurement conversation and a technical one. I used to think it was only procurement.
Broader lesson
At volume, email is not a library. It is an operations problem with a template on top. The design questions are the same as any other serious outbound integration: intent before IO, isolation of urgency, evidence after the fact, and a rate that respects a world you do not own.
If you still talk about mail as a notification, you will keep putting it on the default queue. The default queue is where other people's urgency goes to die.
Closing thought
A million and a half messages a month did not make us an email company. It made it indefensible to treat mail as a helper. Write the intent. Split the work. Keep the body. Believe the provider only as a witness. The inbox is not your server, and it will not adopt your release schedule.