Skip to content

Commit 64c517a

Browse files
feat(api): welcome email on fresh OAuth signup (closes #43)
Third method on the Notifier interface — notifyWelcomeOnSignup — wired into the github-oauth.ts create-fresh outcome. Fires fire-and-forget so the OAuth redirect latency is unaffected; the notifier's own error-handling translates Resend failures to delivered:false without throwing. Template body: warm 2-3 sentence intro pointing at the user's profile, projects directory, help-wanted board, and #welcome Slack channel. Plain-text + HTML alternatives, fullName HTML-escaped, slug URL-encoded. EmailNotifier.notifyWelcomeOnSignup mirrors the existing two methods — same Resend response-shape branching, same delivered:false on missing email / SDK throw / Resend-reported error. Wiring is hidden behind the existing LoggingNotifier → EmailNotifier fallback in the services plugin. When RESEND_API_KEY is unset (sandbox today), welcomes log to pod stdout; once sealed, real emails go out. Tests: 7 new in email-notifier.test.ts (template renderer + 4 notifier paths), 1 new in github-oauth.test.ts (spy assertion that the create-fresh path fires the notifier with the right payload). 310/310 API tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2b2211b commit 64c517a

6 files changed

Lines changed: 253 additions & 4 deletions

File tree

apps/api/src/auth/github-oauth.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,21 @@ export async function completeCallback(
169169
);
170170
result.value.stateApply.apply(fastify.inMemoryState, fastify.fts);
171171

172+
// Fire-and-forget the welcome notification — never block the OAuth
173+
// redirect on notifier latency or failures. The notifier already
174+
// swallows errors internally and returns `{ delivered: false }`; the
175+
// outer .catch handles any unforeseen sync-throw before the SDK is
176+
// reached. See plans/welcome-notification.md.
177+
void fastify.notifier
178+
.notifyWelcomeOnSignup({
179+
email: result.value.profile.email,
180+
fullName: result.value.person.fullName,
181+
slug: result.value.person.slug,
182+
})
183+
.catch((err) => {
184+
fastify.log.error({ err }, 'welcome notification threw (fire-and-forget)');
185+
});
186+
172187
const minted = await mintSessionFor(
173188
result.value.person.id,
174189
result.value.person.accountLevel,

apps/api/src/notify/email-notifier.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ import type {
1818
HelpWantedFillNotification,
1919
HelpWantedInterestNotification,
2020
Notifier,
21+
WelcomeNotification,
2122
} from './index.js';
22-
import { renderFilledEmail, renderInterestEmail } from './templates.js';
23+
import { renderFilledEmail, renderInterestEmail, renderWelcomeEmail } from './templates.js';
2324

2425
export interface EmailNotifierOptions {
2526
/** Resend client (constructed at boot with the API key from env). */
@@ -100,6 +101,44 @@ export class EmailNotifier implements Notifier {
100101
}
101102
}
102103

104+
async notifyWelcomeOnSignup(n: WelcomeNotification): Promise<{ delivered: boolean }> {
105+
if (!n.email) {
106+
this.#log.warn(
107+
{ kind: 'auth.welcome', slug: n.slug },
108+
'welcome: no email address; skipped',
109+
);
110+
return { delivered: false };
111+
}
112+
const tpl = renderWelcomeEmail(n, this.#siteHost);
113+
try {
114+
const result = await this.#resend.emails.send({
115+
from: this.#from,
116+
to: n.email,
117+
subject: tpl.subject,
118+
text: tpl.text,
119+
html: tpl.html,
120+
});
121+
if (result.error) {
122+
this.#log.error(
123+
{ kind: 'auth.welcome', err: result.error, slug: n.slug },
124+
'welcome: Resend reported delivery failure',
125+
);
126+
return { delivered: false };
127+
}
128+
this.#log.info(
129+
{ kind: 'auth.welcome', slug: n.slug, resendId: result.data?.id },
130+
'welcome: email queued for delivery',
131+
);
132+
return { delivered: true };
133+
} catch (err) {
134+
this.#log.error(
135+
{ kind: 'auth.welcome', err, slug: n.slug },
136+
'welcome: email send threw',
137+
);
138+
return { delivered: false };
139+
}
140+
}
141+
103142
async notifyHelpWantedFilled(
104143
n: HelpWantedFillNotification,
105144
): Promise<{ delivered: boolean }> {

apps/api/src/notify/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,22 @@ export interface HelpWantedFillNotification {
3030
readonly filledBySlug: string | null;
3131
}
3232

33+
/**
34+
* Welcome notification — fires once per Person on the GitHub OAuth
35+
* `create-fresh` outcome (a brand-new signup with no laddr-account-claim
36+
* candidates). The email comes from the new PrivateProfile; fullName and
37+
* slug from the public Person record.
38+
*/
39+
export interface WelcomeNotification {
40+
readonly email: string;
41+
readonly fullName: string;
42+
readonly slug: string;
43+
}
44+
3345
export interface Notifier {
3446
notifyHelpWantedInterest(n: HelpWantedInterestNotification): Promise<{ delivered: boolean }>;
3547
notifyHelpWantedFilled(n: HelpWantedFillNotification): Promise<{ delivered: boolean }>;
48+
notifyWelcomeOnSignup(n: WelcomeNotification): Promise<{ delivered: boolean }>;
3649
}
3750

3851
/**
@@ -55,4 +68,9 @@ export class LoggingNotifier implements Notifier {
5568
this.#log.info({ kind: 'help-wanted.filled', ...n }, 'help-wanted fill notification');
5669
return { delivered: true };
5770
}
71+
72+
async notifyWelcomeOnSignup(n: WelcomeNotification): Promise<{ delivered: boolean }> {
73+
this.#log.info({ kind: 'auth.welcome', ...n }, 'welcome notification');
74+
return { delivered: true };
75+
}
5876
}

apps/api/src/notify/templates.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
* overhead than the strings themselves. URLs are absolute (resolved
77
* against the configured siteHost) so links work from email clients.
88
*/
9-
import type { HelpWantedFillNotification, HelpWantedInterestNotification } from './index.js';
9+
import type {
10+
HelpWantedFillNotification,
11+
HelpWantedInterestNotification,
12+
WelcomeNotification,
13+
} from './index.js';
1014

1115
/** Strip an absolute URL down to scheme + host + path. Useful for log lines. */
1216
export function buildRoleUrl(siteHost: string, projectSlug: string, roleId: string): string {
@@ -85,6 +89,59 @@ export interface FilledTemplate {
8589
readonly html: string;
8690
}
8791

92+
export interface WelcomeTemplate {
93+
readonly subject: string;
94+
readonly text: string;
95+
readonly html: string;
96+
}
97+
98+
export function renderWelcomeEmail(
99+
n: WelcomeNotification,
100+
siteHost: string,
101+
): WelcomeTemplate {
102+
const profileUrl = `https://${siteHost}/members/${encodeURIComponent(n.slug)}`;
103+
const projectsUrl = `https://${siteHost}/projects`;
104+
const helpWantedUrl = `https://${siteHost}/help-wanted`;
105+
const subject = `Welcome to Code for Philly, ${n.fullName}`;
106+
107+
const text = [
108+
`Hey ${n.fullName} — welcome to Code for Philly!`,
109+
'',
110+
`You're signed in. A few places to start:`,
111+
` - Your profile: ${profileUrl}`,
112+
` - Browse projects: ${projectsUrl}`,
113+
` - Help wanted: ${helpWantedUrl}`,
114+
'',
115+
`Code for Philly's community lives in Slack — most coordination happens there.`,
116+
`Drop into #welcome to introduce yourself.`,
117+
'',
118+
`— Code for Philly`,
119+
].join('\n');
120+
121+
const html = `<!DOCTYPE html>
122+
<html lang="en">
123+
<body style="font-family: system-ui, sans-serif; max-width: 32rem; margin: 0 auto; padding: 1rem; color: #111;">
124+
<p>Hey <strong>${escapeHtml(n.fullName)}</strong> — welcome to Code for Philly!</p>
125+
<p>You're signed in. A few places to start:</p>
126+
<ul>
127+
<li><a href="${profileUrl}">Your profile</a></li>
128+
<li><a href="${projectsUrl}">Browse projects</a></li>
129+
<li><a href="${helpWantedUrl}">Help wanted</a></li>
130+
</ul>
131+
<p>
132+
Code for Philly's community lives in Slack — most coordination happens there.
133+
Drop into #welcome to introduce yourself.
134+
</p>
135+
<hr style="border: none; border-top: 1px solid #eee; margin: 2rem 0;">
136+
<p style="color: #666; font-size: 0.875rem;">
137+
You're receiving this because you just signed in to Code for Philly with GitHub.
138+
</p>
139+
</body>
140+
</html>`;
141+
142+
return { subject, text, html };
143+
}
144+
88145
export function renderFilledEmail(
89146
n: HelpWantedFillNotification,
90147
siteHost: string,

apps/api/tests/email-notifier.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@
1111
*/
1212
import { describe, expect, it, vi } from 'vitest';
1313
import { EmailNotifier } from '../src/notify/email-notifier.js';
14-
import { renderFilledEmail, renderInterestEmail } from '../src/notify/templates.js';
14+
import {
15+
renderFilledEmail,
16+
renderInterestEmail,
17+
renderWelcomeEmail,
18+
} from '../src/notify/templates.js';
1519
import type {
1620
HelpWantedFillNotification,
1721
HelpWantedInterestNotification,
22+
WelcomeNotification,
1823
} from '../src/notify/index.js';
1924

2025
const noopLogger = {
@@ -51,6 +56,12 @@ const baseFill: HelpWantedFillNotification = {
5156
filledBySlug: 'jane-doe',
5257
};
5358

59+
const baseWelcome: WelcomeNotification = {
60+
email: 'new-user@example.com',
61+
fullName: 'New User',
62+
slug: 'new-user',
63+
};
64+
5465
function makeNotifier(emails: { send: ReturnType<typeof vi.fn> }): EmailNotifier {
5566
return new EmailNotifier({
5667
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -155,6 +166,76 @@ describe('EmailNotifier.notifyHelpWantedInterest', () => {
155166
});
156167
});
157168

169+
describe('renderWelcomeEmail', () => {
170+
it('builds subject + text + html with the user fullName + slug', () => {
171+
const tpl = renderWelcomeEmail(baseWelcome, 'codeforphilly.org');
172+
expect(tpl.subject).toContain('New User');
173+
expect(tpl.text).toContain('Hey New User');
174+
expect(tpl.text).toContain('https://codeforphilly.org/members/new-user');
175+
expect(tpl.text).toContain('https://codeforphilly.org/projects');
176+
expect(tpl.html).toContain('<strong>New User</strong>');
177+
expect(tpl.html).toContain('href="https://codeforphilly.org/members/new-user"');
178+
});
179+
180+
it('escapes HTML in fullName', () => {
181+
const tpl = renderWelcomeEmail(
182+
{ ...baseWelcome, fullName: '<script>alert(1)</script>' },
183+
'codeforphilly.org',
184+
);
185+
expect(tpl.html).not.toContain('<script>');
186+
expect(tpl.html).toContain('&lt;script&gt;');
187+
});
188+
189+
it('URL-encodes the slug for the profile link', () => {
190+
const tpl = renderWelcomeEmail(
191+
{ ...baseWelcome, slug: 'name with spaces' },
192+
'codeforphilly.org',
193+
);
194+
expect(tpl.html).toContain('href="https://codeforphilly.org/members/name%20with%20spaces"');
195+
});
196+
});
197+
198+
describe('EmailNotifier.notifyWelcomeOnSignup', () => {
199+
it('sends via Resend and returns delivered:true', async () => {
200+
const send = vi.fn().mockResolvedValue({ data: { id: 'msg-welcome' }, error: null });
201+
const notifier = makeNotifier({ send });
202+
203+
const result = await notifier.notifyWelcomeOnSignup(baseWelcome);
204+
expect(result).toEqual({ delivered: true });
205+
expect(send).toHaveBeenCalledTimes(1);
206+
const arg = send.mock.calls[0]![0]!;
207+
expect(arg.to).toBe('new-user@example.com');
208+
expect(arg.subject).toContain('Welcome');
209+
expect(arg.text).toContain('New User');
210+
expect(arg.html).toContain('<strong>New User</strong>');
211+
});
212+
213+
it('returns delivered:false when Resend reports an error', async () => {
214+
const send = vi.fn().mockResolvedValue({
215+
data: null,
216+
error: { message: 'Sender domain unverified' },
217+
});
218+
const notifier = makeNotifier({ send });
219+
const result = await notifier.notifyWelcomeOnSignup(baseWelcome);
220+
expect(result).toEqual({ delivered: false });
221+
});
222+
223+
it('returns delivered:false when the SDK throws', async () => {
224+
const send = vi.fn().mockRejectedValue(new Error('network blip'));
225+
const notifier = makeNotifier({ send });
226+
const result = await notifier.notifyWelcomeOnSignup(baseWelcome);
227+
expect(result).toEqual({ delivered: false });
228+
});
229+
230+
it('returns delivered:false on empty email (defensive)', async () => {
231+
const send = vi.fn();
232+
const notifier = makeNotifier({ send });
233+
const result = await notifier.notifyWelcomeOnSignup({ ...baseWelcome, email: '' });
234+
expect(result).toEqual({ delivered: false });
235+
expect(send).not.toHaveBeenCalled();
236+
});
237+
});
238+
158239
describe('EmailNotifier.notifyHelpWantedFilled', () => {
159240
it('sends via Resend and returns delivered:true', async () => {
160241
const send = vi.fn().mockResolvedValue({ data: { id: 'msg-456' }, error: null });

apps/api/tests/github-oauth.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* Each test uses a unique remoteAddress so the 10-req/min/IP cap on
1616
* /api/auth/* doesn't cause one test's setup to fail another test's run.
1717
*/
18-
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
18+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
1919
import { type FastifyInstance } from 'fastify';
2020
import { writeFile, readFile } from 'node:fs/promises';
2121
import { join } from 'node:path';
@@ -514,6 +514,45 @@ describe('GET /api/auth/github/callback — fresh user outcome', () => {
514514
const claims = await verifyAccess(accessValue, JWT_KEY);
515515
expect(claims.sub).toBe(person!.id);
516516
});
517+
518+
it('fires the welcome notification with the new person + email', async () => {
519+
const ip = nextTestIp();
520+
const flow = await startFlow(app, '/', ip);
521+
522+
// Spy on the boot-installed LoggingNotifier (no Resend in tests).
523+
// The notifier call is fire-and-forget — we await the OAuth response
524+
// first, then assert the spy. The notifier's spawn is synchronous up
525+
// to the await inside it, so it's guaranteed to have been called by
526+
// the time the route handler returns.
527+
mock.setGitHubUser({
528+
id: 88888,
529+
login: 'welcome-target',
530+
name: 'Welcome Target',
531+
avatar_url: 'x',
532+
});
533+
mock.setGitHubEmails([
534+
{ email: 'welcome-target@example.com', primary: true, verified: true, visibility: 'public' },
535+
]);
536+
537+
const spy = vi.spyOn(app.notifier, 'notifyWelcomeOnSignup');
538+
539+
const res = await app.inject({
540+
method: 'GET',
541+
url: `/api/auth/github/callback?code=abc&state=${encodeURIComponent(flow.state)}`,
542+
remoteAddress: ip,
543+
cookies: {
544+
cfp_oauth_state: flow.stateCookie,
545+
cfp_oauth_session: flow.oauthSessionCookie,
546+
},
547+
});
548+
expect(res.statusCode).toBe(302);
549+
expect(spy).toHaveBeenCalledWith({
550+
email: 'welcome-target@example.com',
551+
fullName: 'Welcome Target',
552+
slug: 'welcome-target',
553+
});
554+
spy.mockRestore();
555+
});
517556
});
518557

519558
describe('GET /api/auth/github/callback — candidates outcome', () => {

0 commit comments

Comments
 (0)