Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions app/api/service/emails/render/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server'
import { render } from '@react-email/render'
import React from 'react'
import * as BaseEmailComponents from 'emails'
import { ValidationError, ValidatedEmail } from 'emails/_util/validation'
import { checkEmailServiceAuth } from 'util/server-only/email-service'

const EmailComponents = Object.values(BaseEmailComponents).reduce(
(acc, Component) => ({
[Component.definition.version || Component.definition.name]: ValidatedEmail(
Component,
Component.definition.schema
),
...acc,
}),
{}
)


export async function POST(req) {
const authError = checkEmailServiceAuth(req)
if (authError) return authError

try {
const body = await req.json()
const { template_id, data = {} } = body

const EmailComponent = EmailComponents[template_id]
if (!EmailComponent) {
return NextResponse.json(
{ error: `Email template "${template_id}" not found` },
{ status: 404 }
)
}

const html = await render(React.createElement(EmailComponent, data))
return NextResponse.json({ html })
} catch (error) {
console.error('Email render error:', error)
return NextResponse.json(
error instanceof ValidationError
? {
error: error.message,
data: error.data,
}
: {
error: error instanceof Error ? error.message : 'Invalid request',
},
{ status: 400 }
)
}
}
13 changes: 13 additions & 0 deletions app/api/service/emails/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server'
import * as EmailComponents from 'emails'
import { checkEmailServiceAuth } from 'util/server-only/email-service'

export async function GET(request) {
const authError = checkEmailServiceAuth(request)
if (authError) return authError
const emails = Object.values(EmailComponents).map((Component) => {
return Component.definition
})

return NextResponse.json({ results: emails, count: emails.length })
}
51 changes: 51 additions & 0 deletions email-type-list.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
openapi: 3.1.0
info:
title: Email Specifications
description: Defines the structure and data requirements for emails sent by the backend
version: 1.0.0

# Email template provider configuration
x-email-template-providers:
endpoint: "https://example.com/api/service/emails/render"

# Use vendor extensions for email-specific metadata
x-email-templates:
forgot-password:
name: "Forgot Password"
description: "Email that is sent when a user submits the forgot password form."
trigger: "user_forgot_password"
subject: "Reset your password"
template-id: "forgot-password-v1"

components:
schemas:
# Common schemas that can be reused
Language:
type: string
enum: [en, es, fr]
default: en

Theme:
type: string
enum: [default, dark, minimal]
default: default

# Email-specific schemas (must end with "Email")
ForgotPasswordEmail:
type: object
description: Data required for forgot password email template
properties:
resetLink:
type: string
format: uri
default: "https://example.com/reset"
example: "https://example.com/reset?token=abc123"
languages:
type: array
items:
$ref: '#/components/schemas/Language'
default: ["en", "es", "fr"]
theme:
$ref: '#/components/schemas/Theme'
required:
- resetLink
94 changes: 94 additions & 0 deletions emails/_util/validation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React from 'react'
import Ajv from 'ajv'
import addFormats from 'ajv-formats'
import fs from 'fs'
import path from 'path'
import yaml from 'yaml'

export class ValidationError extends Error {
constructor(message, data) {
super(message)
this.name = 'ValidationError'
this.data = data
}
}

// Cache for loaded OpenAPI spec
let openApiSpec = null

// Load OpenAPI schema
const getOpenApiSpec = () => {
if (!openApiSpec) {
const specPath = path.join(process.cwd(), 'email-type-list.yaml')
const specContent = fs.readFileSync(specPath, 'utf8')
openApiSpec = yaml.parse(specContent)
}
return openApiSpec
}

// Get schema for a specific email type from OpenAPI spec
const getEmailSchema = (emailType) => {
const spec = getOpenApiSpec()
const schemaName = `${emailType.charAt(0).toUpperCase()}${emailType.slice(1)}Email`

if (!spec.components?.schemas?.[schemaName]) {
throw new Error(`Email schema not found: ${schemaName}`)
}

return {
...spec.components.schemas[schemaName],
// Include referenced schemas in definitions for Ajv
definitions: spec.components.schemas
}
}

export const ValidatedEmail = (Component, schema) => (props) => {
// If schema is provided inline, use the current validation
// Otherwise, try to get schema from OpenAPI spec
let validationSchema = schema

if (!schema && Component.definition?.version) {
// Extract email type from template version (e.g., "forgot-password-v1" -> "forgotPassword")
const emailType = Component.definition.version
.split('-')
.slice(0, -1) // Remove version suffix
.map((part, index) => index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))
.join('')

try {
validationSchema = getEmailSchema(emailType)
} catch (error) {
console.warn(`Could not load OpenAPI schema for ${emailType}:`, error.message)
// Fall back to inline schema if available
validationSchema = Component.schema
}
}

if (validationSchema) {
// Create Ajv instance
const ajv = new Ajv({ allErrors: true })
addFormats(ajv)

// Compile the schema
const validate = ajv.compile(validationSchema)

// Validate props
const valid = validate(props)

if (!valid) {
const errors = validate.errors.map(error => ({
field: error.instancePath?.replace('/', '') || error.params?.missingProperty,
message: error.message,
value: error.data,
schemaPath: error.schemaPath
}))

throw new ValidationError(
`Validation failed: ${errors.map(e => `${e.field}: ${e.message}`).join(', ')}`,
{ errors }
)
}
}

return <Component {...props} />
}
55 changes: 35 additions & 20 deletions emails/forgot-password.jsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,50 @@
import { Button, Html, Text, Container, Section, Head, Heading, Body } from '@react-email/components'
import { variables } from 'styles/variables'
import { useTranslation } from 'react-i18next'
import './_util/i18n'
import {
Button,
Html,
Text,
Container,
Section,
Head,
Heading,
Body,
} from '@react-email/components'
import * as React from 'react'

export const ForgotPassword = ({ theme = 'dark', language = 'fr', resetLink = 'http://example.com/reset' }) => {
const { t, i18n } = useTranslation('email')

i18n.changeLanguage(language)
const definition = {
name: 'Forgot Password',
description:
'Sends a password reset link when a user submits forgot password form.',
version: 'forgot-password-v1',
}


export const ForgotPassword = ({
resetLink = 'https://example.com/reset',
languages = ['en', 'es', 'fr'],
theme = 'default',
}) => {
const subject = 'Reset your password'
return (
<Html>
<Head />
<Body style={{ fontFamily: 'Arial, sans-serif', backgroundColor: variables[theme]['body-color'], color: variables[theme]['text-color'] }}>
<Body
style={{ fontFamily: 'Arial, sans-serif', backgroundColor: '#f4f4f4' }}
>
<Container style={container}>
<Heading style={heading}>{t('forgot-password.heading')}</Heading>
<Heading style={heading}>{subject}</Heading>
<Text style={text}>
{t('forgot-password.body')}
We received a request to reset your password. Click the button below
to set a new password:
</Text>
<Section style={buttonContainer}>
<Button
href={resetLink}
style={button}
>
{t('forgot-password.button')}
<Button href={resetLink} style={button}>
Reset Password
</Button>
</Section>
<Text style={text}>
{t('forgot-password.disclaimer')}
</Text>
<Text style={footer}>
{t('forgot-password.footer')}
If you didn't request a password reset, please ignore this email.
</Text>
<Text style={footer}>&copy; 2025 YourApp. All rights reserved.</Text>
</Container>
</Body>
</Html>
Expand Down Expand Up @@ -76,4 +89,6 @@ const footer = {
textAlign: 'center',
}

ForgotPassword.definition = definition

export default ForgotPassword
1 change: 1 addition & 0 deletions emails/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './forgot-password'
Loading