Skip to content

Resend Email Service

Resend powers all email delivery in show.fm through two integration paths: SMTP for Supabase Auth and API for application emails.

Source: src/lib/email/

Overview

show.fm uses Resend for all email delivery through two distinct paths:

Email Architecture

Two Integration Paths

PathPurposeConfigurationEmails Sent
Supabase SMTPAuth system emailsSupabase Dashboard → Auth → SMTP SettingsPassword reset, email confirmation, magic links
Resend APIApplication emailsEnvironment variables + src/lib/email/Booking notifications, team invitations, guest verification, network invitations, automation triggers, marketing

Supabase SMTP Configuration

Supabase Auth emails use Resend as the SMTP provider. This is configured through the Supabase Dashboard (Authentication → SMTP Settings), not in code. Changes to auth email templates are made in the Supabase Dashboard under Authentication → Email Templates.

Application Email System

The application email system (src/lib/email/) consists of three main components:

  1. Email Client: Low-level Resend SDK wrapper with configuration management
  2. Email Templates: Pre-built HTML templates for booking, team, guest, and network notifications
  3. Audience Management: Contact creation and topic subscription for marketing

Configuration

Environment Variables

VariableTypeDescriptionRequired
RESEND_API_KEYSecretAPI key from Resend dashboardYes
RESEND_FROM_EMAILStringDefault sender email addressYes
RESEND_AUDIENCE_IDStringAudience/Segment ID for marketing listsFor contacts

Setup

bash
# For local development (.env)
RESEND_API_KEY=re_xxx...
RESEND_FROM_EMAIL=[email protected]
RESEND_AUDIENCE_ID=aud_xxx...

# For Workers (secrets)
wrangler secret put RESEND_API_KEY
wrangler secret put RESEND_FROM_EMAIL

Email Client

createEmailClient(apiKey)

Creates a Resend SDK instance for direct API access.

typescript
import { createEmailClient } from '$lib/email';

const resend = createEmailClient(env.RESEND_API_KEY);
const { data, error } = await resend.emails.send({
	from: 'show.fm <[email protected]>',
	to: '[email protected]',
	subject: 'Hello',
	html: '<p>Hello World</p>'
});

sendEmail(config, options)

High-level email sending function with error handling.

typescript
import { sendEmail, type EmailConfig } from '$lib/email';

const config: EmailConfig = {
	apiKey: env.RESEND_API_KEY,
	fromEmail: '[email protected]',
	fromName: 'show.fm' // Optional
};

const result = await sendEmail(config, {
	to: '[email protected]',
	subject: 'Your booking is confirmed',
	html: '<p>HTML content</p>',
	text: 'Plain text fallback', // Optional
	replyTo: '[email protected]' // Optional
});

if (result.success) {
	console.log(`Sent: ${result.messageId}`);
} else {
	console.error(`Failed: ${result.error}`);
}

Result Type:

typescript
interface EmailResult {
	success: boolean;
	messageId?: string;
	error?: string;
}

Features:

  • Handles both plain email addresses and formatted addresses ("Name <email>")
  • Non-throwing: returns error information in result object
  • Logs errors with context for debugging

Email Templates

Template Architecture

All templates share a common base structure for consistency:

┌─────────────────────────────────────┐
│           Header Section             │
│   (Icon + Gradient Background)       │
├─────────────────────────────────────┤
│           Body Content               │
│   • Info boxes (status)              │
│   • Booking details card             │
│   • Action buttons                   │
│   • Custom content sections          │
├─────────────────────────────────────┤
│           Footer Section             │
│   (Support text + branding)          │
└─────────────────────────────────────┘

Design Specifications:

  • Max width: 600px centered container
  • Table-based layout for email client compatibility
  • Inline CSS for universal support
  • Mobile-responsive design
  • System font stack for fast rendering

Available Templates

1. Booking Request Email

Sent to guests immediately after submitting a booking request.

typescript
import { sendBookingRequestEmail } from '$lib/email';

