<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Houzelle</title>
    <description>The latest articles on DEV Community by Houzelle (@eric-houz).</description>
    <link>https://dev.to/eric-houz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4050735%2F12422937-2508-41ea-992b-559f0b590f0f.webp</url>
      <title>DEV Community: Houzelle</title>
      <link>https://dev.to/eric-houz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eric-houz"/>
    <language>en</language>
    <item>
      <title>Catch-all email aliasing: a different address for every service (Haraka + SRS + DKIM)</title>
      <dc:creator>Houzelle</dc:creator>
      <pubDate>Tue, 28 Jul 2026 07:21:53 +0000</pubDate>
      <link>https://dev.to/eric-houz/catch-all-email-aliasing-a-different-address-for-every-service-haraka-srs-dkim-545a</link>
      <guid>https://dev.to/eric-houz/catch-all-email-aliasing-a-different-address-for-every-service-haraka-srs-dkim-545a</guid>
      <description>&lt;p&gt;Most people use the same email address everywhere. One breach and it leaks for&lt;br&gt;
years. The known fix is a different alias per service — but every tool I tried&lt;br&gt;
made me &lt;strong&gt;create each alias upfront&lt;/strong&gt;. I wanted the opposite: a subdomain where&lt;br&gt;
&lt;em&gt;every&lt;/em&gt; address just works, and the alias is born the moment the first email&lt;br&gt;
arrives.&lt;/p&gt;

&lt;p&gt;This post is the engineering behind that: how to run a &lt;strong&gt;catch-all subdomain&lt;/strong&gt;&lt;br&gt;
mail flow without becoming an open relay, and how to forward mail without&lt;br&gt;
nuking your deliverability (SPF, SRS, DKIM, DMARC). It's what powers&lt;br&gt;
&lt;a href="https://www.tomatoes.run" rel="noopener noreferrer"&gt;Tomatoes.run&lt;/a&gt; in production, but the ideas apply to&lt;br&gt;
any forwarding setup.&lt;/p&gt;
&lt;h2&gt;
  
  
  The core idea: decide at RCPT time, not at signup
&lt;/h2&gt;

&lt;p&gt;Instead of a table of pre-created aliases, you give each user a personal&lt;br&gt;
subdomain — &lt;code&gt;you.example.com&lt;/code&gt; — and treat &lt;strong&gt;every&lt;/strong&gt; local-part as potentially&lt;br&gt;
valid: &lt;code&gt;amazon@you.example.com&lt;/code&gt;, &lt;code&gt;github@you.example.com&lt;/code&gt;, anything. The&lt;br&gt;
validity decision happens &lt;strong&gt;when the mail is received&lt;/strong&gt;, not when an alias is&lt;br&gt;
created.&lt;/p&gt;

&lt;p&gt;The stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Haraka&lt;/strong&gt; (Node.js SMTP server) as the MX.&lt;/li&gt;
&lt;li&gt;A small internal &lt;strong&gt;HTTP API&lt;/strong&gt; (Next.js route) that owns the
forward / reject / tempfail decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres&lt;/strong&gt; for users, aliases and &lt;em&gt;metadata only&lt;/em&gt; — message content is
never stored, it's forwarded immediately.
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inbound mail ──&amp;gt; Haraka (MX) ──hook_rcpt──&amp;gt; internal API ──&amp;gt; decision
                                                              │
                        forward ◄─ rewrite envelope (SRS) ────┘
                        + DKIM sign ──&amp;gt; outbound queue ──&amp;gt; user's real inbox
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Challenge 1 — a catch-all that isn't an open relay
&lt;/h2&gt;

&lt;p&gt;The scary part of "accept any recipient" is accidentally relaying spam. The&lt;br&gt;
trick is to be optimistic only for addresses you &lt;em&gt;own&lt;/em&gt;, and &lt;strong&gt;fail closed&lt;/strong&gt; for&lt;br&gt;
everything else.&lt;/p&gt;

&lt;p&gt;In Haraka's &lt;code&gt;hook_rcpt&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hook_rcpt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rcpt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rcpt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Our own catch-all subdomains: accept optimistically, resolve later.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Custom domains (bring-your-own): must be verified. Ask the API,&lt;/span&gt;
  &lt;span class="c1"&gt;// with a short-TTL cache to avoid a round-trip per RCPT.&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;known&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isVerifiedDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// API + cache&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;known&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;OK&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// API down? DENYSOFT (4xx) — the sender retries, we lose nothing,&lt;/span&gt;
    &lt;span class="c1"&gt;// and we never relay something we couldn't validate. Fail CLOSED.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DENYSOFT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DENYSOFT&lt;/code&gt; (a 4xx tempfail), not &lt;code&gt;DENY&lt;/code&gt;&lt;/strong&gt;, when the validating API is
unreachable. SMTP is store-and-forward: the sending server retries for days.
A few minutes of downtime loses zero mail, and you never blindly accept.&lt;/li&gt;
&lt;li&gt;The custom-domain gate is what keeps you off "open relay" lists. No
verification, no acceptance.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Challenge 2 — forwarding breaks SPF, so rewrite the envelope (SRS)
&lt;/h2&gt;

