<?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: Bucabay</title>
    <description>The latest articles on DEV Community by Bucabay (@bucabay).</description>
    <link>https://dev.to/bucabay</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%2F4010551%2Fff81c40c-e957-4d68-bcc6-c6f8430f6c81.jpg</url>
      <title>DEV Community: Bucabay</title>
      <link>https://dev.to/bucabay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bucabay"/>
    <language>en</language>
    <item>
      <title>Mautic email deliverability: replace SMTP with the Symfony Mailer DSN transport</title>
      <dc:creator>Bucabay</dc:creator>
      <pubDate>Sat, 08 Aug 2026 17:41:11 +0000</pubDate>
      <link>https://dev.to/bucabay/mautic-email-deliverability-replace-smtp-with-the-symfony-mailer-dsn-transport-30lj</link>
      <guid>https://dev.to/bucabay/mautic-email-deliverability-replace-smtp-with-the-symfony-mailer-dsn-transport-30lj</guid>
      <description>&lt;p&gt;&lt;strong&gt;Mautic is a marketing-automation platform, so email is the product, not a side effect.&lt;/strong&gt;&lt;br&gt;
A contact form on a CMS sends a handful of messages a day; a Mautic campaign to a real&lt;br&gt;
segment can fan out to thousands in one send. That makes Mautic's mail transport the single&lt;br&gt;
highest-leverage setting in the whole install — and most Mautic docs still point you at&lt;br&gt;
generic SMTP.&lt;/p&gt;

&lt;p&gt;Mautic 5 doesn't need SMTP for this anymore. It moved from SwiftMailer to Symfony Mailer,&lt;br&gt;
which resolves transports from a &lt;strong&gt;DSN string&lt;/strong&gt;, and MailKite ships a first-party transport&lt;br&gt;
for exactly that: &lt;a href="https://github.com/mailkite/symfony-mailer" rel="noopener noreferrer"&gt;&lt;code&gt;mailkite/symfony-mailer&lt;/code&gt;&lt;/a&gt;.&lt;br&gt;
Install it, set one field, and Mautic's send errors go from a numeric SMTP code to the&lt;br&gt;
API's own explanation of what actually went wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this beats pointing Mautic at SMTP
&lt;/h2&gt;

&lt;p&gt;Generic SMTP gives you a status code and, if you're lucky, a line of server text. It also&lt;br&gt;
means every send is a full protocol round-trip: connect, EHLO, AUTH, MAIL FROM, RCPT TO,&lt;br&gt;
DATA. The DSN transport skips the protocol layer and calls the MailKite API directly, and&lt;br&gt;
when something's wrong — an unverified domain, a suppressed recipient, a rate limit — it&lt;br&gt;
says so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unable to send an email: Can't send yet — domain not verified. (MailKite API status 403).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare that to &lt;code&gt;550 5.7.1 Relaying denied&lt;/code&gt; and try to explain to a client why their&lt;br&gt;
newsletter didn't go out.&lt;/p&gt;
&lt;h2&gt;
  
  
  Install the transport
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require mailkite/symfony-mailer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Requires PHP 8.1+ and Symfony Mailer 6.4 or 7.x — both satisfied by any current Mautic 5&lt;br&gt;
install.&lt;/p&gt;
&lt;h2&gt;
  
  
  Set the DSN
&lt;/h2&gt;

&lt;p&gt;Get an API key from the &lt;a href="https://app.mailkite.dev" rel="noopener noreferrer"&gt;MailKite dashboard&lt;/a&gt;, then set it as&lt;br&gt;
Mautic's Mailer DSN. Where this lives depends on how your instance is configured — either&lt;br&gt;
&lt;code&gt;.env.local&lt;/code&gt; (or wherever your host reads &lt;code&gt;MAILER_DSN&lt;/code&gt;) or, on some Mautic installs,&lt;br&gt;
&lt;strong&gt;Configuration → Email Settings → Service: DSN&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MAILER_DSN=mailkite+api://mk_live_xxx@default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API key sits in the DSN's user slot — there's no separate SMTP username to invent, and&lt;br&gt;
no password field to keep in sync with it. Mautic autoconfigures the transport factory the&lt;br&gt;
moment the package is installed; nothing else to register.&lt;/p&gt;

&lt;p&gt;Clear Mautic's cache after changing the DSN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/console cache:clear
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verify your sending domain
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;from&lt;/code&gt; address on every campaign has to be on a domain with SPF and DKIM published in&lt;br&gt;
MailKite — that's true whether you're sending through this transport or plain SMTP, and&lt;br&gt;
it's the actual determinant of whether Mautic's mail lands in the inbox or spam. See&lt;br&gt;
&lt;a href="https://mailkite.dev/docs/quickstart" rel="noopener noreferrer"&gt;mailkite.dev/docs/quickstart&lt;/a&gt; if you haven't set&lt;br&gt;
that up yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test it
&lt;/h2&gt;

&lt;p&gt;Create a test email in the Mautic editor and use &lt;strong&gt;Send test&lt;/strong&gt;. Check the MailKite&lt;br&gt;
dashboard's message log to confirm delivery — if it fails, the error you see in Mautic now&lt;br&gt;
comes straight from the API instead of a generic SMTP rejection.&lt;/p&gt;

&lt;h2&gt;
  
  
  No shell access? Fall back to SMTP
&lt;/h2&gt;

&lt;p&gt;Some managed Mautic hosts don't give you Composer access. In that case, the plain SMTP path&lt;br&gt;
still works — same domain, same API key as the password, just without the honest-error&lt;br&gt;
behavior described above:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SMTP Host&lt;/td&gt;
&lt;td&gt;&lt;code&gt;smtp.mailkite.dev&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMTP Port&lt;/td&gt;
&lt;td&gt;&lt;code&gt;587&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encryption&lt;/td&gt;
&lt;td&gt;TLS (STARTTLS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Username&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mailkite&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password&lt;/td&gt;
&lt;td&gt;Your API key (&lt;code&gt;mk_live_…&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A note on campaign-scale sending
&lt;/h2&gt;

&lt;p&gt;Symfony Mailer transports — this one included — send one email per request; that's the&lt;br&gt;
contract every transport follows, not a MailKite-specific limitation. Mautic's own&lt;br&gt;
campaign/batch layer is what turns a segment send into individual calls, so this doesn't&lt;br&gt;
change how a campaign send behaves. If you're calling the MailKite API directly, outside&lt;br&gt;
Mautic, for a very large one-off send, use the PHP SDK's &lt;code&gt;sendBatch()&lt;/code&gt; instead of looping&lt;br&gt;
individual sends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DSN not picked up&lt;/strong&gt; — clear the cache (&lt;code&gt;bin/console cache:clear&lt;/code&gt;) after any change to
&lt;code&gt;MAILER_DSN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"User is not set"&lt;/strong&gt; — the API key has to be in the DSN's user slot:
&lt;code&gt;mailkite+api://mk_live_xxx@default&lt;/code&gt;, not &lt;code&gt;mailkite+api://default&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;535 Authentication failed (SMTP fallback path only)&lt;/strong&gt; — the password field must be your
&lt;code&gt;mk_live_…&lt;/code&gt; API key, not a separate SMTP credential.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Campaign sends but individual test doesn't (or vice versa)&lt;/strong&gt; — check that the specific
&lt;code&gt;from&lt;/code&gt; address used matches a verified domain; Mautic allows setting different from
addresses per campaign.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full transport reference, the mapping table, and what the transport refuses rather than&lt;br&gt;
silently drops:&lt;br&gt;
&lt;a href="https://mailkite.dev/docs/integrations/symfony-mailer" rel="noopener noreferrer"&gt;mailkite.dev/docs/integrations/symfony-mailer&lt;/a&gt;.&lt;br&gt;
Platform-specific config reference:&lt;br&gt;
&lt;a href="https://mailkite.dev/docs/integrations/mautic" rel="noopener noreferrer"&gt;mailkite.dev/docs/integrations/mautic&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mailkite.dev/blog/mautic-email-mailkite-symfony-transport/" rel="noopener noreferrer"&gt;mailkite.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>tutorial</category>
      <category>opensource</category>
    </item>
    <item>
      <title>One DSN, three PHP ecosystems: MailKite's Symfony Mailer transport</title>
      <dc:creator>Bucabay</dc:creator>
      <pubDate>Fri, 07 Aug 2026 17:39:30 +0000</pubDate>
      <link>https://dev.to/bucabay/one-dsn-three-php-ecosystems-mailkites-symfony-mailer-transport-5ehh</link>
      <guid>https://dev.to/bucabay/one-dsn-three-php-ecosystems-mailkites-symfony-mailer-transport-5ehh</guid>
      <description>&lt;p&gt;&lt;code&gt;mailkite/symfony-mailer&lt;/code&gt; resolves a &lt;code&gt;mailkite+api://&lt;/code&gt; DSN into a first-party Symfony Mailer transport — which means Mautic, PrestaShop, Drupal, and any plain Symfony app all get it from one package, with honest errors instead of a generic SMTP rejection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;a href="https://github.com/mailkite/symfony-mailer" rel="noopener noreferrer"&gt;&lt;code&gt;mailkite/symfony-mailer&lt;/code&gt;&lt;/a&gt; is a new package that turns &lt;code&gt;mailkite+api://API_KEY@default&lt;/code&gt; into a valid Symfony Mailer DSN. One &lt;code&gt;composer require&lt;/code&gt;, and every &lt;code&gt;MailerInterface::send()&lt;/code&gt; in a Symfony app — or in Mautic, PrestaShop, or anything else built on Symfony Mailer — delivers through MailKite's Send API instead of SMTP. It's extracted from our existing &lt;code&gt;mailkite/laravel&lt;/code&gt; package, which now depends on it rather than carrying its own copy of the mapping logic. 15 tests, no network, all green. &lt;code&gt;composer require mailkite/symfony-mailer&lt;/code&gt; will resolve once the package clears Packagist's one-time submission step — until then, install straight from the &lt;a href="https://github.com/mailkite/symfony-mailer" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt; (&lt;code&gt;v0.1.0&lt;/code&gt; is tagged).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one package reaches three ecosystems
&lt;/h2&gt;

&lt;p&gt;Symfony Mailer resolves transports from a DSN string. That's a small design choice with a large consequence: any platform built on Symfony Mailer inherits the &lt;em&gt;same&lt;/em&gt; extension point, whether or not that platform has anything else to do with Symfony.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;MailKite\Mailer\Transport\MailKiteTransportFactory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Mailer\Mailer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Mailer\Transport\Dsn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$transport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MailKiteTransportFactory&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Dsn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mailkite+api://mk_live_xxx@default'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$mailer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Mailer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$transport&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write a &lt;code&gt;TransportFactoryInterface&lt;/code&gt; implementation once, and it's live everywhere that mechanism is used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A plain Symfony app&lt;/strong&gt; — set &lt;code&gt;MAILER_DSN&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;, done. Autoconfiguration picks up the factory the moment the package is installed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mautic 5+&lt;/strong&gt; — replaced SwiftMailer with Symfony Mailer and a DSN field in &lt;em&gt;Configuration → Email Settings&lt;/em&gt;. Mautic is worth calling out specifically: it's a marketing-automation platform, so email volume per install runs far ahead of a typical CMS or CRM contact form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PrestaShop 8+&lt;/strong&gt; — Symfony-based mailer since PS8, same DSN mechanism.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drupal&lt;/strong&gt;, via the Symfony Mailer Lite module — a smaller install base (17,528 sites) but the same wiring.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We didn't build four integrations. We built one, and the DSN did the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually new here — and what wasn't
&lt;/h2&gt;

&lt;p&gt;The transport itself isn't new code. It's the same &lt;code&gt;AbstractTransport&lt;/code&gt; subclass that's lived inside &lt;code&gt;mailkite/laravel&lt;/code&gt; since that package shipped: &lt;code&gt;doSend()&lt;/code&gt; converts a &lt;code&gt;SentMessage&lt;/code&gt; into a MailKite &lt;code&gt;send&lt;/code&gt; payload via &lt;code&gt;MessageConverter::toEmail()&lt;/code&gt;, calls the API, and stamps the returned message ID back onto the message.&lt;/p&gt;

&lt;p&gt;What's new is the &lt;strong&gt;factory&lt;/strong&gt; — the piece that resolves a DSN string into that transport — and moving the transport itself out from under Laravel so it isn't Laravel's to own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MailKiteTransportFactory&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractTransportFactory&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Dsn&lt;/span&gt; &lt;span class="nv"&gt;$dsn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;TransportInterface&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dsn&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// throws IncompleteDsnException if missing&lt;/span&gt;
        &lt;span class="nv"&gt;$host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$dsn&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getHost&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'https://api.mailkite.dev'&lt;/span&gt;
            &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://%s%s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$dsn&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;':'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$dsn&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MailKiteTransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$endpoint&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getSupportedSchemes&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'mailkite'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mailkite+api'&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;&lt;code&gt;mailkite/laravel&lt;/code&gt;'s &lt;code&gt;composer.json&lt;/code&gt; now requires &lt;code&gt;mailkite/symfony-mailer&lt;/code&gt; and its &lt;code&gt;MailKiteServiceProvider&lt;/code&gt; instantiates &lt;code&gt;MailKite\Mailer\Transport\MailKiteTransport&lt;/code&gt; directly — the ~200 lines of address, body, and attachment mapping logic exist in exactly one place. The Laravel package's own test suite dropped from 14 tests to 4, because it's no longer testing logic it doesn't own; it tests wiring (does &lt;code&gt;MAIL_MAILER=mailkite&lt;/code&gt; resolve to the right transport, does the SDK client singleton bind, does a missing key throw a helpful error).&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest failures instead of a generic SMTP rejection
&lt;/h2&gt;

&lt;p&gt;This is the part worth dwelling on if you're currently pointing Mautic, PrestaShop, or Drupal at generic SMTP. SMTP gives you a numeric code and maybe a line of server-specific text. The MailKite API returns its own error message — "domain not verified," a suppressed recipient, a rate limit — and this transport surfaces it verbatim instead of translating it into an SMTP-shaped abstraction that loses the detail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$mailer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&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="nc"&gt;TransportException&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// "Unable to send an email: Can't send yet — domain not verified. (MailKite API status 403)."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same honesty applies to what the transport &lt;em&gt;won't&lt;/em&gt; silently drop:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Custom header (&lt;code&gt;X-Campaign&lt;/code&gt;, a tag/metadata header)&lt;/td&gt;
&lt;td&gt;Throws — the API's envelope has no room for it, so refusing beats a header that quietly never arrives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inline (&lt;code&gt;cid:&lt;/code&gt;-embedded) image&lt;/td&gt;
&lt;td&gt;Throws — the API has no Content-ID support; host the image at a URL instead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;More than one reply-to address&lt;/td&gt;
&lt;td&gt;Throws — the API accepts exactly one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unverified &lt;code&gt;from&lt;/code&gt; domain&lt;/td&gt;
&lt;td&gt;Throws with the API's own message and HTTP status&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of those is a case where a generic SMTP transport would either accept the message and let it fail downstream, or reject it with a code that doesn't say why. This one tells you the actual reason at the call site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The batch-sending trade-off, made explicit
&lt;/h2&gt;

&lt;p&gt;Symfony-provided transports send one email per request — that's the &lt;code&gt;TransportInterface&lt;/code&gt; contract, and &lt;code&gt;MailKiteTransport&lt;/code&gt; follows it like every other bridge. Most third-party Symfony Mailer transports stop there: no batch path, full stop, one HTTP round-trip per recipient no matter how many you're sending.&lt;/p&gt;

&lt;p&gt;We didn't want to pretend our transport batches when it doesn't, so it's documented instead: for high-volume sends — a Mautic campaign to a real list, a PrestaShop order-email backlog — call the PHP SDK's &lt;code&gt;sendBatch()&lt;/code&gt; directly rather than looping &lt;code&gt;mailer-&amp;gt;send()&lt;/code&gt; per message. It's a different code path for a different job, not a workaround bolted onto the transport.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require mailkite/symfony-mailer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# .env
MAILER_DSN=mailkite+api://mk_live_xxx@default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full mapping table, troubleshooting, and the Mautic-specific walkthrough: &lt;a href="https://mailkite.dev/docs/integrations/symfony-mailer" rel="noopener noreferrer"&gt;mailkite.dev/docs/integrations/symfony-mailer&lt;/a&gt;. Running Mautic specifically? See &lt;a href="https://mailkite.dev/docs/integrations/mautic" rel="noopener noreferrer"&gt;mailkite.dev/docs/integrations/mautic&lt;/a&gt; for the DSN field location and an SMTP fallback for managed hosts without shell access.&lt;/p&gt;

&lt;p&gt;Using Laravel instead of raw Symfony Mailer? &lt;a href="https://github.com/mailkite/laravel" rel="noopener noreferrer"&gt;&lt;code&gt;mailkite/laravel&lt;/code&gt;&lt;/a&gt; wraps this same transport with &lt;code&gt;MAIL_MAILER=mailkite&lt;/code&gt; and Laravel's own config conventions — nothing to change there, it's already using the code in this post.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mailkite.dev/blog/symfony-mailer-transport/" rel="noopener noreferrer"&gt;mailkite.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>MailKite now ships a native mailer for six more frameworks</title>
      <dc:creator>Bucabay</dc:creator>
      <pubDate>Thu, 06 Aug 2026 17:41:44 +0000</pubDate>
      <link>https://dev.to/bucabay/mailkite-now-ships-a-native-mailer-for-six-more-frameworks-nai</link>
      <guid>https://dev.to/bucabay/mailkite-now-ships-a-native-mailer-for-six-more-frameworks-nai</guid>
      <description>&lt;p&gt;MailKite shipped native mail integrations for Spring Boot, ASP.NET Core, Next.js, NestJS, Flask, and FastAPI in one day — three new packages, two verified starters with no new code, and two real bugs found in already-published packages along the way.&lt;/p&gt;

&lt;p&gt;Six frameworks, one day, three new packages. &lt;code&gt;mailkite-spring-boot-starter&lt;/code&gt; gives Spring Boot a native &lt;code&gt;JavaMailSender&lt;/code&gt;. &lt;code&gt;MailKite.AspNetCore&lt;/code&gt; gives ASP.NET Core Identity a native &lt;code&gt;IEmailSender&lt;/code&gt;. &lt;code&gt;next-mailkite&lt;/code&gt; gives Next.js a signature-verified inbound webhook route it didn't have before. NestJS and Flask and FastAPI got runnable starters instead of packages, on purpose, for reasons explained below — and building all six surfaced two real bugs in code we'd already shipped, which we fixed rather than buried.&lt;/p&gt;

&lt;p&gt;The fastest one to show is ASP.NET Core, because the whole idea — swap one interface, every existing call site keeps working — is clearest there. &lt;code&gt;IEmailSender&lt;/code&gt; is the interface ASP.NET Core Identity already calls for every account-confirmation and password-reset email; &lt;code&gt;MailKite.AspNetCore&lt;/code&gt; implements it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// starters/aspnet-core/Program.cs&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddDefaultIdentity&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IdentityUser&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SignIn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequireConfirmedAccount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddEntityFrameworkStores&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApplicationDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// MailKite: every account-confirmation / password-reset email Identity sends goes through&lt;/span&gt;
&lt;span class="c1"&gt;// POST /v1/send via the official MailKite .NET SDK.&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddMailKiteEmailSender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// FromAddress/FromName come from appsettings' "MailKite" section; ApiKey falls back to&lt;/span&gt;
    &lt;span class="c1"&gt;// the MAILKITE_API_KEY environment variable — never commit a real key here.&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire integration. No controller changes, no template rewrites — Identity's Register and ForgotPassword pages just start sending through MailKite. We ran this starter for real: &lt;code&gt;dotnet build&lt;/code&gt; clean, the app boots, &lt;code&gt;/Identity/Account/Register&lt;/code&gt; and &lt;code&gt;/Identity/Account/ForgotPassword&lt;/code&gt; both render, and DI resolves the sender correctly at startup.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;New package&lt;/th&gt;
&lt;th&gt;Already worked&lt;/th&gt;
&lt;th&gt;No seam — docs only&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spring Boot — &lt;code&gt;JavaMailSender&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;NestJS — &lt;code&gt;@nestjs-modules/mailer&lt;/code&gt; + &lt;code&gt;nodemailer-mailkite-transport&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Flask — Flask-Mail → &lt;code&gt;smtplib&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ASP.NET Core — &lt;code&gt;IEmailSender&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;verified, shipped a starter only&lt;/td&gt;
&lt;td&gt;FastAPI — &lt;code&gt;fastapi-mail&lt;/code&gt; → &lt;code&gt;aiosmtplib&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js — webhook + &lt;code&gt;sendEmail()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;runnable starters, SMTP relay or SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Found while building, not before:&lt;/strong&gt; &lt;code&gt;nodemailer-mailkite-transport@0.1.0&lt;/code&gt;'s ESM-only &lt;code&gt;exports&lt;/code&gt; map breaks &lt;code&gt;require()&lt;/code&gt; in NestJS's CJS build (fixed locally in 0.1.1, unreleased) · the Python SDK throws an unhandled &lt;code&gt;JSONDecodeError&lt;/code&gt; when Cloudflare's WAF returns a non-JSON body (confirmed independently on Flask and FastAPI).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Same day, three integration shapes — matched to what each framework's mail layer actually exposes, not forced to fit one template.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Spring Boot: the largest audience we didn't already have a play for
&lt;/h2&gt;

&lt;p&gt;In 2025, 15.6% of professional developers reported using Spring Boot (&lt;a href="https://survey.stackoverflow.co/2025/technology" rel="noopener noreferrer"&gt;Stack Overflow Developer Survey&lt;/a&gt;, 2025) — the biggest framework population on this list that MailKite had no first-party integration for before today. Spring's own mail abstraction is &lt;code&gt;MailSender&lt;/code&gt;/&lt;code&gt;JavaMailSender&lt;/code&gt;, the same seam &lt;code&gt;spring-boot-starter-mail&lt;/code&gt; fills, and &lt;code&gt;mailkite-spring-boot-starter&lt;/code&gt; implements both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- starters/spring-boot/pom.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;dev.mailkite&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;mailkite-spring-boot-starter&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;0.1.0&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# application.properties — MAILKITE_API_KEY env var binds to mailkite.api-key automatically
&lt;/span&gt;&lt;span class="py"&gt;mailkite.default-from&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${MAILKITE_DEFAULT_FROM:hello@yourdomain.com}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;MAILKITE_API_KEY&lt;/code&gt; and any existing &lt;code&gt;@Service&lt;/code&gt; calling &lt;code&gt;JavaMailSender&lt;/code&gt; starts delivering through MailKite — no new code paths. We didn't stop at &lt;code&gt;mvn package&lt;/code&gt;: the demo app actually ran (&lt;code&gt;mvn spring-boot:run&lt;/code&gt;), rendered its send form, and a real &lt;code&gt;POST&lt;/code&gt; reached the live MailKite API and came back with the API's own auth error, proving the full chain — controller → &lt;code&gt;MailKiteMailSender&lt;/code&gt; → SDK → &lt;code&gt;api.mailkite.dev&lt;/code&gt; — is wired correctly end to end. Nine unit tests pass locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  ASP.NET Core: .NET's largest audience, tied at the top
&lt;/h2&gt;

&lt;p&gt;ASP.NET Core tied for the largest professional-developer framework share in the 2025 survey at 21.3% (&lt;a href="https://survey.stackoverflow.co/2025/technology" rel="noopener noreferrer"&gt;Stack Overflow&lt;/a&gt;, 2025). &lt;code&gt;MailKite.AspNetCore&lt;/code&gt; also implements the newer generic &lt;code&gt;IEmailSender&amp;lt;TUser&amp;gt;&lt;/code&gt; that .NET 8's Identity API endpoints expect, so both the classic Identity UI and the newer minimal-API Identity story are covered by the same package.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next.js: the single most-used framework, and it had almost nothing
&lt;/h2&gt;

&lt;p&gt;Next.js was the single most-used framework in the entire 2025 survey at 21.5% (&lt;a href="https://survey.stackoverflow.co/2025/technology" rel="noopener noreferrer"&gt;Stack Overflow&lt;/a&gt;, 2025) — ahead of Express, ahead of ASP.NET Core, ahead of everything. Until today, the only MailKite-and-Next.js code in this repo was a five-line REST snippet. &lt;code&gt;next-mailkite&lt;/code&gt; is a real package: a signature-verified inbound webhook route handler for the App Router, plus a &lt;code&gt;sendEmail()&lt;/code&gt; wrapper.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// starters/nextjs/app/api/mailkite/inbound/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createMailKiteRouteHandler&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next-mailkite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/mailkite-handler&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createMailKiteRouteHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one export verifies the &lt;code&gt;x-mailkite-signature&lt;/code&gt; header and dispatches the parsed event to your own handler — no hand-rolled HMAC. We didn't just unit-test it: the starter's &lt;code&gt;/inbound&lt;/code&gt; log page rendered a real HMAC-signed synthetic webhook payload end to end, and &lt;code&gt;tsc&lt;/code&gt;/&lt;code&gt;next build&lt;/code&gt; are both clean (13/13 vitest cases passing on the package itself).&lt;/p&gt;

&lt;h2&gt;
  
  
  NestJS: the honest answer was "it already worked"
&lt;/h2&gt;

&lt;p&gt;NestJS is a structured TypeScript framework with its own mail convention: &lt;code&gt;@nestjs-modules/mailer&lt;/code&gt;'s &lt;code&gt;MailerModule&lt;/code&gt; takes any nodemailer transport object. Our existing &lt;code&gt;nodemailer-mailkite-transport&lt;/code&gt; package already &lt;em&gt;is&lt;/em&gt; a nodemailer transport object. So instead of writing a &lt;code&gt;nestjs-mailkite&lt;/code&gt; wrapper package nobody needed, we verified the real configuration and shipped a starter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// starters/nestjs/src/mail/mail.module.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MailerModule&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@nestjs-modules/mailer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mailkiteTransport&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nodemailer-mailkite-transport&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;MailerModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forRoot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;mailkiteTransport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MAILKITE_API_KEY&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;Building this the honest way — actually running &lt;code&gt;dist/main.js&lt;/code&gt;, not just type-checking — is what surfaced the first real bug below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flask and FastAPI: no seam to plug into, so no package
&lt;/h2&gt;

&lt;p&gt;Neither Flask-Mail nor &lt;code&gt;fastapi-mail&lt;/code&gt;/&lt;code&gt;fastapi-mailman&lt;/code&gt; expose a swappable transport the way Django's &lt;code&gt;EMAIL_BACKEND&lt;/code&gt;, Rails' &lt;code&gt;ActionMailer&lt;/code&gt;, Laravel's &lt;code&gt;Mail::extend&lt;/code&gt;, or the frameworks above do — both wrap &lt;code&gt;smtplib&lt;/code&gt;/&lt;code&gt;aiosmtplib&lt;/code&gt; directly. There's nothing to register a MailKite adapter &lt;em&gt;into&lt;/em&gt;. So both got a runnable starter instead: Flask-Mail pointed at MailKite's SMTP relay (or the Python SDK directly, for API mode), and a FastAPI app calling the Python SDK from inside &lt;code&gt;run_in_threadpool&lt;/code&gt; so a synchronous SDK call never blocks the event loop.&lt;/p&gt;

&lt;p&gt;Worth flagging directly: FastAPI's PyPI downloads now exceed Flask's — roughly 490M versus 197M monthly as of July 2026 (&lt;a href="https://pepy.tech/projects/fastapi" rel="noopener noreferrer"&gt;pepy.tech&lt;/a&gt;, 2026). That growth doesn't create a code opportunity here. Usage share and buildable integration surface are different axes, and this is the clearest place in the whole batch where they diverge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two real bugs, found by actually running the thing
&lt;/h2&gt;

&lt;p&gt;Both of these came from &lt;em&gt;running&lt;/em&gt; the starters, not just compiling them — and we're not burying either one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;nodemailer-mailkite-transport@0.1.0&lt;/code&gt;'s &lt;code&gt;package.json&lt;/code&gt; &lt;code&gt;exports&lt;/code&gt; map is ESM-only.&lt;/strong&gt; NestJS's default build output is CommonJS, and building + actually running the NestJS starter reproduced &lt;code&gt;ERR_PACKAGE_PATH_NOT_EXPORTED&lt;/code&gt; on &lt;code&gt;require()&lt;/code&gt;. Node ≥22.12 can load a true ES module through &lt;code&gt;require()&lt;/code&gt; directly — the file format was never the problem — the &lt;code&gt;exports&lt;/code&gt; map just had no condition that matched CJS resolution. The fix (adding &lt;code&gt;require&lt;/code&gt;/&lt;code&gt;default&lt;/code&gt; conditions, no source change) is written and tested locally as &lt;code&gt;0.1.1&lt;/code&gt;; it hasn't shipped to npm yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Python SDK throws an unhandled &lt;code&gt;JSONDecodeError&lt;/code&gt; instead of a clean &lt;code&gt;MailKiteError&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;api.mailkite.dev&lt;/code&gt; sits behind Cloudflare, which returns a non-JSON 403 body to requests with no (or a blocked) &lt;code&gt;User-Agent&lt;/code&gt; header — confirmed independently while building both the Flask and FastAPI starters, using nothing but bare &lt;code&gt;urllib&lt;/code&gt;. The SDK's error path doesn't guard the non-JSON case, so it crashes instead of surfacing a normal error. Both starters ship a defensive workaround at the call site; the real fix — a &lt;code&gt;User-Agent&lt;/code&gt; header and a guarded parse in &lt;code&gt;sdks/python&lt;/code&gt; — is still pending.&lt;/p&gt;

&lt;p&gt;Neither bug blocks anything you'd build today. Both are logged in &lt;code&gt;docs/integrations/RELEASING.md&lt;/code&gt; in the source repo so they don't get lost between "found" and "fixed."&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't cover yet
&lt;/h2&gt;

&lt;p&gt;A Symfony Mailer transport (&lt;code&gt;mailkite/symfony-mailer&lt;/code&gt;, covering Symfony itself plus Mautic and PrestaShop) shipped the same day on a separate track — see &lt;a href="https://mailkite.dev/docs/integrations/symfony-mailer" rel="noopener noreferrer"&gt;&lt;code&gt;/docs/integrations/symfony-mailer&lt;/code&gt;&lt;/a&gt; for that one. Django is covered by an open pull request against Anymail, the ecosystem's standard multi-provider backend, not yet merged. Publishing &lt;code&gt;MailKite.AspNetCore&lt;/code&gt; to NuGet is blocked on the base .NET SDK itself landing there first; &lt;code&gt;mailkite-spring-boot-starter&lt;/code&gt; needs its own Maven Central publish wired up. None of that changes what's runnable today — every starter and package above builds and runs against the real API right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why did NestJS and Flask/FastAPI get starters but no new package?
&lt;/h3&gt;

&lt;p&gt;Because a new package should exist only when it closes a real gap. NestJS's existing mailer ecosystem already accepts our transport with zero glue code; Flask and FastAPI have no pluggable mail interface for a package to register into at all. Shipping wrapper code nobody needs is worse than shipping none.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the &lt;code&gt;nodemailer-mailkite-transport&lt;/code&gt; bug affecting me right now?
&lt;/h3&gt;

&lt;p&gt;Only if you consume it via &lt;code&gt;require()&lt;/code&gt; from a CommonJS build — most directly hit if you're using it inside NestJS or another Nest-style CJS project. ESM consumers (including the plain Node.js/Express path this package originally targeted) were never affected. The fix is written and tested; it's waiting on a version bump and publish.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does MailKite still support plain SMTP for these frameworks?
&lt;/h3&gt;

&lt;p&gt;Yes — every framework here can also just point its mail library's SMTP settings at MailKite's relay; that's exactly the path Flask and FastAPI use today. The packages exist for frameworks where a native transport gets you logging, batch sends, and dashboard visibility that SMTP alone can't express.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where's the code?
&lt;/h3&gt;

&lt;p&gt;Packages: &lt;code&gt;integrations/spring-boot-starter/&lt;/code&gt;, &lt;code&gt;integrations/aspnet-core/&lt;/code&gt;, &lt;code&gt;integrations/next-mailkite/&lt;/code&gt;. Runnable demos: &lt;code&gt;starters/spring-boot/&lt;/code&gt;, &lt;code&gt;starters/aspnet-core/&lt;/code&gt;, &lt;code&gt;starters/nextjs/&lt;/code&gt;, &lt;code&gt;starters/nestjs/&lt;/code&gt;, &lt;code&gt;starters/flask/&lt;/code&gt;, &lt;code&gt;starters/fastapi/&lt;/code&gt;. Full docs are linked from each framework's page under &lt;a href="https://mailkite.dev/docs/integrations" rel="noopener noreferrer"&gt;&lt;code&gt;/docs/integrations&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mailkite.dev/blog/mailkite-native-mailers-six-frameworks/" rel="noopener noreferrer"&gt;mailkite.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>dotnet</category>
      <category>java</category>
      <category>python</category>
    </item>
    <item>
      <title>MailKite Server: our SMTP + IMAP edges are now open source</title>
      <dc:creator>Bucabay</dc:creator>
      <pubDate>Sat, 01 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/bucabay/mailkite-server-our-smtp-imap-edges-are-now-open-source-45ih</link>
      <guid>https://dev.to/bucabay/mailkite-server-our-smtp-imap-edges-are-now-open-source-45ih</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; the mail-server half of MailKite is now open source:&lt;br&gt;
&lt;a href="https://github.com/mailkite/server" rel="noopener noreferrer"&gt;github.com/mailkite/server&lt;/a&gt; — the Haraka MX edge,&lt;br&gt;
the SMTP submission edge, the IMAP server, a zero-dependency SQLite backend, and a web&lt;br&gt;
console. AGPL-3.0. Runs on any Node ≥ 22.5 host: a $5 VPS, Docker, Fly, Railway. Five&lt;br&gt;
releases in, it receives real internet mail, dispatches signed inbound webhooks, and&lt;br&gt;
relays outbound through a smarthost — including MailKite Cloud itself.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's in it
&lt;/h2&gt;

&lt;p&gt;Every component is a stateless protocol head. All state sits behind one small HTTP&lt;br&gt;
contract — five IMAP read endpoints, an inbound ingest hook, an SMTP auth check, and a&lt;br&gt;
relay endpoint (&lt;a href="https://github.com/mailkite/server/blob/main/docs/contract.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/contract.md&lt;/code&gt;&lt;/a&gt;).&lt;br&gt;
That design is the whole trick: the bundled SQLite backend and MailKite Cloud are just&lt;br&gt;
two implementations of the same contract, so the edges and the web console run unchanged&lt;br&gt;
against either.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mta/&lt;/code&gt;&lt;/strong&gt; — inbound MX (Haraka): accepts on :25, POSTs raw RFC822 to your backend as
a signed webhook, with live anti-open-relay recipient checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mta-submit/&lt;/code&gt;&lt;/strong&gt; — submission (:587/:465): SMTP AUTH for apps that only speak SMTP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;imap/&lt;/code&gt;&lt;/strong&gt; — IMAP4 on :993: read your received mail from Thunderbird, Apple Mail, or
your agent's IMAP library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;api-local/&lt;/code&gt;&lt;/strong&gt; — the reference backend: one Node process, &lt;code&gt;node:sqlite&lt;/code&gt;,
zero npm dependencies. Per-domain &lt;strong&gt;inbound webhooks&lt;/strong&gt; (HMAC-signed dispatch with a
durable retry queue) and &lt;strong&gt;smarthost outbound&lt;/strong&gt; — point &lt;code&gt;SMARTHOST&lt;/code&gt; at any SMTP relay,
or set &lt;code&gt;SMARTHOST=cloud&lt;/code&gt; to send through MailKite Cloud's deliverability while keeping
storage on your box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ui/&lt;/code&gt;&lt;/strong&gt; — the web console: domains and DNS records, message log, credentials,
webhooks. Signs in with an emailed magic link; first run bootstraps the admin
WordPress-style.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These aren't a code drop — the SMTP/IMAP edges are the same code serving MailKite&lt;br&gt;
production today, and the conformance suite that gates the repo runs against both&lt;br&gt;
backends.&lt;/p&gt;
&lt;h2&gt;
  
  
  One MX, many backends
&lt;/h2&gt;

&lt;p&gt;The MX edge routes &lt;strong&gt;per recipient domain across multiple backends&lt;/strong&gt;&lt;br&gt;
(&lt;a href="https://github.com/mailkite/server/blob/main/docs/multi-backend.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/multi-backend.md&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
each backend claims its domains, RCPT checks consult every claim list, and accepted mail&lt;br&gt;
is signed and ingested to whichever backend owns the recipient. We run this in our own&lt;br&gt;
production — the same MX that fronts MailKite Cloud also delivers to a self-hosted&lt;br&gt;
backend. Which means you can skip running port 25 entirely and point our hosted MX at&lt;br&gt;
your self-hosted install.&lt;/p&gt;
&lt;h2&gt;
  
  
  Already running Haraka?
&lt;/h2&gt;

&lt;p&gt;You don't need to adopt the repo. The plugins are on npm as&lt;br&gt;
&lt;a href="https://www.npmjs.com/package/haraka-plugin-mailkite" rel="noopener noreferrer"&gt;&lt;code&gt;haraka-plugin-mailkite&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;haraka-plugin-mailkite
&lt;span class="c"&gt;# config/plugins: add "mailkite"; config/mailkite.ini: role = mx  (or submit)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your existing MX or submission server then speaks the contract to any backend — the&lt;br&gt;
bundled SQLite one, MailKite Cloud, or your own implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why open source it
&lt;/h2&gt;

&lt;p&gt;Email infrastructure buyers are (rightly) lock-in-averse. The honest answer is to make&lt;br&gt;
the server open and keep the hosted product what it should be: deliverability, IP&lt;br&gt;
reputation, retention, webhooks at scale, and a human when something breaks. Self-host&lt;br&gt;
the parts that are yours; rent the parts that are genuinely hard to run — the&lt;br&gt;
&lt;code&gt;SMARTHOST=cloud&lt;/code&gt; hybrid is exactly that split, one env var at a time.&lt;/p&gt;

&lt;p&gt;If you'd rather not babysit port 25: the same web console connects to&lt;br&gt;
&lt;a href="https://mailkite.dev" rel="noopener noreferrer"&gt;MailKite Cloud&lt;/a&gt; with one credential swap.&lt;/p&gt;

&lt;h2&gt;
  
  
  License
&lt;/h2&gt;

&lt;p&gt;AGPL-3.0 for the code; the MailKite name and logo stay trademarked (forks rebrand).&lt;br&gt;
Contributions welcome — see&lt;br&gt;
&lt;a href="https://github.com/mailkite/server/blob/main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;CONTRIBUTING.md&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>selfhosting</category>
      <category>smtp</category>
      <category>imap</category>
    </item>
    <item>
      <title>I've launched 4 SaaS startups this year - my template</title>
      <dc:creator>Bucabay</dc:creator>
      <pubDate>Sat, 18 Jul 2026 04:39:51 +0000</pubDate>
      <link>https://dev.to/bucabay/ive-launched-4-saas-startups-this-year-my-template-2eap</link>
      <guid>https://dev.to/bucabay/ive-launched-4-saas-startups-this-year-my-template-2eap</guid>
      <description>&lt;p&gt;I've been on a SaaS startup building and launching spree this year since I lost half my web development contracts to AI. I think many developers have been in the same boat. &lt;/p&gt;

&lt;p&gt;Instead of looking for more development work (which is in real flux at the moment) I decided to instead launch my own SaaSes.. SaaSs? &lt;/p&gt;

&lt;p&gt;The first thing I noticed as many have, is AI can scaffold a UI very quickly - in 30 minutes I had a SaaS website - yay! It was quite bland but well designed - ok - nothing fancy. Then came iterating on that website, which took days - then weeks to get it right. AI seems to fix something and break something else at the same time - this went on forever. &lt;/p&gt;

&lt;p&gt;Now the bigger development effort for a SaaS is the backend - the dashboard and the multi-tenancy, the stripe plans/payments integrations, the email sending, authentication, team management, ACL, privacy, security... the list get's long pretty quick - but it is finite if you want it to be. &lt;/p&gt;

&lt;p&gt;So if you spent one week on the website, prepare to spend a month on the dashboard and 6 months on the backend. &lt;/p&gt;

&lt;p&gt;Quickly I learnt the important rule. Make a few choices upfront that minimizes your development. Like the 80/20 rule - but also the discipline to follow through. &lt;/p&gt;

&lt;p&gt;Choose the most important features of your SaaS. Figure out your value proposition and the pain point you fix. Focus only on that and build just that and launch. Don't build anything else - truly - do not. &lt;/p&gt;

&lt;p&gt;If you are building an email sending platform, just build a backend that sends emails. Hook into into your necessary SaaS features, which are authentication, payments. &lt;/p&gt;

&lt;p&gt;The good part is that you realize quickly that you are building a few things over and over, payment, auth, dashboard UI, users profile, teams. (At first, don't even build teams - it's not needed for your main goal - just the fix to the pain point). &lt;/p&gt;

&lt;p&gt;Now - this story is sequential - from when I started to what I learned along the way. However, it's not how you should build a SaaS - it's just the story. To build a SaaS now focus first on the most important thing - your AI development environment. Spend 2-3 days on that. Seriously, your SaaS will take at least a month to build, 2-3 days on the environment your AI will work in will pay off 100 fold. &lt;/p&gt;

&lt;p&gt;I use Claude most the time so it pays to read up on the best practices - &lt;a href="https://platform.claude.com/docs/en/about-claude/use-case-guides/overview" rel="noopener noreferrer"&gt;https://platform.claude.com/docs/en/about-claude/use-case-guides/overview&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I also use a number of other Agents for differnet purposes. GLM2.5 at this time is as good as latest Claude Opus but faster. I use it for smaller fixes that are isolated. For very quick generation - an idea I want to scaffold I will use three differnet agents at once - Claude, GLM, DeepSeek (I now US doesn't like DeepSeek but it's the fastest model almost on par with Claude just for one shot research), combine their ideas into one and then have Claude optimize and choose the best mix. &lt;/p&gt;

&lt;p&gt;The reason I do 3 models is that they open up the possibility space of the response. Claude is optimized for US users and tied to US laws and government controls - it will reply with that as the scoping context that limits the openess of it's reponse. This is critical to understand for marketing research. &lt;/p&gt;

&lt;p&gt;GLM and DeepSeek are open models, general and non US focused, they give broader replies and consider non-US views. Their reponse is not as limited. Especially if you're researching non-US markets their response is valuable. &lt;/p&gt;

&lt;p&gt;Now back to how to actually build your SaaS. &lt;/p&gt;

&lt;p&gt;The 20/20 vision of hindsight. To actually build your SaaS - research first. Start with where your visitors will be coming from. Are you going to focus on Google searches? SEO? Paid Ads? If it's paid Ads, what is your search phrases, how much are the CPC on those - how dense is that market and how rooted are the current leaders? When you answer these questions your choice of SaaS becomes a lot more likely to succeed. &lt;/p&gt;

&lt;p&gt;List the ways you will get customers&lt;/p&gt;

&lt;p&gt;Use my existing network (X, LinkedIn, Dev.to)&lt;br&gt;
Paid Google Ads&lt;br&gt;
Paid Posts on Instagram &lt;br&gt;
SEO &lt;br&gt;
Posting on Reddit, Hacker News&lt;br&gt;
Making blog posts&lt;br&gt;
Creating freebies (Templates, Open Source libraries)&lt;br&gt;
Listings on directories&lt;/p&gt;

&lt;p&gt;How will your target your specific customers &lt;/p&gt;

&lt;p&gt;My linkedin is mostly software engineers - my SaaS is AI image generator - not the right fit - I'll just spam 10K linkedin contacts...&lt;/p&gt;

&lt;p&gt;These answers start allowing you to see which paths will actually bring customers to your SaaS, which changes the choices you make for your SaaS. &lt;/p&gt;

&lt;p&gt;Paid Google Ads - How much is CPC on AI Image generator. What's the CPA if I can make at least 2% sign up and then 2% of those buy the paid plan which is $20. Anwser that and you'll realize AI Image Generator is too costly for google ads unless you've got a huge ad budget. &lt;/p&gt;

&lt;p&gt;Now this goes on.. you'll notice something. What you thought was a good idea for a SaaS usually isn't from a marketing first point of view. When you envision how your customers find your SaaS and how many will sign up and purhcase a subscription. &lt;/p&gt;

&lt;p&gt;You'll notice the good SaaS is one that you research well and find need that is not filled well and marketing for it is reasonable. What was hard was figuring out what that need was. &lt;/p&gt;

&lt;p&gt;Now I can't tell you what those needs are - I'm still researching and building. This is just where I've landed after launching 4 startups (one very 2-3 months) this year. &lt;/p&gt;

&lt;p&gt;To help you on your journey of building your SaaS I've created a boilerplate - &lt;a href="https://saas-startup.mailkite.dev/" rel="noopener noreferrer"&gt;https://saas-startup.mailkite.dev/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is purposefully basic, what's important is the framework is Agent research first. Take a look at the github - &lt;a href="https://github.com/mailkite/saas-startup" rel="noopener noreferrer"&gt;https://github.com/mailkite/saas-startup&lt;/a&gt; &lt;/p&gt;

&lt;p&gt;and importantly the AGENTS.md (CLAUDE.md equivalent) - &lt;a href="https://github.com/mailkite/saas-startup/blob/main/AGENTS.md" rel="noopener noreferrer"&gt;https://github.com/mailkite/saas-startup/blob/main/AGENTS.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If forces the agent to research the market and build UI, UX, features based on it's research. Documents the research, catelogs and refines. Over time the development will stay linear - not become a big pile of spaghetti AI code. &lt;/p&gt;

&lt;p&gt;Please share your thoughts and experience in building SaaS - the new best thing this year. &lt;/p&gt;

</description>
      <category>startup</category>
      <category>ai</category>
      <category>development</category>
      <category>ui</category>
    </item>
    <item>
      <title>Build software that heals itself in the agentic era</title>
      <dc:creator>Bucabay</dc:creator>
      <pubDate>Wed, 01 Jul 2026 22:16:28 +0000</pubDate>
      <link>https://dev.to/bucabay/build-software-that-heals-itself-in-the-agentic-era-540p</link>
      <guid>https://dev.to/bucabay/build-software-that-heals-itself-in-the-agentic-era-540p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://mailkite.dev" rel="noopener noreferrer"&gt;MailKite&lt;/a&gt;, and the open-source &lt;code&gt;mail-parse&lt;/code&gt; library I use as the example is ours. But the pattern is the point — it isn't MailKite-specific, and you can apply it to anything that eats messy input.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Self-healing software is a system architected so that, when it hits input the real world throws at it, it doesn't crash and it doesn't stay broken: it records a structured, PII-free &lt;em&gt;failure signature&lt;/em&gt;, and that signature feeds a repair loop — increasingly, an AI agent — that turns the breakage into a permanent fix behind automated gates. In the agentic era the bottleneck is no longer &lt;em&gt;writing&lt;/em&gt; the fix; a capable agent can do that. The bottleneck is architecting your software so an agent's fix is safe, automatic, and cumulative. This post is that pattern. I'll use our open-source MIME parser (&lt;code&gt;mail-parse&lt;/code&gt;) as the running example — messy input is where software goes to die — but the shape applies to almost any system that eats hostile real-world data.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two honesty notes before I start, because a post that blurs shipped and planned isn't worth reading. First: this is &lt;strong&gt;part one of a two-part series&lt;/strong&gt; — part one is the architecture and what runs today; part two comes after the fully autonomous loop ships and we've watched it heal real input in the wild. Second: I'll label each piece &lt;strong&gt;shipped&lt;/strong&gt; or &lt;strong&gt;in progress&lt;/strong&gt; as I go, and there's a status table at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop the agentic era changes
&lt;/h2&gt;

&lt;p&gt;The classic repair loop is slow and human-shaped: a bug slips into production → someone eventually files an issue → a human reproduces it, writes a patch, ships a release → weeks later every install benefits. It works, but it's measured in weeks and gated on a human being in the loop for every single fix.&lt;/p&gt;

&lt;p&gt;Agents change what's &lt;em&gt;possible&lt;/em&gt; here, not by being trusted to write perfect code, but by being fast and tireless at the boring middle. The interesting question stops being "can an agent write the fix?" (increasingly, yes) and becomes: &lt;strong&gt;when an agent can propose a fix in seconds, how do you build software so that letting it do so isn't reckless?&lt;/strong&gt; Answer that, and your system stops accumulating breakage — every new way the world is wrong becomes a one-time event.&lt;/p&gt;

&lt;p&gt;Five design moves make it work. I'll state each generally, then ground it in the parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Never crash — turn every failure into a structured signal
&lt;/h2&gt;

&lt;p&gt;The foundation of a self-healing system is that failure is a &lt;em&gt;first-class, structured output&lt;/em&gt;, not an exception that unwinds the stack. If your software dies on bad input, there's nothing to heal; if it silently mangles it, there's nothing to detect. The discipline is: always produce the best result you can, and alongside it a machine-readable record of everything you had to paper over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the parser (shipped):&lt;/strong&gt; &lt;code&gt;mail-parse&lt;/code&gt; never throws. An unclosed MIME boundary pops the orphaned context and emits &lt;code&gt;BOUNDARY_NOT_CLOSED&lt;/code&gt;; a &lt;code&gt;charset&lt;/code&gt; that won't decode falls back and emits &lt;code&gt;UNKNOWN_CHARSET&lt;/code&gt;. You always get a message &lt;em&gt;and&lt;/em&gt; a typed list of what was wrong with it. Those diagnostics aren't logging — they're the raw material every downstream loop runs on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@mailkite/mail-parse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// parse() never throws — even on a broken message it returns a best-effort&lt;/span&gt;
&lt;span class="c1"&gt;// result *plus* a typed list of everything it had to paper over.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawMime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// decoded as far as it could&lt;/span&gt;
&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attachments&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// whatever it could recover&lt;/span&gt;
&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;diagnostics&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// → [&lt;/span&gt;
&lt;span class="c1"&gt;//     { code: "BOUNDARY_NOT_CLOSED", scope: "structure" },&lt;/span&gt;
&lt;span class="c1"&gt;//     { code: "UNKNOWN_CHARSET",     scope: "part", contentType: "text/html" },&lt;/span&gt;
&lt;span class="c1"&gt;//   ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Make fixes additive, not surgery — a plugin seam
&lt;/h2&gt;

&lt;p&gt;If every fix means editing the core, fixes are risky, they collide, and no agent (or human) should be trusted to make them at speed. The move is a &lt;strong&gt;registry&lt;/strong&gt;: a seam where new behavior is a self-contained, narrowly-scoped, &lt;em&gt;contained&lt;/em&gt; unit — it can't take down the whole system, and it's obvious what it touches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the parser (shipped):&lt;/strong&gt; fixups are middleware in a PostCSS-style registry — each declares a phase, a match predicate, and a handler, and a middleware that throws becomes a contained &lt;code&gt;MIDDLEWARE_ERROR&lt;/code&gt; diagnostic while the chain keeps going. A new format quirk is &lt;em&gt;a new middleware with a narrow predicate&lt;/em&gt;, not a patch threaded through the core. That containment is exactly what later lets a &lt;em&gt;generated&lt;/em&gt; fix be admitted without betting the system on it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A new format quirk is a self-contained middleware with a narrow predicate —&lt;/span&gt;
&lt;span class="c1"&gt;// not a patch threaded through the core.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tnef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;decode&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/ms-tnef&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;extractWinmailDat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tnef&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// If handler throws, the parser records a contained MIDDLEWARE_ERROR&lt;/span&gt;
&lt;span class="c1"&gt;// diagnostic and the rest of the chain keeps running.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Name failures identically everywhere — without leaking data
&lt;/h2&gt;

&lt;p&gt;To fix a class of breakage you first have to &lt;em&gt;name&lt;/em&gt; it, the same way across every install, without ever collecting private data. That's a &lt;strong&gt;failure signature&lt;/strong&gt;: a deterministic hash over &lt;em&gt;structure only&lt;/em&gt;. It does two things at once — it lets a thousand installs hitting the same bug collapse into one prioritized signal, and it gives the repair loop a precise, shareable target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the parser (shipped):&lt;/strong&gt; the signature is an FNV-1a hash over PII-free features — diagnostic codes, content-type, transfer-encoding, a byte-shape fingerprint, mailer family, structure path — and &lt;em&gt;never&lt;/em&gt; bytes, addresses, or subjects. Two installs on opposite sides of the world hitting the same Outlook-TNEF quirk compute the same hash. A multi-granularity rollup lets you cluster loosely or tightly. (It's pinned identical across our TypeScript, Python, and Go ports by a golden-corpus test, so the herd can't drift.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;FailureSignature&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// = fnv1a(canonicalize(features))&lt;/span&gt;
  &lt;span class="nl"&gt;features&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;envelope&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;structure&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;part&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;diagnosticCodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;  &lt;span class="c1"&gt;// e.g. ["UNKNOWN_CHARSET"]&lt;/span&gt;
    &lt;span class="nl"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// the offending leaf's declared type&lt;/span&gt;
    &lt;span class="nl"&gt;transferEncoding&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;byteSignature&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// hex magic of the first N bytes — never content&lt;/span&gt;
    &lt;span class="nl"&gt;mailerFamily&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// X-Mailer normalized → "Outlook/16"&lt;/span&gt;
    &lt;span class="nl"&gt;structurePath&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// "multipart/mixed&amp;gt;…&amp;gt;application/ms-tnef"&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;Nothing in there is content — no subject, no addresses, no body bytes — so the same broken email produces the same hash in every language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mailparse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;compute_signature&lt;/span&gt;

&lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute_signature&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;part&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;diagnosticCodes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UNKNOWN_CHARSET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contentType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text/plain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transferEncoding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base64&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# "13586f32bb2840c6" — byte-identical in Node, Python, and Go
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Two loops: fix the core for everyone, patch the edge safely
&lt;/h2&gt;

&lt;p&gt;Self-healing has two speeds, and you want both.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The cold loop — fix the library for everyone.&lt;/strong&gt; &lt;em&gt;(Shipped.)&lt;/em&gt; When the parser degrades it emits a &lt;code&gt;FailureReport&lt;/code&gt;. Where it goes is the deployer's choice — reporting is &lt;strong&gt;opt-in, with no default phone-home&lt;/strong&gt; — but point the built-in reporter at the core repo and it files exactly &lt;em&gt;one deduplicated GitHub issue per signature&lt;/em&gt; (a hidden &lt;code&gt;parse-signature:&lt;/code&gt; marker makes it idempotent; N installs → 1 issue), containing the structural signature and, in writing, &lt;em&gt;no message content&lt;/em&gt;. A responder — a human, or an AI coding routine triggered by the issue — reproduces from the scrubbed signature, fixes the core, and opens a PR that CI won't merge unless a golden corpus and a benign-input regression set both stay green. The fix ships to every install, in every language.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The hot loop — patch one edge now.&lt;/strong&gt; &lt;em&gt;(In progress: designed, next.)&lt;/em&gt; A library release takes time, and some quirks are concentrated in a single tenant's weird upstream system. For those, the design is an agent, handed the sealed failing fixture, that writes a &lt;em&gt;narrowly-scoped&lt;/em&gt; middleware plus a golden test pinning its behavior — a stopgap that heals that edge immediately while the cold loop fixes the root cause for everyone.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Trust the gates, not the generator — the security crux
&lt;/h2&gt;

&lt;p&gt;Here's the part the agentic era forces you to get right, because the hot loop means &lt;strong&gt;running code a model wrote against real production data.&lt;/strong&gt; You do not make that safe by trusting the model. You make it safe by building an architecture where a &lt;em&gt;fully compromised or simply wrong&lt;/em&gt; generated fix still can't do harm. Almost the entire hot-loop design &lt;em&gt;(in progress)&lt;/em&gt; is that safety envelope:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sandboxed execution.&lt;/strong&gt; Generated fixes run as &lt;strong&gt;Wasm (Extism)&lt;/strong&gt; with &lt;strong&gt;deny-by-default capabilities&lt;/strong&gt; and a hard CPU/fuel budget — no network, no filesystem, no ambient authority. A bad fix can transform its input or burn its fuel and die; it can't reach anything else. (Generation and CI run in a separate sandbox, isolated from production.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adversarial gates the model doesn't author.&lt;/strong&gt; A fix is admitted only if it clears &lt;em&gt;system-owned&lt;/em&gt; tests: it must fire zero times against a benign corpus of well-formed input (no collateral damage), it must satisfy the golden test generated &lt;em&gt;from the failing case&lt;/em&gt; (it actually fixes the thing), and it must clear a &lt;strong&gt;specificity floor&lt;/strong&gt; (its predicate is narrow, not a catch-all). The agent proposes; adversarial tests dispose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canary, then commit.&lt;/strong&gt; An admitted fix rolls out at 5% → 25% → 100%, watched against a structural agreement metric — a bad fix is caught on a sliver of traffic, not all of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A kill switch per fix.&lt;/strong&gt; Every generated unit is individually disableable by config, no redeploy — instant, reversible rollback.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What the hot-loop agent generates (designed, next): a narrowly-scoped&lt;/span&gt;
&lt;span class="c1"&gt;// middleware that fires ONLY on the failing signature — plus a golden test.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;decode&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charset&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-user-defined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// the one quirk, nothing else&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;decodeAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;windows-1252&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="c1"&gt;// Admitted only if it fires zero times on the benign corpus, passes the&lt;/span&gt;
&lt;span class="c1"&gt;// golden test from the failing case, and clears the specificity floor —&lt;/span&gt;
&lt;span class="c1"&gt;// none of which the agent wrote.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's what makes autonomy defensible: a vetted fix can auto-promote &lt;strong&gt;with no human in the loop&lt;/strong&gt; — not because we trust the model, but because what stands between a generated fix and production isn't anyone's judgment, it's a sandbox it can't escape, a battery of adversarial tests it didn't write, a canary that bounds blast radius, and a switch that undoes it. This is the same thesis behind &lt;a href="https://mailkite.dev/blog/agent-inbox-security-by-design/" rel="noopener noreferrer"&gt;how we built our agent inbox&lt;/a&gt;: in the agentic era you stop trying to make the model un-foolable and instead bound what a fooled model is &lt;em&gt;allowed to do&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where else this pattern fits
&lt;/h2&gt;

&lt;p&gt;MIME is a vivid example because email is gloriously broken, but the pattern fits anywhere software meets messy, adversarial, or drifting real-world input. The same five moves — tolerant core, plugin seam, anonymous failure signature, cold/hot loops, gated sandbox — map cleanly onto:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ingesting messy formats.&lt;/strong&gt; CSV and bank-statement imports, PDF/OCR extraction, HTML scraping, log parsing, address and phone normalization. Every one is a hostile-input boundary that today either throws or silently corrupts. Signature the failure, let an agent add a scoped normalizer, gate it on a golden corpus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party API and webhook adapters.&lt;/strong&gt; Upstream payloads drift or go malformed and your integration breaks in prod. An adapter that emits a &lt;em&gt;schema-drift signature&lt;/em&gt; instead of a 500 lets an agent write a narrow shim for that provider's quirk — sandboxed, canaried — while a core fix follows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data pipelines / ETL schema drift.&lt;/strong&gt; An upstream column gets renamed or a type changes; the pipeline emits a signature rather than poisoning the warehouse, and an agent proposes the mapping behind tests that must stay green on the historical data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Abuse, spam, and fraud rules.&lt;/strong&gt; A new evasion pattern is exactly a new failure signature. An agent generates a candidate rule that must fire zero times against a known-good corpus before it's canaried — the benign-corpus gate is the whole safety story, and it's identical to the parser's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client and device compatibility shims.&lt;/strong&gt; Quirky browsers, email clients, IoT firmware, legacy POS terminals — each non-conforming client is a per-quirk plugin, added on demand, contained, and kill-switchable, instead of a growing tangle of &lt;code&gt;if (userAgent...)&lt;/code&gt; in the core.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In each case the expensive, human-shaped part — noticing, reproducing, scoping, testing — is what the pattern automates, and the sandbox-plus-gates is what makes automating it safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's live today vs. what's next
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tolerant core (never throws, typed diagnostics)&lt;/td&gt;
&lt;td&gt;✅ Live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Additive plugin seam (registry, contained fixes)&lt;/td&gt;
&lt;td&gt;✅ Live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PII-free failure signatures (deterministic, deduping)&lt;/td&gt;
&lt;td&gt;✅ Live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-language parity (golden corpus + signature pinning)&lt;/td&gt;
&lt;td&gt;✅ Live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shadow harness (observe-only, structure-only compare)&lt;/td&gt;
&lt;td&gt;✅ Live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold loop (opt-in, anonymous, deduplicated GitHub issues)&lt;/td&gt;
&lt;td&gt;✅ Live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hot loop (AI-generated fixes)&lt;/td&gt;
&lt;td&gt;🔧 Designed, next&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wasm sandbox + capability/fuel limits&lt;/td&gt;
&lt;td&gt;🔧 Designed, next&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adversarial gates, canary rollout, per-fix kill switch&lt;/td&gt;
&lt;td&gt;🔧 Designed, next&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does "self-healing software" actually mean here today?&lt;/strong&gt;&lt;br&gt;
Today: the system never dies on bad input, it records a precise PII-free signature of what broke, and identical signatures across all installs collapse into one deduplicated GitHub issue that drives a fix shipped to everyone. The fully autonomous part — an agent generating and shipping a sandboxed fix with no human in the loop — is designed and coming next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Isn't letting an AI agent patch production reckless?&lt;/strong&gt;&lt;br&gt;
It would be if you trusted the agent's output. The design doesn't: generated fixes run as capability-denied Wasm with a fuel budget, are admitted only by adversarial tests the agent didn't write (benign-corpus zero-fire, a golden test from the failing case, a specificity floor), are canaried, and are individually kill-switchable. You trust the gates and the isolation, not the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does any of this send my data anywhere?&lt;/strong&gt;&lt;br&gt;
No. Reporting is opt-in with no default phone-home, and the failure signature is structural only — codes, types, byte-shape, mailer family — never bytes, addresses, or subjects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I apply the pattern without a MIME parser?&lt;/strong&gt;&lt;br&gt;
Yes — that's the point. Any boundary where you eat messy real-world input (imports, scrapers, API adapters, ETL, abuse rules, compatibility shims) can adopt the same five moves: tolerant core, plugin seam, anonymous failure signature, cold/hot loops, and a gated sandbox for generated fixes.&lt;/p&gt;




&lt;p&gt;Software will always meet a new way the world is wrong; the agentic era is a chance to make each new way a one-time event instead of a permanent scar. &lt;code&gt;mail-parse&lt;/code&gt; is our open-source instance of the pattern, in TypeScript, Python, and Go — see the &lt;a href="https://mailkite.dev/docs/libraries" rel="noopener noreferrer"&gt;libraries&lt;/a&gt;, and if you'd rather get the parsed message without running any of it, &lt;a href="https://mailkite.dev/docs/quickstart" rel="noopener noreferrer"&gt;point a domain at MailKite&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part two comes after the autonomous loop ships.&lt;/strong&gt; Everything labeled &lt;em&gt;in progress&lt;/em&gt; above — the AI hot loop, the Wasm sandbox, the adversarial gates and canary rollout — gets its own post once it's live and we've watched it heal real input. And that feedback is the whole point: it arrives only through the &lt;strong&gt;anonymous, opt-in&lt;/strong&gt; failure signal described above — structural, PII-free, and never sent unless you wire up a reporter — so part two will be written from what actually broke in the wild, not from a single byte of anyone's data.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was first published &lt;a href="https://mailkite.dev/blog/self-healing-software-agentic-era/" rel="noopener noreferrer"&gt;on the MailKite blog&lt;/a&gt;. Related: &lt;a href="https://mailkite.dev/blog/agent-inbox-security-by-design/" rel="noopener noreferrer"&gt;You can't prompt your way out of prompt injection&lt;/a&gt; applies the same "trust the architecture, not the model" philosophy to AI agents with email.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>architecture</category>
      <category>security</category>
    </item>
  </channel>
</rss>
