fix(email): gate resend wrappers behind ALLOW_DEV_TEST_ENDPOINTS

All five resend.js send wrappers (registration, cancellation, waitlist,
series pass, welcome) dispatched live in dev. Add a skipEmailInDev guard
mirroring the gate in pre-registrants/invite.post.js so dev runs and e2e
don't fire real Resend sends. Also add the monthly-onboarding Slack-timing
line to the welcome email. Unit-tested.
This commit is contained in:
Jennie Robinson Faber 2026-05-24 22:17:24 +01:00
parent a9312c423b
commit dac423afcd
2 changed files with 118 additions and 0 deletions

View file

@ -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 <events@babyghosts.org>",
@ -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 <events@babyghosts.org>",
@ -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 <events@babyghosts.org>",
@ -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 <events@babyghosts.org>",
@ -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 <ghostguild@babyghosts.org>",
@ -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.`,
});

View file

@ -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')
})
})
})