Tour Dash
Plan · 10 September 2026

Every email the platform should send.

Tour Dash tells people things in the app and nowhere else. A vendor who is not looking at the bell does not know an agent booked them, and a showing tomorrow morning reminds nobody. The Resend account now has room for 50,000 messages a month, so the constraint is no longer the quota — it is deciding what is worth interrupting somebody for, and building it once in a place every future feature inherits.

01

What is already standing

More than it looks like. Nothing here needs to be invented; the pieces have simply never been joined up.

PieceStateWhat it means for this
In-app noticesLiveSix SQL triggers, two edge functions and one RPC all write a row to public.notifications. Every one of them is a place an email could come from.
pg_netInstalledThe database can make HTTP calls on its own. This is how the lead alerts already reach the team.
resend_api_keyIn VaultStored once, decrypted inside a security definer function, never near the browser.
send_lead_email()Live, firedA working Resend call from Postgres. It has sent real mail on waiting-list signups and new accounts. Proof the path works end to end.
Auth templates13, generatedsupabase/email-templates/build.mjs holds the shell, the colours and the voice. Notification email should look like it came from the same company, so it should come out of the same generator.
Resend domainVerified 4 SepSPF and DKIM both pass and both align. DMARC is p=none with aggregate reports on.
pg_cronNot installedThe one genuine gap. Anything that has to happen before an event rather than in response to one needs a clock, and there isn't one.

For scale: public.notifications holds 22 rows in its entire history across 14 accounts, and a good share of those came from this week's own testing. Nothing about this plan is near a rate limit. That is a reason to be careful rather than relaxed — the domain has almost no sending reputation, and the first thing it does at volume will decide how the next year of mail is filed.

02

One fan-out point, not thirty

The tempting shape is to send an email at each place something happens. It is also the shape that guarantees the next feature forgets.

Every notice already converges on a single insert into public.notifications. Putting the email on that table means the ten notices that exist today get email without touching any of the code that writes them, and every notice written from here on is covered by construction. One trigger, one template registry, one place to reason about whether somebody is being told too much.

StepWhereWhat it does
1. The noticeExisting triggers and functionsUnchanged, except that each one now also names an email_key and passes the extra facts an email needs in email_payload.
2. The triggernotify_email, after insertDoes nothing if email_key is null. Otherwise one net.http_post carrying the notification id and a shared secret. It never blocks the write and never sees a template.
3. The renderersend-notification-email edge functionLoads the row and the recipient, checks preferences and that it has not already been sent, renders HTML and plain text, calls Resend, stamps emailed_at.

Three new columns on notifications carry it: email_key text is both the template name and the on switch — null means the notice stays in the app, which keeps the decision about what deserves an interruption in one readable place. email_payload jsonb holds what the one-line message does not: the date, the address, the amount, the other person's name and number. emailed_at timestamptz is the guard, so a retried webhook or a re-run trigger cannot mail somebody twice.

The templating deliberately does not live in PL/pgSQL. send_lead_email() concatenates a table of label-value pairs, which is right for two staff addresses and wrong for anything a customer reads. User-facing mail needs the real shell, both MIME parts, copy that changes by role, and working deep links — that belongs in TypeScript next to the generator that already produces the auth templates, so there is one voice and one palette rather than two that drift.

03

The catalogue: what exists, and gets email

Ten notices are live in the app today. Each one is here because somebody is waiting on it, is owed money by it, or would otherwise drive to a house for nothing.

EventWho hearsWhy it earns an email
Somebody appliedThe agent who postedA person is waiting on a decision. The faster this lands, the better the marketplace feels to the applicant.
You're bookedWhoever is coveringWork has been committed to on a specific date and money is attached. This is the single most important message the platform sends.
A job was sent to youThe inviteeAn invite-only job is off the public board by design, so the notice is the only way it is ever seen. This one already got lost once.
Not this timeThe applicantFrees them to take other work that day. Silence here is the thing that makes a board feel dead.
The job was called offEveryone still pendingThey would otherwise keep holding the slot.
A booking was cancelledThe other sideSomebody was going to get in a car. Time-critical by definition.
A contractor withdrewThe agentThe job is open again and the showing still has to be covered, often within a day.
Your refund is on the wayThe agentMoney leaving and coming back should never be something you have to go and check.
Your lead soldThe sellerMoney, and a 72-hour dispute window they should know is running.
A lead was disputedThe sellerA payout is on hold. Finding that out by noticing an amount is missing is the worst version of this.
04

