| status | done | ||
|---|---|---|---|
| depends | |||
| specs |
|
||
| issues |
|
||
| pr | 98 |
specs/behaviors/help-wanted-roles.md requires:
- Express interest notifies the project's maintainer — email required, Slack DM optional (deferred).
- Auto-aging reminders (90/180 days) — also deferred per the spec ("v1 ships without this").
Today's LoggingNotifier is a no-op stub that logs the intent and returns delivered: true without sending anything. This plan replaces it with a real email notifier for the two notification kinds the interface already declares (notifyHelpWantedInterest, notifyHelpWantedFilled).
Email-only is the agreed first cut — Slack DM is deferred to #95 because sending DMs out from a workspace bot needs a different credential trust than our existing SAML-IdP-for-Slack relationship.
Closes #82.
- behaviors/help-wanted-roles.md — the "express interest" notification (email leg only).
- api/projects-help-wanted.md — the route that triggers it.
Three options, in order of fit:
| Pros | Cons | |
|---|---|---|
| Resend (HTTPS API) | Modern, simple, generous free tier, good deliverability, no SMTP config | Adds a vendor account |
| Postmark / SES (HTTPS) | Similar shape | Same vendor-account overhead |
| Generic SMTP | No vendor lock-in | Deliverability is fragile from a small instance; spam reputation needs warming |
Lean Resend. Free tier covers our v1 traffic; their Node SDK is a single HTTPS call per send; failures surface as exceptions we can log + swallow per spec ("returns 202 to the caller regardless"). Vendor lock-in is shallow — Postmark/SES would be a one-file swap if we ever wanted to migrate.
RESEND_API_KEY— sealed secret in the cluster repo (codeforphilly-ng.secrets/). When unset, the notifier falls back to the existingLoggingNotifierso dev + test don't need a real key.CFP_NOTIFICATION_FROM— sender address. Default"Code for Philly <notifications@codeforphilly.org>". Lives in the ConfigMap.
apps/api/src/notify/email-notifier.ts:
export class EmailNotifier implements Notifier {
constructor(opts: { resendApiKey: string; fromAddress: string; siteHost: string; logger: FastifyBaseLogger });
async notifyHelpWantedInterest(n: HelpWantedInterestNotification): Promise<{ delivered: boolean }> {
if (!n.maintainerEmail) {
this.#log.warn({ ... }, 'help-wanted interest: no maintainer email; skipped');
return { delivered: false };
}
const html = renderInterestEmail(n, this.#siteHost);
const text = renderInterestText(n, this.#siteHost);
try {
await this.#resend.emails.send({ from, to, subject, html, text });
return { delivered: true };
} catch (err) {
this.#log.error({ err, ... }, 'help-wanted interest: email send failed');
return { delivered: false };
}
}
// same shape for notifyHelpWantedFilled
}Plain-text + HTML alternative per the same payload. Templates live in apps/api/src/notify/templates/:
help-wanted-interest.{html,txt}.ts— interpolates the notification fields into a short body.help-wanted-filled.{html,txt}.ts— sibling for the fill case.
Strings inline in TS (no template engine — they're small and we already have the data structured). The body links back to the role on the live site using siteHost from env (e.g. https://next-v2.codeforphilly.org/projects/<slug>#help-wanted).
Replace the LoggingNotifier construction in apps/api/src/plugins/services.ts:
const notifier: Notifier = fastify.config.RESEND_API_KEY
? new EmailNotifier({
resendApiKey: fastify.config.RESEND_API_KEY,
fromAddress: fastify.config.CFP_NOTIFICATION_FROM,
siteHost: fastify.config.CFP_SITE_HOST,
logger: fastify.log,
})
: new LoggingNotifier(fastify.log);Logging fallback keeps tests + dev working without setup.
- Unit-test the template renderers (deterministic output for fixed inputs).
- Integration-test the notifier with a mock Resend SDK (
vi.spyOntheemails.sendmethod). - Help-wanted-interest route test: confirms the route still returns 202 when the notifier throws (delivery failure must not fail the request per spec).
specs/behaviors/help-wanted-roles.md— no behavior change; the spec already declares email-required.docs/operations/deploy.mdenv table — addRESEND_API_KEY,CFP_NOTIFICATION_FROM.docs/operations/secrets.md— addRESEND_API_KEYto the sealed-secret roster..env.example— document both new envs.
-
EmailNotifier.notifyHelpWantedInterestcalls Resend with the right payload (subject + text + html + maintainer email + sender). - Missing maintainer email →
delivered: false, no Resend call, warning logged. - Resend SDK throwing →
delivered: false, error logged. - Resend reporting
{ error }(e.g. unverified sender domain) →delivered: false, error logged. - When
RESEND_API_KEYis unset, services plugin installsLoggingNotifier— all 290 pre-existing API tests unaffected. -
CFP_NOTIFICATION_FROMdefaults to"Code for Philly <notifications@codeforphilly.org>"via env JSON schema. - Templates: interest body interpolates name/slug/role/project/message; HTML escapes user content (
<script>→<script>); message blockquote omitted when message is null. Filled body names the filler when set; omits the link when null. -
npm run type-check && npm run lintclean; 302/302 API tests pass (290 pre-existing + 11 new + 1 already counted from earlier batches).
- Deliverability warm-up. A new Resend sender may get throttled / spam-filtered on day one. Mitigate by setting up SPF/DKIM/DMARC on
codeforphilly.orgbefore flipping the env on, and sending a few tests to known-good recipients first. Operator step, not code. - Bounce + complaint handling. v1 doesn't subscribe to Resend's bounce webhooks. If a maintainer's email is dead, we'll log the failure but won't surface it back to the API consumer. Tracked as a follow-up once we have a UX hook (e.g., a "your maintainer's email bounced" surfaced on the project page).
- Rate-limiting upstream. Resend's free tier caps outbound; the rate-limiter on
/express-interestalready prevents floods, but worth double-checking the cap vs. our expected v1 traffic. - PII in logs. The notifier logs
maintainerEmailon failure, which lands in pod logs. Perbehaviors/storage.md→ "PII-aware redaction" we should be careful about that. Email-on-error-path is probably acceptable, but worth a redaction-stripping pass when implementing. (Slug + role title are fine.)
Four implementation commits — status-flip + resend dep + impl + closeout.
Surprises:
- HTML escaping was load-bearing. Templates pull
interestedPersonFullName,roleTitle, etc. straight from user-provided fields. Without escaping, a maliciously-named role title (<script>alert(1)</script>) would render as live HTML in the maintainer's email client. Added a smallescapeHtml()helper + an explicit test that confirms<script>becomes<script>in the output. - Resend SDK's two failure shapes. The SDK can either throw (network blip, malformed args) OR resolve with
{ data: null, error: { message } }(Resend's API rejected the send — e.g. unverified sender domain). Both paths translate todelivered: false; the error log lines differ slightly so operators can distinguish them. - Unset-API-key fallback was the right call. Falling back to
LoggingNotifierwhenRESEND_API_KEYis absent kept every existing test passing without configuration. Also means an operator can deploy this code without setting up Resend first; notifications drop to logs silently until the secret lands. Resend.emails.sendtakes bothtextandhtml. Sending both gives email clients that prefer plain-text the same content; some inbox apps' preview panes also render text first.
- Newsletter export endpoint. The user-facing per-account
newsletterfield is wired (PATCH /api/people/:slug/newsletter), but there's no staff export to feed an actual newsletter sender. Spec exists inbehaviors/private-storage.md → listAllProfiles; not part of this issue. - Bounce / complaint webhooks. Resend can POST delivery events back to us; we don't subscribe. A dead maintainer email silently fails. Worth doing if/when we surface "your contact email looks broken" UI on the project page.
- PII redaction in logs. Notifier logs the maintainer's email on Resend failure. Write-only path (no API surface that exposes it), but worth tightening to hash+domain when we do a broader PII-in-logs sweep.
- Slack DM channel — tracked at #95. Depends on deeper Slack integration trust (separate spec). When it lands, compose alongside EmailNotifier via a CompoundNotifier.
- Templating engine. Inline string concatenation is fine for two short templates. If we add 3+ more notification kinds (welcome on signup, auto-aging reminders), pull templates into a small TS-native template module rather than dragging in mjml/handlebars.