NorthBlue · email & contact

Every message we send.
And every one we don't.

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.

01What NorthBlue sends today

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.

To the guest

Booking confirmation

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.

To the hotel desk

Concierge handoff

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.

Both are fire-and-forget by design.

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.

02Two domains, two jobs

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.

northblue.app the apex — people Google Workspace MX 1 smtp.google.com akmal@northblue.app — a real inbox hello@ · support@ — shared addresses Receives. Humans read and reply. mail.northblue.app a subdomain — robots Resend envelope: send.mail.northblue.app bookings@mail.northblue.app — the sender Confirmations and concierge handoffs Sends only. Nobody reads this mailbox. separate
Two systems, one registered domain. They never collide because DNS records attach to names, and these are different names.

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.

DNS lives at Vercel, not at the registrar.

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.

03The records, and what each one proves

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.

SPF TXT on send.mail “This server may send for me.” DKIM TXT on resend._domainkey.mail “Nothing was altered in transit.” DMARC TXT on _dmarc.mail “If those fail, here's what to do.” Delivered to the inbox
SPF and DKIM are evidence; DMARC is the instruction for what to do when the evidence is missing. A domain with none of them is treated as guilty by default.
NameTypeValueWhat it does
resend._domainkey.mailTXTp=MIGfMA0… The public half of the key Resend signs every message with. A receiver re-checks the signature against this.
send.mailTXTv=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.mailMX10 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.mailTXTv=DMARC1; p=none; Policy. p=none means “deliver anyway, but this domain has its paperwork in order.”
@ (apex)MX1 smtp.google.com Google Workspace. Unrelated to sending — this is how @northblue.app receives.
@ (apex)TXTgoogle-site-verification=… Proof of ownership for Google. Does nothing after verification, but removing it un-verifies the domain.
Why SPF sits on 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.

One SPF record per name. Never two.

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.

04What makes an email fire

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.

Stripe webhook payment_intent.succeeded Confirmation page guest returns to the tab Reconcile sweep /api/jobs/run, daily cron finalizeStripeBooking guarded update: status must still be pending_payment / held One email. Ever. The status predicate is part of the UPDATE, so exactly one caller can match it. Whichever arrives first wins; the other two match no rows and send nothing.
Three independent paths, one email. The guarantee is in the database, not in application logic — which is why it holds even if all three fire at once.

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.

05Four ways to book, and who gets told

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.

Paid ticket create-payment-intent Free / voucher /api/bookings Min-party group status: held Seller approval Stripe card charged Confirmation Confirmation “nothing to pay” Place held “once enough join” Request sent
Every route now ends in a message, but not the same message. A booking that cost nothing must not claim a payment, and a request nobody has accepted must not claim to be confirmed.
Booking statusWhat the guest is toldTotal 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.
The silence was the bug, and it was invisible.

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.

The 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.

06What actually lands in the inbox

Same envelope, same layout, three different truths. The only parts that change are the heading, one sentence, and the label on the total.

bookings@mail.northblue.app · Booking confirmed — Sunset Sail
NB
Your booking is confirmed

Hi Alex, your payment went through and your spot is locked in.

Experience
Sunset Sail
Adult × 2$96
Total paid$96
bookings@mail.northblue.app · Booking confirmed — Bike Hire
NB
Your booking is confirmed

Hi Alex, your spot is locked in. There is nothing to pay for this one.

Experience
Bike Hire
Bike × 1Free
TotalFree
bookings@mail.northblue.app · Request sent — Private Tour
NB
Your request is with the team

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.

Private Tour × 2$240
Total on hold$240
bookings@mail.northblue.app · You are on the list — Whale Watch
NB
Your place is held

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.

Whale Watch × 1$60
Total on hold$60
bookings@mail.northblue.app · Not available — Private Tour
NB
The operator could not take this one

Hi Alex, unfortunately this could not be confirmed. The hold on your card has been released. They said: “No pilot available that morning.”

Private Tour × 2$240
Total (not charged)$240
bookings@mail.northblue.app · Not running — Whale Watch
NB
This departure is not going ahead

Hi Alex, not enough people joined this one, so it will not be running. The hold on your card has been released.

Whale Watch × 1$60
Total (not charged)$60
The money sentence is picked, never guessed.

“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.

A product that takes no card says so instead.

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.

A zero total reads “Free”, not “$0”.

Small thing, but $0 reads like a missing value or a pricing bug. Free reads like a decision somebody made.

07How we hold a guest's contact details

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.

ColumnHoldsNotes
guest_emailRequired. Where the confirmation goes.Validated loosely — a stricter pattern mostly rejects addresses that are in fact valid.
guest_phoneOptional. E.164 where it could be normalized.The number an SMS confirmation would go to, once that exists.
guest_roomOptional. Hotel room number.Only meaningful when the booking came through a hotel guest app.
guest_notesThe guest's own words, and nothing else.Shown to the seller as written.
Phone normalization is deliberately conservative.

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.

08Where a reply goes

Nowhere. This is the sharpest edge in the current setup.

The confirmation email invites a reply it cannot receive.

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:

  1. Set a reply-to. Add 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.
  2. Turn on inbound. Add 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.

09When mail doesn't arrive

In order. Most outages are the first two.

  1. Is a key configured? With 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.
  2. Is 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.
  3. Check Resend → Logs. Every attempt appears with its status. A 403 means the domain is not verified; a bounce means the address is dead. Note that these logs expire after thirty days.
  4. Check the booking row. Once delivery recording lands, 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.
  5. Check the status. A booking sitting in pending_payment is supposed to be silent. Confirm the booking actually reached a status that earns an email before assuming a delivery fault.
  6. Check the address. A guest who mistypes their email pays and receives nothing, with no account to sign into. The confirmation page has a correction form for exactly this — the booking id is the authorization, the same bearer-token model the booking page uses.
  7. Redeploy after any env change. Environment variables only take effect on a new deployment. A correct value on an old deployment is still the old value.
A failed send never breaks a booking — and that is the trade.

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.

10What isn't built

Named plainly, so nobody assumes otherwise.

Missing

SMS

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.

Built

The threshold follow-up

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.

Missing

Reminders

Nothing goes out before an experience. No “tomorrow at 10am”, no directions, no weather note. The cron that would carry it already runs daily.

Partial

Escaping

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.

Partial

Plain-text bodies

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.

By design

Retries

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.