The catalogue: what is missing entirely

These have no email and no in-app notice. Each needs both halves built. Reading the code, the gaps cluster around exactly the moments people care about most: money actually moving, and a job that is about to happen.

EventWho hearsWhy it is missing, and why it matters
Your booking is confirmedThe agentThe agent only ever sees this on screen at the moment they pay. Nothing lands in their inbox with the date, the address and who is covering — the note they would actually want to forward or search for later.
Tomorrow, and again in the morningBoth sidesThe highest-value message on a showings platform, and the one thing that cannot be built without a clock. A no-show costs an agent a client.
The work is done, your money is releasedThe vendorstripe-release is the most consequential function in the system and it writes no notification of any kind. The vendor's share leaves the platform in silence.
Payouts are not set up yetThe vendorAn account without Connect finished cannot be booked at all, and today nothing ever tells them. They just never get work and never learn why.
A counter-proposal came inThe agentAn applicant naming their own price is a decision waiting on the agent, and it is indistinguishable from an ordinary application right now.
Somebody reviewed youThe revieweehandle_new_review updates the rating average and stops. Reputation changes without a word.
How did it go?Both sidesReviews are the whole trust layer, and nothing has ever asked for one.
Here is who to callThe covering agentBuyer contact can be added or changed after the booking. The person driving there is not told it appeared.
Welcome, once confirmedNew accountsSignup email ends at "confirm your address". The first thing to actually do is never said.
New work near youOpt-in, weeklyThe one growth message, and the one with real reputation risk. It comes last for that reason.

Two of these change the shape of things rather than adding to them. Payouts not being set up is a state, not an event — a nudge two days after signing up, and again if a job is sent to somebody who cannot be booked. And a review request is the only message on the list with no trigger anywhere near it; it hangs off the same clock as the reminders.

05

What people can switch off

Settings currently says, plainly and correctly, that there is nothing to unsubscribe from because nothing is being sent. That sentence is about to stop being true, and what replaces it should not be a wall of switches that all mean roughly the same thing.

GroupDefaultReasoning
Your jobs and bookingsAlways onBeing booked, being turned down, a cancellation, a job called off. If somebody has committed to work on a date, they get told. This is transactional and is not offered as a choice while the account is open, the same way sign-in email is not.
RemindersOnGenuinely optional — a busy agent with the calendar synced may not want two more. Switchable without argument.
MoneyOnReleases, refunds, payouts, disputes. Off is a real choice for somebody who reads their wallet, but it should be a deliberate one.
New work near youOn, one tap offThe only group that is not about something the person is already involved in. It carries the unsubscribe weight for the whole system, so it gets the clearest off switch.

One email_prefs jsonb column on profiles holds it, and every email footer links to it. The link has to work without signing in first — an unsubscribe that demands a password is the reason people press the spam button instead — so it carries a signed token, and the route reads that rather than a session.

The same reasoning puts List-Unsubscribe and List-Unsubscribe-Post headers on everything outside the always-on group. Gmail and Microsoft both now expect one-click unsubscribe from any sender doing volume, and a domain with no history gets less benefit of the doubt than most.

06

The clock we do not have

Reminders, review requests and payout nudges are all the same problem: something that has to happen at a time, not in response to a write. Everything built so far is a trigger, and a trigger cannot fire tomorrow morning.

pg_cron is the smaller of the two options and keeps the whole system in one place: a job every fifteen minutes that finds confirmed appointments falling inside the next window and inserts the reminder notices, which then flow out through the same fan-out as everything else. Reminders become ordinary notifications that happen to have been written by a clock. The alternative — a Cloudflare cron trigger calling an edge function — means a second deployment target and a second place to look when something does not send.

