Skip to content

Commit 46f4ff7

Browse files
Merge pull request #5 from foundersandcoders/feature/ap-13-resend-email-integration
feat: add Resend email integration for magic links (AP-13)
2 parents 4e710c1 + 43c238f commit 46f4ff7

File tree

8 files changed

+179
-11
lines changed

8 files changed

+179
-11
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
AIRTABLE_API_KEY=
22
AIRTABLE_BASE_ID=
33
RESEND_API_KEY=
4+
RESEND_FROM_EMAIL=
45
DISCORD_WEBHOOK_URL=
56
MAGIC_LINK_SECRET=
67
SESSION_SECRET=

package-lock.json

Lines changed: 89 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
},
4444
"dependencies": {
4545
"airtable": "^0.12.2",
46-
"jsonwebtoken": "^9.0.3"
46+
"jsonwebtoken": "^9.0.3",
47+
"resend": "^6.6.0"
4748
}
4849
}

src/lib/server/email.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Resend } from 'resend';
2+
import { RESEND_API_KEY, RESEND_FROM_EMAIL } from '$env/static/private';
3+
4+
const resend = new Resend(RESEND_API_KEY);
5+
6+
interface SendMagicLinkResult {
7+
success: boolean;
8+
error?: string;
9+
}
10+
11+
export async function sendMagicLinkEmail(
12+
to: string,
13+
magicLinkUrl: string,
14+
type: 'staff' | 'student',
15+
): Promise<SendMagicLinkResult> {
16+
const subject = type === 'staff'
17+
? 'Staff Login - Apprentice Pulse'
18+
: 'Student Login - Apprentice Pulse';
19+
20+
const html = `
21+
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
22+
<h1 style="color: #333;">Apprentice Pulse</h1>
23+
<p>Click the button below to log in:</p>
24+
<a href="${magicLinkUrl}" style="display: inline-block; background: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 16px 0;">
25+
Log in
26+
</a>
27+
<p style="color: #666; font-size: 14px;">
28+
This link expires in 15 minutes.<br>
29+
If you didn't request this, you can safely ignore this email.
30+
</p>
31+
<p style="color: #999; font-size: 12px;">
32+
Or copy this link: ${magicLinkUrl}
33+
</p>
34+
</div>
35+
`;
36+
37+
const text = `
38+
Apprentice Pulse
39+
40+
Click this link to log in:
41+
${magicLinkUrl}
42+
43+
This link expires in 15 minutes.
44+
If you didn't request this, you can safely ignore this email.
45+
`.trim();
46+
47+
try {
48+
const { error } = await resend.emails.send({
49+
from: RESEND_FROM_EMAIL,
50+
to,
51+
subject,
52+
html,
53+
text,
54+
});
55+
56+
if (error) {
57+
console.error('Resend error:', error);
58+
return { success: false, error: error.message };
59+
}
60+
61+
return { success: true };
62+
}
63+
catch (err) {
64+
console.error('Email send error:', err);
65+
return { success: false, error: 'Failed to send email' };
66+
}
67+
}

src/routes/api/auth/staff/login/+server.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
22
import type { RequestHandler } from './$types';
33
import { findStaffByEmail } from '$lib/airtable/sveltekit-wrapper';
44
import { generateMagicToken } from '$lib/server/auth';
5+
import { sendMagicLinkEmail } from '$lib/server/email';
56

67
export const POST: RequestHandler = async ({ request, url }) => {
78
const { email } = await request.json();
@@ -22,9 +23,11 @@ export const POST: RequestHandler = async ({ request, url }) => {
2223
const verifyUrl = new URL('/api/auth/verify', url.origin);
2324
verifyUrl.searchParams.set('token', token);
2425

25-
// TODO: Send email with magic link (AP-13)
26-
// For now, log token for testing
27-
console.log(`[Staff] Magic link: ${verifyUrl.pathname}${verifyUrl.search}`);
26+
const result = await sendMagicLinkEmail(email, verifyUrl.toString(), 'staff');
27+
28+
if (!result.success) {
29+
return json({ error: 'Failed to send email. Please try again.' }, { status: 500 });
30+
}
2831

2932
return json({ message: 'Magic link sent! Check your email.' });
3033
};

src/routes/api/auth/staff/login/server.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ vi.mock('$lib/server/auth', () => ({
1010
generateMagicToken: vi.fn(() => 'mock-token'),
1111
}));
1212

13+
vi.mock('$lib/server/email', () => ({
14+
sendMagicLinkEmail: vi.fn(() => Promise.resolve({ success: true })),
15+
}));
16+
1317
import { findStaffByEmail } from '$lib/airtable/sveltekit-wrapper';
1418
import { generateMagicToken } from '$lib/server/auth';
1519

src/routes/api/auth/student/login/+server.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
22
import type { RequestHandler } from './$types';
33
import { findApprenticeByEmail } from '$lib/airtable/sveltekit-wrapper';
44
import { generateMagicToken } from '$lib/server/auth';
5+
import { sendMagicLinkEmail } from '$lib/server/email';
56

67
export const POST: RequestHandler = async ({ request, url }) => {
78
const { email } = await request.json();
@@ -22,9 +23,11 @@ export const POST: RequestHandler = async ({ request, url }) => {
2223
const verifyUrl = new URL('/api/auth/verify', url.origin);
2324
verifyUrl.searchParams.set('token', token);
2425

25-
// TODO: Send email with magic link (AP-13)
26-
// For now, log token for testing
27-
console.log(`[Student] Magic link: ${verifyUrl.pathname}${verifyUrl.search}`);
26+
const result = await sendMagicLinkEmail(email, verifyUrl.toString(), 'student');
27+
28+
if (!result.success) {
29+
return json({ error: 'Failed to send email. Please try again.' }, { status: 500 });
30+
}
2831

2932
return json({ message: 'Magic link sent! Check your email.' });
3033
};

src/routes/api/auth/student/login/server.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ vi.mock('$lib/server/auth', () => ({
1010
generateMagicToken: vi.fn(() => 'mock-token'),
1111
}));
1212

13+
vi.mock('$lib/server/email', () => ({
14+
sendMagicLinkEmail: vi.fn(() => Promise.resolve({ success: true })),
15+
}));
16+
1317
import { findApprenticeByEmail } from '$lib/airtable/sveltekit-wrapper';
1418
import { generateMagicToken } from '$lib/server/auth';
1519

0 commit comments

Comments
 (0)