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:
parent
a9312c423b
commit
dac423afcd
2 changed files with 118 additions and 0 deletions
|
|
@ -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.`,
|
||||
});
|
||||
|
||||
|
|
|
|||
85
tests/server/utils/resend.test.js
Normal file
85
tests/server/utils/resend.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue