33 KiB
Backend Email Module
Overview
The Email module provides transactional email functionality for user authentication flows including email verification, password reset, and user invitations. It uses Nodemailer with AWS SES (Simple Email Service) as the SMTP transport.
Location: backend/src/services/email/
Total Files: 7
Architecture Diagram
┌─────────────────────────────────────────────────────────────────────┐
│ Auth Service │
│ (services/auth.ts) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ signup() │ │ signin() │ │ sendPasswordReset() │ │
│ └────────┬────────┘ └─────────────────┘ └──────────┬──────────┘ │
│ │ │ │
│ │ sends verification │ sends reset │
│ ▼ ▼ │
└───────────┼───────────────────────────────────────────┼─────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Email Module │
│ (services/email/) │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ EmailSender │ │
│ │ (index.js) │ │
│ │ • Nodemailer transport │ │
│ │ • AWS SES configuration │ │
│ │ • send() method │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │PasswordReset │ │ AddressVerif. │ │ Invitation │ │
│ │ Email │ │ Email │ │ Email │ │
│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ passwordReset │ │ addressVerif. │ │ invitation │ │
│ │ Email.html │ │ Email.html │ │ Template.html │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ AWS SES (SMTP) │
│ email-smtp.us-east-1.amazonaws.com:587 │
└─────────────────────────────────────────────────────────────────────┘
Directory Structure
services/email/
├── index.js # EmailSender class (45 LOC)
├── list/ # Email template classes
│ ├── passwordReset.ts # Password reset email
│ ├── addressVerification.ts # Email verification email
│ └── invitation.ts # User invitation email
└── htmlTemplates/ # HTML email templates
├── passwordReset/
│ └── passwordResetEmail.html (52 LOC)
├── addressVerification/
│ └── emailAddressVerification.html (52 LOC)
└── invitation/
└── invitationTemplate.html (55 LOC)
Core Components
EmailSender Class (index.ts)
The main email sending service using Nodemailer.
import nodemailer from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import config from '../../config.ts';
import type { EmailSendResult, EmailTemplate } from '../../types/index.js';
export default class EmailSender {
constructor(private readonly email: EmailTemplate) {}
async send(): Promise<EmailSendResult> {
const htmlContent = await this.email.html();
const transporter = nodemailer.createTransport(this.transportConfig);
const mailOptions: SMTPTransport.MailOptions = {
from: this.from,
to: this.email.to,
subject: this.email.subject,
html: htmlContent,
headers: {
'X-SES-CONFIGURATION-SET': 'flatlogic-app',
},
};
return transporter.sendMail(mailOptions);
}
static get isConfigured(): boolean {
return Boolean(config.email.auth.pass && config.email.auth.user);
}
get transportConfig(): SMTPTransport.Options {
return config.email;
}
get from(): string {
return config.email.from;
}
}
Key Methods:
| Method | Type | Description |
|---|---|---|
constructor(email) |
Instance | Accepts email template object |
send() |
Async | Sends email via Nodemailer |
isConfigured |
Static getter | Checks if SMTP credentials exist |
transportConfig |
Getter | Returns SMTP config |
from |
Getter | Returns sender address |
Email Templates
Template Interface
All email templates implement the same interface:
interface EmailTemplate {
to: string;
subject: string;
html(): Promise<string> | string;
}
1. Password Reset Email (list/passwordReset.ts)
Sent when user requests password reset.
export default class PasswordResetEmail implements EmailTemplate {
constructor({ to, link }: LinkEmailTemplateOptions) {
this.to = to;
this.link = link;
}
get subject() {
return getNotification(
'emails.passwordReset.subject',
getNotification('app.title')
);
// → "Reset your password for Tour Builder Platform"
}
async html() {
const template = await fs.readFile(templatePath, 'utf8');
return template
.replace(/{appTitle}/g, appTitle)
.replace(/{resetUrl}/g, this.link)
.replace(/{accountName}/g, this.to);
}
};
Template Variables:
{appTitle}- Application name{resetUrl}- Password reset link{accountName}- User email address
2. Email Verification (list/addressVerification.ts)
Sent after user registration.
export default class EmailAddressVerificationEmail implements EmailTemplate {
constructor({ to, link }: LinkEmailTemplateOptions) {
this.to = to;
this.link = link;
}
get subject() {
return getNotification(
'emails.emailAddressVerification.subject',
getNotification('app.title')
);
// → "Verify your email for Tour Builder Platform"
}
async html() {
const template = await fs.readFile(templatePath, 'utf8');
return template
.replace(/{appTitle}/g, appTitle)
.replace(/{signupUrl}/g, this.link)
.replace(/{to}/g, this.to);
}
};
Template Variables:
{appTitle}- Application name{signupUrl}- Email verification link{to}- User email address
3. User Invitation (list/invitation.ts)
Sent when admin invites new user.
export default class InvitationEmail implements EmailTemplate {
constructor({ to, host }: InvitationEmailTemplateOptions) {
this.to = to;
this.host = host;
}
get subject() {
return getNotification(
'emails.invitation.subject',
getNotification('app.title')
);
// → "You've been invited to Tour Builder Platform"
}
async html() {
const template = await fs.readFile(templatePath, 'utf8');
const signupUrl = `${this.host}&invitation=true`;
return template
.replace(/{appTitle}/g, appTitle)
.replace(/{signupUrl}/g, signupUrl)
.replace(/{to}/g, this.to);
}
};
Template Variables:
{appTitle}- Application name{signupUrl}- Account setup link with&invitation=true{to}- User email address
HTML Email Templates
Template Structure
All HTML templates follow consistent styling:
<!DOCTYPE html>
<html>
<head>
<style>
.email-container {
max-width: 600px;
margin: auto;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.email-header {
background-color: #3498db; /* Primary blue */
color: #fff;
padding: 16px;
text-align: center;
}
.email-body {
padding: 16px;
}
.email-footer {
padding: 16px;
background-color: #f7fafc;
text-align: center;
color: #4a5568;
font-size: 14px;
}
.link-primary {
color: #3498db;
text-decoration: none;
}
.btn-primary {
background-color: #3498db;
color: #fff !important;
padding: 8px 16px;
border-radius: 4px;
text-decoration: none;
display: inline-block;
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">...</div>
<div class="email-body">...</div>
<div class="email-footer">
Thanks,<br/>
The {appTitle} Team
</div>
</div>
</body>
</html>
Template Comparison
| Template | Header Text | Call-to-Action | Button Style |
|---|---|---|---|
| Password Reset | "Reset your password for {appTitle}" | Link | Text link |
| Email Verification | "Verify your email for {appTitle}!" | Link | Text link |
| Invitation | "Welcome to {appTitle}!" | Button | Primary button |
Configuration
SMTP Settings (config.ts)
email: {
from: 'Tour Builder Platform <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {
user: process.env.EMAIL_USER || '',
pass: process.env.EMAIL_PASS,
},
tls: {
rejectUnauthorized: process.env.EMAIL_TLS_REJECT_UNAUTHORIZED !== 'false',
},
}
Environment Variables
| Variable | Required | Description |
|---|---|---|
EMAIL_USER |
Yes | SMTP username (AWS SES IAM user) |
EMAIL_PASS |
Yes | SMTP password (AWS SES IAM credentials) |
EMAIL_TLS_REJECT_UNAUTHORIZED |
No | Set to 'false' to skip TLS verification |
AWS SES Configuration
The system uses AWS SES in the us-east-1 region:
Host: email-smtp.us-east-1.amazonaws.com
Port: 587 (STARTTLS)
Authentication: SMTP credentials from IAM
Configuration Set: flatlogic-app
Integration Points
Auth Service Integration
The Auth service (services/auth.ts) is the primary consumer:
import EmailAddressVerificationEmail from './email/list/addressVerification.ts';
import InvitationEmail from './email/list/invitation.ts';
const PasswordResetEmail = require('./email/list/passwordReset.ts').default;
const EmailSender = require('./email/index.ts').default;
class Auth {
// Called during signup
static async sendEmailAddressVerificationEmail(email, host) {
const token = await UsersDBApi.generateEmailVerificationToken(email);
const link = `${host}/verify-email?token=${token}`;
const emailObj = new EmailAddressVerificationEmail({ to: email, link });
return new EmailSender(emailObj).send();
}
// Called for password reset or invitation
static async sendPasswordResetEmail(email, type = 'register', host) {
const token = await UsersDBApi.generatePasswordResetToken(email);
const link = `${host}/password-reset?token=${token}`;
const emailObj = type === 'invitation'
? new InvitationEmail({ to: email, host: link })
: new PasswordResetEmail({ to: email, link });
return new EmailSender(emailObj).send();
}
}
Users Service Integration
User invitations are sent when creating users:
// services/users.ts
static async create({ data, currentUser, sendInvitationEmails = true, host }) {
// ... create user ...
if (sendInvitationEmails) {
AuthService.sendPasswordResetEmail(email, 'invitation', host);
}
}
static async bulkImport(req, res, sendInvitationEmails = true, host) {
// ... import users from CSV ...
if (!sendInvitationEmails) {
emailsToInvite.forEach((email) => {
AuthService.sendPasswordResetEmail(email, 'invitation', host);
});
}
}
Auth Routes Integration
Auth routes expose email functionality:
// routes/auth.js
// Check if email is configured (public)
router.get('/email-configured', (req, res) => {
const payload = EmailSender.isConfigured;
res.status(200).send(payload);
});
// Resend verification email (authenticated)
router.put('/send-email-address-verification-email', jwtAuth, async (req, res) => {
await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email);
res.status(200).send(true);
});
// Request password reset (public)
router.put('/send-password-reset-email', async (req, res) => {
const host = getRequestHost(req);
await AuthService.sendPasswordResetEmail(req.body.email, 'register', host);
res.status(200).send(true);
});
Token Management
Token Generation
Tokens are generated in db/api/users.js:
static async _generateToken(keyNames, email, options) {
const users = await db.users.findOne({
where: { email: email.toLowerCase() },
transaction,
});
const token = crypto.randomBytes(20).toString('hex');
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const tokenExpiresAt = Date.now() + TOKEN_EXPIRY_MS;
if (users) {
await users.update({
[keyNames[0]]: token, // emailVerificationToken or passwordResetToken
[keyNames[1]]: tokenExpiresAt, // ...ExpiresAt
updatedById: currentUser.id,
}, { transaction });
}
return token;
}
static async generateEmailVerificationToken(email, options) {
return this._generateToken(
['emailVerificationToken', 'emailVerificationTokenExpiresAt'],
email, options
);
}
static async generatePasswordResetToken(email, options) {
return this._generateToken(
['passwordResetToken', 'passwordResetTokenExpiresAt'],
email, options
);
}
Token Validation
static async findByPasswordResetToken(token, options) {
return db.users.findOne({
where: {
passwordResetToken: token,
passwordResetTokenExpiresAt: {
[db.Sequelize.Op.gt]: Date.now(), // Not expired
},
},
transaction,
});
}
static async findByEmailVerificationToken(token, options) {
return db.users.findOne({
where: {
emailVerificationToken: token,
emailVerificationTokenExpiresAt: {
[db.Sequelize.Op.gt]: Date.now(),
},
},
transaction,
});
}
static async markEmailVerified(id, options) {
const user = await db.users.findByPk(id, { transaction });
await user.update({
emailVerified: true,
emailVerificationToken: null,
emailVerificationTokenExpiresAt: null,
}, { transaction });
}
Token Properties
| Token Type | Field | Expiry Field | TTL |
|---|---|---|---|
| Email Verification | emailVerificationToken |
emailVerificationTokenExpiresAt |
24 hours |
| Password Reset | passwordResetToken |
passwordResetTokenExpiresAt |
24 hours |
Email Flows
1. User Registration Flow
┌────────────┐ ┌──────────────┐ ┌─────────────┐
│ User │────▶│ POST /auth │────▶│ Auth.signup │
│ Signs Up │ │ /signup │ │ │
└────────────┘ └──────────────┘ └──────┬──────┘
│
▼
┌──────────────────────────────────────────┐
│ if (EmailSender.isConfigured) { │
│ await sendEmailAddressVerification(); │
│ } │
└───────────────────┬──────────────────────┘
│
┌──────────────────┼──────────────────┐
▼ ▼
┌────────────────┐ ┌────────────────┐
│ Generate Token │ │ Skip │
│ (24hr expiry) │ │ (not configured)│
└───────┬────────┘ └────────────────┘
│
▼
┌────────────────┐
│ Send Email │
│ (verification) │
└───────┬────────┘
│
▼
┌────────────────┐
│ User clicks │
│ /verify-email │
│ ?token=xxx │
└───────┬────────┘
│
▼
┌────────────────┐
│ Auth.verifyEmail│
│ markEmailVerified│
└────────────────┘
2. Password Reset Flow
┌────────────┐ ┌────────────────────┐ ┌───────────────────────┐
│ User │────▶│ PUT /auth/send- │────▶│ Auth.sendPassword │
│ Forgot Pwd │ │ password-reset-email│ │ ResetEmail() │
└────────────┘ └────────────────────┘ └───────────┬───────────┘
│
▼
┌───────────────────────┐
│ generatePasswordReset │
│ Token (24hr) │
└───────────┬───────────┘
│
▼
┌───────────────────────┐
│ Send Email │
│ (PasswordResetEmail) │
└───────────┬───────────┘
│
▼
┌───────────────────────┐
│ User clicks │
│ /password-reset │
│ ?token=xxx │
└───────────┬───────────┘
│
▼
┌───────────────────────┐
│ PUT /auth/password- │
│ reset │
│ { token, password } │
└───────────────────────┘
3. User Invitation Flow
┌────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Admin │────▶│ POST /users │────▶│ UsersService │
│ Creates │ │ { email } │ │ .create() │
│ User │ └──────────────┘ └────────┬────────┘
└────────────┘ │
▼
┌─────────────────────────┐
│ AuthService.sendPassword│
│ ResetEmail(email, │
│ 'invitation', host) │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ InvitationEmail │
│ (Welcome to {appTitle}!)│
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ User clicks link │
│ /password-reset?token= │
│ xxx&invitation=true │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Sets password │
│ (account activated) │
└─────────────────────────┘
Email Behavior Modes
Email Configured Mode
When EMAIL_USER and EMAIL_PASS are set:
- Email verification required before login
- Password reset emails sent on request
- User invitations include email
Email Not Configured Mode
When credentials are missing:
EmailSender.isConfiguredreturnsfalse- Users auto-verified on signin:
user.emailVerified = true - Password reset/invitation silently skipped
- Frontend adapts UI accordingly
// Auth service adapts behavior
static async signin(email, password) {
// ...
if (!EmailSender.isConfigured) {
user.emailVerified = true; // Auto-verify when email disabled
}
if (!user.emailVerified) {
throw new ValidationError('auth.userNotVerified');
}
// ...
}
Notification Catalog
Email subjects use the notification system (services/notifications/list.ts):
emails: {
invitation: {
subject: "You've been invited to {0}",
body: `
<p>Hello,</p>
<p>You've been invited to {0} set password for your {1} account.</p>
<p><a href='{2}'>{2}</a></p>
<p>Thanks,</p>
<p>Your {0} team</p>
`,
},
emailAddressVerification: {
subject: "Verify your email for {0}",
body: `
<p>Hello,</p>
<p>Follow this link to verify your email address.</p>
<p><a href='{0}'>{0}</a></p>
<p>If you didn't ask to verify this address, you can ignore this email.</p>
<p>Thanks,</p>
<p>Your {1} team</p>
`,
},
passwordReset: {
subject: "Reset your password for {0}",
body: `
<p>Hello,</p>
<p>Follow this link to reset your {0} password for your {1} account.</p>
<p><a href='{2}'>{2}</a></p>
<p>If you didn't ask to reset your password, you can ignore this email.</p>
<p>Thanks,</p>
<p>Your {0} team</p>
`,
},
}
Error Handling
Email-Related Errors
| Error Code | Message | When Thrown |
|---|---|---|
auth.emailAddressVerificationEmail.error |
"Email not recognized" | Token generation fails |
auth.emailAddressVerificationEmail.invalidToken |
"Email verification link is invalid or has expired" | Invalid/expired verification token |
auth.passwordReset.error |
"Email not recognized" | Password reset token generation fails |
auth.passwordReset.invalidToken |
"Password reset link is invalid or has expired" | Invalid/expired reset token |
auth.userNotVerified |
"Sorry, your email has not been verified yet" | Login without email verification |
Error Flow
static async sendEmailAddressVerificationEmail(email, host) {
let link;
try {
const token = await UsersDBApi.generateEmailVerificationToken(email);
link = `${host}/verify-email?token=${token}`;
} catch (error) {
console.error(error);
throw new ValidationError('auth.emailAddressVerificationEmail.error');
}
// Create and send email
const emailObj = new EmailAddressVerificationEmail({ to: email, link });
return new EmailSender(emailObj).send();
}
Testing
Unit Testing Email Templates
describe('PasswordResetEmail', () => {
it('should generate correct subject', () => {
const email = new PasswordResetEmail({
to: 'user@example.com',
link: 'https://app.com/reset?token=abc',
});
expect(email.subject).toBe('Reset your password for Tour Builder Platform');
});
it('should render HTML with placeholders', async () => {
const email = new PasswordResetEmail({
to: 'user@example.com',
link: 'https://app.com/reset?token=abc',
});
const html = await email.html();
expect(html).toContain('Tour Builder Platform');
expect(html).toContain('https://app.com/reset?token=abc');
expect(html).toContain('user@example.com');
});
});
Integration Testing Email Sending
describe('EmailSender', () => {
it('should validate required fields', async () => {
const sender = new EmailSender({});
await expect(sender.send()).rejects.toThrow('email.to is required');
});
it('should send email via nodemailer', async () => {
const mockTransport = { sendMail: jest.fn().mockResolvedValue({}) };
jest.spyOn(nodemailer, 'createTransport').mockReturnValue(mockTransport);
const email = new PasswordResetEmail({
to: 'test@example.com',
link: 'https://example.com',
});
const sender = new EmailSender(email);
await sender.send();
expect(mockTransport.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'test@example.com',
subject: expect.stringContaining('Reset your password'),
})
);
});
});
Testing Without SMTP
Set credentials to empty for local development:
EMAIL_USER=
EMAIL_PASS=
Result:
EmailSender.isConfiguredreturnsfalse- Users auto-verified on login
- No emails sent
Customization
Adding New Email Template
- Create HTML template:
<!-- services/email/htmlTemplates/welcome/welcomeEmail.html -->
<!DOCTYPE html>
<html>
<head>
<style>/* Same styles as other templates */</style>
</head>
<body>
<div class="email-container">
<div class="email-header">Welcome to {appTitle}!</div>
<div class="email-body">
<p>Hello {userName},</p>
<p>Your account has been activated.</p>
</div>
<div class="email-footer">Thanks,<br/>The {appTitle} Team</div>
</div>
</body>
</html>
- Create email class:
// services/email/list/welcome.ts
import { promises as fs } from 'fs';
import path from 'path';
import { getNotification } from '../../notifications/helpers.ts';
import type { EmailTemplate } from '../../../types/index.js';
interface WelcomeEmailOptions {
to: string;
userName: string;
}
export default class WelcomeEmail implements EmailTemplate {
to: string;
private readonly userName: string;
constructor({ to, userName }: WelcomeEmailOptions) {
this.to = to;
this.userName = userName;
}
get subject(): string {
return getNotification('emails.welcome.subject', getNotification('app.title'));
}
async html(): Promise<string> {
const templatePath = path.resolve(
process.cwd(),
'src/services/email/htmlTemplates/welcome/welcomeEmail.html',
);
const template = await fs.readFile(templatePath, 'utf8');
const appTitle = getNotification('app.title');
return template
.replace(/{appTitle}/g, appTitle)
.replace(/{userName}/g, this.userName);
}
};
- Add notification entry:
// services/notifications/list.ts
emails: {
// ... existing ...
welcome: {
subject: "Welcome to {0}!",
body: "...",
},
}
- Use in service:
import WelcomeEmail from './email/list/welcome.ts';
const email = new WelcomeEmail({ to: 'user@example.com', userName: 'John' });
await new EmailSender(email).send();
Dependencies
| Package | Version | Purpose |
|---|---|---|
nodemailer |
^6.x | SMTP transport |
assert |
built-in | Input validation |
fs.promises |
built-in | Template file reading |
path |
built-in | Template path resolution |
Related Documentation
- Auth Module - Authentication service integration
- Services Module - Service layer overview
- Notifications Module - Error messages and i18n
- User Management - User invitation flow