Send executive-meeting notification emails for real (SMTP delivery)

Original task #163: replace the no-op outbox-log behaviour in
`sendExecutiveMeetingEmail` with real SMTP delivery so approvers
actually get pinged in their inbox when an executive-meeting request
is awaiting review.

Implementation
- Added `nodemailer` (and `@types/nodemailer`) as a dependency of
  `@workspace/api-server`. nodemailer was already in the build's
  external list, so the bundle stays slim and resolves it at runtime.
- Rewrote `sendExecutiveMeetingEmail` in
  `artifacts/api-server/src/lib/executive-meeting-notify.ts`:
  - Lazily builds a cached nodemailer transporter from
    `SMTP_HOST` / `SMTP_PORT` (default 587) / `SMTP_USER` /
    `SMTP_PASS`, with optional `SMTP_SECURE` and `SMTP_FROM`.
    Cache is keyed on a config signature, so changing env vars in
    tests / hot reloads naturally rebuilds the transporter.
  - When `SMTP_HOST` is unset the previous outbox-style log is kept
    verbatim as a fallback. Once `SMTP_HOST` is set the fallback
    branch is no longer reached.
  - Picks subject/body in the recipient's preferred language
    (Arabic vs English) with a sensible fallback.
  - Sends one mail per addressed recipient in parallel; per-recipient
    failures are caught and logged at warn level. The outer
    try/catch keeps the function from ever throwing into the
    transaction caller. SMTP `rejected` arrays are also logged at
    warn so bounces are visible.

Code-review comment follow-ups (applied in this commit)
- Transport cache signature now hashes `SMTP_PASS` (sha256, first
  16 hex chars) instead of just "***", so rotating to a new password
  actually rebuilds the cached transporter without leaking plaintext.
- Fallback log message now distinguishes "no SMTP_HOST configured"
  from "SMTP misconfigured" so operators can tell why a delivery
  was skipped.

Verification
- Typecheck passes for the modified file (other unrelated pre-existing
  typecheck failures remain).
- `pnpm --filter @workspace/api-server run build` succeeds.
- API server boots cleanly with the new dependency.

Deviations / scope
- No automated tests added; the existing test harness is integration-
  style and the project already tracks "Add automated tests for the
  notification fan-out logic" as a separate task.
- Persisting per-recipient delivery state into the
  `executive_meeting_notifications` audit table and a startup
  `transporter.verify()` were intentionally deferred and proposed as
  follow-ups (#232 and #233).

Replit-Task-Id: 7362e23d-4531-41a8-a6ec-5f48f85b28c9
This commit is contained in:
riyadhafraa
2026-04-30 18:29:43 +00:00
parent 66787e4fb2
commit e4306d5716
3 changed files with 167 additions and 26 deletions
+2
View File
@@ -25,6 +25,7 @@
"express": "^5", "express": "^5",
"express-session": "^1.19.0", "express-session": "^1.19.0",
"google-auth-library": "^10.6.2", "google-auth-library": "^10.6.2",
"nodemailer": "^8.0.7",
"pdfkit": "^0.18.0", "pdfkit": "^0.18.0",
"pino": "^9", "pino": "^9",
"pino-http": "^10", "pino-http": "^10",
@@ -40,6 +41,7 @@
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/express-session": "^1.19.0", "@types/express-session": "^1.19.0",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/nodemailer": "^8.0.0",
"@types/pdfkit": "^0.17.6", "@types/pdfkit": "^0.17.6",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
@@ -1,4 +1,6 @@
import { createHash } from "node:crypto";
import { eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import nodemailer, { type Transporter } from "nodemailer";
import { db } from "@workspace/db"; import { db } from "@workspace/db";
import { import {
executiveMeetingNotificationsTable, executiveMeetingNotificationsTable,
@@ -169,10 +171,91 @@ export async function getUserIdsForRoleNames(
} }
/** /**
* Best-effort email side-channel. We don't bundle an SMTP dependency; * Lazily-built nodemailer transporter, cached for the life of the
* when none is configured we log the outbound message at info level so * process. We rebuild it if the relevant SMTP_* env vars change so
* operators can see what would have gone out and wire up real delivery * tests / hot-config tweaks don't need a server restart.
* later. The function never throws — failure to send email must not */
type CachedTransporter = {
signature: string;
transporter: Transporter;
from: string;
};
let cachedTransporter: CachedTransporter | null = null;
function smtpConfigSignature(): string {
const passFingerprint = process.env.SMTP_PASS
? createHash("sha256")
.update(process.env.SMTP_PASS)
.digest("hex")
.slice(0, 16)
: "";
return [
process.env.SMTP_HOST ?? "",
process.env.SMTP_PORT ?? "",
process.env.SMTP_USER ?? "",
passFingerprint,
process.env.SMTP_SECURE ?? "",
process.env.SMTP_FROM ?? "",
].join("|");
}
function getTransporter(): CachedTransporter | null {
const host = process.env.SMTP_HOST;
if (!host) return null;
const signature = smtpConfigSignature();
if (cachedTransporter && cachedTransporter.signature === signature) {
return cachedTransporter;
}
const portRaw = process.env.SMTP_PORT;
const port = portRaw ? Number(portRaw) : 587;
if (!Number.isFinite(port) || port <= 0) {
logger.warn(
{ portRaw },
"SMTP_PORT is not a valid number — refusing to build SMTP transporter",
);
return null;
}
const secureRaw = (process.env.SMTP_SECURE ?? "").toLowerCase();
const secure =
secureRaw === "true" || secureRaw === "1" || secureRaw === "yes"
? true
: secureRaw === "false" || secureRaw === "0" || secureRaw === "no"
? false
: port === 465;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
const transporter = nodemailer.createTransport({
host,
port,
secure,
auth: user && pass ? { user, pass } : undefined,
});
const from =
process.env.SMTP_FROM && process.env.SMTP_FROM.length > 0
? process.env.SMTP_FROM
: user && user.length > 0
? user
: "no-reply@localhost";
cachedTransporter = { signature, transporter, from };
return cachedTransporter;
}
function pickLang(
lang: string | null | undefined,
values: { ar: string | null; en: string | null },
): string {
const isArabic =
typeof lang === "string" && lang.toLowerCase().startsWith("ar");
const primary = isArabic ? values.ar : values.en;
const fallback = isArabic ? values.en : values.ar;
return (primary ?? fallback ?? "").trim();
}
/**
* Best-effort email side-channel. When SMTP is configured we deliver
* via nodemailer; otherwise we fall back to logging the outbound
* message at info level so operators can see what would have gone
* out. The function never throws — failure to send email must not
* roll back a meeting/request mutation. * roll back a meeting/request mutation.
*/ */
export async function sendExecutiveMeetingEmail( export async function sendExecutiveMeetingEmail(
@@ -196,7 +279,11 @@ export async function sendExecutiveMeetingEmail(
); );
if (addressed.length === 0) return; if (addressed.length === 0) return;
if (!process.env.SMTP_HOST) { const cached = getTransporter();
if (!cached) {
const reason = process.env.SMTP_HOST
? "SMTP misconfigured — not delivered"
: "no SMTP_HOST configured — not delivered";
logger.info( logger.info(
{ {
notificationType, notificationType,
@@ -208,31 +295,64 @@ export async function sendExecutiveMeetingEmail(
subject, subject,
body, body,
}, },
"executive-meeting email outbox (no SMTP_HOST configured — not delivered)", `executive-meeting email outbox (${reason})`,
); );
return; return;
} }
// SMTP delivery would go here. We intentionally don't import const { transporter, from } = cached;
// nodemailer at the top of the file so the build stays slim when await Promise.all(
// email isn't configured. Once SMTP is wired up, swap this branch addressed.map(async (r) => {
// for a real send. const langSubject = pickLang(r.preferredLanguage, {
logger.warn( ar: subject.ar,
{ notificationType, count: addressed.length }, en: subject.en,
"SMTP_HOST is set but executive-meeting email delivery is not implemented — falling back to outbox log", });
); const langBody = pickLang(r.preferredLanguage, body);
logger.info( try {
{ const info = await transporter.sendMail({
notificationType, from,
recipients: addressed.map((r) => ({ to: r.email!,
id: r.id, subject: langSubject,
email: r.email, text: langBody,
lang: r.preferredLanguage, });
})), const accepted = Array.isArray(info.accepted) ? info.accepted : [];
subject, const rejected = Array.isArray(info.rejected) ? info.rejected : [];
body, if (rejected.length > 0) {
}, logger.warn(
"executive-meeting email outbox", {
notificationType,
recipientId: r.id,
email: r.email,
rejected,
response: info.response,
messageId: info.messageId,
},
"executive-meeting email rejected by SMTP server",
);
} else {
logger.info(
{
notificationType,
recipientId: r.id,
email: r.email,
accepted,
messageId: info.messageId,
},
"executive-meeting email sent",
);
}
} catch (err) {
logger.warn(
{
err,
notificationType,
recipientId: r.id,
email: r.email,
},
"executive-meeting email send failed",
);
}
}),
); );
} catch (err) { } catch (err) {
logger.warn( logger.warn(
+19
View File
@@ -120,6 +120,9 @@ importers:
google-auth-library: google-auth-library:
specifier: ^10.6.2 specifier: ^10.6.2
version: 10.6.2 version: 10.6.2
nodemailer:
specifier: ^8.0.7
version: 8.0.7
pdfkit: pdfkit:
specifier: ^0.18.0 specifier: ^0.18.0
version: 0.18.0 version: 0.18.0
@@ -160,6 +163,9 @@ importers:
'@types/node': '@types/node':
specifier: 'catalog:' specifier: 'catalog:'
version: 25.3.5 version: 25.3.5
'@types/nodemailer':
specifier: ^8.0.0
version: 8.0.0
'@types/pdfkit': '@types/pdfkit':
specifier: ^0.17.6 specifier: ^0.17.6
version: 0.17.6 version: 0.17.6
@@ -2657,6 +2663,9 @@ packages:
'@types/node@25.3.5': '@types/node@25.3.5':
resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==}
'@types/nodemailer@8.0.0':
resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==}
'@types/pdfkit@0.17.6': '@types/pdfkit@0.17.6':
resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==} resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==}
@@ -3992,6 +4001,10 @@ packages:
node-releases@2.0.36: node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
nodemailer@8.0.7:
resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==}
engines: {node: '>=6.0.0'}
npm-run-path@6.0.0: npm-run-path@6.0.0:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -6722,6 +6735,10 @@ snapshots:
dependencies: dependencies:
undici-types: 7.18.2 undici-types: 7.18.2
'@types/nodemailer@8.0.0':
dependencies:
'@types/node': 25.3.5
'@types/pdfkit@0.17.6': '@types/pdfkit@0.17.6':
dependencies: dependencies:
'@types/node': 25.3.5 '@types/node': 25.3.5
@@ -8044,6 +8061,8 @@ snapshots:
node-releases@2.0.36: {} node-releases@2.0.36: {}
nodemailer@8.0.7: {}
npm-run-path@6.0.0: npm-run-path@6.0.0:
dependencies: dependencies:
path-key: 4.0.0 path-key: 4.0.0