kanji-buddy

Email nudges

A daily job that emails you when work has piled up — designed around one promise: at most one a week, and none at all if you're keeping up.

There was no model to copy

WaniKani has no review-reminder emails. It is one of its most-requested missing features, with threads running from 2013 to 2020, and the answer has always been third-party workarounds. The community notifiers that do exist mostly poll hourly and mail on anything due — precisely the nagging this had to avoid. So the policy here is designed rather than cloned.

Four gates, all of which must pass

GateDefaultWhy
Enough waiting20+ itemsOne skipped item is not a backlog.
It has actually sat thereoldest 3+ daysForty reviews that came due two hours ago isn't neglect, it's Tuesday.
You've gone quietno study for 3 daysThe load-bearing one — see below.
We haven't written lately7-day cooldownThe promise.

Plus: notifications on, an address present, not on vacation. All the thresholds are constants in one frozen object in lib/nudge/policy.ts, and the decision itself is a pure function — no clock, no database — so every branch is testable.

Why the activity gate is checked first

pendingSince is a property of the queue; lastActivityAt is a property of the person. They look like the same signal and are not.

Someone who studies daily but always bails on one brutal item has an available_at that never moves — it only advances when a review is submitted — so their backlog is permanently fourteen months old. A policy gated on backlog age alone would email them every cooldown for the rest of their life, telling them they had been away while they were studying daily. That failure is permanent rather than transient, which makes it worse than an occasional bad send.

So the activity gate comes first, and the item threshold catches the residue (one stale item is 1, not 20). Read pendingSince narrowly: it is a freshness suppressor, not a staleness detector.

How a run works

Vercel Cron ──daily 10:00 UTC──▶ GET /api/v1/internal/nudges │ one grouped query: who is a candidate │ decideNudge() per person — pure │ send via Resend, then mark │ ≤ 10 per run

Candidates come from a single grouped query rather than calling the dashboard builder per user — that runs eight queries each. The lesson and review predicates are copied from lib/dashboard.ts and must stay in step, so a test cross-checks the two against the same seed.

Send first, then mark

lastNotifiedAt is written only after the provider accepts the message. Marking first would let an outage silently swallow a week of nudges with nothing to show for it. The worst case this way is one duplicate, bounded twice over: the cron fires once a day, and an idempotency key of nudge:<userId>:<date> makes a same-day retry return the original send.

The email

Two side-by-side cards — reviews in radical blue, lessons in kanji pink, the same mapping as the app’s counters — under a line saying how long it has been. No tracking pixel, no click tracking; a reminder to a handful of friends does not need analytics, and a pixel is exactly the marketing tell to avoid.

The layout is a two-cell <table>. Flexbox and grid are unreliable in mail clients — Outlook renders with Word’s engine — so a table is the only way two cards sit side by side everywhere. When one pile is empty the single card goes full width.

Rendering throws if the unsubscribe link cannot be built. Mail whose opt-out would not verify must never leave the building.

Sending

Resend, called with bare fetch — no HTTP dependency, the same convention as everything else here. The Sender is an interface, so the whole job is tested end to end with a fake and no network.

EMAIL_FROM must be a domain you own and have verified. An address you merely receive mail at — a gmail.com one — is rejected with a 403, because Resend will only send as a domain you control. Verifying mblrc.me meant two DNS records; it did not require buying an email host, which is the misunderstanding that nearly sent this down a worse path.

Turning it off

Every message carries a one-click HMAC link needing no login, plus List-Unsubscribe headers so Gmail shows its own button. The link opens a page that only verifies; the opt-out itself is a POST. See The API for why that separation is not optional.

Everything fails closed

no CRON_SECRET          → 503, not 401
no RESEND_API_KEY       → one legible error in the run result, not N failures
no unsubscribe secret   → refuses to render the email at all

Trying it without sending

npm run nudge:preview -- --show-email   # who'd be mailed, and the copy
npm run nudge:test -- you@example.com  # one real send, ignoring the policy

nudge:preview is deliberately read-only with no --send flag: sending is the cron route’s job, so the script can never be the thing that mails everybody by accident.