diff --git a/server/utils/resend.js b/server/utils/resend.js index 528ab94..6b69505 100644 --- a/server/utils/resend.js +++ b/server/utils/resend.js @@ -2,6 +2,17 @@ import { Resend } from "resend"; const resend = new Resend(useRuntimeConfig().resendApiKey); +// In dev/test runs (ALLOW_DEV_TEST_ENDPOINTS=true) skip live email dispatch so +// local flows and e2e don't fire real Resend sends. Mirrors the gate in +// server/api/admin/pre-registrants/invite.post.js. +const skipEmailInDev = (label, to) => { + if (process.env.ALLOW_DEV_TEST_ENDPOINTS === "true") { + console.log(`[resend] DEV MODE — skipping ${label}`, { to }); + return true; + } + return false; +}; + const formatEventDate = (dateString, timeZone = "America/Toronto") => { const date = new Date(dateString); return new Intl.DateTimeFormat("en-US", { @@ -58,6 +69,10 @@ Paid: $${registration.amountPaid.toFixed(2)} CAD`; ticketSection = "\nThis event is free for Ghost Guild members.\n"; } + if (skipEmailInDev("registration email", registration.email)) { + return { success: true, skipped: true }; + } + try { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", @@ -96,6 +111,10 @@ We look forward to seeing you there!`, export async function sendEventCancellationEmail(registration, eventData) { const baseUrl = process.env.BASE_URL || "https://ghostguild.org"; + if (skipEmailInDev("cancellation email", registration.email)) { + return { success: true, skipped: true }; + } + try { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", @@ -129,6 +148,10 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) { const baseUrl = process.env.BASE_URL || "https://ghostguild.org"; const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`; + if (skipEmailInDev("waitlist notification email", waitlistEntry.email)) { + return { success: true, skipped: true }; + } + try { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", @@ -188,6 +211,10 @@ export async function sendSeriesPassConfirmation(options) { }) .join("\n\n"); + if (skipEmailInDev("series pass confirmation email", to)) { + return { success: true, skipped: true }; + } + try { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", @@ -226,6 +253,10 @@ ${eventList}`, export async function sendWelcomeEmail(member) { const baseUrl = process.env.BASE_URL || "https://ghostguild.org"; + if (skipEmailInDev("welcome email", member.email)) { + return { success: true, skipped: true }; + } + try { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", @@ -238,6 +269,8 @@ Welcome to Ghost Guild! You're now part of the ${member.circle} circle. Sign in to your dashboard to get started: ${baseUrl}/member/dashboard +Your Slack invitation arrives in our monthly onboarding waves — there may be a short wait. + If you have questions, just reply to this email.`, }); diff --git a/tests/server/utils/resend.test.js b/tests/server/utils/resend.test.js new file mode 100644 index 0000000..dbbbeb1 --- /dev/null +++ b/tests/server/utils/resend.test.js @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import { + sendEventRegistrationEmail, + sendEventCancellationEmail, + sendWaitlistNotificationEmail, + sendSeriesPassConfirmation, + sendWelcomeEmail, +} from '../../../server/utils/resend.js' + +// Hoisted spy so the resend mock and the assertions share one reference. +const { sendSpy } = vi.hoisted(() => ({ sendSpy: vi.fn() })) + +vi.mock('resend', () => ({ + Resend: class { + constructor() { + this.emails = { send: sendSpy } + } + }, +})) + +const eventData = { + title: 'Co-op Basics', + startDate: '2026-06-01T17:00:00.000Z', + endDate: '2026-06-01T18:00:00.000Z', + location: 'Online', + slug: 'co-op-basics', +} + +const registration = { email: 'reg@example.com', name: 'Reg', ticketType: 'member', amountPaid: 0 } + +const seriesPassOptions = { + to: 'pass@example.com', + name: 'Pass Holder', + series: { title: 'Workshop Series' }, + ticket: { type: 'member', price: 0, currency: 'CAD', isFree: true }, + events: [eventData], + paymentId: null, +} + +const member = { email: 'welcome@example.com', name: 'New Member', circle: 'Community' } + +describe('resend email wrappers — ALLOW_DEV_TEST_ENDPOINTS gate', () => { + beforeEach(() => { + sendSpy.mockReset() + sendSpy.mockResolvedValue({ data: { id: 'email_1' }, error: null }) + }) + afterEach(() => { + delete process.env.ALLOW_DEV_TEST_ENDPOINTS + }) + + describe('when ALLOW_DEV_TEST_ENDPOINTS=true', () => { + beforeEach(() => { + process.env.ALLOW_DEV_TEST_ENDPOINTS = 'true' + }) + + const cases = [ + ['registration', () => sendEventRegistrationEmail(registration, eventData)], + ['cancellation', () => sendEventCancellationEmail(registration, eventData)], + ['waitlist', () => sendWaitlistNotificationEmail(registration, eventData)], + ['series pass', () => sendSeriesPassConfirmation(seriesPassOptions)], + ['welcome', () => sendWelcomeEmail(member)], + ] + + it.each(cases)('skips the live send for %s', async (_label, call) => { + const result = await call() + expect(result).toEqual({ success: true, skipped: true }) + expect(sendSpy).not.toHaveBeenCalled() + }) + }) + + describe('when the gate is off', () => { + it('dispatches a live send and returns success', async () => { + const result = await sendWelcomeEmail(member) + expect(sendSpy).toHaveBeenCalledTimes(1) + expect(result).toEqual({ success: true, data: { id: 'email_1' } }) + }) + + it('includes the monthly-onboarding Slack-timing line in the welcome email', async () => { + await sendWelcomeEmail(member) + const sent = sendSpy.mock.calls[0][0] + expect(sent.text).toContain('monthly onboarding waves') + }) + }) +})