Skip to content

feat(email,scheduler,offline): idempotent sends, template validation, cron validation, connectivity debounce - #1289

Merged
RUKAYAT-CODER merged 1 commit into
rinafcode:mainfrom
ThatCodeBabe:feat/email-idempotency-template-validation-cron-connectivity
Aug 30, 2026
Merged

feat(email,scheduler,offline): idempotent sends, template validation, cron validation, connectivity debounce#1289
RUKAYAT-CODER merged 1 commit into
rinafcode:mainfrom
ThatCodeBabe:feat/email-idempotency-template-validation-cron-connectivity

Conversation

@ThatCodeBabe

Copy link
Copy Markdown
Contributor

Four independent hardening fixes: two in transactional email, one in the export scheduler, one in offline mode.

Closes #1171
Closes #1177
Closes #1178
Closes #1181


#1171 — Debounce offline-mode connectivity flapping

src/utils/pwaUtils.ts, src/hooks/useOfflineMode.tsx

createConnectivityDebouncer reports a connectivity state only once it has held for the window. Two properties beyond plain debouncing matter here:

  • A value returning to the last settled one cancels the pending timer rather than restarting it. Offline → online → offline inside the window is not a change at all and should fire nothing.
  • The callback fires only on an actual change, so a run of online events on an already-online connection stays silent.

useOfflineMode drives its online/offline listeners through it and syncs only on a settled online state, so a train tunnel or a wifi handover no longer starts a sync that the next event interrupts — which is how the queue ended up never draining while burning a retry on each attempt. The hook now also exposes isOnline and flushConnectivity, and cancels any pending transition on unmount so a timer cannot fire against a torn-down service.

#1177 — Deduplicate transactional emails by idempotency key

src/lib/email/queue.ts, src/lib/email/index.ts, new src/lib/email/idempotency.ts

EmailQueue.enqueue accepts an idempotencyKey; a second enqueue under the same key returns the first send's result rather than delivering again. Three details make it actually hold:

  • The key is registered before the send starts, so two concurrent enqueues dedupe, not just sequential ones. Registering on completion would let a double-clicked button through.
  • A failed send releases its key. A failure is not a delivery, and blocking the retry would turn a transient SMTP error into a password reset the user never receives.
  • Keys expire on a TTL (24h by default) with pruneIdempotencyCache and clearIdempotencyCache, so the map is not an unbounded leak in a long-lived process.

deriveIdempotencyKey derives a key from message content for callers with nothing better to hand, normalising recipient order and case — those are not part of what the message is. Tags and replyTo are excluded for the same reason.

#1178 — Validate email template variables at render time

src/lib/email/templates.ts, src/lib/email/types.ts

Required variables are extracted from the templates themselves, not a hand-maintained list, so a declared schema cannot drift away from what the template actually references. getTemplate now throws MissingTemplateVariablesError rather than rendering an unresolved placeholder as an empty string — a password-reset email whose link silently renders as nothing is worse than one never sent, because the user cannot tell it is broken.

  • An empty or whitespace-only value counts as missing; it renders as nothing, which is the exact failure being guarded against.
  • Zero and false count as suppliedexpiresInMinutes: 0 is a value, not an omission.
  • Unused payload keys are reported alongside missing ones: userName passed to a template that wants name produces one missing and one unused entry, which is a far better clue than a blank space in the delivered mail.
  • allowMissing keeps previews and tests able to render an incomplete payload.

The one existing caller (hybridEvents) supplies all five variables of the verification template, so nothing in the app starts throwing.

#1181 — Validate cron expressions in the export scheduler

src/lib/export-scheduler/cron-parser.ts, src/lib/export-scheduler/scheduler-service.ts

validateCron returns every field-level reason an expression was rejected — field, offending value, and why — instead of a bare boolean, so a form can tell the user that 0 0 * * 9 has a dayOfWeek outside 0-7. Every field is checked rather than stopping at the first, so one save surfaces every mistake.

Parsing is now strict. parseInt stops at the first non-digit, so the previous validator read "5x" as 5 and accepted 0 0 5x * * — an expression cron itself rejects. 7 is accepted as a second spelling of Sunday, as crontab does.

On the service side, validateSchedule exposes the check for the save path, and queueExportJob validates before enqueuing: queuing first and then failing to compute the next run leaves the schedule due forever, re-running the same export on every sweep. Each schedule is also isolated inside checkDueSchedules — a malformed expression previously threw out of getNextRunTime and aborted the loop, so one bad string stopped every other schedule from running too.


A delivery bug found while adding dedupe

EmailQueue.process took the resolve of a single enqueue call and used it for whatever job it dequeued. With maxConcurrent: 2 and two messages in flight, an enqueue could resolve with an unrelated message's result. Each job now carries its own resolver.

Verification

vitest (touched files)   123 passed / 10 files
tsc --noEmit             clean
next lint --max-warnings=0   clean
validate:ui              passed (43 pre-existing warnings)
validate:web3            passed
next build               clean

Three suites fail on main for reasons unrelated to this branch and are untouched here: services/offlineSync.test.ts (the session gate added in 367328b is unsatisfied under test — fixed in the sibling PR for #1169/#1170), services/certificate-service.test.ts, and services/serviceAccount.test.ts (which needs goerli.infura.io). Worth noting for the CI test step: it runs timeout 30s pnpm vitest run --coverage and exits 0 when the 30 seconds elapse, so a slow or hanging suite reports success — on this machine vitest run on main did not finish at all, one worker sitting at 99% CPU for over six minutes.

… cron validation, connectivity debounce

Closes rinafcode#1171
Closes rinafcode#1177
Closes rinafcode#1178
Closes rinafcode#1181

rinafcode#1171 — Debounce offline-mode connectivity flapping.
createConnectivityDebouncer reports a state only once it has held for
the window, and a value returning to the last settled one cancels the
pending timer rather than restarting it: offline to online and back
inside the window is not a change and should fire nothing. useOfflineMode
drives its listeners through it and syncs only on a settled online
state, so a wifi handover no longer starts a sync that the next event
interrupts.

rinafcode#1177 — Deduplicate transactional emails by idempotency key. EmailQueue
registers the key before the send starts, so concurrent duplicates
dedupe as well as sequential ones, and a repeated enqueue returns the
first send's result instead of delivering twice. A failed send releases
its key, since a failure is not a delivery and must not block the retry.
Keys expire on a TTL, with pruning and clearing exposed.
deriveIdempotencyKey derives one from message content for callers that
have nothing better, normalising recipient order and case.

rinafcode#1178 — Validate email template variables at render time. The required
variables are read from the templates themselves rather than a
hand-maintained list, so the schema cannot drift from what the templates
use. getTemplate now throws MissingTemplateVariablesError instead of
rendering an unresolved placeholder as an empty string; an empty or
whitespace value counts as missing, while zero and false do not.
Unused payload keys are reported too, since `userName` for a template
wanting `name` is a better clue than a blank space in the sent mail.
allowMissing keeps previews and tests working.

rinafcode#1181 — Validate cron expressions in the export scheduler. validateCron
returns every field-level reason an expression was rejected rather than
a bare boolean, so a form can say which field is wrong. Parsing is now
strict: parseInt stopped at the first non-digit and accepted "5x" as 5.
7 is accepted as Sunday, as crontab does. The scheduler validates before
enqueuing, and each schedule is isolated in the sweep — one malformed
expression used to throw out of getNextRunTime and abort the loop, so a
single bad string stopped every other schedule from running.

Also fixes a delivery bug found while adding dedupe: every enqueue
shared one resolver, so with two messages in flight an enqueue could
resolve with an unrelated message's result.

123 tests pass across the touched files.
@drips-wave

drips-wave Bot commented Aug 30, 2026

Copy link
Copy Markdown

@ThatCodeBabe Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@RUKAYAT-CODER

Copy link
Copy Markdown
Contributor

Thank you for contributing to the project.

@RUKAYAT-CODER
RUKAYAT-CODER merged commit 8be7fb5 into rinafcode:main Aug 30, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants