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.
Emails are optional by design
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. EmailClient loads its configuration on startup from environment variables. If those environment variables aren't set, the email client is None, and no emails are ever sent. The rest of the app just checks whether EmailClient is None. That part was quite straightforward.
A comment insert that never checked its own org
Featherboard is multi-tenant, so every URL is /{org-slug}/..., and a CurrentOrg 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: create_comment 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.
The fix was simple: check that the idea being commented on actually belongs to the org in the URL via SELECT EXISTS(...) — and return a 403 if it doesn't, before any write happens.
Resolving the commenter's name
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 email 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.
Emails use absolute URLs
So far, everything in the app redirected to pages via a relative path, e.g. /acme/ideas/10. 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. https://featherboard.net/acme/ideas/10. That's why I added another env var, PUBLIC_URL, and used it to build the final URL that goes in the email.
The borrow checker won't let you return something that was just deallocated
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:
let Some(public_url) = env::var("PUBLIC_URL").ok().map(|e| e.trim_end_matches('/')) else {
return None;
};
This code doesn't compile, because it returns a &str pointing into a String that's about to be deallocated. The String (e) only exists for the duration of the closure, once .map() returns, it's dropped. Returning a &str into it would be a pointer into freed memory, and Rust's borrow checker refuses to let that happen.
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.
Grouping templates by destination
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: pages/ for full pages, htmx/ for partial/swap content, and emails/ for notification emails.
Thank you for following along and as always, here's the codebase: https://github.com/RafalManka/Featherboard
Top comments (1)
The org-check bug is the one multi-tenant apps get wrong in exactly the same place: the URL carries the tenant, the body carries a primary key, and nothing forces the two to agree until someone crafts a request. Resolving and re-checking ownership before the write, rather than trusting the path, is the fix I now reach for reflexively. That it surfaced while writing an email notification and not during a security review is very honest.
Two follow-ups. Did you audit the other write paths for the same shape, or was the comment insert the only place an id arrives from the body? And on
PUBLIC_URL: when it is unset, do emails fall back to relative links silently or does the send fail loudly? A misconfigured self-hosted instance quietly producing dead links in someone's inbox is the kind of support ticket that never gets traced back to config.