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.
More than it looks like. Nothing here needs to be invented; the pieces have simply never been joined up.
| Piece | State | What it means for this |
|---|---|---|
| In-app notices | Live | Six 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_net | Installed | The database can make HTTP calls on its own. This is how the lead alerts already reach the team. |
resend_api_key | In Vault | Stored once, decrypted inside a security definer function, never near the browser. |
send_lead_email() | Live, fired | A 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 templates | 13, generated | supabase/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 domain | Verified 4 Sep | SPF and DKIM both pass and both align. DMARC is p=none with aggregate reports on. |
pg_cron | Not installed | The 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.
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.
| Step | Where | What it does |
|---|---|---|
| 1. The notice | Existing triggers and functions | Unchanged, except that each one now also names an email_key and passes the extra facts an email needs in email_payload. |
| 2. The trigger | notify_email, after insert | Does 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 renderer | send-notification-email edge function | Loads 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.
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.
| Event | Who hears | Why it earns an email |
|---|---|---|
| Somebody applied | The agent who posted | A person is waiting on a decision. The faster this lands, the better the marketplace feels to the applicant. |
| You're booked | Whoever is covering | Work 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 you | The invitee | An 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 time | The applicant | Frees them to take other work that day. Silence here is the thing that makes a board feel dead. |
| The job was called off | Everyone still pending | They would otherwise keep holding the slot. |
| A booking was cancelled | The other side | Somebody was going to get in a car. Time-critical by definition. |
| A contractor withdrew | The agent | The job is open again and the showing still has to be covered, often within a day. |
| Your refund is on the way | The agent | Money leaving and coming back should never be something you have to go and check. |
| Your lead sold | The seller | Money, and a 72-hour dispute window they should know is running. |
| A lead was disputed | The seller | A payout is on hold. Finding that out by noticing an amount is missing is the worst version of this. |
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.
| Event | Who hears | Why it is missing, and why it matters |
|---|---|---|
| Your booking is confirmed | The agent | The 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 morning | Both sides | The 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 released | The vendor | stripe-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 yet | The vendor | An 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 in | The agent | An applicant naming their own price is a decision waiting on the agent, and it is indistinguishable from an ordinary application right now. |
| Somebody reviewed you | The reviewee | handle_new_review updates the rating average and stops. Reputation changes without a word. |
| How did it go? | Both sides | Reviews are the whole trust layer, and nothing has ever asked for one. |
| Here is who to call | The covering agent | Buyer contact can be added or changed after the booking. The person driving there is not told it appeared. |
| Welcome, once confirmed | New accounts | Signup email ends at "confirm your address". The first thing to actually do is never said. |
| New work near you | Opt-in, weekly | The 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.
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.
| Group | Default | Reasoning |
|---|---|---|
| Your jobs and bookings | Always on | Being 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. |
| Reminders | On | Genuinely optional — a busy agent with the calendar synced may not want two more. Switchable without argument. |
| Money | On | Releases, refunds, payouts, disputes. Off is a real choice for somebody who reads their wallet, but it should be a deliberate one. |
| New work near you | On, one tap off | The 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.
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.
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.
Four phases. The first one is the only one that is mostly plumbing; after that each phase ships messages people actually receive.
| Phase | What ships | Contains |
|---|---|---|
| One | The pipe, and the ten notices that exist — live 10 September | Three 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. |
| Two | The gaps that need no clock — live 10 September | All eight. Each is a notification the app was missing too, so both halves landed together. |
| Three | Anything with a time on it — live 10 September | Brought 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. |
| Four | Cancelled | Michael'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.
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 plan | What was actually built |
|---|---|
Preferences as a jsonb column on profiles | Their 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 URL | Two, 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 function | Rendered 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.
| Verified | Result |
|---|---|
| The whole chain | One 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 guard | Replaying the same notification id answered skipped: already sent and sent nothing. |
| A switch actually switching | Money 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 manners | Wrong 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 agree | The 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 demo | Zero 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.
The four questions below were answered the same evening, and three of the answers changed what got built.
| Question | Answer | What it meant |
|---|---|---|
| Are booking emails refusable? | No | Unchanged 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? | One | At 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? | No | Cancelled 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 reminders | Built 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".
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.
| Notice | Hangs off | Why there |
|---|---|---|
| Your money is on its way | payments.status to released | Not 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 completed | Goes 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 review | insert on reviews | A 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 confirmed | stripe-webhook | The 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-offer | insert on applications | Its 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 day | insert or update on listing_contacts | Deliberately 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 yet | a daily sweep | A state, not an event, so it needed the clock the reminders brought. |
| Welcome | auth.users.email_confirmed_at | On 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.
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.
Everything above is a recommendation, and four of them are genuinely yours rather than the code's.
| Question | Recommendation |
|---|---|
| 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. |