Creates new email templates for Greenhouse EO. Handles React Email components, template registration, type updates, preview metadata, and delivery integration. Invoke when building new transactional or broadcast emails.
You are a senior developer creating production email templates for Greenhouse EO. You follow the project's exact email architecture — React Email components, centralized delivery layer, auto-context hydration, Resend provider.
| Layer | Technology | Details |
|---|---|---|
| Template Engine | React Email | @react-email/components v1.0.10 |
| Provider | Resend | resend v6.9.4 |
| Delivery | Centralized | src/lib/email/delivery.ts → sendEmail() |
| Context | Auto-hydrated | src/lib/email/context-resolver.ts |
| Types | TypeScript strict | src/lib/email/types.ts |
| Registration | Template registry |
src/lib/email/templates.ts |
| Locale | es/en | Via locale prop, es default |
| AI Images | Imagen 4 via Vertex AI | @google/genai v1.45.0 |
| AI Animations | Gemini → SVG | CSS keyframes, brand palette |
src/
emails/
components/
EmailLayout.tsx # Shared layout: header gradient + body card + footer
EmailButton.tsx # Styled CTA button
constants.ts # Brand tokens: EMAIL_COLORS, EMAIL_FONTS, LOGO_URL, APP_URL
[TemplateName]Email.tsx # One file per template
[TemplateName]Email.test.tsx # Optional test
lib/
email/
types.ts # EmailType union, EmailDomain, interfaces
templates.ts # registerTemplate() + registerPreviewMeta()
delivery.ts # sendEmail() — central entry point
context-resolver.ts # Auto-resolves userName, clientName, locale from PG
tokens.ts # ResolvedEmailContext types + DEFAULT_PLATFORM_CONTEXT
subscriptions.ts # getSubscribers/addSubscriber for broadcast types
unsubscribe.ts # JWT-signed unsubscribe URLs
rate-limit.ts # 10 emails/hour per recipient
ai/
image-generator.ts # generateImage() (Imagen 4) + generateAnimation() (Gemini → SVG)
google-genai.ts # GoogleGenAI Vertex AI client singleton
resend.ts # Resend client singleton
public/
images/
emails/ # Pre-generated hero/header images for email templates
banners/ # Profile banners (7 categories, Imagen 4)
generated/ # Ad-hoc generated images
animations/
generated/ # SVG animations (Gemini)
scripts/
generate-banners.mts # Batch banner generation script (reference for email images)
File: src/lib/email/types.ts
Add the new type to EmailType:
export type EmailType =
| 'password_reset'
| 'invitation'
| 'verify_email'
| 'payroll_export'
| 'payroll_receipt'
| 'notification'
| 'your_new_type' // ← add here
File: src/emails/YourNewEmail.tsx
Mandatory patterns:
import { Heading, Section, Text } from '@react-email/components'
import EmailButton from './components/EmailButton'
import EmailLayout from './components/EmailLayout'
import { EMAIL_COLORS, EMAIL_FONTS } from './constants'
// 1. Define a props interface — every prop has a default for preview
interface YourNewEmailProps {
recipientName?: string
someData: string
locale?: 'es' | 'en'
unsubscribeUrl?: string // Only if broadcast type
}
// 2. Export default function with defaults on every prop
export default function YourNewEmail({
recipientName = 'María González',
someData = 'Preview value',
locale = 'es',
unsubscribeUrl
}: YourNewEmailProps) {
// 3. Localization via inline t object (NOT i18n library)
const t = locale === 'en' ? {
heading: 'English heading',
greeting: (name?: string) => name ? `Hi ${name},` : 'Hi,',
cta: 'View in Greenhouse',
fallback: 'If the button does not work, copy and paste this address into your browser:'
} : {
heading: 'Título en español',
greeting: (name?: string) => name ? `Hola ${name?.split(' ')[0]},` : 'Hola,',
cta: 'Ver en Greenhouse',
fallback: 'Si el botón no funciona, copia y pega esta dirección en tu navegador:'
}
return (
// 4. Always wrap in EmailLayout
<EmailLayout previewText={t.heading} locale={locale} unsubscribeUrl={unsubscribeUrl}>
{/* 5. Heading — Poppins, 24px, weight 700 */}
<Heading style={{
fontFamily: EMAIL_FONTS.heading,
fontSize: '24px',
fontWeight: 700,
color: EMAIL_COLORS.text,
margin: '0 0 8px',
lineHeight: '32px'
}}>
{t.heading}
</Heading>
{/* 6. Greeting text */}
<Text style={{
fontSize: '15px',
color: EMAIL_COLORS.secondary,
lineHeight: '24px',
margin: '0 0 20px'
}}>
{t.greeting(recipientName)}
</Text>
{/* 7. Body content */}
<Text style={{
fontSize: '15px',
color: EMAIL_COLORS.secondary,
lineHeight: '24px',
margin: '0 0 28px'
}}>
{/* Your content here */}
</Text>
{/* 8. CTA button — centered section */}
<Section style={{ textAlign: 'center' as const, margin: '0 0 28px' }}>
<EmailButton href="https://greenhouse.efeoncepro.com/path">
{t.cta}
</EmailButton>
</Section>
{/* 9. Fallback URL for email clients that hide buttons */}
<Text style={{
fontSize: '12px',
color: EMAIL_COLORS.muted,
lineHeight: '18px',
margin: '0',
wordBreak: 'break-all'
}}>
{t.fallback} {'https://greenhouse.efeoncepro.com/path'}
</Text>
</EmailLayout>
)
}
Style reference — use these exact tokens:
// Colors (from src/emails/constants.ts)
EMAIL_COLORS.background // '#F2F4F7' — page background
EMAIL_COLORS.containerBg // '#FFFFFF' — card background
EMAIL_COLORS.headerBg // '#022a4e' — Midnight Navy
EMAIL_COLORS.headerAccent // '#0375db' — Core Blue gradient stop
EMAIL_COLORS.primary // '#0375db' — CTA buttons, links
EMAIL_COLORS.primaryHover // '#025bb0'
EMAIL_COLORS.text // '#1A1A2E' — headings
EMAIL_COLORS.secondary // '#344054' — body text
EMAIL_COLORS.muted // '#667085' — disclaimers, footer
EMAIL_COLORS.border // '#E4E7EC' — separators
EMAIL_COLORS.success // '#12B76A' — positive indicators
EMAIL_COLORS.footerBg // '#F9FAFB'
// Fonts
EMAIL_FONTS.heading // Poppins — for headings and CTA text
EMAIL_FONTS.body // DM Sans — for body text
// URLs
LOGO_URL // 'https://greenhouse.efeoncepro.com/branding/logo-white-email.png'
APP_URL // 'https://greenhouse.efeoncepro.com'
Data display patterns (for emails with tables/summaries):
// Summary row pattern (from PayrollReceiptEmail)
const summaryRow = (label: string, value: string, emphasis = false) => (
<table style={{ width: '100%', borderCollapse: 'collapse', borderBottom: `1px solid ${EMAIL_COLORS.border}` }}>
<tbody>
<tr>
<td style={{ padding: '10px 0', fontFamily: EMAIL_FONTS.body, fontSize: '14px', color: EMAIL_COLORS.secondary, fontWeight: 500, width: '55%' }}>
{label}
</td>
<td style={{ padding: '10px 0', fontFamily: EMAIL_FONTS.heading, fontSize: emphasis ? '18px' : '15px', color: EMAIL_COLORS.text, fontWeight: emphasis ? 700 : 600, textAlign: 'right', whiteSpace: 'nowrap' }}>
{value}
</td>
</tr>
</tbody>
</table>
)
// Summary card wrapper
<Section style={{
backgroundColor: '#F8FAFC',
border: `1px solid ${EMAIL_COLORS.border}`,
borderRadius: '12px',
padding: '18px 18px 8px',
margin: '0 0 24px',
}}>
{summaryRow('Label', 'Value')}
{summaryRow('Total', '$1,000', true)}
</Section>
File: src/lib/email/templates.ts
Add import at top:
import YourNewEmail from '@/emails/YourNewEmail'
Add registerTemplate() call (after the existing ones, before the preview meta section):
registerTemplate('your_new_type', (context: {
// List ALL props the template needs
someData: string
recipientName?: string
locale?: 'es' | 'en'
unsubscribeUrl?: string // Only for broadcast types
}) => {
const locale = context.locale || 'es'
return {
subject: locale === 'en'
? 'English subject — Greenhouse'
: 'Asunto en español — Greenhouse',
react: YourNewEmail({
someData: context.someData,
recipientName: context.recipientName,
locale,
unsubscribeUrl: context.unsubscribeUrl
}),
text: buildYourNewPlainText(context) // Plain text fallback
// attachments: [...] // Optional: for emails with PDF/CSV
}
})
Plain text builder pattern:
const buildYourNewPlainText = (context: {
someData: string
recipientName?: string
locale?: 'es' | 'en'
}) => {
const locale = context.locale || 'es'
const greeting = locale === 'en'
? (context.recipientName ? `Hi ${context.recipientName},` : 'Hi,')
: (context.recipientName ? `Hola ${context.recipientName.split(' ')[0]},` : 'Hola,')
return [
greeting,
'',
'... plain text body ...',
'',
`→ ${process.env.NEXT_PUBLIC_APP_URL || 'https://greenhouse.efeoncepro.com'}/path`,
'',
'— Greenhouse by Efeonce Group'
].filter(Boolean).join('\n')
}
File: src/lib/email/templates.ts (at the end, in the preview meta section)
registerPreviewMeta('your_new_type', {
label: 'Label descriptivo en español',
description: 'Descripción breve del email para el admin preview',
domain: 'finance', // One of: 'identity' | 'payroll' | 'finance' | 'hr' | 'delivery' | 'system'
supportsLocale: true,
defaultProps: {
someData: 'Valor de ejemplo para preview',
recipientName: 'María González'
},
propsSchema: [
{ key: 'someData', label: 'Datos principales', type: 'text' },
{ key: 'recipientName', label: 'Nombre del destinatario', type: 'text' }
// type: 'text' | 'number' | 'select' | 'boolean'
// For select: add options: ['opt1', 'opt2']
]
})
import { sendEmail } from '@/lib/email/delivery'
// Pattern A: Direct send (with explicit recipients)
await sendEmail({
emailType: 'your_new_type',
domain: 'finance',
recipients: [{ email: '[email protected]', name: 'User Name', userId: 'user-123' }],
context: {
someData: 'actual value'
// recipientName auto-hydrated by context-resolver if not provided
// locale auto-hydrated from client_users.locale
},
sourceEntity: 'your_feature_name',
actorEmail: session.user.email
})
// Pattern B: Broadcast (auto-resolves subscribers from email_subscriptions)
await sendEmail({
emailType: 'your_new_type',
domain: 'finance',
// recipients omitted — pulls from email_subscriptions table
context: { someData: 'actual value' },
attachments: [{ filename: 'report.pdf', content: pdfBuffer, contentType: 'application/pdf' }]
})
| Domain | Use for |
|---|---|
identity | Auth flows: password reset, invitation, email verification |
payroll | Payroll exports, individual receipts |
finance | Invoicing, billing, financial reports |
hr | HR notifications, leave, org changes |
delivery | Project delivery, asset reviews, deadlines |
system | Generic notifications, platform alerts |
The delivery layer (sendEmail()) auto-resolves these fields for every recipient via PostgreSQL lookup. You do NOT need to provide them — they are injected into the template context automatically:
| Field | Source | Description |
|---|---|---|
userName | client_users.full_name | Full name |
recipientFirstName | Extracted from full_name | First name |
clientName | clients.client_name | Company name |
clientId | client_users.client_id | Client ID |
locale | client_users.locale | 'es' or 'en' |
tenantType | client_users.tenant_type | 'client' or 'efeonce_internal' |
platformUrl | env or default | https://greenhouse.efeoncepro.com |
supportEmail | hardcoded | [email protected] |
Caller-provided values take precedence over auto-resolved values.
For broadcast emails (e.g., payroll_export, notifications that go to all subscribers):
BROADCAST_EMAIL_TYPES in src/lib/email/delivery.ts (line ~404)unsubscribeUrl?: string in your template propsunsubscribeUrl to <EmailLayout>src/lib/email/subscriptions.ts:
addSubscriber(emailType, email, name?, userId?)removeSubscriber(emailType, email)getSubscribers(emailType) — called automatically by sendEmail() when no recipients providedTemplates can return attachments in the EmailTemplateRenderResult:
return {
subject: '...',
react: <Component />,
text: '...',
attachments: [{
filename: 'report.pdf',
content: pdfBuffer, // Buffer
contentType: 'application/pdf'
}]
}
Callers can also pass attachments via sendEmail({ attachments }). Both are merged.
Emails can include AI-generated hero images to make them visually richer. The project uses Imagen 4 (raster images) via Vertex AI.
Before generating any image, ALWAYS invoke the /greenhouse-ux skill first to get a design brief for the hero image. The UX advisor decides:
Process:
/greenhouse-ux — describe the email's purpose and ask for the hero image brief (objects, colors, composition)Do NOT skip the UX step and write prompts directly — the UX advisor ensures visual coherence with the email design system (white bg, brand navy/blue/teal/green, clay 3D style, semantically relevant objects).
CRITICAL: Email images MUST be stored in the GCS public media bucket, NOT in public/ served by Vercel. Reasons learned from production:
NEXT_PUBLIC_APP_URL is not set in Vercel — URL resolution breaks across environmentsBuckets per environment:
| Environment | Bucket | URL pattern |
|---|---|---|
| Staging | efeonce-group-greenhouse-public-media-staging | https://storage.googleapis.com/efeonce-group-greenhouse-public-media-staging/emails/... |
| Production | efeonce-group-greenhouse-public-media-prod | https://storage.googleapis.com/efeonce-group-greenhouse-public-media-prod/emails/... |
Env var: GREENHOUSE_PUBLIC_MEDIA_BUCKET — set per environment in Vercel.
sips --resampleWidth 560 (macOS) — target under 200KBgcloud storage cp image.png gs://efeonce-group-greenhouse-public-media-staging/emails/image.png
gcloud storage cp image.png gs://efeonce-group-greenhouse-public-media-prod/emails/image.png
public/images/emails/ for local dev and as a git recordconst MEDIA_BUCKET = process.env.GREENHOUSE_PUBLIC_MEDIA_BUCKET || 'efeonce-group-greenhouse-public-media-prod'
const HERO_IMAGE_URL = `https://storage.googleapis.com/${MEDIA_BUCKET}/emails/your-image.png`
Core module: src/lib/ai/image-generator.ts
import { generateImage } from '@/lib/ai/image-generator'
const result = await generateImage(
'Clay 3D render on a pure white background. [describe objects]...',
{ aspectRatio: '16:9', format: 'png', filename: 'email-hero-name.png' }
)
Batch script: scripts/generate-email-images.mts — run with npx tsx scripts/generate-email-images.mts
Mandatory style: Clay 3D on white background. This is the canonical style for Greenhouse email heroes.
Prompt structure:
Clay 3D render on a pure white background. [Describe the main object in midnight navy (#022a4e)].
[Describe secondary objects using core blue (#0375db) and teal accents].
[Describe small accent in success green (#12B76A) if approval-related].
All objects have rounded edges, matte clay textures, and cast soft diffused shadows
directly below onto the white surface. Minimal composition, centered, professional.
Soft ambient studio lighting from above. Cool neutral shadows, no warm tones.
No text, no people, no logos. 16:9 aspect ratio.
Rules:
pure white background — the image sits inside a white email card, must blend seamlesslymatte clay textures, rounded edges, soft diffused shadowsNo text, no people, no logosCool neutral shadows, no warm tones — prevents Imagen from adding warm tinted backgroundsDomain object mapping (clay 3D):
| Domain | Primary object | Secondary objects | Accent |
|---|---|---|---|
| identity | Navy key or shield | Blue lock, teal envelope | Green checkmark |
| payroll | Navy payslip/document | Blue calculator, teal coins | Green badge |
| finance | Navy chart/ledger | Blue coins, teal arrow up | Green growth indicator |
| hr | Navy calendar | Blue checkmark, teal clock | Green ribbon |
| delivery | Navy package/box | Blue rocket, teal clipboard | Green star |
| system | Navy gear/bell | Blue wrench, teal notification | Green pulse |
Place the <Img> as the first child inside <EmailLayout>:
import { Img } from '@react-email/components'
const MEDIA_BUCKET = process.env.GREENHOUSE_PUBLIC_MEDIA_BUCKET || 'efeonce-group-greenhouse-public-media-prod'
const HERO_IMAGE_URL = `https://storage.googleapis.com/${MEDIA_BUCKET}/emails/your-image.png`
// Inside the component:
<EmailLayout previewText={t.heading} locale={locale}>
<Img
src={HERO_IMAGE_URL}
alt=""
width={560}
height={305}
style={{
width: '100%',
height: 'auto',
borderRadius: '8px',
margin: '0 0 24px',
display: 'block'
}}
/>
{/* rest of email content */}
</EmailLayout>
imagen-4.0-generate-001)sips --resampleWidth 560)gcloud storage cp)gcloud storage cp)public/images/emails/ in git (local dev + record)GREENHOUSE_PUBLIC_MEDIA_BUCKET env var for URLalt="" (decorative), width and height attributes set/admin/emails/preview)width and height attributes — many email clients need them for layoutalt="" for decorative images (screen readers skip them)<Container> max-width in EmailLayout<style> and <script>. Only use raster PNG in emailsPascalCaseEmail.tsx in src/emails/ — always suffix with Emailt object pattern, NOT i18n libraries. Spanish is default.name.split(' ')[0]), English uses full nameas const for textAlign: Always cast textAlign: 'center' as const (TypeScript requirement)EmailLayout wraps everything: Never render <Html> or <Body> directly in a templateEmailButton for CTAs: Never use raw <Button> from @react-email/componentsEmailLayout and EmailButton live in src/emails/components/EMAIL_COLORS and EMAIL_FONTS from src/emails/constants.ts"Descriptive text — Greenhouse" or "Descriptive text — Period/Context"$1.234.567 (no decimals), USD → US$1,234.56EmailType union in src/lib/email/types.tssrc/emails/[Name]Email.tsx with defaults on all propsEmailLayout, EmailButton, EMAIL_COLORS, EMAIL_FONTSt objectregisterTemplate() in src/lib/email/templates.tsregisterPreviewMeta() for admin previewBROADCAST_EMAIL_TYPES + accepts unsubscribeUrlsendEmail() from src/lib/email/delivery.ts"Text — Context" (no emojis)GREENHOUSE_PUBLIC_MEDIA_BUCKET env var for URL resolutionbash services/ops-worker/deploy.sh from repo root)pnpm build passesnpx tsc --noEmit passesIMPORTANT: Emails triggered by outbox events (leave requests, payroll, etc.) are processed by the ops-worker Cloud Run service, NOT by Vercel serverless functions. The ops-worker bundles the email templates at build time via esbuild. If you add or modify email templates, you MUST redeploy the ops-worker or the new templates won't be available in production.
When to redeploy ops-worker:
src/lib/email/templates.ts (new registrations)src/emails/*.tsx (new or modified templates)src/lib/sync/projections/notifications.ts (new email dispatch logic)src/lib/email/delivery.ts (delivery behavior)How to redeploy:
# From the repo root (NOT from services/ops-worker/)
bash services/ops-worker/deploy.sh
Verify deployment:
gcloud run revisions list --service ops-worker --region us-east4 --project efeonce-group --limit 3
Architecture: The ops-worker runs 3 Cloud Scheduler jobs every 5 minutes that process outbox events → notification projection → sendEmail(). Vercel only handles the admin preview API and the test send endpoint — it does NOT process outbox events in production.
# Start React Email preview server on port 3001
pnpm email:dev
Templates with default props render in the browser for visual testing. Admin preview is available at /api/admin/emails/preview?template=your_new_type&locale=es.
| Purpose | Path |
|---|---|
| Types | src/lib/email/types.ts |
| Registry | src/lib/email/templates.ts |
| Delivery | src/lib/email/delivery.ts |
| Context resolver | src/lib/email/context-resolver.ts |
| Tokens | src/lib/email/tokens.ts |
| Resend client | src/lib/resend.ts |
| Layout | src/emails/components/EmailLayout.tsx |
| Button | src/emails/components/EmailButton.tsx |
| Constants | src/emails/constants.ts |
| Subscriptions | src/lib/email/subscriptions.ts |
| Unsubscribe | src/lib/email/unsubscribe.ts |
| Rate limit | src/lib/email/rate-limit.ts |
| Admin preview API | src/app/api/admin/emails/preview/route.ts |
| Delivery history API | src/app/api/admin/email-deliveries/route.ts |
| Image generator | src/lib/ai/image-generator.ts |
| GenAI client | src/lib/ai/google-genai.ts |
| Generate image API | src/app/api/internal/generate-image/route.ts |
| Generate animation API | src/app/api/internal/generate-animation/route.ts |
| Banner batch script | scripts/generate-banners.mts |
| Email images dir | public/images/emails/ |
| Banner resolver (reference) | src/lib/person-360/resolve-banner.ts |