Personal website and blog for philipithomas.com.
- Three newsletters: Contraption (essays), Workshop (notes), Postcard (monthly updates)
- MDX content with full React component support
- Static generation with Next.js App Router
- RSS and JSON feeds (combined + per-newsletter)
- LLM-friendly
.mdendpoints for all content - Google sign-in (OAuth2 authorization code flow)
- Email subscription via printing-press backend
nvm use 24
pnpm install
pnpm devOpen http://localhost:3000.
The admin uses the normal subscriber session in development. Bring the development database schema up to date, then start the site normally:
pnpm db:migrate
pnpm devOpen http://localhost:3000, sign in through the normal
email form, and enter 000000 when asked for the six-digit code. That fixed
code works for any email only under next dev; production still requires the
generated one-time code. Printing press access remains limited to addresses in
ADMIN_EMAILS.
pnpm db:migrate changes whichever database is named by the loaded
DATABASE_URL_UNPOOLED or DATABASE_URL. Confirm that the local environment
points to the development Neon branch before running it.
Inbound Twilio traffic is webhook-based. The app does not poll Twilio for new
calls or messages. Vercel must expose the production routes below, and the
Twilio number must point to them with HTTP POST.
Set these environment variables in Vercel before cutover:
PHONE_NUMBER=
TWILIO_SID=
TWILIO_SECRET=
OWNER_PHONE_NUMBER=Configure the Twilio number:
- Voice, "A call comes in":
https://www.philipithomas.com/api/phone/voice - Messaging, "A message comes in":
https://www.philipithomas.com/api/phone/sms
Every inbound webhook must include Twilio's X-Twilio-Signature header. The
app validates the exact public URL and all form parameters with
TWILIO_SECRET, the account auth token. Never put that token in a webhook URL.
When PHONE_NUMBER is configured, public subscribe prompts offer SMS and the
voice webhook plays the generated greeting, then offers "press 1" for
voicemail and "press 2" to subscribe the caller ID to SMS updates. Before the
caller chooses, the prompt identifies recurring new-post texts and states the
frequency, rate, HELP, and STOP disclosures. No input falls through to
voicemail.
The SMS webhook stores inbound replies, handles signup, HELP, and STOP words,
and emails admins about normal replies. SUBSCRIBE replies with a branded
confirmation that identifies the recurring new-post message type, says that
frequency varies and message and data rates may apply, and includes both HELP
and STOP instructions. When Twilio has not already handled the keyword, HELP
returns the support address and repeats the frequency, rate, and STOP details.
Voice-menu signups send the same subscription confirmation SMS when Twilio
accepts it; if that confirmation send fails, the spoken confirmation still
tells the caller to text STOP at any time. STOP hard-deletes the local SMS
subscriber and its history instead of retaining an inactive tombstone. A later
keyword or voice-menu signup creates fresh local state, although Twilio may
continue blocking delivery until the handset sends START or UNSTOP.
When a number first becomes active through either a SUBSCRIBE text or the
voice menu, the app also sends one Bell onboarding MMS with the contact card at
https://www.philipithomas.com/bell.vcf. Repeating SUBSCRIBE while the number
is active does not resend the onboarding message. STOP deletes the local SMS
subscriber and its history, so a later signup creates a fresh subscription and
sends the card again. Twilio may still require START or UNSTOP to lift its own
delivery block. The public /bell.vcf permalink returns a vCard 3.0 contact
named Bell with the sending number, the configured site organization and
website, and an embedded JPEG Bell contact image. On iPhone, Messages opens the
attachment in the native contact preview, where the person taps Create New
Contact. The site cannot save the contact silently.
New SMS opt-ins, whether they come from a SUBSCRIBE text or the voice menu,
also email admins with the source path, Twilio webhook metadata such as city,
state, caller name, message SID, or call SID when Twilio provides it, and an
area-code hint for common US/Canada numbers.
Keyword handling is both Twilio-aware and application-layer: Twilio may apply
its own START, STOP, or HELP behavior and include OptOutType in the webhook.
The app creates a fresh local subscriber for signup keywords and hard-deletes
all local SMS data for STOP while avoiding duplicate Twilio keyword responses. A durable signup
workflow sends app-owned confirmations, pauses for three seconds, and then
sends the distinct one-time Bell onboarding card. Twilio-classified START keeps
Twilio's own confirmation and enters the workflow at the pause. The onboarding
copy tells an iPhone user to tap Create New Contact and invites questions about
philipithomas.com; an SMS link to /bell.vcf is the fallback when
the MMS cannot be attached. The configured Twilio
Advanced Opt-Out responses are the user-visible replies for classified START,
STOP, and HELP messages, so their START and HELP copy must stay aligned with the
disclosures and support address above. STOP deletes pending and historical
sms_sends rows for that number, and an in-flight workflow treats a missing row
as unsendable.
Newsletter SMS delivery runs inside the same Vercel Workflow as email delivery:
the admin send page enqueues sms_sends rows after the email pass, sends them
through Twilio's REST API from PHONE_NUMBER, and records outbound texts in the
Phone panel. SMS subscribers are separate from email subscribers and are opted
into every newsletter as one list.
PHONE_NUMBER is the public E.164 Twilio number for the active environment. It
appears on subscribe and contact surfaces, including the /contact.md mirror,
and is the caller ID for click-to-call.
OWNER_PHONE_NUMBER is the private E.164 number that click-to-call rings first.
The admin "Send test text to me" button also sends test newsletter texts there.
After deploy, verify:
WORKFLOW_SMOKE_BASE_URL=https://www.philipithomas.com CRON_SECRET=$CRON_SECRET pnpm workflow:smokeThen confirm the production flag is still off before launch. With the flag on in
preview, send SUBSCRIBE, HELP, and STOP to PHONE_NUMBER, call it and
press both menu options, and confirm /printing-press/phone shows the inbound
and outbound thread history. Use a fresh number to verify that both text and
voice signup paths send the Bell card once. Then send STOP and confirm that a
fresh voice signup creates a new local subscription. Twilio may reject its
outbound confirmation until the handset sends START or UNSTOP.
Open the attachment on an actual iPhone, confirm the Bell image and fields
appear, and save it manually.
In the Twilio Console, also confirm the Advanced Opt-Out START and HELP replies
identify the program, include the support address, and match the frequency,
message-and-data-rate, HELP, and STOP disclosures above.
Every scheduled route records best-effort start, success, and failure
heartbeats in cron_job_health. The private Printing press Health page shows
those timestamps and uses cadence-specific grace windows to identify failed or
overdue jobs. Heartbeat writes are diagnostic: their failure never changes the
result of the underlying suppression sync, Bell retention, or subscriber
backup job. Preview deployments keep this health store read-only, even if they
share the production DATABASE_URL; VERCEL_ENV=preview disables both
lifecycle writes and first-read activation. The schema migration creates empty
health and activation tables.
The first read from successfully deployed monitoring code atomically records a
versioned activation marker and starts every fixed job's grace period; a real
heartbeat can also create its own row first. Activation happens only once, so
later reads preserve both existing history and a missing-row failure instead
of silently granting a new grace period.
The Production health GitHub Actions workflow checks the redacted,
bearer-protected /api/cron/health endpoint hourly and runs the existing
Workflow smoke after a successful production deployment. Add the production
Vercel CRON_SECRET as the GitHub repository secret
PRODUCTION_CRON_SECRET to enable both checks. Until that secret is present,
the jobs emit a notice and exit successfully rather than producing a false
alarm. Configure the repository's Actions failure notifications or an alerting
integration so a failed dead-man check reaches an operator.
The repository cannot complete these provider-side operations:
- migrate AWS credentials to a least-privilege IAM role trusted through Vercel OIDC, then remove the long-lived production keys;
- configure the desired Actions alert destination and escalation policy;
- run and record a Neon point-in-time restore drill; and
- choose and configure any additional encrypted off-site destination for the subscriber export. The monthly email backup remains unchanged here.
Posts live in content/ as MDX files:
content/
├── contraption/ # Essays and launches
├── workshop/ # Work in progress notes
├── postcard/ # Monthly updates
└── pages/ # Static pages (terms, privacy)
File format: YYYY-MM-DD-slug.mdx with frontmatter:
---
title: "Post Title"
description: "Optional description"
publishedAt: "2026-01-15"
coverImage: "/images/covers/slug.jpg" # optional
---Deployable images live under public/images/ and should be web-sized, not
camera originals. Before committing new JPG/PNG/WebP/AVIF files, run:
pnpm images:optimize public/images/path/to/image.jpg
pnpm content:checkThe optimizer resizes public raster images to the site policy: longest edge at most 5120px and file size at most 8MB. It skips files that already fit the policy, so rerunning it on the same image does not keep recompressing it. Keep full-resolution originals outside this app repository.
pnpm build finishes by measuring the Brotli-compressed modern boot JavaScript
for the homepage, content route, newsletter archives, and photography page. The
guard discovers hashed files from modern script tags in the emitted HTML,
excludes nomodule fallbacks, uses the prerender manifest to check every
generated post/content page, and fails when a route exceeds its reviewed
budget. To rerun it against an existing .next build without rebuilding, use:
pnpm performance:checkWhen an intentional feature needs more eager client JavaScript, prefer moving it behind interaction or a dynamic import. Raise a budget only after reviewing the resulting first-load tradeoff. This metric does not include chunks fetched later by interaction or a dynamic import. It also does not count Next.js Link viewport prefetches: those prepare another route and are separate from the current route's boot JavaScript.
Copyright (c) 2020-2026 The Contraption Company LLC. All rights reserved.
This repository is source-available for viewing and reference, but no open license is granted. See LICENSE.