2026-07-03 16:11:24 +02:00

962 lines
33 KiB
Markdown

# 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.
```typescript
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:
```typescript
interface EmailTemplate {
to: string;
subject: string;
html(): Promise<string> | string;
}
```
### 1. Password Reset Email (`list/passwordReset.ts`)
Sent when user requests password reset.
```typescript
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.
```typescript
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.
```typescript
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:
```html
<!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`)
```javascript
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:
```typescript
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:
```javascript
// 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:
```javascript
// 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`:
```javascript
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
```javascript
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.isConfigured` returns `false`
- Users auto-verified on signin: `user.emailVerified = true`
- Password reset/invitation silently skipped
- Frontend adapts UI accordingly
```javascript
// 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`):
```javascript
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
```javascript
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
```javascript
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
```javascript
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:
```bash
EMAIL_USER=
EMAIL_PASS=
```
Result:
- `EmailSender.isConfigured` returns `false`
- Users auto-verified on login
- No emails sent
---
## Customization
### Adding New Email Template
1. **Create HTML template:**
```html
<!-- 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>
```
2. **Create email class:**
```javascript
// 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);
}
};
```
3. **Add notification entry:**
```typescript
// services/notifications/list.ts
emails: {
// ... existing ...
welcome: {
subject: "Welcome to {0}!",
body: "...",
},
}
```
4. **Use in service:**
```typescript
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](./auth.md) - Authentication service integration
- [Services Module](./services.md) - Service layer overview
- [Notifications Module](./notifications.md) - Error messages and i18n
- [User Management](../../../documentation/user-management.md) - User invitation flow