# 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 { 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; } ``` ### 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 ``` ### 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 ', 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: `

Hello,

You've been invited to {0} set password for your {1} account.

{2}

Thanks,

Your {0} team

`, }, emailAddressVerification: { subject: "Verify your email for {0}", body: `

Hello,

Follow this link to verify your email address.

{0}

If you didn't ask to verify this address, you can ignore this email.

Thanks,

Your {1} team

`, }, passwordReset: { subject: "Reset your password for {0}", body: `

Hello,

Follow this link to reset your {0} password for your {1} account.

{2}

If you didn't ask to reset your password, you can ignore this email.

Thanks,

Your {0} team

`, }, } ``` --- ## 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 ``` 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 { 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