diff --git a/.env.example b/.env.example index 72e79503c..446f93d39 100644 --- a/.env.example +++ b/.env.example @@ -368,9 +368,27 @@ NOTIFICATION_ENCRYPTION_KEY= # Get from: resend.com/api-keys RESEND_API_KEY= -# "From" address for email notifications (must be a verified Resend sender domain) +# "From" address for email notifications (must be a verified Resend sender domain). +# ALWAYS include a display name in "Name " form — without it, Gmail +# and Outlook fall back to rendering the local-part ("alerts") as the sender +# name, which reads like a scary alarm even for editorial content. The digest +# path (see RESEND_FROM_BRIEF below) enforces this at runtime via +# scripts/lib/resend-from.cjs; set this var with the wrapper anyway so the +# realtime-alert path (notification-relay.cjs) gets the same protection once +# the relay-side normalizer lands. +# RESEND_FROM_EMAIL is used by notification-relay.cjs for realtime push alerts. RESEND_FROM_EMAIL=WorldMonitor +# "From" address for the daily Intelligence Brief / digest email. Kept +# separate from RESEND_FROM_EMAIL so editorial mail doesn't ship from the +# `alerts@` mailbox (which reads like an incident alarm). Used by +# scripts/seed-digest-notifications.mjs, which normalizes the value via +# scripts/lib/resend-from.cjs — a bare address is coerced to +# "WorldMonitor Brief " at runtime with a loud warning, so a +# misconfigured env cannot silently re-introduce the bare-local-part bug. +# Falls back to RESEND_FROM_EMAIL if unset, so existing deploys keep working. +RESEND_FROM_BRIEF=WorldMonitor Brief + # Vite-exposed Convex URL for frontend entitlement service (VITE_ prefix required for client-side access) VITE_CONVEX_URL= diff --git a/scripts/lib/resend-from.cjs b/scripts/lib/resend-from.cjs new file mode 100644 index 000000000..885339c77 --- /dev/null +++ b/scripts/lib/resend-from.cjs @@ -0,0 +1,32 @@ +'use strict'; + +/** + * Coerce a Resend `from:` value into a form that renders a friendly + * display name in Gmail / Outlook / Apple Mail. When the value is a + * bare email address (no "Name " wrapper), clients fall + * back to the local-part as the sender name — so `alerts@worldmonitor.app` + * shows up as "alerts" in the inbox, which reads like an incident + * alarm when the mail is actually a curated editorial brief. + * + * We coerce (rather than fail-closed) so a misconfigured Railway env + * does NOT take the cron down; the coercion emits a loud warning so + * operators can see and fix the misconfiguration in logs. + * + * @param {string | null | undefined} raw - env value (possibly empty). + * @param {string} defaultDisplayName - friendly name to wrap bare addresses with. + * @param {(msg: string) => void} [warn] - warning sink (default: console.warn). + * @returns {string | null} normalized sender, or null when raw is empty. + */ +function normalizeResendSender(raw, defaultDisplayName, warn) { + const warnFn = typeof warn === 'function' ? warn : (m) => console.warn(m); + const value = typeof raw === 'string' ? raw.trim() : ''; + if (!value) return null; + if (value.includes('<') && value.includes('>')) return value; + warnFn( + `[resend] sender "${value}" lacks display name — coercing to "${defaultDisplayName} <${value}>". ` + + `Set the env var in "Name " form to silence this.`, + ); + return `${defaultDisplayName} <${value}>`; +} + +module.exports = { normalizeResendSender }; diff --git a/scripts/seed-digest-notifications.mjs b/scripts/seed-digest-notifications.mjs index 3d78b41f4..8e84d85f0 100644 --- a/scripts/seed-digest-notifications.mjs +++ b/scripts/seed-digest-notifications.mjs @@ -29,6 +29,7 @@ const { decrypt } = require('./lib/crypto.cjs'); const { callLLM } = require('./lib/llm-chain.cjs'); const { fetchUserPreferences, extractUserContext, formatUserProfile } = require('./lib/user-context.cjs'); const { Resend } = require('resend'); +const { normalizeResendSender } = require('./lib/resend-from.cjs'); import { readRawJsonFromUpstash, redisPipeline } from '../api/_upstash-json.js'; import { composeBriefFromDigestStories, @@ -58,7 +59,17 @@ const CONVEX_SITE_URL = const RELAY_SECRET = process.env.RELAY_SHARED_SECRET ?? ''; const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN ?? ''; const RESEND_API_KEY = process.env.RESEND_API_KEY ?? ''; -const RESEND_FROM = process.env.RESEND_FROM_EMAIL ?? 'WorldMonitor '; +// Brief/digest is an editorial daily read, not an incident alarm — route it +// off the `alerts@` mailbox so recipients don't see a scary "alert" from-name +// in their inbox. normalizeResendSender coerces a bare email address into a +// "Name " wrapper at runtime (with a loud warning), so a Railway env +// like `RESEND_FROM_BRIEF=brief@worldmonitor.app` can't re-introduce the bug +// that `.env.example` documents. +const RESEND_FROM = + normalizeResendSender( + process.env.RESEND_FROM_BRIEF ?? process.env.RESEND_FROM_EMAIL, + 'WorldMonitor Brief', + ) ?? 'WorldMonitor Brief '; if (process.env.DIGEST_CRON_ENABLED === '0') { console.log('[digest] DIGEST_CRON_ENABLED=0 — skipping run'); diff --git a/tests/resend-sender-normalize.test.mjs b/tests/resend-sender-normalize.test.mjs new file mode 100644 index 000000000..1f2378956 --- /dev/null +++ b/tests/resend-sender-normalize.test.mjs @@ -0,0 +1,75 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { normalizeResendSender } = require('../scripts/lib/resend-from.cjs'); + +const silent = () => {}; + +test('returns null for empty, null, undefined, or whitespace-only input', () => { + assert.equal(normalizeResendSender(null, 'WorldMonitor', silent), null); + assert.equal(normalizeResendSender(undefined, 'WorldMonitor', silent), null); + assert.equal(normalizeResendSender('', 'WorldMonitor', silent), null); + assert.equal(normalizeResendSender(' ', 'WorldMonitor', silent), null); +}); + +test('passes a properly wrapped sender through unchanged', () => { + assert.equal( + normalizeResendSender('WorldMonitor ', 'Default', silent), + 'WorldMonitor ', + ); + assert.equal( + normalizeResendSender('WorldMonitor Brief ', 'Default', silent), + 'WorldMonitor Brief ', + ); +}); + +test('trims surrounding whitespace before returning a wrapped sender', () => { + assert.equal( + normalizeResendSender(' WorldMonitor Brief ', 'Default', silent), + 'WorldMonitor Brief ', + ); +}); + +test('wraps a bare email address with the supplied default display name', () => { + assert.equal( + normalizeResendSender('brief@worldmonitor.app', 'WorldMonitor Brief', silent), + 'WorldMonitor Brief ', + ); + assert.equal( + normalizeResendSender('alerts@worldmonitor.app', 'WorldMonitor Alerts', silent), + 'WorldMonitor Alerts ', + ); +}); + +test('emits exactly one warning when coercing a bare address', () => { + const warnings = []; + normalizeResendSender('brief@worldmonitor.app', 'WorldMonitor Brief', (m) => warnings.push(m)); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /lacks display name/); + assert.match(warnings[0], /WorldMonitor Brief /); +}); + +test('does not warn when the value already has a display-name wrapper', () => { + const warnings = []; + normalizeResendSender( + 'WorldMonitor Brief ', + 'Default', + (m) => warnings.push(m), + ); + assert.equal(warnings.length, 0); +}); + +test('defaults to console.warn when no warning sink is supplied', () => { + const original = console.warn; + const captured = []; + console.warn = (m) => captured.push(m); + try { + normalizeResendSender('bare@example.com', 'Name'); + assert.equal(captured.length, 1); + assert.match(captured[0], /lacks display name/); + } finally { + console.warn = original; + } +});