Freebo is multi-tenant. Every operator has their own brand — logo, colors, website URL — and we pull that brand into customer-facing emails. When a customer books a charter, their confirmation looks like it came from the operator, not from us. That is the point.
There is a single service, buildBranding(), that reads location_settings and produces a typed branding object with .logoUrl, .primaryColor, .website, and a handful of other things. Every email template gets a copy. One source of truth. Clean.
The pipeline works beautifully until the day you send an email that isn’t customer-facing.
Where the pattern breaks
Operators get their own emails too. Welcome messages, password resets, “your Stripe account is ready,” payout notes. Those emails are not for the tenant’s customers. They are from Freebo, to the operator.
But they still go through the same email system, which means they still get the same branding payload.
And that is how the “Go to your dashboard” button in the operator welcome email ends up linking to the operator’s own marketing site.
A reasonable template author, looking at the data available in the email context, sees branding.website sitting right there and thinks: “that’s a URL, that is good enough for the CTA.” The resulting email tells a brand-new operator to click a big blue button to visit their dashboard, and the button ships them straight to their own homepage.
In a vacuum it is kind of harmless — they know how to find their own website. In context it is terrible. The entire job of the welcome email is to walk the operator INTO the product. The URL it needs is the Freebo app URL, not theirs.
Tenant branding is for emails you send to your tenant’s customers, not emails you send to your tenant.
— The rule I pinned on the fridge after this one
The fix, in two lines of intent
Product-facing emails get their own dashboardUrl field, hardcoded from env.WEB_APP_URL with a fallback. The template reads from that, not from branding.website. branding stays scoped to the customer-facing templates where it was always supposed to live.
<Button href={data.branding.website}>
Go to your dashboard
</Button> <Button href={data.dashboardUrl ?? 'https://app.freebo.ai/dashboard'}>
Go to your dashboard
</Button> Two lines. The architectural lesson is bigger than the diff.
The Novu gotcha that makes it worse
The fix is more fragile than it looks, because we send through Novu.
Novu workflows have a payloadSchema — a declaration of what the workflow’s payload contains. If you pass a field that isn’t declared there, Novu silently strips it on the way in. The template downstream receives undefined for that field. Your trigger call in the API looks fine. The payload you passed looks fine. Your test renders look wrong and you cannot figure out why.
So adding dashboardUrl to the welcome email is not one change. It is four:

Miss step two and the template renders with dashboardUrl === undefined, which in a lot of templating setups falls through to the OLD branding-website link, because that is still the default on the line you didn’t delete yet. You “ship the fix,” the preview looks right because the preview uses the old payload, and in production you fixed nothing.
And while I was in there
The welcome email also had a footer that said, in dignified gray type:
This is an internal operations alert. Do not forward to customers.
This is a sensible default for every staff-facing email EXCEPT the one that is the FIRST thing a brand-new operator sees after signing up for the product. “Welcome to the platform! Also, do not share this.” Not the vibe.
Staff layouts are great for the eighty other things we send staff — payout notifications, balance-due alerts, cancellation receipts. They are wrong for the one email where the operator has not yet decided whether this product is for real. StaffLayout now takes an optional prop to swap or hide that footer, and the welcome email is the only template in the whole package that opts out.
The actual lesson
Multi-tenant SaaS generates a class of bug that only exists because of the tenancy.
Every piece of data in the system has three audiences: the product (you), the tenant, and the tenant’s customers. Each audience sees a different version of your templates, your UI, your pricing, your errors. If you let one audience’s data leak into a template meant for a different audience, you have written a bug that only shows up after a real tenant signs up and a real email gets sent. You cannot unit-test it, because in a unit test the branding fields are fake and the “wrong domain” looks the same as any other domain.
The remedy is not a test. The remedy is architecture:
- Separate payload shapes per audience. Operator emails get
dashboardUrl. Customer emails getbranding. Neither template type can access the other field, because it is not in its payload schema. - Separate layouts.
StaffLayoutversusCustomerLayout. Different footers, different unsubscribe behavior, different defaults. - Never read from
brandingin a template that goes to the tenant themselves.brandingis the gift you give your tenants to show to THEIR customers. It is not source data for YOUR own CTAs.
The fastest way to catch the whole class of bug is to read every email template with one question in your head: if this went to the wrong audience, what breaks? If the answer is “nothing, the content still makes sense,” great. If the answer is “the CTA points to the wrong domain” or “the footer tells the recipient not to share an email that is literally for them” — you have just found one.