diff --git a/.env.example b/.env.example index 5bb6b4f..d538c4d 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ SESSION_SECRET= DATABASE_URL= APP_BASE_URL= APP_ENV= +# dev: the relay captures texts and emails in smtp4dev (mail.dev.noctusoft.com) +# instead of sending them. Leave unset on uat and production. +RELAY_APP_ENV= # Sender for sign-in codes. Empty: the relay's shared sender. Set to # no-reply@coachatron.com once coachatron.com is authenticated in SendGrid. COACHATRON_EMAIL_FROM= diff --git a/.railway/railway.ts b/.railway/railway.ts index c87c457..18bed96 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -59,6 +59,8 @@ export default defineRailway((ctx) => { RELAY_BASE_URL: preserve(), RELAY_CONNECT_PRODUCT: preserve(), RELAY_INBOUND_SECRET: preserve(), + // dev only: texts and emails are captured in smtp4dev, never sent. + RELAY_APP_ENV: preserve(), RELAY_WEBHOOK_SECRET: preserve(), SESSION_SECRET: preserve(), STORE_BASE_URL: preserve(), diff --git a/README.md b/README.md index 7fea549..ac0e3b2 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,17 @@ forwards Twilio's form fields to `/webhooks/sms`, signed with number serves every environment for sending; incoming texts go to uat while testing and to production at launch (`docs/PLATFORM.md` section 3). +## Testing texts without a phone (development) + +Development runs with `RELAY_APP_ENV=dev`. The relay then captures every text +and email instead of sending it, in smtp4dev at +[mail.dev.noctusoft.com](https://mail.dev.noctusoft.com/) (IP-allowlisted). A +text to `+1 555 555 0100` shows up as an email to +`sms-15555550100@sms.capture.noctusoft.com`. Nothing reaches a phone, so any +number, including one outside the US and Canada, can sign in on development to +try the text flows. uat and production leave `RELAY_APP_ENV` unset and really +send. + ## Texting ceilings Every outbound text goes through `sendText()` in `src/domain/outbound.ts` diff --git a/ROADMAP.md b/ROADMAP.md index 7593252..a54d3a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1282,3 +1282,19 @@ Still to do, outside this repo: 3. Railway uat: `RELAY_INBOUND_SECRET`, `INBOUND_SMS_URL`, `COACHATRON_SMS_NUMBER`. Production follows at launch. +--- + +## M15: Texts captured on development, like smtp4dev for email +Status: [x] done +Goal: Try every text flow on development without a phone and without +texting anyone. +Acceptance: +- [x] Texts carry `X-App-Env` from `RELAY_APP_ENV`, like emails already did; + with `dev` the relay captures them in smtp4dev as + `sms-@sms.capture.noctusoft.com` (relay `sms-env.js`) +- [x] With `RELAY_APP_ENV=dev`, any number can get a text code, since the + relay skips its +1 rule for captured texts; otherwise +1 only + (`test/dev-sms-capture.test.ts`) +- [x] Development has `RELAY_APP_ENV=dev`; uat and production leave it unset +- [x] All five quality-bar commands still exit 0 + diff --git a/src/domain/outbound.ts b/src/domain/outbound.ts index 8a494b3..df534de 100644 --- a/src/domain/outbound.ts +++ b/src/domain/outbound.ts @@ -81,6 +81,19 @@ export function isDomesticDestination(phone: string): boolean { return /^\+1\d{10}$/.test(phone); } +/** RELAY_APP_ENV=dev: the relay captures every text in the dev mail catcher + * (smtp4dev at mail.dev.noctusoft.com) instead of sending it, and skips its + * own caps there. Nothing reaches a phone, so any number can be tried. */ +export function textsAreCaptured(): boolean { + return ['dev', 'development', 'local'].includes((process.env.RELAY_APP_ENV ?? '').trim().toLowerCase()); +} + +/** Whether a text to this number can go out: +1 numbers, or any number + * while texts are captured in dev. */ +export function canText(phone: string): boolean { + return isDomesticDestination(phone) || (textsAreCaptured() && /^\+\d{8,15}$/.test(phone)); +} + export type SendOutcome = 'sent' | 'capped' | 'refused'; export interface OutboundText { @@ -158,7 +171,7 @@ async function log( export async function sendText(db: DbClient, msg: OutboundText, now: Date = new Date()): Promise { const body = toOneSegment(msg.body); - if (!isDomesticDestination(msg.to)) { + if (!canText(msg.to)) { await log(db, msg, body, 'refused-destination', null, now); return 'refused'; } diff --git a/src/relay/sms.ts b/src/relay/sms.ts index 497cfbd..b142796 100644 --- a/src/relay/sms.ts +++ b/src/relay/sms.ts @@ -6,10 +6,17 @@ export interface SendSmsRequest { } export async function sendSms(req: SendSmsRequest): Promise<{ id: string }> { - const res = await relayFetch('/sms/send', { - method: 'POST', - body: JSON.stringify({ to: req.to, body: req.body }), - }); + // X-App-Env (from RELAY_APP_ENV): dev is captured in smtp4dev as an email + // to sms-@sms.capture.noctusoft.com and never texted; uat is sent + // with a "[UAT] " prefix; unset is sent as-is (relay sms-env.js). + const res = await relayFetch( + '/sms/send', + { + method: 'POST', + body: JSON.stringify({ to: req.to, body: req.body }), + }, + { appEnv: true }, + ); if (!res.ok) throw new Error(`relay sms send failed: ${res.status}`); return (await res.json()) as { id: string }; } diff --git a/src/routes/coach.ts b/src/routes/coach.ts index 45c7eb7..a13f517 100644 --- a/src/routes/coach.ts +++ b/src/routes/coach.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response, type NextFunction } from 'express'; import { getDb, type DbClient } from '../db/client.js'; -import { isDomesticDestination, sendEmailMessage, sendText, signInCodeEmail } from '../domain/outbound.js'; +import { canText, sendEmailMessage, sendText, signInCodeEmail } from '../domain/outbound.js'; import { normalizePhone, normalizeEmail, @@ -134,7 +134,7 @@ coachRouter.post('/signin/otp', async (req, res) => { return; } const db = getDb(); - if (contact.kind === 'phone' && !isDomesticDestination(contact.phone)) { + if (contact.kind === 'phone' && !canText(contact.phone)) { res.status(422).send(renderContactForm(rawContact, "We can't text that number yet. Use your email instead.", saved)); return; } @@ -703,7 +703,7 @@ coachRouter.post('/app/overflow/:sessionId', requireAuth, async (req, res) => { coachRouter.post('/app/phone', requireAuth, async (req, res) => { const db = getDb(); const phone = normalizePhone(field(req.body, 'phone')); - if (!phone || !isDomesticDestination(phone)) { + if (!phone || !canText(phone)) { res.redirect(303, '/app/schedule?phone=bad'); return; } diff --git a/test/dev-sms-capture.test.ts b/test/dev-sms-capture.test.ts new file mode 100644 index 0000000..dfe1bcf --- /dev/null +++ b/test/dev-sms-capture.test.ts @@ -0,0 +1,76 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { freshDb } from './helpers/db.js'; +import { withServer } from './helpers/server.js'; +import { withRelay } from './helpers/relay.js'; +import type { DbClient } from '../src/db/client.js'; + +async function withAppEnv(value: string | undefined, fn: () => Promise): Promise { + const prev = process.env.RELAY_APP_ENV; + if (value === undefined) delete process.env.RELAY_APP_ENV; + else process.env.RELAY_APP_ENV = value; + try { + await fn(); + } finally { + if (prev === undefined) delete process.env.RELAY_APP_ENV; + else process.env.RELAY_APP_ENV = prev; + } +} + +function post(base: string, path: string, body: Record): Promise { + return fetch(`${base}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + redirect: 'manual', + }); +} + +async function coachPhones(db: DbClient): Promise { + return (await db.query<{ phone: string }>('select phone from coach order by id')).rows.map((r) => r.phone); +} + +test('without RELAY_APP_ENV, texts carry no X-App-Env and only +1 numbers get one', async () => { + await withAppEnv(undefined, async () => { + await withRelay(async (relay) => { + await freshDb(); + await withServer(async (base) => { + assert.equal((await post(base, '/signin/otp', { contact: '5550001111' })).status, 303); + assert.equal(relay.sms[0].appEnv, null); + assert.equal((await post(base, '/signin/otp', { contact: '+63 917 123 4567' })).status, 422); + }); + }); + }); +}); + +test('with RELAY_APP_ENV=dev, every text is marked for capture and any number can sign in', async () => { + await withAppEnv('dev', async () => { + await withRelay(async (relay) => { + const db = await freshDb(); + await withServer(async (base) => { + const ask = await post(base, '/signin/otp', { contact: '+63 917 123 4567' }); + assert.equal(ask.status, 303, 'a Philippine number is fine when nothing is really texted'); + const sent = relay.sms.find((m) => m.to === '+639171234567'); + assert.ok(sent); + assert.equal(sent.appEnv, 'dev'); + const code = /code is (\d{6})/.exec(sent.body)![1]; + const done = await post(base, '/signin/verify', { to: '+639171234567', code, name: 'Acel Tester', tz: 'Asia/Manila' }); + assert.equal(done.status, 303); + assert.deepEqual(await coachPhones(db), ['+639171234567']); + }); + }); + }); +}); + +test('with RELAY_APP_ENV=uat, texts are tagged by the relay but still only reach +1 numbers', async () => { + await withAppEnv('uat', async () => { + await withRelay(async (relay) => { + await freshDb(); + await withServer(async (base) => { + assert.equal((await post(base, '/signin/otp', { contact: '5550002222' })).status, 303); + assert.equal(relay.sms[0].appEnv, 'uat'); + assert.equal((await post(base, '/signin/otp', { contact: '+63 917 123 4567' })).status, 422); + }); + }); + }); +}); diff --git a/test/fakes/relay.ts b/test/fakes/relay.ts index fc3577a..f3f6596 100644 --- a/test/fakes/relay.ts +++ b/test/fakes/relay.ts @@ -9,6 +9,8 @@ import { listenLoopback } from '../helpers/listen.js'; export interface FakeSms { to: string; body: string; + /** The X-App-Env header the app sent, if any. */ + appEnv?: string | null; } export interface FakeEmail { @@ -143,7 +145,7 @@ export async function startFakeRelay(): Promise { app.post('/sms/send', (req, res) => { if (!requireApiKey(req, res)) return; - sms.push({ to: String(req.body.to), body: String(req.body.body) }); + sms.push({ to: String(req.body.to), body: String(req.body.body), appEnv: req.header('x-app-env') ?? null }); res.json({ id: `sms_${sms.length}` }); });