feat: email each new reminder via Cloudflare Email Routing - #9
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
mhmic-telegram-cron | e8adfb5 | Sep 06 2026, 08:38 PM |
There was a problem hiding this comment.
🟡 Changes recommended
Email-send failures can surface full recipient addresses in the notifications chat, which is a avoidable information disclosure risk.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds an email delivery channel (via Cloudflare SEND_EMAIL) for each newly posted reminder, while keeping Telegram as the primary channel and ensuring email failures don’t cause re-posts on the next cron tick.
Changes:
- Introduces
src/email.tsto compose and send reminder emails (per-recipient) and adds HTML/text sanitization helpers. - Updates handler flow so Telegram sends first, then email is attempted best-effort with failures reported to the notifications chat.
- Updates test setup (Vitest + Cloudflare pool), adds unit tests for email composition/sending, and refreshes related dependencies/config/docs.
File summaries
| File | Description |
|---|---|
| wrangler.jsonc | Adds SEND_EMAIL binding configuration. |
| vitest.config.ts | Removes prior workers config file. |
| vitest.config.mts | Adds new Vitest config using cloudflareTest() plugin and test bindings (incl. email secrets). |
| tsconfig.json | Updates Cloudflare Vitest pool types entry. |
| tests/unit/utils.test.ts | Adds coverage for htmlToText and escapeHtml. |
| tests/unit/handlers.test.ts | Mocks email sending and adds handler-level email behavior tests. |
| tests/unit/email.test.ts | Adds comprehensive unit tests for composing/sending reminder emails. |
| tests/env.d.ts | Removes custom cloudflare:test ProvidedEnv typing augmentation. |
| src/utils.ts | Adds htmlToText (via cheerio) and escapeHtml. |
| src/handlers.ts | Sends Telegram first, then best-effort email with failure notifications. |
| src/env.d.ts | Adds durable secret typings that survive wrangler types regeneration. |
| src/email.ts | New module to build and send reminder emails via SEND_EMAIL. |
| README.md | Documents email notification behavior and configuration. |
| package.json | Bumps dev tooling versions; adds/keeps cheerio dependency for HTML decoding. |
| package-lock.json | Updates lockfile for dependency/version bumps. |
| .env.example | Adds EMAIL_FROM / EMAIL_TO placeholders and notes. |
Review details
- Files reviewed: 12/17 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Moves to wrangler 4.129.0, @cloudflare/vitest-pool-workers 0.22.0 and vitest 4. The existing suite passes unchanged on the new toolchain. Three interface changes came with it: - The pool no longer exports ./config. Its options are now passed to the cloudflareTest() Vite plugin, and because the package is ESM only the config has to be loaded as ESM, hence vitest.config.mts. - cloudflare:test types moved to the ./types subpath, and ProvidedEnv is gone — env is typed as Cloudflare.Env directly, so tests/env.d.ts is obsolete. - Generated Env types changed shape. Secrets now live in src/env.d.ts and are merged into the generated interfaces. wrangler types rebuilds worker-configuration.d.ts from wrangler.jsonc alone, so secrets added to that file by hand are silently lost on every regeneration; they survive now. The lockfile was regenerated with npm 12 to work around an arborist crash in npm 10 while resolving vitest 4's peer graph. npm ci still installs it correctly on npm 10, which is what CI runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UxmzyMHFg4eau4kqNuxiHP
Adds email as a second delivery channel alongside Telegram. After the audio is posted to the chat, the same reminder is emailed via Cloudflare Email Service's structured send() through the SEND_EMAIL binding. The message links to the post and the audio file rather than attaching it, which keeps it well inside the size limit. Email is best effort. By the time it runs the audio has already gone out, so throwing would leave the KV timestamp unwritten and re-post the same audio on the next tick. A failed email is reported to NOTIFICATIONS_CHAT_ID instead and the run still succeeds. EMAIL_TO is a comma separated list, and each recipient gets its own message rather than one message addressed to all of them: the recipients are mailing lists that do not need to see each other in the To header, and a rejection then fails only its own list. Every recipient is attempted before the error is raised. Messages carry Auto-Submitted: auto-generated. A list fans each message out to every member, so without it one member's out-of-office would reply back to the whole list. The send tests stub the binding, which would pass even if the payload shape were wrong, so one test hands a message to the runtime unstubbed — the local simulator rejects a message with no sender and returns a message ID for a well-formed one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UxmzyMHFg4eau4kqNuxiHP
b88a3fb to
5541ba8
Compare
A failed send put the recipient address into the thrown error, which the handler forwards to sendErrorNotification and so into the Telegram chat. That chat need not carry the recipient list, and the addresses are config, not diagnostics. Two changes, because the address can reach that message by two routes: - The aggregated error no longer interpolates the address. Which recipient failed is still logged per recipient, where the full detail belongs. - sendErrorNotification masks the local part of any address, keeping the domain. The provider's own rejection text can quote the address it rejected, which is outside this code's control, so the scrub sits at the boundary where messages leave for the chat and covers every notification path rather than the email one alone. The three new tests were confirmed to fail with either change reverted. Reported by Copilot review on #9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UxmzyMHFg4eau4kqNuxiHP
There was a problem hiding this comment.
Human review message for agents - We should NEVER do this. Everything should be generated by wrangler
| * reading there; the full address stays in the logs. | ||
| */ | ||
| export function redactEmails(text: string): string { | ||
| return text.replace(/[A-Za-z0-9._%+'-]+@([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)/g, '***@$1'); |
There was a problem hiding this comment.
NIT ? optional: the regex requires a dot in the domain ((?:\.[A-Za-z0-9-]+)+), so a single-label address like user@localhost or user@internal would pass through unmasked. Real recipients are FQDNs so this is unlikely to bite, but masking anything with an @ in a failure message would make the redaction total.
| * which would otherwise show up literally in an email subject line. | ||
| */ | ||
| export function htmlToText(html: string): string { | ||
| return load(html).root().text().replace(/\s+/g, ' ').trim(); |
There was a problem hiding this comment.
NIT ? optional: replace(/\s+/g, ' ') folds whitespace unconditionally, which can merge words separated by tags (e.g. <em>Patience</em> <strong>and</strong> <em>Prayer</em> ? Patience and Prayer). Harmless for the titles in this repo, just noting the behavior.
omarmosid
left a comment
There was a problem hiding this comment.
Review summary
This is a well-put-together PR: email as a best-effort second channel behind Telegram, with the failure semantics thought through properly. The tension is real ??? by the time email runs, the audio has already gone out, so failing the run would re-post it on the next tick ??? and the code resolves it correctly in handlers.ts: email errors are caught, reported to the notifications chat, and the KV timestamp is still written. Telegram-first ordering is also right: if the audio never posts there is nothing to announce.
Strengths I want to call out:
- Per-recipient
send()calls (email.ts:86-107) mean one rejected mailing list never suppresses the others, and every recipient is attempted before the error is raised. Auto-Submitted: auto-generated(email.ts:98) preventing list auto-replies is a thoughtful production detail, and the README's Google Group guidance is genuinely useful.- The
redactEmailscommit is a nice follow-up ??? the thrown error now carries no recipient list at all, and masking insendErrorNotificationis good defense-in-depth for any provider error that quotes an address. Clean design (log the detail, mask at the chat boundary). - Test coverage is notably strong: stubbed send tests for behavior, plus the unstubbed binding test that validates the payload shape against the runtime.
No blockers. Two optional nits were posted inline (redactEmails single-label domain edge case, and htmlToText whitespace folding). Personal taste aside, I'm happy with this as-is.
There was a problem hiding this comment.
Human review:
no need to rename to mts.
also not sure if we need the miniflare standins, should double check docs:
https://developers.cloudflare.com/workers/testing/vitest-integration/configuration/
Adds email as a second delivery channel alongside Telegram. After the audio
is posted to the chat, the same reminder is emailed to the addresses in
EMAIL_TO through the SEND_EMAIL binding.
The message links to the post and the audio file rather than attaching it,
which keeps it well inside Email Routing's size limit.
Email is best effort. By the time it runs the audio has already gone out, so
throwing would leave the KV timestamp unwritten and re-post the same audio on
the next tick. A failed email is reported to NOTIFICATIONS_CHAT_ID instead and
the run still succeeds. Each recipient is attempted independently so one
unverified address cannot suppress the rest.
The RFC 5322 message is built by hand rather than with mimetext, which pulls
in @babel/runtime-corejs3 and core-js-pure for what is a string builder. The
builders are pure functions, so subject encoding, MIME structure and header
injection are covered directly by unit tests.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01UxmzyMHFg4eau4kqNuxiHP