Skip to content

Latest commit

 

History

History
152 lines (109 loc) · 9.62 KB

File metadata and controls

152 lines (109 loc) · 9.62 KB
status done
depends
specs
specs/behaviors/help-wanted-roles.md
specs/api/projects-help-wanted.md
issues
82
pr 98

Plan: Real notifier — email-only first cut

Scope

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.

Implements

Approach

1. Pick a transport

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.

2. Env

  • RESEND_API_KEY — sealed secret in the cluster repo (codeforphilly-ng.secrets/). When unset, the notifier falls back to the existing LoggingNotifier so dev + test don't need a real key.
  • CFP_NOTIFICATION_FROM — sender address. Default "Code for Philly <notifications@codeforphilly.org>". Lives in the ConfigMap.

3. EmailNotifier class

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
}

4. Template

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).

5. Plugin wiring

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.

6. Tests

  • Unit-test the template renderers (deterministic output for fixed inputs).
  • Integration-test the notifier with a mock Resend SDK (vi.spyOn the emails.send method).
  • Help-wanted-interest route test: confirms the route still returns 202 when the notifier throws (delivery failure must not fail the request per spec).

7. Spec/docs updates

  • specs/behaviors/help-wanted-roles.md — no behavior change; the spec already declares email-required.
  • docs/operations/deploy.md env table — add RESEND_API_KEY, CFP_NOTIFICATION_FROM.
  • docs/operations/secrets.md — add RESEND_API_KEY to the sealed-secret roster.
  • .env.example — document both new envs.

Validation

  • EmailNotifier.notifyHelpWantedInterest calls 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_KEY is unset, services plugin installs LoggingNotifier — all 290 pre-existing API tests unaffected.
  • CFP_NOTIFICATION_FROM defaults 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>&lt;script&gt;); 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 lint clean; 302/302 API tests pass (290 pre-existing + 11 new + 1 already counted from earlier batches).

Risks / unknowns

  • Deliverability warm-up. A new Resend sender may get throttled / spam-filtered on day one. Mitigate by setting up SPF/DKIM/DMARC on codeforphilly.org before 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-interest already prevents floods, but worth double-checking the cap vs. our expected v1 traffic.
  • PII in logs. The notifier logs maintainerEmail on failure, which lands in pod logs. Per behaviors/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.)

Notes

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 small escapeHtml() helper + an explicit test that confirms <script> becomes &lt;script&gt; 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 to delivered: false; the error log lines differ slightly so operators can distinguish them.
  • Unset-API-key fallback was the right call. Falling back to LoggingNotifier when RESEND_API_KEY is 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.send takes both text and html. Sending both gives email clients that prefer plain-text the same content; some inbox apps' preview panes also render text first.

Follow-ups

  • Newsletter export endpoint. The user-facing per-account newsletter field is wired (PATCH /api/people/:slug/newsletter), but there's no staff export to feed an actual newsletter sender. Spec exists in behaviors/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.