const result = await sendBookingRequestEmail(config, {
	guestName: 'John Smith',
	guestEmail: '[email protected]',
	podcastTitle: 'The Tech Show',
	bookingLinkName: 'Guest Interview',
	startTime: new Date(),
	endTime: new Date(),
	duration: 60,
	timezone: 'America/New_York',
	hostConfirmationMessage: 'Looking forward to chatting!' // Optional
});

Subject: Your booking request for [Podcast] has been received

Content:

  • Purple gradient header with clock icon
  • Booking details card
  • Pending approval notice (info box)
  • Optional host message section
  • "What happens next?" guidance

2. Host Notification Email

Sent to the podcast host when a guest submits a booking request.

typescript
import { sendHostNotificationEmail } from '$lib/email';

const result = await sendHostNotificationEmail(config, {
	hostName: 'Jane Host',
	hostEmail: '[email protected]',
	guestName: 'John Smith',
	guestEmail: '[email protected]',
	guestPhone: '+1 555-1234', // Optional
	podcastTitle: 'The Tech Show',
	bookingLinkName: 'Guest Interview',
	startTime: new Date(),
	endTime: new Date(),
	duration: 60,
	timezone: 'America/New_York',
	customFieldResponses: [
		// Optional
		{ label: 'Topic', value: 'AI in 2025' },
		{ label: 'Bio', value: 'Software engineer...' }
	],
	dashboardUrl: 'https://app.podcasterplus.com/bookings/abc123'
});

Subject: New booking request: [Guest] for [Booking Type]

Content:

  • Orange gradient header with bell icon
  • Action required notice (warning box)
  • Guest information card (green background)
  • Booking details card
  • Additional information section (custom fields)
  • CTA button linking to dashboard

3. Booking Confirmed Email

Sent to guests when the host confirms their booking.

typescript
import { sendBookingConfirmedEmail } from '$lib/email';

const result = await sendBookingConfirmedEmail(config, {
	guestName: 'John Smith',
	guestEmail: '[email protected]',
	podcastTitle: 'The Tech Show',
	bookingLinkName: 'Guest Interview',
	startTime: new Date(),
	endTime: new Date(),
	duration: 60,
	timezone: 'America/New_York',
	meetingUrl: 'https://meet.google.com/abc-xyz' // Optional
});

Subject: Confirmed: Your [Podcast] recording session

Content:

  • Green gradient header with checkmark icon
  • Booking details card
  • Recording-link section built by generateMeetingBlock (src/lib/email/templates/meeting-block.ts), shared with the rescheduled and meeting-link-updated templates so no single one can keep a stale "Join Google Meet" label. Three outcomes: a platform-labelled CTA when a link exists, an in-person note, or nothing at all. The URL is escaped on every interpolation: it is host input now, not Google-generated, and may legally contain & or '.
  • "What to expect" checklist, whose join bullet is conditional — the old copy said "using the link above" even when the link block was suppressed
  • Reschedule guidance

Sent to a session's confirmed attendees when the host changes where the session is recorded, and by the Send-meeting-invite recovery endpoint (which previously persisted a link and told nobody).

typescript
import { sendMeetingLinkUpdatedEmail } from '$lib/email';

const result = await sendMeetingLinkUpdatedEmail(config, {
	guestName: 'John Smith',
	guestEmail: '[email protected]',
	podcastTitle: 'The Tech Show',
	bookingLinkName: 'Guest Interview',
	startTime: new Date(),
	endTime: new Date(),
	duration: 60,
	timezone: 'America/New_York',
	mode: 'updated', // 'added' | 'updated' | 'removed'
	meetingUrl: 'https://riverside.fm/studio/abc',
	meetingPlatform: 'riverside'
});

One template covers all three transitions because they are the same message to a guest ("here is where to go now"), and splitting them would give three places for the wording to drift. Not sent when a host switches TO Google Meet: no room exists at that moment, so the honest message is the one that goes out with the invite afterwards.

4. Booking Declined Email

Sent to guests when the host declines their booking.

typescript
import { sendBookingDeclinedEmail } from '$lib/email';

const result = await sendBookingDeclinedEmail(config, {
	guestName: 'John Smith',
	guestEmail: '[email protected]',
	podcastTitle: 'The Tech Show',
	bookingLinkName: 'Guest Interview',
	startTime: new Date(),
	endTime: new Date(),
	duration: 60,
	timezone: 'America/New_York',
	declineReason: 'Schedule conflict this week' // Optional
});

Subject: Your [Podcast] booking request was not confirmed

Content:

  • Red gradient header with X icon
  • Booking details card
  • Reason section (if provided, red background)
  • Encouragement to rebook
  • Contact host guidance

Template Helpers

generateBaseEmail(options)

Creates the base HTML structure for all emails.

typescript
import { generateBaseEmail } from '$lib/email';

const html = generateBaseEmail({
	title: 'Email Title',
	headerGradient: 'linear-gradient(135deg, #7c3aed 0%, #a855f7 100%)',
	headerIconSvg: '<svg>...</svg>',
	headerTitle: 'Booking Received',
	bodyContent: '<p>Your content here</p>',
	footerContent: '<p>Footer content</p>'
});

generateBookingDetailsCard(options)

Creates a consistent booking details card.

typescript
import { generateBookingDetailsCard } from '$lib/email';

const cardHtml = generateBookingDetailsCard({
	podcastTitle: 'The Tech Show',
	bookingLinkName: 'Guest Interview',
	formattedDate: 'Monday, January 15, 2025',
	formattedTime: '2:00 PM - 3:00 PM',
	duration: 60,
	timezone: 'America/New_York',
	brandColor: '#7c3aed' // Optional, defaults to purple
});

generateInfoBox(message, type)

Creates color-coded notification boxes.

typescript
import { generateInfoBox } from '$lib/email';

// Blue info box
const infoHtml = generateInfoBox('Your request is being reviewed.', 'info');

// Yellow warning box
const warningHtml = generateInfoBox('Action required within 24 hours.', 'warning');

// Green success box
const successHtml = generateInfoBox('Your booking is confirmed!', 'success');

Color Schemes:

TypeBackgroundText ColorUse Case
infoBlue (#dbeafe)#1e40afInformational notices
warningYellow (#fef3c7)#92400eAction required
successGreen (#d1fae5)#065f46Confirmation messages

5. Guest Verification Email

Sent to guests to verify their identity before granting portal access.

typescript
import { sendGuestVerificationEmail } from '$lib/email';

const result = await sendGuestVerificationEmail(config, {
	guestName: 'John Smith',
	guestEmail: '[email protected]',
	podcastTitle: 'The Tech Show',
	episodeTitle: 'Episode 42: AI in Podcasting',
	verificationUrl: 'https://app.podcasterplus.com/guest/e/abc/verify?code=xyz'
});

Subject: Verify your access to [Episode Title]

Content:

  • Verification link for guest portal access
  • Episode and podcast context

Tracking Disabled

Guest verification emails have disableTracking: true to avoid modifying security-sensitive URLs.

6. Team Invitation Email

Sent when a host or admin invites someone to join the podcast team.

typescript
import { sendTeamInvitationEmail } from '$lib/email';

const result = await sendTeamInvitationEmail(config, {
	inviterName: 'Jane Host',
	inviterEmail: '[email protected]',
	recipientEmail: '[email protected]',
	podcastTitle: 'The Tech Show',
	podcastCoverUrl: 'https://media.podcasterplus.com/covers/tech-show.jpg', // Optional
	role: 'admin',
	customMessage: 'Welcome to the production team!', // Optional
	inviteUrl: 'https://app.podcasterplus.com/invite/abc123token',
	expiresAt: new Date('2025-02-22')
});

Subject: You've been invited to join [Podcast] as [Role]

Content:

  • Purple gradient header with team/users icon
  • Inviter details and podcast information
  • Role badge with description:
    • memberCo-Host: "You can work on the episodes you're added to, including editing show notes and inviting guests." (#293: no live analytics, no broad episode editing)
    • adminProducer: "You can manage everything except billing and deletion."
  • Optional personal message section
  • Podcast info card (with cover image when available)
  • "Accept Invitation" CTA button
  • Expiration warning with formatted date

Special Features:

  • Reply-To set to inviter's email address
  • Both HTML and plain text versions generated
  • Custom message section conditionally rendered

See Team Invitation Flow for the full invitation lifecycle.

7. Network Invitation Email

Sent when a host invites a guest from the Guest Network to appear on their podcast. The invitation includes a direct link to a booking page so the guest can schedule a recording session.

typescript
import { sendNetworkInvitationEmail } from '$lib/email';

const result = await sendNetworkInvitationEmail(config, {
	inviterName: 'Jane Host',
	inviterEmail: '[email protected]',
	recipientName: 'John Guest',
	recipientEmail: '[email protected]',
	podcastTitle: 'The Tech Show',
	podcastCoverUrl: 'https://media.podcasterplus.com/covers/tech-show.jpg',
	bookingLinkName: 'Guest Interview',
	bookingUrl: 'https://book.podcasterplus.com/the-tech-show/guest-interview',
	customMessage: 'Loved your recent talk!',
	expiresAt: new Date('2026-03-01')
});

Subject: [Inviter] wants you as a guest on [Podcast]

Content:

  • Purple gradient header with microphone icon
  • Greeting and invitation message with inviter and podcast names
  • Optional custom message section from the inviter
  • Podcast info card (cover image, title, booking link name)
  • "Book a Time" CTA button linking to the booking page
  • Expiration warning with formatted date
  • Decline instructions and inviter contact email

Special Features:

  • Reply-To set to inviter's email address
  • Booking URL built from PUBLIC_BOOK_URL + podcast slug + booking link slug
  • Both HTML and plain text versions generated

The booking URL is constructed by the API layer from the booking_links and podcasts tables, tying the network_invitations.booking_link_id FK to a concrete scheduling page.

See Guest Network API for the full invitation lifecycle.

8. Network Invitation Response Email

Sent to the host when a guest accepts or declines a network invitation.

typescript
import { sendNetworkInvitationResponseEmail } from '$lib/email';

const result = await sendNetworkInvitationResponseEmail(config, {
	recipientEmail: '[email protected]',
	recipientName: 'Jane Host',
	inviteeName: 'John Guest',
	podcastTitle: 'The Tech Show',
	accepted: true,
	responseMessage: 'Looking forward to it!',
	bookingUrl: 'https://book.podcasterplus.com/the-tech-show/guest-interview'
});

Subject: [Guest] accepted your invitation to [Podcast] (or "declined")

Content:

  • Green gradient header with check icon (accepted) or red gradient with X icon (declined)
  • Status message with guest name, accept/decline result, and podcast title
  • Optional response message from the guest
  • If accepted: "View Booking Page" CTA with the booking URL and note that the guest can now book
  • If declined: Encouragement to browse the Guest Network for other guests

Special Features:

  • Booking URL only included in acceptance emails (sourced from the invitation's booking_link_id)
  • Color-coded header and status text based on accept/decline
  • Both HTML and plain text versions generated

Plain Text Fallbacks

Every HTML template has a corresponding plain text generator for email clients that don't support HTML:

typescript
import {
	generateBookingRequestEmailText,
	generateHostNotificationEmailText,
	generateBookingConfirmedEmailText,
	generateBookingDeclinedEmailText,
	generateTeamInvitationEmailText,
	generateNetworkInvitationEmailText,
	generateNetworkInvitationResponseEmailText
} from '$lib/email/templates';

These are automatically used by the high-level send functions.

Audience Management

addContactToAudience(config, data)

Adds contacts to Resend audiences for marketing emails.

typescript
import { addContactToAudience, type ResendContactConfig } from '$lib/email';

const config: ResendContactConfig = {
	apiKey: env.RESEND_API_KEY,
	audienceId: env.RESEND_AUDIENCE_ID,
	topicIds: ['topic_marketing', 'topic_updates'] // Optional
};

const result = await addContactToAudience(config, {
	email: '[email protected]',
	firstName: 'John',
	lastName: 'Smith',
	subscriptionTier: 'pro' // Optional, stored as property
});

if (result.success) {
	console.log(`Contact created: ${result.contactId}`);
}

Design Principle: Non-blocking by design. Errors are logged but don't throw, ensuring signup flows complete even if Resend is temporarily unavailable.

parseFullName(fullName)

Utility to split full names for contact creation.

typescript
import { parseFullName } from '$lib/email';

const { firstName, lastName } = parseFullName('John Smith');
// { firstName: 'John', lastName: 'Smith' }

const { firstName, lastName } = parseFullName('Dr. John Van Der Berg');
// { firstName: 'John', lastName: 'Van Der Berg' }

Handles:

  • Single names (first name only)
  • Multiple last name parts
  • Honorifics and prefixes
  • Spaces, hyphens, and apostrophes

Integration with Automation

The email system integrates with the Automation Engine via the Automation Executor Worker:

Key Difference: The Automation Executor uses notification_templates from the database with magic tag replacement, while the direct email functions use pre-built templates for specific booking flow events.

See Automation Executor for details on automated email sending.

Error Handling

All email operations follow a non-throwing pattern:

typescript
const result = await sendEmail(config, options);

if (!result.success) {
	// Handle error - result.error contains message
	console.error('Email failed:', result.error);

	// Decide: retry, queue for later, or notify admin
	if (result.error?.includes('rate limit')) {
		await scheduleRetry(options);
	}
}

Common Error Scenarios:

ErrorCauseResolution
Invalid API keyMisconfigured secretVerify RESEND_API_KEY in environment
Rate limitedToo many requestsImplement queue-based sending
Invalid recipientMalformed email addressValidate before sending
Template errorMissing required dataCheck template data requirements

Testing

The email system has comprehensive test coverage in src/lib/email/__tests__/.

Mocking Resend

typescript
import { vi } from 'vitest';

vi.mock('resend', () => ({
	Resend: vi.fn().mockImplementation(() => ({
		emails: {
			send: vi.fn().mockResolvedValue({
				data: { id: 'mock-email-id' },
				error: null
			})
		},
		contacts: {
			create: vi.fn().mockResolvedValue({
				data: { id: 'mock-contact-id' },
				error: null
			})
		}
	}))
}));

Test Categories

  • Email Service Tests (index.test.ts): Core sending, error handling, address formatting
  • Contacts Tests (contacts.test.ts): Audience management, name parsing
  • Template Tests (templates/__tests__/): HTML/text generation, optional fields

Rate Limits & Quotas

PlanDaily LimitRate Limit
Free100 emails1/second
Pro50,000 emails10/second
EnterpriseUnlimitedCustom

Best Practices:

  • Use Cloudflare Queues for high-volume automation emails
  • Batch non-urgent emails during low-traffic periods
  • Monitor delivery rates in Resend dashboard

Delivery Tracking

Resend delivery tracking is fully implemented via the Notification System:

  • Webhook integration: POST /api/notifications/webhooks/resend processes all Resend webhook events (email.sent, email.delivered, email.opened, email.clicked, email.bounced, email.complained, email.failed)
  • Email tracking tokens: Open pixels, click wrappers, and unsubscribe links are embedded in notification emails
  • Status hierarchy: Delivery status never downgrades (e.g., opened won't revert to delivered)

See Email Tracking for the full tracking system and Notification API for webhook endpoint details.

Security Considerations

API Key Protection

  • Store RESEND_API_KEY as a Cloudflare secret, never in code
  • Use environment-specific keys (development vs production)
  • Rotate keys periodically via Resend dashboard

Email Content Security

  • Templates use escaped content by default
  • User-provided content is sanitized before insertion
  • No JavaScript execution in HTML templates
  • Links are validated before rendering

Sender Verification

  • Domain must be verified in Resend dashboard
  • SPF, DKIM, and DMARC records configured
  • Verified domains prevent spoofing and improve deliverability

Internal documentation - Not for public distribution