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
| Path | Purpose | Configuration | Emails Sent |
|---|---|---|---|
| Supabase SMTP | Auth system emails | Supabase Dashboard → Auth → SMTP Settings | Password reset, email confirmation, magic links |
| Resend API | Application emails | Environment 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:
- Email Client: Low-level Resend SDK wrapper with configuration management
- Email Templates: Pre-built HTML templates for booking, team, guest, and network notifications
- Audience Management: Contact creation and topic subscription for marketing
Configuration
Environment Variables
| Variable | Type | Description | Required |
|---|---|---|---|
RESEND_API_KEY | Secret | API key from Resend dashboard | Yes |
RESEND_FROM_EMAIL | String | Default sender email address | Yes |
RESEND_AUDIENCE_ID | String | Audience/Segment ID for marketing lists | For contacts |
Setup
# 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_EMAILEmail Client
createEmailClient(apiKey)
Creates a Resend SDK instance for direct API access.
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.
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:
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.
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.
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.
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
3b. Meeting Link Updated Email (#200)
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).
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.
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.
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.
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.
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:
| Type | Background | Text Color | Use Case |
|---|---|---|---|
info | Blue (#dbeafe) | #1e40af | Informational notices |
warning | Yellow (#fef3c7) | #92400e | Action required |
success | Green (#d1fae5) | #065f46 | Confirmation messages |
5. Guest Verification Email
Sent to guests to verify their identity before granting portal access.
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.
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:
member→ Co-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)admin→ Producer: "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.
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.
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:
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.
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.
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:
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:
| Error | Cause | Resolution |
|---|---|---|
| Invalid API key | Misconfigured secret | Verify RESEND_API_KEY in environment |
| Rate limited | Too many requests | Implement queue-based sending |
| Invalid recipient | Malformed email address | Validate before sending |
| Template error | Missing required data | Check template data requirements |
Testing
The email system has comprehensive test coverage in src/lib/email/__tests__/.
Mocking Resend
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
| Plan | Daily Limit | Rate Limit |
|---|---|---|
| Free | 100 emails | 1/second |
| Pro | 50,000 emails | 10/second |
| Enterprise | Unlimited | Custom |
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/resendprocesses 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.,
openedwon't revert todelivered)
See Email Tracking for the full tracking system and Notification API for webhook endpoint details.
Security Considerations
API Key Protection
- Store
RESEND_API_KEYas 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
Related Documentation
- Services Overview - All third-party integrations
- Automation Executor - Worker that sends automated emails
- Automation Architecture - Event-driven automation system
- Testing Mocks - Mock factories for testing