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);
|
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 formatEventDate = (dateString, timeZone = "America/Toronto") => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
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";
|
ticketSection = "\nThis event is free for Ghost Guild members.\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (skipEmailInDev("registration email", registration.email)) {
|
||||||
|
return { success: true, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await resend.emails.send({
|
const { data, error } = await resend.emails.send({
|
||||||
from: "Ghost Guild <events@babyghosts.org>",
|
from: "Ghost Guild <events@babyghosts.org>",
|
||||||
|
|
@ -96,6 +111,10 @@ We look forward to seeing you there!`,
|
||||||
export async function sendEventCancellationEmail(registration, eventData) {
|
export async function sendEventCancellationEmail(registration, eventData) {
|
||||||
const baseUrl = process.env.BASE_URL || "https://ghostguild.org";
|
const baseUrl = process.env.BASE_URL || "https://ghostguild.org";
|
||||||
|
|
||||||
|
if (skipEmailInDev("cancellation email", registration.email)) {
|
||||||
|
return { success: true, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await resend.emails.send({
|
const { data, error } = await resend.emails.send({
|
||||||
from: "Ghost Guild <events@babyghosts.org>",
|
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 baseUrl = process.env.BASE_URL || "https://ghostguild.org";
|
||||||
const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`;
|
const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`;
|
||||||
|
|
||||||
|
if (skipEmailInDev("waitlist notification email", waitlistEntry.email)) {
|
||||||
|
return { success: true, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await resend.emails.send({
|
const { data, error } = await resend.emails.send({
|
||||||
from: "Ghost Guild <events@babyghosts.org>",
|
from: "Ghost Guild <events@babyghosts.org>",
|
||||||
|
|
@ -188,6 +211,10 @@ export async function sendSeriesPassConfirmation(options) {
|
||||||
})
|
})
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
|
if (skipEmailInDev("series pass confirmation email", to)) {
|
||||||
|
return { success: true, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await resend.emails.send({
|
const { data, error } = await resend.emails.send({
|
||||||
from: "Ghost Guild <events@babyghosts.org>",
|
from: "Ghost Guild <events@babyghosts.org>",
|
||||||
|
|
@ -226,6 +253,10 @@ ${eventList}`,
|
||||||
export async function sendWelcomeEmail(member) {
|
export async function sendWelcomeEmail(member) {
|
||||||
const baseUrl = process.env.BASE_URL || "https://ghostguild.org";
|
const baseUrl = process.env.BASE_URL || "https://ghostguild.org";
|
||||||
|
|
||||||
|
if (skipEmailInDev("welcome email", member.email)) {
|
||||||
|
return { success: true, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await resend.emails.send({
|
const { data, error } = await resend.emails.send({
|
||||||
from: "Ghost Guild <ghostguild@babyghosts.org>",
|
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:
|
Sign in to your dashboard to get started:
|
||||||
${baseUrl}/member/dashboard
|
${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.`,
|
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