<?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: Rafal Manka</title>
    <description>The latest articles on DEV Community by Rafal Manka (@rafalmanka).</description>
    <link>https://dev.to/rafalmanka</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%2F4051873%2F1b6580b0-5bb2-4b29-8ca1-93b006a79bad.jpg</url>
      <title>DEV Community: Rafal Manka</title>
      <link>https://dev.to/rafalmanka</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rafalmanka"/>
    <language>en</language>
    <item>
      <title>Featherboard's email notifications via SMTP and lettre: a security gap, and a lesson from Rust's borrow checker</title>
      <dc:creator>Rafal Manka</dc:creator>
      <pubDate>Tue, 15 Sep 2026 03:05:53 +0000</pubDate>
      <link>https://dev.to/rafalmanka/featherboards-email-notifications-via-smtp-and-lettre-a-security-gap-and-a-lesson-from-rusts-221p</link>
      <guid>https://dev.to/rafalmanka/featherboards-email-notifications-via-smtp-and-lettre-a-security-gap-and-a-lesson-from-rusts-221p</guid>
      <description>&lt;p&gt;The next phase of development is done and since the last update, admins can now delete ideas as well as merge duplicate ideas into one. The phase I'm currently working on is email notifications. The first part sounds simple: an admin gets an email when someone comments on an idea. Simple enough, but once I actually dove into the implementation, it turned out to be more involved than it sounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Emails are optional by design
&lt;/h2&gt;

&lt;p&gt;The core design principle of Featherboard is that it should be simple and work out of the box without configuration. So the app needs to run perfectly fine even if no SMTP client has been configured. &lt;code&gt;EmailClient&lt;/code&gt; loads its configuration on startup from environment variables. If those environment variables aren't set, the email client is &lt;code&gt;None&lt;/code&gt;, and no emails are ever sent. The rest of the app just checks whether &lt;code&gt;EmailClient&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt;. That part was quite straightforward.&lt;/p&gt;

&lt;h2&gt;
  
  
  A comment insert that never checked its own org
&lt;/h2&gt;

&lt;p&gt;Featherboard is multi-tenant, so every URL is &lt;code&gt;/{org-slug}/...&lt;/code&gt;, and a &lt;code&gt;CurrentOrg&lt;/code&gt; extractor resolves that slug into an org id on every request. Writing the notification meant fetching the idea's title to put in the email, and doing that fetch is what exposed a bug that had nothing to do with email: &lt;code&gt;create_comment&lt;/code&gt; was inserting a comment for whatever idea id showed up in the URL, without ever checking it belonged to the org in that URL. A crafted request with right org in the path, someone else's idea id in the body would have quietly attached a comment to a different org's idea.&lt;/p&gt;

&lt;p&gt;The fix was simple: check that the idea being commented on actually belongs to the org in the URL via &lt;code&gt;SELECT EXISTS(...)&lt;/code&gt; — and return a 403 if it doesn't, before any write happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resolving the commenter's name
&lt;/h2&gt;

&lt;p&gt;Logging in to Featherboard is optional and you can post a comment as a guest or while logged in, and either way you type a name into the comment form. For the notification email specifically, I didn't want it to just repeat whatever was typed into that field: I resolve the commenter's name from the session first, and only fall back to the guest-typed name when nobody's logged in. Right now that's only how the &lt;em&gt;email&lt;/em&gt; picks a name and the comment that actually gets stored and displayed on the page still always uses the typed field, logged in or not. I still need to add that implementation later, so a logged-in user's displayed comment and their notification email can currently show different names for the same comment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Emails use absolute URLs
&lt;/h2&gt;

&lt;p&gt;So far, everything in the app redirected to pages via a relative path, e.g. &lt;code&gt;/acme/ideas/10&lt;/code&gt;. But when sending emails to someone's inbox, a relative path means nothing. There's no page open to resolve it against. I needed to construct a fully qualified URL instead, e.g. &lt;code&gt;https://featherboard.net/acme/ideas/10&lt;/code&gt;. That's why I added another env var, &lt;code&gt;PUBLIC_URL&lt;/code&gt;, and used it to build the final URL that goes in the email.&lt;/p&gt;

&lt;h2&gt;
  
  
  The borrow checker won't let you return something that was just deallocated
&lt;/h2&gt;

&lt;p&gt;This one is a little curious, but it captures perfectly how Rust's ownership model works. Here's my first attempt at writing code to avoid a double slash after the domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;public_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PUBLIC_URL"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="nf"&gt;.trim_end_matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;None&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;This code doesn't compile, because it returns a &lt;code&gt;&amp;amp;str&lt;/code&gt; pointing into a &lt;code&gt;String&lt;/code&gt; that's about to be deallocated. The &lt;code&gt;String&lt;/code&gt; (&lt;code&gt;e&lt;/code&gt;) only exists for the duration of the closure, once &lt;code&gt;.map()&lt;/code&gt; returns, it's dropped. Returning a &lt;code&gt;&amp;amp;str&lt;/code&gt; into it would be a pointer into freed memory, and Rust's borrow checker refuses to let that happen.&lt;/p&gt;

&lt;p&gt;Coming from a garbage-collected language, this is genuinely surprising the first few times. In Kotlin, for example, a function like that just returns a new string, and the original stays alive for as long as something references it. That's the garbage collector's problem, not the programmer's. Rust has no collector standing behind it, so it enforces the lifetime at compile time instead of leaving it to chance at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grouping templates by destination
&lt;/h2&gt;

&lt;p&gt;As Featherboard's codebase grows, I added email HTML templates alongside the usual htmx templates, and they all need to live together in one project. Instead of dumping everything into one folder, I split templates into three: &lt;code&gt;pages/&lt;/code&gt; for full pages, &lt;code&gt;htmx/&lt;/code&gt; for partial/swap content, and &lt;code&gt;emails/&lt;/code&gt; for notification emails.&lt;/p&gt;

&lt;p&gt;Thank you for following along and as always, here's the codebase: &lt;a href="https://github.com/RafalManka/Featherboard" rel="noopener noreferrer"&gt;https://github.com/RafalManka/Featherboard&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>axum</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Turning Featherboard into a multi-tenant application: GitHub OAuth and path-based routing</title>
      <dc:creator>Rafal Manka</dc:creator>
      <pubDate>Tue, 01 Sep 2026 17:56:14 +0000</pubDate>
      <link>https://dev.to/rafalmanka/turning-featherboard-into-a-multi-tenant-application-github-oauth-and-path-based-routing-48m4</link>
      <guid>https://dev.to/rafalmanka/turning-featherboard-into-a-multi-tenant-application-github-oauth-and-path-based-routing-48m4</guid>
      <description>&lt;p&gt;The next phase of development is done. At this stage, Featherboard starts to become something you can actually go ahead and host on your own server, one that can support multiple teams and multiple products. User authentication so far is implemented via GitHub OAuth login, but in future phases other providers will be added, as well as regular email login. At this point I've removed the temporary hardcoded token authentication. Also, multi-tenant routing is implemented via the &lt;code&gt;app.featherboard.net/{slug}/...&lt;/code&gt; path, where &lt;code&gt;slug&lt;/code&gt; is the name of the organization. This part of the implementation required the most debugging on my side, because all links had to be modified and the slug had to be added dynamically to all paths. Fortunately, Axum provides an extractor that allows intercepting requests and extracting organization data from the path very elegantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Goodbye to the shared-token hack
&lt;/h2&gt;

&lt;p&gt;So far, as a placeholder, the &lt;code&gt;admin&lt;/code&gt; role was granted when a request contained a header with a token that was hardcoded and stored in an environment variable, &lt;code&gt;FEATHERBOARD_ADMIN_TOKEN&lt;/code&gt;. Naturally, this is not a sustainable solution and had to be replaced by actual authentication logic. This was replaced by a &lt;code&gt;users&lt;/code&gt; table, GitHub OAuth login, and an &lt;code&gt;is_admin&lt;/code&gt; flag on the users table. Even though that implementation sounds simple, getting there wasn't as straightforward as I would have liked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Single binary with embedded static files
&lt;/h2&gt;

&lt;p&gt;In Featherboard I embed all the static files I use throughout the project, so that everything is contained within a single binary file. That includes sqlx migration files as well as HTML templates. The main reason for that decision was that I wanted the hoster not to have to worry about whether they have all the dependencies in the right relative folders. One notable particularity of sqlx migrations is that they are addition-only - if you want to change something in the schema you need to create an additional migration file, because otherwise the migration will be rejected and the app will crash on startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting &lt;code&gt;UNIQUE&lt;/code&gt; indexes right
&lt;/h2&gt;

&lt;p&gt;I added a &lt;code&gt;UNIQUE&lt;/code&gt; index on the GitHub account so the user does not register twice. This makes total sense - until you realize that it also prevents a user from creating an account in multiple organizations: if you log in to product A, you won't be able to log in to product B. I realized this during a debugging session, and I was right to try creating an account in another organization - the login failed immediately, as expected. That's not what we wanted, so instead I had to create a compound index on GitHub user id and organization slug.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub does not guarantee to give you an email
&lt;/h2&gt;

&lt;p&gt;In order to create an account within Featherboard, you need an email and an OAuth token. But GitHub allows you to make your email address private, so you need to fall back on the &lt;code&gt;/user/emails&lt;/code&gt; endpoint to retrieve the email address if it isn't provided in the original request. The &lt;code&gt;/user/emails&lt;/code&gt; endpoint returns a list of emails, and I had to get the primary one from the list. This wasn't really documented properly in GitHub's documentation, and I had to figure it out during debugging as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Token upsert as a single operation
&lt;/h2&gt;

&lt;p&gt;GitHub login needs to update the users table, specifically the &lt;code&gt;provider&lt;/code&gt; and &lt;code&gt;provider_user_id&lt;/code&gt; columns; next, it needs to decide whether to create a new entry or update an existing one based on what's already there. Fortunately, SQLite supports exactly the tool I need for this, specifically the syntax: &lt;code&gt;INSERT ... ON CONFLICT (provider, provider_user_id) DO UPDATE ... RETURNING id;&lt;/code&gt; - a single statement that creates or updates a record and returns its id. I could have just used a &lt;code&gt;SELECT&lt;/code&gt; to check if a record already exists, and then &lt;code&gt;INSERT&lt;/code&gt; or &lt;code&gt;UPDATE&lt;/code&gt; respectively, but that wouldn't be as elegant as this single statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-tenant routing: the &lt;code&gt;{org-slug}&lt;/code&gt; quagmire
&lt;/h2&gt;

&lt;p&gt;Routing was complex and required a lot of changes, because the root of every URL changes in a way that requires me to dynamically inject a path parameter, and also extract that path parameter for every request. Fortunately, Rust's Axum supports extractors, which allowed me to write a simple &lt;code&gt;impl&lt;/code&gt; for an extractor that parses the path and creates a &lt;code&gt;CurrentOrg&lt;/code&gt; struct from it. Then, in each function handler, I just add that parameter to the handler signature for the extractor to work automatically. It was a very clean and pleasant experience.&lt;/p&gt;

&lt;p&gt;None of the bugs I experienced were unusual, and it was quite easy to figure out what I was doing wrong. But even with such a simple-sounding implementation, the amount of work I had to put in was surprisingly large, and at each of those steps I could easily have shot myself in the foot.&lt;/p&gt;

&lt;p&gt;Here's the code if you're interested, ready to grab or follow along: &lt;a href="https://github.com/RafalManka/Featherboard" rel="noopener noreferrer"&gt;https://github.com/RafalManka/Featherboard&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>axum</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why Featherboard is AGPL-3.0, single, self-contained binary</title>
      <dc:creator>Rafal Manka</dc:creator>
      <pubDate>Tue, 28 Jul 2026 18:11:15 +0000</pubDate>
      <link>https://dev.to/rafalmanka/why-featherboard-is-agpl-30-single-self-contained-binary-3cmh</link>
      <guid>https://dev.to/rafalmanka/why-featherboard-is-agpl-30-single-self-contained-binary-3cmh</guid>
      <description>&lt;p&gt;Phase 1 is now complete. The submit -&amp;gt; vote -&amp;gt; comment -&amp;gt; status-tag -&amp;gt; filter path is done. Before moving to Phase 2, I'd like to reflect on a big fundamental decision I've made and the reasoning behind it. I never really contemplated what the real-world consequences of choosing the right license are, but now I get it.&lt;/p&gt;

&lt;p&gt;Why I chose AGPL-3.0 and not MIT or Apache-2.0. The business model is open-core: anyone can host a copy of the app and run it on their own infrastructure. But if you don't want to run it yourself, you can always pay me to do it. Everyone wins.&lt;/p&gt;

&lt;p&gt;If I'd chosen MIT or Apache 2.0, anyone could just fork my repository, run it as a hosted SaaS with their own extensions on top, and never have to share their changes with anyone. Not even plain GPL closes this gap, since its copyleft only kicks in when you &lt;em&gt;distribute&lt;/em&gt; the software, and running a modified version as a web service isn't distribution. That is what they call the "SaaS loophole" and it's the reason why the AGPL license was created.&lt;/p&gt;

&lt;p&gt;Why a single, self-contained binary? Because the target audience is someone who wants this to work fast with minimal setup and little infrastructure - not someone who wants to run their own Postgres database, Docker infrastructure, and expensive AWS hosting solutions, then resolve a whole bunch of dependencies just to load the page after hours of struggling. I chose SQLite and &lt;code&gt;rust-embed&lt;/code&gt; for static assets and Askama baked-in templates. That allowed me to package the entire web server into one 6.8MB executable file.&lt;/p&gt;

&lt;p&gt;Additionally, htmx is bundled with the binary so no CDN dependency is needed - one less thing to worry about, and you don't even need internet access. Self-hosting is supposed to be all about removing external dependencies, so &lt;code&gt;htmx.min.js&lt;/code&gt; is baked into the binary and VENDOR.md is attached to respect licensing requirements.&lt;/p&gt;

&lt;p&gt;The effect? All my decisions point in the same direction - minimize hassle for the hoster and make the install as easy as possible.&lt;/p&gt;

&lt;p&gt;Code's here if you want to follow along or try it yourself ;-)  &lt;a href="https://github.com/RafalManka/Featherboard" rel="noopener noreferrer"&gt;https://github.com/RafalManka/Featherboard&lt;/a&gt;   &lt;/p&gt;

</description>
      <category>opensource</category>
      <category>rust</category>
      <category>showdev</category>
      <category>selfhosted</category>
    </item>
  </channel>
</rss>
