<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Writing – Naufal Firoos</title>
    <link>https://byros.dev/en/articles/</link>
    <atom:link href="https://byros.dev/en/feed.xml" rel="self" type="application/rss+xml" />
    <description>Articles by Naufal Firoos on building full-stack systems: .NET, Next.js, PostgreSQL, infrastructure, and reviewing AI-written code.</description>
    <language>en</language>
    <item>
      <title>Why Tempo sends email through an outbox</title>
      <link>https://byros.dev/en/articles/outbox-instead-of-direct-email/</link>
      <guid isPermaLink="true">https://byros.dev/en/articles/outbox-instead-of-direct-email/</guid>
      <pubDate>Thu, 05 Nov 2026 00:00:00 GMT</pubDate>
      <description>A sign-up should not fail because SMTP is slow. How Tempo writes email to an outbox table in the same transaction and lets a background job deliver it.</description>
      <category>dotnet</category>
      <category>postgresql</category>
      <category>reliability</category>
      <category>tempo</category>
      <content:encoded><![CDATA[<p>Tempo is a time-tracking and invoicing tool I built with a .NET 10 API and PostgreSQL. It sends a handful of emails: account verification, workspace invites, invoice notices. Calling the mail sender inside the request works until the mail server is slow, or down, or rate-limits you, and then a user&#39;s sign-up fails for a reason that has nothing to do with them.</p>
<h2>The rule</h2>
<p>A business change and the email it causes are written together, or not at all. The request never talks to SMTP.</p>
<p>When a handler creates an invite, it adds an <code>OutboxMessage</code> row with a type and a JSON payload to the same <code>DbContext</code>, and <code>SaveChanges</code> commits both in one transaction. If the transaction rolls back, there is no orphaned email. If it commits, the email is guaranteed to be picked up later.</p>
<h2>The dispatcher</h2>
<p>A hosted <code>BackgroundService</code> does the sending:</p>
<ul>
<li>Every 15 seconds it reads pending rows: not yet dispatched, fewer than five attempts, oldest first, 25 at a time.</li>
<li>It drains the backlog in a loop until a batch comes back short. One batch per tick fell behind after a burst of sign-ups, and verification mail arrived minutes late.</li>
<li>Each row is rendered into an email and sent. On success it gets a <code>DispatchedAt</code> timestamp. On failure the retry count goes up and the first 500 characters of the error are stored, so a stuck message explains itself.</li>
<li>If no SMTP host is configured, the rows are only marked as dispatched. Local development and tests never need a mail server.</li>
</ul>
<h2>What it costs</h2>
<p>One more table, one background worker to keep healthy, and email that is a few seconds late instead of instant. In exchange, a request&#39;s success depends only on the database, retries happen without anyone noticing, and every message that failed five times is still in a table where I can read why.</p>
<h2>What I would add next</h2>
<p>A dead-letter view in the admin area for rows that hit the retry limit, and a metric for the age of the oldest pending message. That number is the one that tells you the dispatcher has stopped.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
