962 lines
33 KiB
Markdown
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
|