Email Customization Guide
This guide covers advanced customization options including custom transports, delivery tracking via webhooks, and testing strategies.
Custom Transports
Transport Interface
All transports implement the EmailTransport interface:
interface EmailTransport {
readonly name: string;
send(message: EmailMessage): Promise<EmailResult>;
}Creating a Custom Transport
import { type EmailTransport, type EmailMessage, type EmailResult } from 'glasswork/email';
import nodemailer from 'nodemailer';
export class SMTPTransport implements EmailTransport {
readonly name = 'smtp';
private transporter: nodemailer.Transporter;
constructor(config: SMTPConfig) {
this.transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
});
}
async send(message: EmailMessage): Promise<EmailResult> {
const result = await this.transporter.sendMail({
from: message.from,
to: Array.isArray(message.to) ? message.to.join(', ') : message.to,
subject: message.subject,
html: message.html,
text: message.text,
attachments: message.attachments?.map(att => ({
filename: att.filename,
content: att.content,
contentType: att.contentType,
})),
});
return {
messageId: result.messageId,
success: true,
metadata: { provider: 'smtp' },
};
}
}Using Multiple Transports
Implement a fallback strategy:
class FallbackTransport implements EmailTransport {
readonly name = 'fallback';
constructor(
private primary: EmailTransport,
private secondary: EmailTransport,
) {}
async send(message: EmailMessage): Promise<EmailResult> {
try {
return await this.primary.send(message);
} catch (error) {
console.warn(`Primary transport failed, using fallback:`, error);
return await this.secondary.send(message);
}
}
}
// Usage
const transport = new FallbackTransport(
new SESTransport({ region: 'eu-west-1' }),
new SMTPTransport({ host: 'smtp.backup.com', port: 587 }),
);Delivery Tracking with Webhooks
AWS SES can notify your application about delivery status via SNS webhooks.
Setting Up the Webhook Handler
import { Hono } from 'hono';
import { createSESWebhookHandler, verifySNSSignature, handleSNSSubscription, parseSESNotification } from 'glasswork/email';
const app = new Hono();
// Option 1: Use the convenience handler factory
app.post('/webhooks/ses',
createSESWebhookHandler({
// Verification is enabled by default. Only accept your own SNS topic.
allowedTopicArns: [process.env.SNS_TOPIC_ARN!],
onDelivered: async (event, c) => {
console.log(`Email ${event.messageId} delivered to ${event.recipient}`);
// Update your database
await db.email.update({
where: { messageId: event.messageId },
data: {
status: 'DELIVERED',
deliveredAt: event.timestamp,
},
});
},
onBounced: async (event, c) => {
console.log(`Email ${event.messageId} bounced: ${event.reason}`);
// Handle bounce
await db.email.update({
where: { messageId: event.messageId },
data: {
status: 'BOUNCED',
bounceType: event.bounceType,
bounceReason: event.reason,
},
});
// For permanent bounces, mark email as invalid
if (event.bounceType === 'permanent') {
await db.user.update({
where: { email: event.recipient },
data: { emailValid: false },
});
}
},
onComplaint: async (event, c) => {
console.log(`Complaint from ${event.recipient}`);
// Unsubscribe user from marketing emails
await db.emailPreference.update({
where: { email: event.recipient },
data: { unsubscribed: true },
});
},
})
);Using Individual Middleware Components
For more control, use the middleware components directly:
app.post('/webhooks/ses',
// Step 1: Verify the SNS signature
verifySNSSignature({
allowedTopicArns: [process.env.SNS_TOPIC_ARN!],
}),
// Step 2: Handle subscription confirmations
handleSNSSubscription(),
// Step 3: Parse and handle the notification
async (c) => {
const event = await parseSESNotification(c);
if (!event) {
return c.json({ received: true });
}
// Custom handling logic
switch (event.type) {
case 'delivered':
await handleDelivery(event);
break;
case 'bounced':
await handleBounce(event);
break;
case 'complaint':
await handleComplaint(event);
break;
}
return c.json({ processed: true });
}
);Event Types
interface DeliveredEvent {
type: 'delivered';
messageId: string;
timestamp: Date;
recipient: string;
processingTimeMs?: number;
smtpResponse?: string;
}
interface BouncedEvent {
type: 'bounced';
messageId: string;
timestamp: Date;
recipient: string;
bounceType: 'permanent' | 'transient';
bounceSubType: string;
reason: string;
}
interface ComplaintEvent {
type: 'complaint';
messageId: string;
timestamp: Date;
recipient: string;
complaintType: string;
userAgent?: string;
}Email Attachments
Attachment strings are interpreted as base64; pass a Buffer for binary data when possible. The SES transport rejects CR/LF in custom header values and attachment metadata, and reserves structural MIME headers such as Subject and Content-Type.
Sending Attachments
await emailService.send({
to: 'user@example.com',
subject: 'Your Invoice',
html: '<p>Please find your invoice attached.</p>',
attachments: [
{
filename: 'invoice.pdf',
content: pdfBuffer, // Buffer or base64 string
contentType: 'application/pdf',
},
],
});Inline Images
Use Content-ID for inline images:
await emailService.send({
to: 'user@example.com',
subject: 'Newsletter',
html: '<img src="cid:logo" alt="Logo" /><p>Welcome to our newsletter!</p>',
attachments: [
{
filename: 'logo.png',
content: logoBuffer,
contentType: 'image/png',
disposition: 'inline',
contentId: 'logo',
},
],
});Testing
Mock Transport
Use the built-in mock transport for testing:
import { MockTransport, EmailService } from 'glasswork/email';
describe('Email notifications', () => {
let transport: MockTransport;
let emailService: EmailService;
beforeEach(() => {
transport = new MockTransport();
emailService = new EmailService(transport, {
from: 'test@example.com',
});
});
it('should send welcome email', async () => {
await emailService.send({
to: 'user@example.com',
subject: 'Welcome!',
html: '<p>Hello</p>',
});
expect(transport.getSentEmails()).toHaveLength(1);
expect(transport.getLastEmail()).toMatchObject({
to: 'user@example.com',
subject: 'Welcome!',
});
});
it('should send to correct recipient', async () => {
await emailService.send({
to: 'admin@example.com',
subject: 'Alert',
html: '<p>Something happened</p>',
});
expect(transport.hasEmailTo('admin@example.com')).toBe(true);
expect(transport.hasEmailTo('user@example.com')).toBe(false);
});
afterEach(() => {
transport.clear(); // Reset between tests
});
});Testing Templates
import { render } from './compiled/welcome.js';
describe('Welcome template', () => {
it('should render with all variables', () => {
const result = render({
name: 'John',
dashboardUrl: 'https://app.example.com',
});
expect(result.html).toContain('John');
expect(result.html).toContain('https://app.example.com');
expect(result.text).toContain('John');
});
it('should use default values', () => {
const result = render({
dashboardUrl: 'https://app.example.com',
});
expect(result.html).toContain('there'); // Default for name
});
});Integration Testing with LocalStack
import { SESTransport } from 'glasswork/email';
const transport = new SESTransport({
region: 'us-east-1',
endpoint: 'http://localhost:4566', // LocalStack
});
// Emails will be sent to LocalStack's SES mockTracking Sent Emails
Use onSent to record an email after a successful send. For example, you can save the provider's message ID and the template name in your database. If the transport fails, the hook is not called.
The hook receives three arguments:
result: the provider's response, including the message ID.message: the email that was sent, including its recipient and subject.context: the template name and any tracking data you supplied for this email.
For template emails, context.template contains the registered name, such as welcome. For emails sent with sendRaw(), it is undefined.
const emailService = new TemplatedEmailService({
config: { transport, from: 'noreply@example.com' },
templates,
onSent: async (result, message, context) => {
// Log to database
await db.emailLog.create({
data: {
messageId: result.messageId,
to: Array.isArray(message.to) ? message.to : [message.to],
subject: message.subject,
sentAt: new Date(),
template: context.template ?? 'raw',
metadata: context.metadata,
},
});
// Send to analytics
analytics.track('email_sent', {
messageId: result.messageId,
template: context.template,
});
},
});Adding Tracking Data
Pass metadata when sending an email to link it to a record in your application:
await emailService.send('welcome', {
to: 'user@example.com',
context: { name: 'Alice', dashboardUrl: 'https://app.example.com' },
metadata: { reference: 'signup-123' },
});The hook receives this data as context.metadata. Glasswork does not send it to the email provider or add template variables and tokens to it. The context used to render the template is separate from the tracking context passed to onSent.
Each email has its own tracking context, so sending several emails at the same time keeps their template names and metadata separate.
