Implement email notifications for PhotoVault using Resend and React Email.
When this skill activates, you MUST follow the expert workflow before writing any code:
Spawn Domain Expert using the Task tool with this prompt:
Read the expert prompt at: C:\Users\natha\Stone-Fence-Brain\VENTURES\PhotoVault\claude\experts\resend-expert.md
Then research the codebase and write an implementation plan to: docs/claude/plans/email-[task-name]-plan.md
Task: [describe the user's request]
Spawn QA Critic after expert returns, using Task tool:
Read the QA critic prompt at: C:\Users\natha\Stone-Fence-Brain\VENTURES\PhotoVault\claude\experts\qa-critic-expert.md
Review the plan at: docs/claude/plans/email-[task-name]-plan.md
Write critique to: docs/claude/plans/email-[task-name]-critique.md
Present BOTH plan and critique to user - wait for approval before implementing
DO NOT read files and start coding. DO NOT rationalize that "this is simple." Follow the workflow.
Email clients strip <style> tags, ignore CSS classes, and render tables differently. Everything must be inline.
// ❌ BAD: CSS classes don't work
<div className="button">Click me</div>
// ✅ GOOD: Inline styles
<a href={url} style={{
backgroundColor: '#f59e0b',
color: '#000000',
padding: '12px 24px',
borderRadius: '8px',
textDecoration: 'none',
display: 'inline-block',
}}>
Click me
</a>
Most emails are read on phones. Design for 320px width first.
const container = {
maxWidth: '600px',
padding: '20px',
margin: '0 auto',
}
The preview text appears in the inbox next to the subject.
import { Preview } from '@react-email/components'
<Preview>Your gallery "Smith Wedding" is ready with 247 photos</Preview>
Using CSS classes or external stylesheets
// WRONG: Won't render
<style>{`.button { background: blue; }`}</style>
<a className="button">Click</a>
// RIGHT: Inline everything
<a style={{ backgroundColor: 'blue', padding: '12px 24px' }}>Click</a>
Using flexbox or grid
// WRONG: Not supported in most email clients
<div style={{ display: 'flex' }}>
// RIGHT: Use tables for layout
import { Row, Column } from '@react-email/components'
<Row>
<Column>Left content</Column>
<Column>Right content</Column>
</Row>
Forgetting alt text on images
// WRONG: Images often blocked
<Img src={url} />
// RIGHT: Always include meaningful alt
<Img src={url} alt="Preview of your wedding photos" />
Generic subject lines
// WRONG: Low open rate
subject: 'Update from PhotoVault'
// RIGHT: Specific and actionable
subject: 'Your "Smith Wedding" gallery is ready - 247 photos inside'
Not handling send failures
// WRONG: Silent failure
await resend.emails.send({ ... })
// RIGHT: Handle errors
const { data, error } = await resend.emails.send({ ... })
if (error) {
console.error('Email failed:', error)
}
// src/lib/email/templates/gallery-ready.tsx
import {
Body, Container, Head, Heading, Html,
Img, Link, Preview, Section, Text,
} from '@react-email/components'
interface GalleryReadyEmailProps {
clientName: string
galleryName: string
photoCount: number
previewImageUrl: string
galleryUrl: string
}
export function GalleryReadyEmail({
clientName, galleryName, photoCount, previewImageUrl, galleryUrl,
}: GalleryReadyEmailProps) {
return (
<Html>
<Head />
<Preview>Your "{galleryName}" gallery is ready - {photoCount} photos inside</Preview>
<Body style={main}>
<Container style={container}>
<Img src="https://photovault.photo/logo.png" alt="PhotoVault" width={150} />
<Heading style={heading}>Hi {clientName}!</Heading>
<Text style={text}>
Your photos from <strong>{galleryName}</strong> are ready.
Your photographer has uploaded {photoCount} photos.
</Text>
{previewImageUrl && (
<Img src={previewImageUrl} alt={`Preview from ${galleryName}`} width={560} />
)}
<Section style={{ textAlign: 'center', marginTop: '30px' }}>
<Link href={galleryUrl} style={button}>View Your Photos</Link>
</Section>
</Container>
</Body>
</Html>
)
}
const main = {
backgroundColor: '#0a0a0a',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}
const container = { maxWidth: '600px', margin: '0 auto', padding: '40px 20px' }
const heading = { color: '#ffffff', fontSize: '28px', fontWeight: 'bold' }
const text = { color: '#a3a3a3', fontSize: '16px', lineHeight: '26px' }
const button = {
backgroundColor: '#f59e0b',
color: '#000000',
padding: '14px 28px',
borderRadius: '8px',
textDecoration: 'none',
fontWeight: 'bold',
display: 'inline-block',
}
// src/lib/email/email-service.ts
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
interface SendEmailParams {
to: string | string[]
subject: string
react: React.ReactElement
replyTo?: string
}
export async function sendEmail({ to, subject, react, replyTo }: SendEmailParams) {
try {
const { data, error } = await resend.emails.send({
from: 'PhotoVault <noreply@photovault.photo>',
to: Array.isArray(to) ? to : [to],
subject,
react,
replyTo: replyTo || 'support@photovault.photo',
})
if (error) {
console.error('[Email] Send failed:', error)
return { success: false, error }
}
console.log('[Email] Sent successfully:', data?.id)
return { success: true, id: data?.id }
} catch (error) {
console.error('[Email] Unexpected error:', error)
return { success: false, error }
}
}
| Template | Trigger | Recipient |
|---|---|---|
gallery-ready |
Photographer marks ready | Client |
payment-success |
Checkout completed | Client |
payment-failed |
Invoice failed | Client |
invitation |
Photographer invites client | Client |
commission-earned |
Client pays | Photographer |
| Element | Value |
|---|---|
| Primary color | #f59e0b (amber) |
| Background | #0a0a0a (near black) |
| Text | #a3a3a3 (gray) |
| Headings | #ffffff (white) |
RESEND_API_KEY=re_...
FROM_EMAIL=PhotoVault <noreply@photovault.photo>
# Preview locally
npx react-email dev