Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,19 @@ CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars
# CFP_SITE_HOST=codeforphilly.org

# ---------------------------------------------------------------------------
# Outbound notifications (Resend)
# Outbound notifications (Postmark)
# ---------------------------------------------------------------------------

# Resend API key for the help-wanted email notifier. When unset, the
# notifier falls back to a no-op LoggingNotifier so dev + tests work
# without an account. See plans/notifier-email.md.
# RESEND_API_KEY=re_…
# Postmark server token for the email notifier (help-wanted, welcome,
# password-reset). When unset, the notifier falls back to a no-op
# LoggingNotifier so dev + tests work without an account. See
# plans/postmark-notifier.md and docs/operations/secrets.md.
# POSTMARK_SERVER_TOKEN=…

# Postmark message stream to send on. Defaults to `outbound`, the
# transactional stream every Postmark server ships with.
# POSTMARK_MESSAGE_STREAM=outbound

# From-address for outbound notifications. RFC 5322 form.
# Only used when RESEND_API_KEY is set.
# Only used when POSTMARK_SERVER_TOKEN is set.
# CFP_NOTIFICATION_FROM="Code for Philly <notifications@codeforphilly.org>"
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"fastify": "^5.8.5",
"gitsheets": "^2.2.0",
"jose": "^6.2.3",
"resend": "^6.12.4",
"postmark": "^5.1.0",
"samlify": "^2.13.0",
"sharp": "^0.34.5",
"uuidv7": "^1.2.1",
Expand Down
50 changes: 20 additions & 30 deletions apps/api/scripts/cutover-mailout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,26 @@
* them to sign in and claim their account. Run manually at T+90 per
* specs/behaviors/account-migration.md#cutover-window-policy.
*
* --dry-run prints the would-be send list and exits — no Resend calls, no
* --dry-run prints the would-be send list and exits — no Postmark calls, no
* disk writes. The CI test exercises only --dry-run.
*
* Usage:
* npm run -w apps/api script:cutover-mailout -- --dry-run
* npm run -w apps/api script:cutover-mailout -- --send --from=hello@codeforphilly.org
*
* Env:
* RESEND_API_KEY — required for actual sends (otherwise --send refuses)
* POSTMARK_SERVER_TOKEN — required for actual sends (otherwise --send refuses)
* POSTMARK_MESSAGE_STREAM — optional; defaults to `outbound`
* CFP_PUBLIC_URL — base URL used in the email body (defaults to
* https://codeforphilly.org)
* CFP_DATA_REPO_PATH + STORAGE_BACKEND + bucket envs — same shape as the API
*/
import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';

import { ServerClient } from 'postmark';

import { PostmarkTransport } from '../src/notify/postmark-transport.js';
import { openPublicStore, type PublicStore } from '../src/store/public.js';
import {
FilesystemPrivateStore,
Expand Down Expand Up @@ -195,7 +199,7 @@ export async function runMailout(opts: MailoutOptions): Promise<MailoutReport> {
}

// ---------------------------------------------------------------------------
// Env wiring + Resend send
// Env wiring + Postmark send
// ---------------------------------------------------------------------------

function requireEnv(name: string): string {
Expand All @@ -220,33 +224,19 @@ function buildPrivateStore(): PrivateStore {
});
}

/** Resend HTTP send. Fetch-based to avoid adding a new dep at this stage. */
async function resendSend(input: {
to: string;
from: string;
subject: string;
html: string;
text: string;
}): Promise<void> {
const apiKey = requireEnv('RESEND_API_KEY');
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'authorization': `Bearer ${apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify({
from: input.from,
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
}),
/**
* Postmark send via the same transport the API's notifier uses. The SDK
* throws on any non-2xx, which runMailout() records per-recipient in
* `failed` rather than aborting the run.
*/
function buildPostmarkSend(): NonNullable<MailoutOptions['send']> {
const transport = new PostmarkTransport({
client: new ServerClient(requireEnv('POSTMARK_SERVER_TOKEN')),
messageStream: process.env['POSTMARK_MESSAGE_STREAM'] || undefined,
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Resend ${res.status}: ${body.slice(0, 200)}`);
}
return async (input) => {
await transport.send(input);
};
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -299,7 +289,7 @@ async function main(): Promise<void> {
mode: args.dryRun ? 'dry-run' : 'send',
from: args.from,
publicUrl: args.publicUrl ?? process.env['CFP_PUBLIC_URL'],
send: args.send ? resendSend : undefined,
send: args.send ? buildPostmarkSend() : undefined,
});

process.stderr.write(
Expand Down
19 changes: 13 additions & 6 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,21 @@ export const EnvSchema = z.object({
*/
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.
* Postmark server token for the email notifier. When unset, the services
* plugin falls back to LoggingNotifier so dev + test runs don't need a
* real token. See plans/postmark-notifier.md.
*/
RESEND_API_KEY: z.string().optional(),
POSTMARK_SERVER_TOKEN: z.string().optional(),
/**
* Postmark message stream outbound mail is sent on. `outbound` is the
* transactional default stream every Postmark server ships with. Only
* relevant when POSTMARK_SERVER_TOKEN is set.
*/
POSTMARK_MESSAGE_STREAM: z.string().default('outbound'),
/**
* From-address for outbound notifications. RFC 5322 form
* (e.g. `"Code for Philly <notifications@codeforphilly.org>"`). Only
* relevant when RESEND_API_KEY is set.
* relevant when POSTMARK_SERVER_TOKEN is set.
*/
CFP_NOTIFICATION_FROM: z
.string()
Expand Down Expand Up @@ -118,7 +124,8 @@ 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' },
POSTMARK_SERVER_TOKEN: { type: 'string' },
POSTMARK_MESSAGE_STREAM: { type: 'string', default: 'outbound' },
CFP_NOTIFICATION_FROM: {
type: 'string',
default: 'Code for Philly <notifications@codeforphilly.org>',
Expand Down
Loading