One thing to settle before writing it: appointments.scheduled_date is a date and scheduled_time is text, with no timezone anywhere on the row. "9:00 AM" is not a moment until you know where. Everything is in one metro today so it works out, but a reminder job does arithmetic on these values and that is exactly where an assumption like this stops being harmless. The job feed already had a timezone migration; this needs the same treatment, and it is worth doing first rather than discovering it at 6am in somebody's inbox.

07

Reputation is the constraint, not the quota

Fifty thousand messages a month against a marketplace this size is effectively unlimited. A booking that runs the whole way through — application, decision, confirmation, two reminders each side, release, two review requests — is somewhere around fifteen emails across both people. The quota covers a few thousand bookings a month, which is a problem worth having later.

What is actually scarce is the domain's standing. tourdash.io started sending on 4 September and has sent a handful of messages. A beta agent already found her confirmation email in spam, and her brokerage is on Microsoft 365 — as most brokerages are, and M365 junks unknown senders by default. Volume arriving suddenly, from a domain with no history, into tenants that already distrust it, is the one way to make this worse rather than better.

So: every message gets a real plain-text part alongside the HTML, which the auth templates cannot have because Supabase only stores one field — these are ours, and they can. Send the transactional messages first and let them build history, because those are the ones people open. Hold the weekly digest until the DMARC reports come back clean and until there is enough work on the board that it is worth opening. And keep the volume honest: a message nobody wanted costs more than the one it delivered.

08

The order of the work

Four phases. The first one is the only one that is mostly plumbing; after that each phase ships messages people actually receive.

PhaseWhat shipsContains
OneThe pipe, and the ten notices that exist — live 10 SeptemberThree columns on notifications, the notify_email trigger, the send-notification-email function, the shared shell and plain-text renderer in the existing generator, and templates for the ten live notices. Preferences column and the token-signed preferences page. Settings rewritten to describe something true.
TwoThe gaps that need no clock — live 10 SeptemberAll eight. Each is a notification the app was missing too, so both halves landed together.
ThreeAnything with a time on it — live 10 SeptemberBrought forward and built the same day. The timezone question settled on appointments, then pg_cron, the sweep, and one reminder the evening before. The review request and the payout nudge move to phase two, since they need no clock of their own beyond this one.
FourCancelledMichael's call: no weekly digest. The switch for it shipped that morning and came out the same evening rather than sitting on a settings screen describing something that will never exist.

Team alerts get a small extension along the way. Waiting-list signups and new accounts already reach the team; a first booking, a dispute, a refund and a Connect failure should too. Those reuse send_lead_email() exactly as it stands and cost almost nothing to add.

09

Phase one, as built

Live on 10 September, the same day this was written. It went in as described above, with three changes the code argued for once it was being written rather than planned.

Decided in the planWhat was actually built
Preferences as a jsonb column on profilesTheir own table, email_preferences, with three boolean columns. The select policy on profiles is qual: true for authenticated, so every signed-in account can read every column of every profile through the API. Preferences there would merely be untidy; the unsubscribe token there would hand every account a working unsubscribe link for every other account. Booleans rather than jsonb because the set is small and known, which makes "who turned reminders off" a query.
One unsubscribe URLTwo, because they are different things. The footer link opens a page a person reads. The List-Unsubscribe header points at an endpoint a mail client POSTs to on its own, without asking anybody. Pointing the header at the app would have given Gmail a single-page app that answers every POST with HTML and changes nothing.
Templates rendered in the edge functionRendered there, but through the same frame as the auth email: the shell moved to supabase/functions/_shared/email-shell.js, plain JavaScript because Node reads it for the auth generator and Deno reads it for the sender. Regenerating the 13 auth templates after the move produced byte-identical files, which is the proof the refactor changed none of them.

Two bugs turned up while the producers were being edited, both fixed in the same migration. handle_new_application had quietly reverted to "New Application" in title case with the job name in straight quotes and no link, because profile-preferences-migration.sql ran after notifications-migration.sql and carried an older copy of the function forward with it. And a contractor who withdrew from a booking was being told the agent had gone with someone else, about a job they had walked away from themselves — the edge function marks their own application rejected, which is what the notice trigger reads. That was already wrong in the app and would have been emailed, so applications.closed_reason now records why an application closed and the trigger stays quiet when somebody is being told about their own decision.

VerifiedResult
The whole chainOne notification inserted live: trigger fired, pg_net posted, the function rendered and sent, Resend reported delivered, and emailed_at was stamped. The message carried both an HTML and a plain-text part.
The double-send guardReplaying the same notification id answered skipped: already sent and sent nothing.
A switch actually switchingMoney turned off, then a money notice written: it reached the bell, emailed_at stayed null, and the function answered skipped: money switched off. A preferences screen that gates nothing is worse than none.
The endpoint's mannersWrong secret and no secret both 401. Unknown token reads as expired rather than confirming whether an address is on the platform. A body naming switches that do not exist is refused rather than written.
The two date formatters agreeThe SQL one and the JavaScript one both render 11 September 2026 as "Friday, 11 September at 9:00 AM". They exist separately because triggers and edge functions both write notices, and disagreeing about a date is exactly the failure worth checking for.
The demoZero Supabase requests across three /demo routes, unchanged.

All probe rows were removed afterwards and the table is back to the 22 notices it held before. Nothing backfills: the trigger is AFTER INSERT, so nobody was mailed about last week.

10

The reminder, and the text

The four questions below were answered the same evening, and three of the answers changed what got built.

QuestionAnswerWhat it meant
Are booking emails refusable?NoUnchanged from the recommendation. Being booked, turned down or cancelled on reaches people whatever they have chosen, and the preferences screen says so rather than leaving it to be discovered.
One reminder or two?OneAt 6pm the day before, which is the last point where somebody who cannot make it can still say so and let the agent find cover. A reminder two hours ahead only tells them they are about to be late.
Does the weekly digest ship?NoCancelled outright, and its switch removed the same evening. A control describing a feature that will never exist is worse than no control.
Text messages?Yes, for remindersBuilt against Twilio and shipped switched off. US business texting needs an A2P 10DLC brand and campaign registration with the carriers first, which takes days to weeks, so the code waits on the paperwork rather than the other way round.

The reminder is written by a fifteen-minute pg_cron sweep that inserts ordinary notifications, so it inherits the templates, the preference check, the send and the stamp that everything else already goes through. Both sides hear: the person covering needs the address and whatever the agent said about getting in, and the agent needs to know it is still covered and by whom.

The timezone question is settled. appointments now carries starts_at, a real instant built from the date and the text time in the zone the market is in. It is a stored column rather than a generated one because timestamp at time zone is STABLE rather than IMMUTABLE, so Postgres will neither generate the expression nor index it. Two edge cases are decided by the sweep rather than the template: a job booked after its own reminder time gets no reminder, because the confirmation sent minutes earlier already carries the same time, address and note; and the word "tomorrow" comes from the data, so a catch-up run after an outage cannot say tomorrow about this afternoon.

Texting is opt-in and stays opt-in. The switch going on is the consent record, stamped by a database trigger so no caller can forget it, and cleared when it goes off. An account with no phone number sees the switch explain itself and refuse to move, rather than a control that looks like it works and can never deliver. Every message carries its own opt-out, because a registered sender is one that offers a way out in the message itself.

Verified by moving one real booking into its reminder window with texts off for the brokerage address: the sweep found it, wrote both notices, stamped the appointment, sent the covering side's email (delivered at Resend, both MIME parts), skipped the other with "reminders switched off", and answered "twilio not configured" for both texts. The booking, its timestamps and both accounts' preferences were put back afterwards. One copy bug turned up in the process and was fixed: the footer said "booked for this job tomorrow" under a message headed Today.

One thing shipped that morning was wrong and is now fixed. appointments.scheduled_time is text holding a 24-hour clock string, and the formatter passed it through untouched, so a cancellation email would have read "at 17:00:00". It reads "5:00 PM".