&lt;p&gt;Naive forwarding looks like this: mail comes in for &lt;code&gt;you&lt;/code&gt;, you resend it to the&lt;br&gt;
user's real inbox keeping the original &lt;code&gt;MAIL FROM&lt;/code&gt;. The receiving MX checks&lt;br&gt;
&lt;strong&gt;SPF&lt;/strong&gt; on that &lt;code&gt;MAIL FROM&lt;/code&gt; domain… and sees &lt;em&gt;your&lt;/em&gt; server sending on behalf of&lt;br&gt;
someone else's domain → &lt;strong&gt;SPF fail&lt;/strong&gt; → spam folder or reject.&lt;/p&gt;

&lt;p&gt;The fix is &lt;strong&gt;SRS (Sender Rewriting Scheme)&lt;/strong&gt;: rewrite the envelope sender to&lt;br&gt;
your own domain, encoded so bounces can be reversed back to the original&lt;br&gt;
sender.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// forward:  bob@gmail.com  -&amp;gt;  SRS0=hash=tt=gmail.com=bob@example.com&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bounce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;srs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;originalMailFrom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// on a bounce hitting SRS0=... @example.com, reverse it back:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;srs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bounceRecipient&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// -&amp;gt; bob@gmail.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now SPF is checked against &lt;em&gt;your&lt;/em&gt; domain, which &lt;em&gt;does&lt;/em&gt; authorize your server.&lt;br&gt;
The hash makes the token tamper-proof and reversible, so DSNs still reach the&lt;br&gt;
real sender. Keep the SRS secret stable — rotating it invalidates in-flight&lt;br&gt;
bounce addresses.&lt;/p&gt;
&lt;h2&gt;
  
  
  Challenge 3 — DKIM-sign outbound, per domain
&lt;/h2&gt;

&lt;p&gt;Even with SPF happy, unsigned forwarded mail is suspicious. So the outbound&lt;br&gt;
message is &lt;strong&gt;DKIM-signed&lt;/strong&gt; with your domain (&lt;code&gt;d=example.com&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The interesting case is &lt;strong&gt;bring-your-own-domain&lt;/strong&gt;. When a user adds their own&lt;br&gt;
domain, you generate an RSA keypair, store the private key, and publish the&lt;br&gt;
public key as their DNS TXT record. Outbound mail for that user then gets&lt;br&gt;
signed &lt;strong&gt;twice&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;d=example.com&lt;/code&gt; on the SRS return-path (envelope alignment), and&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;d=theircustomdomain.com&lt;/code&gt; on the visible &lt;code&gt;From:&lt;/code&gt; (author-domain alignment),&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;so &lt;strong&gt;DMARC&lt;/strong&gt; passes on the domain that actually appears in the headers. Two&lt;br&gt;
signatures, one message — a small additive &lt;code&gt;queue_outbound&lt;/code&gt; hook that reuses the&lt;br&gt;
DKIM signing stream.&lt;/p&gt;
&lt;h2&gt;
  
  
  Challenge 4 — the "no pre-creation" magic
&lt;/h2&gt;

&lt;p&gt;Back at the API, the decision endpoint is where the product logic lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// POST /internal/email/receive  { alias, domain, sender, ... }&lt;/span&gt;
&lt;span class="c1"&gt;// returns one of: forward | reject | tempfail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Unknown-but-valid recipient → &lt;strong&gt;auto-create the alias&lt;/strong&gt; on first email and
&lt;code&gt;forward&lt;/code&gt;. The alias simply &lt;em&gt;appears&lt;/em&gt; in the dashboard; the user never
created it.&lt;/li&gt;
&lt;li&gt;Free-plan cap reached → &lt;code&gt;reject&lt;/code&gt; new aliases (existing ones keep working).&lt;/li&gt;
&lt;li&gt;Disabled alias → &lt;code&gt;reject&lt;/code&gt;, so leaked addresses go silent at the server edge.&lt;/li&gt;
&lt;li&gt;Only &lt;strong&gt;metadata&lt;/strong&gt; is recorded (sender, date, size, status). The body is
streamed straight through, never persisted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That single "decide at receive time" inversion is what removes the&lt;br&gt;
create-an-alias-first step entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tempfail vs reject semantics&lt;/strong&gt;: use 4xx when &lt;em&gt;you&lt;/em&gt; might be wrong
(dependency down), 5xx only when the address is genuinely invalid/blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DMARC alignment&lt;/strong&gt; is about the &lt;em&gt;header From&lt;/em&gt;, not the envelope — hence the
per-domain DKIM signature above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catch-all + spam&lt;/strong&gt;: every address existing means every address can be
spammed. Per-alias disable (server-side reject) is the escape hatch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't store content.&lt;/strong&gt; It's less liability and, honestly, a better privacy
story — you only ever hold metadata.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This runs in production as &lt;a href="https://www.tomatoes.run" rel="noopener noreferrer"&gt;Tomatoes.run&lt;/a&gt;, a&lt;br&gt;
France/EU-hosted take on per-service email aliases (independent, GDPR by&lt;br&gt;
design). If you've fought SPF/DKIM/SRS on forwarding before, I'd love your war&lt;br&gt;
stories in the comments — deliverability is a rabbit hole and I'm still digging. 🍅&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
