Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions .railway/railway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
16 changes: 16 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<digits>@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

15 changes: 14 additions & 1 deletion src/domain/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -158,7 +171,7 @@ async function log(

export async function sendText(db: DbClient, msg: OutboundText, now: Date = new Date()): Promise<SendOutcome> {
const body = toOneSegment(msg.body);
if (!isDomesticDestination(msg.to)) {
if (!canText(msg.to)) {
await log(db, msg, body, 'refused-destination', null, now);
return 'refused';
}
Expand Down
15 changes: 11 additions & 4 deletions src/relay/sms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<digits>@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 };
}
6 changes: 3 additions & 3 deletions src/routes/coach.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
76 changes: 76 additions & 0 deletions test/dev-sms-capture.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>): Promise<void> {
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<string, string>): Promise<Response> {
return fetch(`${base}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
redirect: 'manual',
});
}

async function coachPhones(db: DbClient): Promise<string[]> {
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);
});
});
});
});
4 changes: 3 additions & 1 deletion test/fakes/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -143,7 +145,7 @@ export async function startFakeRelay(): Promise<FakeRelay> {

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}` });
});

Expand Down
Loading