mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(email): route Intelligence Brief off the alerts@ mailbox (#3321)
* fix(email): route Intelligence Brief off the alerts@ mailbox The daily "WorldMonitor Intelligence Brief" email was shipping from `alerts@worldmonitor.app` with a display name that — if the Railway env override dropped the `Name <…>` wrapper — Gmail/Outlook fell back to rendering the local-part ("alerts" / "alert") as the sender name. Recipients saw a scary-looking "alert" in their inbox for what is actually a curated editorial read. Split the sender so editorial mail can't share the `alerts@` mailbox with incident pushes: - New env var `RESEND_FROM_BRIEF` (default `WorldMonitor Brief <brief@worldmonitor.app>`) consumed by seed-digest-notifications.mjs. - Falls back to `RESEND_FROM_EMAIL`, then to the built-in default, so existing deploys keep working and the rollout is a single Railway env flip on the digest service. - notification-relay.cjs (realtime push alerts) intentionally keeps `RESEND_FROM_EMAIL` / `alerts@` — accurate for that path. - .env.example documents the display-name rule so the bare-address trap can't re-introduce the bug. Rollout: set `RESEND_FROM_BRIEF=WorldMonitor Brief <brief@worldmonitor.app>` on the `seed-digest-notifications` Railway service. Domain-level Resend verification already covers the new local-part; no DNS change needed. * fix(email): runtime normalize sender to prevent bare-address regression PR review feedback from codex: > P2 — RESEND_FROM_BRIEF is consumed verbatim, so an operator can > still set brief@worldmonitor.app without a display name and > recreate the same Gmail/Outlook rendering bug for the daily brief. > Today that protection is only documentation in .env.example, not > runtime enforcement. Add a small shared helper `scripts/lib/resend-from.cjs` that coerces a bare email address into a "Name <addr>" wrapper with a loud warning log, and wire it into the digest path. - Bare-address input (e.g. `brief@worldmonitor.app`) is rewritten to `WorldMonitor Brief <brief@worldmonitor.app>` so Gmail/Outlook stop falling back to the local-part as the display name. - Coercion emits a single `console.warn` line per boot so operators see the signal in Railway logs and can fix the underlying env. - Fail-safe (not fail-closed) — a misconfigured env does NOT take the cron down. Also resolves the P3 doc-vs-runtime divergence by reverting .env.example's RESEND_FROM_EMAIL default from "WorldMonitor Alerts <...>" back to "WorldMonitor <...>" to match the existing notification-relay.cjs runtime default. The realtime-alert path will get the same normalizer treatment in a follow-up PR that cohesively touches notification-relay.cjs + Dockerfile.relay. tests: 7 new cases in tests/resend-sender-normalize.test.mjs covering empty/null/whitespace input, wrapped passthrough, trim, bare-address coercion, warning emission, no-warning on wrapped, console.warn default sink. Runs under `npm run test:data`.
This commit is contained in:
20
.env.example
20
.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 <addr@domain>" 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 <alerts@worldmonitor.app>
|
||||
|
||||
# "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 <addr>" 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 <brief@worldmonitor.app>
|
||||
|
||||
# Vite-exposed Convex URL for frontend entitlement service (VITE_ prefix required for client-side access)
|
||||
VITE_CONVEX_URL=
|
||||
|
||||
|
||||
32
scripts/lib/resend-from.cjs
Normal file
32
scripts/lib/resend-from.cjs
Normal file
@@ -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 <addr@domain>" 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 <addr@domain>" form to silence this.`,
|
||||
);
|
||||
return `${defaultDisplayName} <${value}>`;
|
||||
}
|
||||
|
||||
module.exports = { normalizeResendSender };
|
||||
@@ -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 <alerts@worldmonitor.app>';
|
||||
// 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 <addr>" 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 <brief@worldmonitor.app>';
|
||||
|
||||
if (process.env.DIGEST_CRON_ENABLED === '0') {
|
||||
console.log('[digest] DIGEST_CRON_ENABLED=0 — skipping run');
|
||||
|
||||
75
tests/resend-sender-normalize.test.mjs
Normal file
75
tests/resend-sender-normalize.test.mjs
Normal file
@@ -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 <alerts@worldmonitor.app>', 'Default', silent),
|
||||
'WorldMonitor <alerts@worldmonitor.app>',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeResendSender('WorldMonitor Brief <brief@worldmonitor.app>', 'Default', silent),
|
||||
'WorldMonitor Brief <brief@worldmonitor.app>',
|
||||
);
|
||||
});
|
||||
|
||||
test('trims surrounding whitespace before returning a wrapped sender', () => {
|
||||
assert.equal(
|
||||
normalizeResendSender(' WorldMonitor Brief <brief@worldmonitor.app> ', 'Default', silent),
|
||||
'WorldMonitor Brief <brief@worldmonitor.app>',
|
||||
);
|
||||
});
|
||||
|
||||
test('wraps a bare email address with the supplied default display name', () => {
|
||||
assert.equal(
|
||||
normalizeResendSender('brief@worldmonitor.app', 'WorldMonitor Brief', silent),
|
||||
'WorldMonitor Brief <brief@worldmonitor.app>',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeResendSender('alerts@worldmonitor.app', 'WorldMonitor Alerts', silent),
|
||||
'WorldMonitor Alerts <alerts@worldmonitor.app>',
|
||||
);
|
||||
});
|
||||
|
||||
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 <brief@worldmonitor\.app>/);
|
||||
});
|
||||
|
||||
test('does not warn when the value already has a display-name wrapper', () => {
|
||||
const warnings = [];
|
||||
normalizeResendSender(
|
||||
'WorldMonitor Brief <brief@worldmonitor.app>',
|
||||
'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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user