What leaves the system, what makes it leave, which domain it leaves from, and why a guest sometimes hears nothing at all. Companion to payments, which covers the money — this covers the part the guest actually reads. Audited against the code on 2026-08-30.
Two recipients, one builder each. That is the whole outbound surface — there is no newsletter, no reminder, no receipt separate from the confirmation, and nothing at all by SMS. The guest’s builder has five wordings, because a booking can end in five different places, but they are one template carrying one set of facts.
Sent once, when a booking becomes real. Carries the experience, the time, the hotel, the line items, the total, and a link back to the booking page. This is the guest's ticket.
Sent when the AI concierge decides a human is needed. Carries a summary of what the guest wants and the last six messages of the conversation. Goes to CONCIERGE_HANDOFF_EMAIL, capped at ten per hotel per day.
Neither send can break the thing that triggered it. A booking that is paid for and confirmed must never be undone because Resend was unreachable, so every failure is logged and swallowed. The cost of that choice is covered in §09.
The single most confusing thing about this setup: mail leaves from one domain and arrives at another. That is deliberate, and it is standard practice.
Keeping automated mail on a subdomain is the reason a bounced confirmation cannot damage the reputation of the address a hotel writes to. If NorthBlue ever gets flagged for sending, it is mail.northblue.app that suffers, and akmal@northblue.app carries on working.
The domain is registered with Spaceship, but its nameservers point at ns1.vercel-dns.com and ns2.vercel-dns.com. Every record below is edited in the Vercel dashboard under the team's Domains section. The only thing that must never change at Spaceship is the nameservers themselves.
Four DNS records make guest mail deliverable. Each answers a different question a receiving server asks, and all four have to answer before Gmail stops being suspicious.
| Name | Type | Value | What it does |
|---|---|---|---|
resend._domainkey.mail | TXT | p=MIGfMA0… |
The public half of the key Resend signs every message with. A receiver re-checks the signature against this. |
send.mail | TXT | v=spf1 include:amazonses.com ~all |
Names the servers allowed to send. Checked against the envelope sender, which is why it sits on send.mail and not on mail. |
send.mail | MX | 10 feedback-smtp.us-east-1.amazonses.com |
Where bounces go. This is not an inbox — it is how Resend learns an address is dead. |
_dmarc.mail | TXT | v=DMARC1; p=none; |
Policy. p=none means “deliver anyway, but this domain has its paperwork in order.” |
@ (apex) | MX | 1 smtp.google.com |
Google Workspace. Unrelated to sending — this is how @northblue.app receives. |
@ (apex) | TXT | google-site-verification=… |
Proof of ownership for Google. Does nothing after verification, but removing it un-verifies the domain. |
send.mail and DKIM on mail.
They are checked against different things. DKIM is checked against the domain in the From: header — mail.northblue.app. SPF is checked against the envelope sender, which Resend sets to send.mail.northblue.app. Putting either on the wrong name silently fails, and nothing in the Resend dashboard explains why.
When Google Workspace asks for an SPF record at the apex, that is safe — it is a different name from send.mail. But two SPF records on the same name is a hard failure, not a merge. If a service ever asks for a second one at a name that already has one, the two must be combined into a single record.
A confirmation is not sent when a booking is created. It is sent when a booking becomes real, and for a card payment that can be discovered by three different routes.
The concierge handoff has no such machinery. It fires inline when the agent decides a human is needed, deduplicated within a single request and rate-limited to ten per hotel per day so a script cannot mail the desk without limit.
Only one of the four booking routes goes through Stripe. The other three never touch a payment intent, which means finalizeStripeBooking — the thing that sends the email — never runs for them.
| Booking status | What the guest is told | Total reads |
|---|---|---|
| confirmed paid | “Your payment went through and your spot is locked in.” | Total paid |
| confirmed free | “Your spot is locked in. There is nothing to pay for this one.” | Total: Free |
| requested | “Your request is with the team. Nothing has been charged.” | Total if confirmed |
| held | “This one runs once enough people join, and your place is held until it does.” | Total |
| pending_payment | Nothing. That guest is still on the card form. | — |
| cancelled · abandoned · mock_confirmed | Nothing. | — |
A free booking confirmed correctly, decremented capacity correctly, appeared on the seller's dashboard correctly, and told the guest nothing. Nothing errored, so nothing surfaced. It was found by taking a real booking on production and waiting for an email that never came.
pending_payment exclusion is load-bearing.
It is tempting to email on every status. Do not. A guest sitting on the card form has bought nothing, and a message saying “confirmed” would be a promise of a seat that no money has been taken for — a worse failure than silence.
Same envelope, same layout, three different truths. The only parts that change are the heading, one sentence, and the label on the total.
Hi Alex, your payment went through and your spot is locked in.
Hi Alex, your spot is locked in. There is nothing to pay for this one.
Hi Alex, we have passed this on. We are holding the amount on your card and will only take it if the operator accepts — you will get another email either way.
Hi Alex, this one runs once enough people join, and your place is held until it does. We are holding the amount on your card and will only take it if the departure goes ahead.
Hi Alex, unfortunately this could not be confirmed. The hold on your card has been released. They said: “No pilot available that morning.”
Hi Alex, not enough people joined this one, so it will not be running. The hold on your card has been released.
“You were not charged”, “the hold on your card has been released” and “your refund is on its way and should land in 5–10 days” are three different promises, and the guest can tell which one they got. The builder chooses from payment_state, so a released authorization is never described as a refund the guest then waits for — and a real refund is never undersold as though nothing had happened.
The two requests above are holding money because they went through Stripe checkout. A manual-request product never takes a card at all, and its copy says “Nothing has been charged” rather than claiming a hold that does not exist.
Small thing, but $0 reads like a missing value or a pricing bug. Free reads like a decision somebody made.
Email is required at checkout. Phone and room are optional, and until recently they were not really stored at all.
Phone and room used to be packed into the front of guest_notes as a line reading Room 402 · Phone +1 555 000 1234, because the booking functions in Postgres accept a notes string and nothing else. That worked while a human was the only reader. It stopped working the moment anything needed to find a phone number, because it meant pattern-matching free text that a guest can also type into.
| Column | Holds | Notes |
|---|---|---|
guest_email | Required. Where the confirmation goes. | Validated loosely — a stricter pattern mostly rejects addresses that are in fact valid. |
guest_phone | Optional. E.164 where it could be normalized. | The number an SMS confirmation would go to, once that exists. |
guest_room | Optional. Hotel room number. | Only meaningful when the booking came through a hotel guest app. |
guest_notes | The guest's own words, and nothing else. | Shown to the seller as written. |
A number that already carries a country code is kept as one. A bare ten-digit number is assumed North American, because that is the only country that can be safely inferred — the checkout has no country picker, and a tourist typing 07700 900123 has entered a valid local number in several countries at once. Anything else is stored exactly as typed: unsendable, but not lost, and still dialable by a seller reading the booking.
Bookings taken before the migration still carry their details in the old note format. The seller dashboard reads both — columns first, the legacy note only when the columns are empty — so an old booking shows a phone number whether or not the backfill has been run, and never shows the contact line duplicated into the note it was lifted from.
Nowhere. This is the sharpest edge in the current setup.
Every booking confirmation ends with “Questions? Just reply — the concierge team reads every message.” It is sent from bookings@mail.northblue.app, no reply_to is set, and that subdomain has no inbox. The MX record on send.mail is for bounce handling, not for delivering replies to a person.
A guest who replies is writing to a mailbox nobody has ever opened. There are two ways out, and either one closes it:
reply_to on the Resend payload pointing at a real Google Workspace address on the apex. One field, and replies land somewhere a person reads.MX 10 inbound-smtp.us-east-1.amazonaws.com on mail and enable Resend Inbound, so the sending address itself can receive.Until one of them is done, the honest fix is to change the sentence rather than keep the promise. A confirmation that names a contact the guest can actually reach beats one that invites a reply into a void.
In order. Most outages are the first two.
RESEND_API_KEY unset the sender logs a warning and returns. No error, no retry, no trace beyond that line. Nothing has ever been sent.EMAIL_FROM set? Unset, it falls back to onboarding@resend.dev, which only ever delivers to the address the Resend account was registered with. This is the failure that looks like success: it works perfectly when you test with your own address and silently reaches nobody else.confirmation_email_attempted_at, confirmation_email_id and confirmation_email_error answer “was this guest ever emailed?” long after the provider's own log is gone.pending_payment is supposed to be silent. Confirm the booking actually reached a status that earns an email before assuming a delivery fault.Because the sender swallows every failure, a Resend outage is invisible from the guest's side and from the seller's. The booking is fine; only the message is missing. That is the right call, and it is exactly why the durable record in step 4 matters.
Named plainly, so nobody assumes otherwise.
Nothing sends a text. The guest_phone column exists and is normalized toward E.164 specifically so this can be built, but no provider is wired up. In the US this needs A2P 10DLC brand and campaign registration before a single message can be sent — days to weeks of paperwork, and the long pole in any SMS plan.
Was the gap here: the promotion from held to confirmed happens inside Postgres, so no application code ran and those guests were told their place was held and never that it was going ahead. The cutoff sweep in /api/jobs/run now settles each decided departure — every member gets the confirmation, or the “not running” email, in the same pass that decides it.
Nothing goes out before an experience. No “tomorrow at 10am”, no directions, no weather note. The cron that would carry it already runs daily.
Experience titles and hotel names are written by sellers, and concierge transcripts by guests. Both are interpolated into email HTML. Fencing them at that boundary is in flight and should land before the first real send.
HTML with no text alternative is a recognized spam signal, and the confirmation is the one message that must not be filtered. Also in flight.
A failed send is not retried. The durable job queue that drains concierge work could carry this, but a confirmation that arrives an hour late is worth less than one that never blocks a booking.