11

Phase two, and where each one hangs

Eight moments that already happened and that nobody was told about. The rule throughout was to hang each one off a state change that already exists rather than off an edited code path, so there is no second place to remember.

NoticeHangs offWhy there
Your money is on its waypayments.status to releasedNot stripe-release itself. That function has four paths to completion, and hooking the row means none of them can miss it.
How did it go?appointments.status to completedGoes to the agent only. The person who did the work is hearing about their money in the same second, and that message carries the review ask itself. Two emails landing together about one job is how a useful notice teaches somebody to ignore the next one.
You have a new reviewinsert on reviewsA second trigger rather than an edit to handle_new_review. Keeping the rating average correct and telling somebody about it are different concerns, and one failing should not take the other with it.
Your booking is confirmedstripe-webhookThe agent had only ever seen this on screen at the moment they paid. Nothing landed in their inbox with the date, the address and who is covering it.
A counter-offerinsert on applicationsIts own template rather than a variant of "New application". Somebody naming their own price is a different decision, and both numbers now sit side by side.
Contact details for the dayinsert or update on listing_contactsDeliberately carries no name and no number. That table exists with its own policy so a buyer's phone number is not readable by every signed-in account; posting it into an inbox would undo that to save one tap.
Nobody can book you yeta daily sweepA state, not an event, so it needed the clock the reminders brought.
Welcomeauth.users.email_confirmed_atOn confirmation, not on signup, so it cannot arrive before the link that lets somebody sign in and read it. A failure here is swallowed: a welcome must never be why an account cannot be confirmed.

The payouts nudge changed shape once the data was read. The plan said "the vendor", and the live database has no vendor accounts at all: all fourteen are agents, and agents cover each other's showings for money. Filtering by account type would have meant the nudge never fired for anybody. The test is intent instead — somebody who has applied for a job or been invited to one has said they want work and cannot receive it — which leaves an agent who only ever posts jobs unbothered about a payout they will never take. Two real people qualify today, both of them silently unbookable.

Reviews also gained their own switch. They had briefly been folded under reminders, which the settings page describes as the note the evening before a job; a switch that turns off more than it says is how a preferences page stops being believed. Four switches now: reminders by email, reminders by text, money, reviews.

All four database triggers were fired against live rows and every payload and recipient checked, then everything was put back: the appointment, the payment, the review, the rating average and both accounts' preferences. With money and reviews switched off for the duration, three of the four notices answered "switched off" and sent nothing, while the buyer contact notice sent anyway because it is marked critical — somebody is driving to a house. That is the categorisation proving itself in both directions.

12

What is still open

Texts need a Twilio account, a number, and the 10DLC registration cleared before a single one sends. Until then the switch is visible, the consent is recorded, and the send path answers "twilio not configured" and moves on. That is the only thing in this plan waiting on something outside the code.

Two notices have been built but never fired by a real event, because neither event has happened yet on this database: the welcome, which needs somebody to confirm a new account, and the payouts nudge, which runs for the first time at 14:30 UTC. Both were checked by rendering and by reading the installed functions back, which is weaker than watching one arrive.

Everything the plan listed is now either live or deliberately cancelled.

13

How the four questions were put

Everything above is a recommendation, and four of them are genuinely yours rather than the code's.

QuestionRecommendation
Are booking emails refusable?No. If someone is booked for work on a date, they get told. Everything else is switchable. The alternative is a vendor missing a job they were paid for because of a checkbox.
One reminder or two?Two — the evening before, for planning, and the morning of, for actually leaving the house. They do different jobs. Both switchable together.
Does the weekly digest ship at all?Yes, but last, and not until the board has enough on it that opening it is worth the reader's time. Shipping it early is how a young domain teaches Microsoft to junk it.
Text messages?Not yet, and not in this plan. Every account now has a phone number, so it is possible — but it is a second provider, a second consent story and a second set of rules. The morning-of reminder is the only message that clearly wants it, and that is an argument to have after the email version has run for a while.