Skip to content

feat: email the owner on bookings, leads, and tickets - #124

Merged
goetchstone merged 2 commits into
mainfrom
fix/owner-notifications
Oct 1, 2026
Merged

goetchstone merged 2 commits into
mainfrom
fix/owner-notifications

Conversation

@goetchstone

Copy link
Copy Markdown
Owner

Why

The founder missed a booked consult. When someone booked, the app emailed only the booker. Nothing ever notified the business about a booking, a client cancellation, a lead-magnet download, or a support ticket; they all landed silently in the dashboard.

What changes

Owner notifications (server/email/notify-owner.ts)

  • Sent for every booking, client cancellation, lead and ticket.
  • Recipient is Settings → Company email. If that's unset, every active admin (oldest first), and the fallback is logged.
  • Booking notices include name, email, phone, notes and the time in the business timezone. Reply-To is the client.
  • A repeat lead is labeled "Returning lead".

Calendar events (lib/ics.ts, RFC 5545)

  • Booking: METHOD:PUBLISH to both sides. Client cancellation: METHOD:CANCEL to both, same UID.
  • PUBLISH, not REQUEST: Gmail/Outlook show Decline on a REQUEST, and a client who declines would think they'd cancelled while the slot stays booked.
  • No ATTENDEE: RFC 5546 forbids it on PUBLISH, and CalDAV calendars would re-invite the client.
  • A client only ever sees the company email as organizer. If it's unset, they get no calendar event rather than an admin's login address.

Bugs fixed in the same flow

  • The client's cancel link returned 404 (/book/cancel); it now goes to /portal/appointments/cancel. The reminder email shares the same builder.
  • Confirmation and reminder times now show in the client's zone with a label ("6:00 AM PDT"). The date was formatted in the server's UTC.
  • An invalid browser timezone is rejected before saving. Before, it threw after the booking was saved, so the visitor saw an error.
  • Public-form text (booking, lead, ticket) is stripped of control characters at the boundary. A NUL byte returned 500 before. Email subjects collapse controls, so visitor input can't add headers.
  • Email sends are isolated (allSettled plus an async wrapper). A failure, even a throw while building the client's email, never blocks the owner's notice and never fails a saved booking. Each failure is logged with the appointment id.

Verified against the production image + Mailpit

  • Booking: owner and client emails, correct recipients and Reply-To, text/calendar; method=PUBLISH parts, zone-labeled times, working cancel link (200).
  • Cancel: method=CANCEL to both.
  • Pat\r\nBcc: attacker as a name: no Bcc header, no extra recipient; escaped in the event.
  • ESC/NUL in names: stripped from the stored row, subject and event. A NUL-only name returns 400.
  • Invalid timezone: 400, nothing saved.
  • No company email: falls back to admin with a warning; the client gets no event.
  • Client-email throw (legacy invalid timezone): the owner's notice is still sent.
  • Unreachable SMTP: booking still 200, each failure logged.
  • Lead twice: "New lead", then "Returning lead". Ticket: owner notified.
  • Gates: tsc, 124 tests (was 104), Semgrep 0, audit OK.

Review note: the adversarial review hit a session limit. The correctness lens completed and all 8 of its findings were checked by hand: 7 real, fixed here; the 8th is operator guidance (Gmail self-send, below). The security lens didn't run, so I covered it by hand. Header, HTML and ICS injection plus control characters are tested above. Spam-relay exposure is unchanged: the confirmation to a visitor-supplied address predates this change, and all three forms stay rate-limited.

After deploy (operator)

  1. Settings → Company email = the inbox you watch, and not the same Gmail account used for SMTP (Gmail files self-sent mail under Sent, not Inbox).
  2. Book a test consult on the live site, and check that your Inbox gets the notice and the calendar event. Then cancel it from the email link.

🤖 Generated with Claude Code

Only visitors were ever emailed, so a booked consult was missed.
Bookings add calendar events, zone-labeled times, and a working
cancel link; public form text is stripped of control characters.
@goetchstone
goetchstone merged commit 5d8bd06 into main Oct 1, 2026
8 checks passed
@goetchstone
goetchstone deleted the fix/owner-notifications branch October 1, 2026 01:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant