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

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.isConfigured returns false
  • 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

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.isConfigured returns false
  • Users auto-verified on login
  • No emails sent

Customization

Adding New Email Template

  1. 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>
  1. 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);
  }
};
  1. Add notification entry:
// services/notifications/list.ts
emails: {
  // ... existing ...
  welcome: {
    subject: "Welcome to {0}!",
    body: "...",
  },
}
  1. 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