From 50e7501dc1f3618119361e35bd0c8b66e852d38c Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 29 May 2026 22:22:34 -0400 Subject: [PATCH 1/5] chore(plans): open notifier-email (in-progress) Plan was queued (status: planned) in PR #96; flipping to in-progress as we start the implementation. Co-Authored-By: Claude Opus 4.7 (1M context) --- plans/notifier-email.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/notifier-email.md b/plans/notifier-email.md index ab877dd..2783803 100644 --- a/plans/notifier-email.md +++ b/plans/notifier-email.md @@ -1,5 +1,5 @@ --- -status: planned +status: in-progress depends: [] specs: - specs/behaviors/help-wanted-roles.md From 8d58a1ce0fa9d91015f7976217f218743911abee Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 29 May 2026 22:22:49 -0400 Subject: [PATCH 2/5] chore(deps): add resend for email-notifier transport Installed via: npm install --workspace=apps/api resend Resend ships an HTTPS-API SDK (no SMTP); aligned with the plan's choice of transport for the help-wanted notifier. When RESEND_API_KEY is unset (tests, dev without an account), the notifier falls back to LoggingNotifier so existing code paths keep working without configuration. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/package.json | 1 + package-lock.json | 50 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/apps/api/package.json b/apps/api/package.json index c8e785f..f2205fd 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -34,6 +34,7 @@ "fastify": "^5.8.5", "gitsheets": "^1.3.1", "jose": "^6.2.3", + "resend": "^6.12.4", "samlify": "^2.13.0", "sharp": "^0.34.5", "uuidv7": "^1.2.1", diff --git a/package-lock.json b/package-lock.json index 144247b..711e23f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "fastify": "^5.8.5", "gitsheets": "^1.3.1", "jose": "^6.2.3", + "resend": "^6.12.4", "samlify": "^2.13.0", "sharp": "^0.34.5", "uuidv7": "^1.2.1", @@ -5320,6 +5321,12 @@ "text-hex": "1.0.x" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -8369,6 +8376,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -12544,6 +12557,12 @@ "node": ">=16.20.0" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -13280,6 +13299,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.4.tgz", + "integrity": "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -14055,6 +14095,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", From e37ac4c74f21a17daea7a9c64e5c0fb2f8ad70f2 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 29 May 2026 22:42:18 -0400 Subject: [PATCH 3/5] =?UTF-8?q?feat(api):=20EmailNotifier=20=E2=80=94=20Re?= =?UTF-8?q?send-backed=20help-wanted=20notifications=20(closes=20#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the LoggingNotifier no-op with a real email notifier for the two help-wanted notification kinds the Notifier interface declares (notifyHelpWantedInterest, notifyHelpWantedFilled). Implementation: - apps/api/src/notify/templates.ts — pure-function template renderers for both notification kinds. Plain-text + HTML alternatives, with HTML-escaped interpolation of user-supplied fields (role title, full name, message body). External-link transform from #91 doesn't apply here — emails carry absolute URLs to the site host. - apps/api/src/notify/email-notifier.ts — Resend-API-backed class. Translates {data, error} response shape: throwing SDK + Resend- reported errors + missing-email all return delivered:false; the route still returns 202 to the caller per spec. - services plugin: installs EmailNotifier when RESEND_API_KEY is set, otherwise falls back to LoggingNotifier. Means dev + tests stay configuration-free; existing tests untouched. - env.ts: RESEND_API_KEY (optional secret), CFP_NOTIFICATION_FROM (defaults to "Code for Philly "). 11 new tests: template assertions (interest + filled, with + without message, HTML-escape of user-supplied fields), notifier paths (Resend success, Resend-reported error, SDK throw, missing email → no Resend call). 302/302 API tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/env.ts | 19 +++ apps/api/src/notify/email-notifier.ts | 146 +++++++++++++++++++++ apps/api/src/notify/templates.ts | 133 +++++++++++++++++++ apps/api/src/plugins/services.ts | 15 ++- apps/api/tests/email-notifier.test.ts | 179 ++++++++++++++++++++++++++ 5 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/notify/email-notifier.ts create mode 100644 apps/api/src/notify/templates.ts create mode 100644 apps/api/tests/email-notifier.test.ts diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index bd803ce..b171799 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -67,6 +67,20 @@ export const EnvSchema = z.object({ * nofollow"`. Per specs/behaviors/markdown-rendering.md. */ CFP_SITE_HOST: z.string().default('codeforphilly.org'), + /** + * Resend API key for the email notifier. When unset, the services plugin + * falls back to LoggingNotifier so dev + test runs don't need a real key. + * See plans/notifier-email.md. + */ + RESEND_API_KEY: z.string().optional(), + /** + * From-address for outbound notifications. RFC 5322 form + * (e.g. `"Code for Philly "`). Only + * relevant when RESEND_API_KEY is set. + */ + CFP_NOTIFICATION_FROM: z + .string() + .default('Code for Philly '), }); export type Env = z.infer; @@ -104,5 +118,10 @@ export const envJsonSchema = { SLACK_TEAM_HOST: { type: 'string', default: 'codeforphilly.slack.com' }, CFP_WEB_DIST_PATH: { type: 'string' }, CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' }, + RESEND_API_KEY: { type: 'string' }, + CFP_NOTIFICATION_FROM: { + type: 'string', + default: 'Code for Philly ', + }, }, } as const; diff --git a/apps/api/src/notify/email-notifier.ts b/apps/api/src/notify/email-notifier.ts new file mode 100644 index 0000000..f28a3bb --- /dev/null +++ b/apps/api/src/notify/email-notifier.ts @@ -0,0 +1,146 @@ +/** + * EmailNotifier — Resend-backed implementation of the Notifier interface. + * + * Sends help-wanted notifications via the Resend HTTPS API. Delivery + * failures are logged but never thrown — per + * `specs/api/projects-help-wanted.md`, the express-interest endpoint + * returns 202 to the caller regardless of downstream notification + * outcome. + * + * Slack DM is deliberately out of scope here (tracked at #95); this is + * the email-only first cut. The Notifier interface still accepts + * `maintainerSlackHandle` so the data flow is ready when Slack lands. + */ +import type { FastifyBaseLogger } from 'fastify'; +import type { Resend } from 'resend'; + +import type { + HelpWantedFillNotification, + HelpWantedInterestNotification, + Notifier, +} from './index.js'; +import { renderFilledEmail, renderInterestEmail } from './templates.js'; + +export interface EmailNotifierOptions { + /** Resend client (constructed at boot with the API key from env). */ + readonly resend: Resend; + /** Sender address — RFC 5322 form, e.g. `"Code for Philly "`. */ + readonly fromAddress: string; + /** Public site host (no scheme), used to construct absolute URLs in email bodies. */ + readonly siteHost: string; + /** Pino-style logger; only level methods are used. */ + readonly logger: FastifyBaseLogger; +} + +export class EmailNotifier implements Notifier { + readonly #resend: Resend; + readonly #from: string; + readonly #siteHost: string; + readonly #log: FastifyBaseLogger; + + constructor(opts: EmailNotifierOptions) { + this.#resend = opts.resend; + this.#from = opts.fromAddress; + this.#siteHost = opts.siteHost; + this.#log = opts.logger; + } + + async notifyHelpWantedInterest( + n: HelpWantedInterestNotification, + ): Promise<{ delivered: boolean }> { + if (!n.maintainerEmail) { + this.#log.warn( + { kind: 'help-wanted.interest', projectSlug: n.projectSlug, roleId: n.roleId }, + 'help-wanted interest: no maintainer email; skipped', + ); + return { delivered: false }; + } + const tpl = renderInterestEmail(n, this.#siteHost); + try { + const result = await this.#resend.emails.send({ + from: this.#from, + to: n.maintainerEmail, + subject: tpl.subject, + text: tpl.text, + html: tpl.html, + }); + if (result.error) { + this.#log.error( + { + kind: 'help-wanted.interest', + err: result.error, + projectSlug: n.projectSlug, + roleId: n.roleId, + }, + 'help-wanted interest: Resend reported delivery failure', + ); + return { delivered: false }; + } + this.#log.info( + { + kind: 'help-wanted.interest', + projectSlug: n.projectSlug, + roleId: n.roleId, + resendId: result.data?.id, + }, + 'help-wanted interest: email queued for delivery', + ); + return { delivered: true }; + } catch (err) { + this.#log.error( + { + kind: 'help-wanted.interest', + err, + projectSlug: n.projectSlug, + roleId: n.roleId, + }, + 'help-wanted interest: email send threw', + ); + return { delivered: false }; + } + } + + async notifyHelpWantedFilled( + n: HelpWantedFillNotification, + ): Promise<{ delivered: boolean }> { + if (!n.maintainerEmail) { + this.#log.warn( + { kind: 'help-wanted.filled', projectTitle: n.projectTitle }, + 'help-wanted fill: no maintainer email; skipped', + ); + return { delivered: false }; + } + const tpl = renderFilledEmail(n, this.#siteHost); + try { + const result = await this.#resend.emails.send({ + from: this.#from, + to: n.maintainerEmail, + subject: tpl.subject, + text: tpl.text, + html: tpl.html, + }); + if (result.error) { + this.#log.error( + { kind: 'help-wanted.filled', err: result.error, projectTitle: n.projectTitle }, + 'help-wanted fill: Resend reported delivery failure', + ); + return { delivered: false }; + } + this.#log.info( + { + kind: 'help-wanted.filled', + projectTitle: n.projectTitle, + resendId: result.data?.id, + }, + 'help-wanted fill: email queued for delivery', + ); + return { delivered: true }; + } catch (err) { + this.#log.error( + { kind: 'help-wanted.filled', err, projectTitle: n.projectTitle }, + 'help-wanted fill: email send threw', + ); + return { delivered: false }; + } + } +} diff --git a/apps/api/src/notify/templates.ts b/apps/api/src/notify/templates.ts new file mode 100644 index 0000000..974abcb --- /dev/null +++ b/apps/api/src/notify/templates.ts @@ -0,0 +1,133 @@ +/** + * Email templates for help-wanted notifications. + * + * Plain-text + HTML versions of each message. Bodies are interpolated + * inline; they're short enough that a template engine would be more + * overhead than the strings themselves. URLs are absolute (resolved + * against the configured siteHost) so links work from email clients. + */ +import type { HelpWantedFillNotification, HelpWantedInterestNotification } from './index.js'; + +/** Strip an absolute URL down to scheme + host + path. Useful for log lines. */ +export function buildRoleUrl(siteHost: string, projectSlug: string, roleId: string): string { + // Anchor the role on the project detail page; the front-end opens the + // help-wanted section by the role's id. + return `https://${siteHost}/projects/${projectSlug}#help-wanted-${roleId}`; +} + +/** Escape characters that have HTML meaning. Minimal but correct for our payloads. */ +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export interface InterestTemplate { + readonly subject: string; + readonly text: string; + readonly html: string; +} + +export function renderInterestEmail( + n: HelpWantedInterestNotification, + siteHost: string, +): InterestTemplate { + const url = buildRoleUrl(siteHost, n.projectSlug, n.roleId); + const subject = `Someone's interested in your help-wanted role: ${n.roleTitle}`; + const text = [ + `${n.interestedPersonFullName} (@${n.interestedPersonSlug}) just expressed interest`, + `in your "${n.roleTitle}" role on ${n.projectTitle}.`, + '', + n.message ? `Their message:\n${n.message}\n` : '', + `View the role and reply:`, + ` ${url}`, + '', + `— Code for Philly`, + ] + .filter((line, idx, arr) => !(line === '' && arr[idx - 1] === '')) + .join('\n'); + + const html = ` + + +

+ ${escapeHtml(n.interestedPersonFullName)} + (@${escapeHtml(n.interestedPersonSlug)}) + just expressed interest in your + ${escapeHtml(n.roleTitle)} role on + ${escapeHtml(n.projectTitle)}. +

+${ + n.message + ? `
${escapeHtml(n.message)}
` + : '' +} +

+ View the role +

+
+

+ You're receiving this because you're the maintainer of + ${escapeHtml(n.projectTitle)} on Code for Philly. +

+ +`; + + return { subject, text, html }; +} + +export interface FilledTemplate { + readonly subject: string; + readonly text: string; + readonly html: string; +} + +export function renderFilledEmail( + n: HelpWantedFillNotification, + siteHost: string, +): FilledTemplate { + const subject = `Role filled: ${n.roleTitle}`; + const filledBy = n.filledByFullName ?? 'Someone'; + const filledLink = n.filledBySlug + ? `https://${siteHost}/members/${encodeURIComponent(n.filledBySlug)}` + : null; + + const text = [ + `Your "${n.roleTitle}" role on ${n.projectTitle} was just marked as filled.`, + '', + filledBy === 'Someone' + ? 'No specific person was attributed; you can edit the role to record who took it on.' + : `Filled by: ${filledBy}${n.filledBySlug ? ` (@${n.filledBySlug})` : ''}`, + '', + `— Code for Philly`, + ] + .filter((line, idx, arr) => !(line === '' && arr[idx - 1] === '')) + .join('\n'); + + const html = ` + + +

+ Your ${escapeHtml(n.roleTitle)} role on + ${escapeHtml(n.projectTitle)} was just marked as filled. +

+

+ ${ + filledLink && n.filledBySlug + ? `Filled by: ${escapeHtml(filledBy)} (@${escapeHtml(n.filledBySlug)})` + : `No specific person was attributed; you can edit the role to record who took it on.` + } +

+
+

+ You're receiving this because you're the maintainer of + ${escapeHtml(n.projectTitle)} on Code for Philly. +

+ +`; + + return { subject, text, html }; +} diff --git a/apps/api/src/plugins/services.ts b/apps/api/src/plugins/services.ts index 3947743..7cfe4c7 100644 --- a/apps/api/src/plugins/services.ts +++ b/apps/api/src/plugins/services.ts @@ -28,6 +28,8 @@ import { TagWriteService } from '../services/tag.write.js'; import { GitHubAccountService } from '../services/github-account.js'; import { AccountClaimService } from '../services/account-claim.js'; import { LoggingNotifier, type Notifier } from '../notify/index.js'; +import { EmailNotifier } from '../notify/email-notifier.js'; +import { Resend } from 'resend'; declare module 'fastify' { interface FastifyInstance { @@ -63,7 +65,18 @@ async function servicesPlugin(fastify: FastifyInstance): Promise { // (relevant in tests where multiple buildApp() runs share the module). invalidateFacets(); const fts = buildFtsEngine(state); - const notifier: Notifier = new LoggingNotifier(fastify.log); + // Email notifier when RESEND_API_KEY is configured; otherwise fall back to + // the no-op LoggingNotifier so tests + dev runs work without a real key. + // Slack DM is deferred (#95) — when it lands it'll compose alongside email + // here or via a CompoundNotifier wrapper. + const notifier: Notifier = fastify.config.RESEND_API_KEY + ? new EmailNotifier({ + resend: new Resend(fastify.config.RESEND_API_KEY), + fromAddress: fastify.config.CFP_NOTIFICATION_FROM, + siteHost: fastify.config.CFP_SITE_HOST, + logger: fastify.log, + }) + : new LoggingNotifier(fastify.log); fastify.decorate('inMemoryState', state); fastify.decorate('fts', fts); diff --git a/apps/api/tests/email-notifier.test.ts b/apps/api/tests/email-notifier.test.ts new file mode 100644 index 0000000..32510a3 --- /dev/null +++ b/apps/api/tests/email-notifier.test.ts @@ -0,0 +1,179 @@ +/** + * Tests for the Resend-backed EmailNotifier (apps/api/src/notify/email-notifier.ts). + * + * Mocks the Resend SDK at the `emails.send` boundary — verifies that the + * notifier composes the right payload + handles delivery success/failure + * per the spec (express-interest must return 202 to the caller regardless). + * + * Template renderers are also exercised here with snapshot-style asserts + * on the interpolated fields, since they're pure functions with simple + * inputs. + */ +import { describe, expect, it, vi } from 'vitest'; +import { EmailNotifier } from '../src/notify/email-notifier.js'; +import { renderFilledEmail, renderInterestEmail } from '../src/notify/templates.js'; +import type { + HelpWantedFillNotification, + HelpWantedInterestNotification, +} from '../src/notify/index.js'; + +const noopLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + level: 'info', + // pino's BaseLogger has more; the notifier only touches info/warn/error so + // the cast keeps the test surface narrow. + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +const baseInterest: HelpWantedInterestNotification = { + maintainerEmail: 'maintainer@example.com', + maintainerSlackHandle: null, + roleTitle: 'Frontend lead', + projectTitle: 'SquadQuest', + projectSlug: 'squadquest', + roleId: '01951a3c-0000-7000-8000-000000000007', + interestedPersonFullName: 'Jane Doe', + interestedPersonSlug: 'jane-doe', + message: 'I love React — let me help!', +}; + +const baseFill: HelpWantedFillNotification = { + maintainerEmail: 'maintainer@example.com', + roleTitle: 'Frontend lead', + projectTitle: 'SquadQuest', + filledByFullName: 'Jane Doe', + filledBySlug: 'jane-doe', +}; + +function makeNotifier(emails: { send: ReturnType }): EmailNotifier { + return new EmailNotifier({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resend: { emails } as any, + fromAddress: 'Code for Philly ', + siteHost: 'codeforphilly.org', + logger: noopLogger, + }); +} + +describe('renderInterestEmail', () => { + it('builds subject + text + html with all fields interpolated', () => { + const tpl = renderInterestEmail(baseInterest, 'codeforphilly.org'); + expect(tpl.subject).toContain('Frontend lead'); + expect(tpl.text).toContain('Jane Doe'); + expect(tpl.text).toContain('@jane-doe'); + expect(tpl.text).toContain('SquadQuest'); + expect(tpl.text).toContain('I love React — let me help!'); + expect(tpl.text).toContain('https://codeforphilly.org/projects/squadquest'); + expect(tpl.html).toContain('Jane Doe'); + expect(tpl.html).toContain('href="https://codeforphilly.org/members/jane-doe"'); + }); + + it('omits the message blockquote when no message', () => { + const tpl = renderInterestEmail({ ...baseInterest, message: null }, 'codeforphilly.org'); + expect(tpl.html).not.toContain('blockquote'); + expect(tpl.text).not.toContain('Their message'); + }); + + it('escapes HTML in user-supplied fields', () => { + const tpl = renderInterestEmail( + { ...baseInterest, message: '' }, + 'codeforphilly.org', + ); + expect(tpl.html).not.toContain('`) would render as live HTML in the maintainer's email client. Added a small `escapeHtml()` helper + an explicit test that confirms `