Mail Component Reference
Every binding key, configuration variant, interface, and internal mechanism of MailComponent. For the task-oriented walkthrough, see Usage & Examples.
Files:
packages/core-server/src/components/mail/component.tspackages/core-server/src/components/mail/common/types/packages/core-server/src/components/mail/common/constants.tspackages/core-server/src/components/mail/common/keys.tspackages/core-server/src/components/mail/services/mail.service.tspackages/core-server/src/components/mail/services/template.service.tspackages/core-server/src/components/mail/services/generators/packages/core-server/src/components/mail/providers/mail-transporter.provider.tspackages/core-server/src/components/mail/providers/mail-queue-executor.provider.tspackages/core-server/src/components/mail/helpers/transporters/nodemail-transporter.helper.tspackages/core-server/src/components/mail/helpers/transporters/mailgun-transporter.helper.tspackages/core-server/src/components/mail/helpers/transporters/amazon-ses-transporter.helper.tspackages/core-server/src/components/mail/helpers/executors/direct-executor.helper.tspackages/core-server/src/components/mail/helpers/executors/internal-queue-executor.helper.tspackages/core-server/src/components/mail/helpers/executors/bull-mq-executor.helper.tspackages/core-server/src/components/mail/utilities/type.utility.tspackages/core-server/src/components/mail/utilities/verification.utility.ts
Quick reference
| Item | Value |
|---|---|
| Package | @venizia/ignis |
| Subpath | @venizia/ignis/mail |
| Component class | MailComponent |
| Runtimes | Both (Bun, Node.js) |
| Component | Purpose |
|---|---|
MailComponent | Registers mail services, transporters, generators, and the queue executor |
MailService | send(), sendBatch(), sendTemplate(), verify() |
TemplateEngineService | {{variable}} substitution engine, in-memory template registry |
NodemailerTransportHelper | SMTP transport via nodemailer |
MailgunTransportHelper | Mailgun HTTP API transport via mailgun.js |
AmazonSesTransportHelper | Amazon SES HTTPS API transport via @aws-sdk/client-sesv2 |
DirectMailExecutorHelper | Runs the processor immediately, no queue |
InternalQueueMailExecutorHelper | In-memory queue (SequentialQueueHelper) |
BullMQMailExecutorHelper | Redis-backed queue, distributed workers |
MailTransportProvider | Factory: TMailOptions -> IMailTransport |
MailQueueExecutorProvider | Factory: IMailQueueExecutorConfig -> IMailQueueExecutor |
NumericCodeGenerator | Cryptographically random numeric codes |
RandomTokenGenerator | Cryptographically random base64url tokens |
DefaultVerificationDataGenerator | Composes both generators into an IVerificationData |
Import paths
import {
MailComponent,
MailKeys,
MailProviders,
MailErrorCodes,
MailDefaults,
MailExecutorErrors,
MailQueueExecutorTypes,
BullMQExecutorModes,
MailService,
TemplateEngineService,
NumericCodeGenerator,
RandomTokenGenerator,
DefaultVerificationDataGenerator,
MailTransportProvider,
MailQueueExecutorProvider,
} from '@venizia/ignis/mail';
import type {
TMailOptions,
IBaseMailOptions,
INodemailerMailOptions,
IMailgunMailOptions,
IAmazonSesMailOptions,
ICustomMailOptions,
IGenericMailOptions,
IMailService,
IMailTemplateEngine,
IMailMessage,
IMailSendResult,
IMailTransport,
IMailAttachment,
IMailQueueExecutor,
IMailQueueExecutorConfig,
IMailQueueOptions,
IMailQueueResult,
IMailProcessorResult,
ITemplate,
IVerificationCodeGenerator,
IVerificationTokenGenerator,
IVerificationDataGenerator,
IVerificationData,
IVerificationGenerationOptions,
TMailProvider,
TNodemailerConfig,
TNodemailerModule,
TMailgunConfig,
TMailgunModule,
TAmazonSesConfig,
TAmazonSesModule,
} from '@venizia/ignis/mail';Architecture
┌─────────────────────────────────────────────────┐
│ Your Application │
│ │
│ preConfigure() │
│ ├── this.component(MailComponent, { options }) │
│ │ or binds MailKeys.MAIL_OPTIONS by hand │
│ └── binds MailKeys.MAIL_QUEUE_EXECUTOR_CONFIG │
│ (optional -- defaults to `direct`) │
└───────────────────────┬─────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ MailComponent.configure(opts?) │
│ binds MAIL_OPTIONS from the call site, │
│ then calls binding() -- at registerComponents │
└───────────────────────┬─────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ MailComponent.binding() │
│ │
│ ├── initGenerators() (transient bindings) │
│ │ ├── NumericCodeGenerator │
│ │ ├── RandomTokenGenerator │
│ │ └── DefaultVerificationDataGenerator │
│ │ │
│ ├── initProviders() (singleton bindings) │
│ │ ├── MailTransportProvider │
│ │ └── MailQueueExecutorProvider │
│ │ │
│ ├── initServices() (singleton bindings) │
│ │ ├── MailService │
│ │ └── TemplateEngineService │
│ │ │
│ └── createAndBindInstances() │
│ ├── Transport Instance ◄── MAIL_OPTIONS │
│ └── Queue Executor ◄── QUEUE_CONFIG │
│ (or the direct │
│ default) │
└─────────────────────────────────────────────────┘MailService and the queue executor are independent consumers of the transport/config. IMailQueueExecutor never calls MailService. See How it works on the Overview for that distinction.
Source: component.ts
Binding keys
| Key | Constant | Type | Required | Default |
|---|---|---|---|---|
@app/components/mail/options | MailKeys.MAIL_OPTIONS | TMailOptions | Yes | -- |
@app/components/mail/queue/executor-config | MailKeys.MAIL_QUEUE_EXECUTOR_CONFIG | IMailQueueExecutorConfig | No | { type: MailQueueExecutorTypes.DIRECT } |
@app/components/mail/service | MailKeys.MAIL_SERVICE | IMailService | No | MailService (singleton) |
@app/components/mail/services/template-engine | MailKeys.MAIL_TEMPLATE_ENGINE | IMailTemplateEngine | No | TemplateEngineService (singleton) |
@app/components/mail/transport-provider | MailKeys.MAIL_TRANSPORT_PROVIDER | TGetMailTransportFn | No | MailTransportProvider (singleton) |
@app/components/mail/transport-instance | MailKeys.MAIL_TRANSPORT_INSTANCE | IMailTransport | No | Created by the component |
@app/components/mail/queue-executor-provider | MailKeys.MAIL_QUEUE_EXECUTOR_PROVIDER | TGetMailQueueExecutorFn | No | MailQueueExecutorProvider (singleton) |
@app/components/mail/queue-executor-instance | MailKeys.MAIL_QUEUE_EXECUTOR_INSTANCE | IMailQueueExecutor | No | Created by the component |
@app/components/mail/verification/code-generator | MailKeys.MAIL_VERIFICATION_CODE_GENERATOR | IVerificationCodeGenerator | No | NumericCodeGenerator (transient) |
@app/components/mail/verification/token-generator | MailKeys.MAIL_VERIFICATION_TOKEN_GENERATOR | IVerificationTokenGenerator | No | RandomTokenGenerator (transient) |
@app/components/mail/verification/data-generator | MailKeys.MAIL_VERIFICATION_DATA_GENERATOR | IVerificationDataGenerator | No | DefaultVerificationDataGenerator (transient) |
IMPORTANT
MailKeys.MAIL_OPTIONS is the only value MailComponent requires. It throws Mail options not configured in binding() if the key is still unbound by then. MailKeys.MAIL_QUEUE_EXECUTOR_CONFIG is read with isOptional: true -- when it is not bound, createAndBindInstances() falls back to { type: MailQueueExecutorTypes.DIRECT } rather than failing startup.
There are two ways to supply MAIL_OPTIONS, and neither imposes an ordering rule:
| How | What happens |
|---|---|
this.component(MailComponent, { options }) | The application stores the options against the component's binding key. MailComponent.configure(opts) binds MAIL_OPTIONS itself, then runs binding() |
this.bind({ key: MailKeys.MAIL_OPTIONS }).toValue(...) | The key is already bound when binding() reads it |
configure() runs at the registerComponents boot step, four steps after preConfigure() returns. A this.bind() placed anywhere in preConfigure() therefore lands in time, before or after this.component(...). Supply both and the call-site options win - configure() rebinds the key.
Source: common/keys.ts
Configuration
Transport options (TMailOptions)
A discriminated union on provider, extending IBaseMailOptions:
interface IBaseMailOptions {
from?: string;
fromName?: string;
attachmentRoot?: string;
maxAttachmentBytes?: number;
}
interface INodemailerMailOptions extends IBaseMailOptions {
provider: 'nodemailer';
config: TNodemailerConfig; // SMTPTransport | SMTPTransport.Options | string
module?: TNodemailerModule; // the peer itself; see "Peer dependency loading"
}
interface IMailgunMailOptions extends IBaseMailOptions {
provider: 'mailgun';
config: TMailgunConfig; // AnyType & { domain: string } -- also requires username, key at runtime
module?: TMailgunModule; // the peer itself; see "Peer dependency loading"
}
interface IAmazonSesMailOptions extends IBaseMailOptions {
provider: 'amazon-ses';
config: TAmazonSesConfig; // { region, credentials?, endpoint? }
module?: TAmazonSesModule; // the peer itself; see "Peer dependency loading"
}
interface ICustomMailOptions extends IBaseMailOptions {
provider: 'custom';
config: IMailTransport; // Must implement send() and verify()
}
interface IGenericMailOptions extends IBaseMailOptions {
provider: string;
config: Record<string, AnyType>;
}
type TMailOptions =
| INodemailerMailOptions
| IAmazonSesMailOptions
| IMailgunMailOptions
| ICustomMailOptions
| IGenericMailOptions;Every provider shares the IBaseMailOptions fields:
| Option | Type | Default | Meaning |
|---|---|---|---|
from | string | - | Default sender when a message sets no from |
fromName | string | - | Display name rendered around from as "fromName" <from> |
attachmentRoot | string | - | The only directory an attachment.path is read from. Unset, every path is refused. Must be an absolute path to an existing directory |
maxAttachmentBytes | number | 26214400 (25 MB) | Decoded bytes of attachment content one message may carry, all attachments together. Must be a positive integer |
Both are checked when the component starts; a bad value fails the boot with Invalid mail options | <option> ... (INVALID_CONFIGURATION, 500).
See Attachments for how both apply.
Nodemailer (SMTP with basic auth):
{
provider: MailProviders.NODEMAILER,
from: 'noreply@example.com',
fromName: 'Example App',
config: {
host: 'smtp.gmail.com',
port: 465,
secure: true,
auth: { user: 'your-email@gmail.com', pass: 'your-app-password' },
},
}Nodemailer (OAuth2):
{
provider: MailProviders.NODEMAILER,
from: applicationEnvironment.get<string>('APP_ENV_MAIL_FROM') ?? 'noreply@example.com',
fromName: applicationEnvironment.get<string>('APP_ENV_MAIL_FROM_NAME') ?? 'Example App',
config: {
host: applicationEnvironment.get<string>('APP_ENV_MAIL_HOST') ?? 'smtp.gmail.com',
port: +(applicationEnvironment.get<number>('APP_ENV_MAIL_PORT') ?? 465),
secure: toBoolean(applicationEnvironment.get<boolean>('APP_ENV_MAIL_SECURE') ?? true),
auth: {
type: 'oauth2',
user: applicationEnvironment.get<string>('APP_ENV_MAIL_USER'),
clientId: applicationEnvironment.get<string>('APP_ENV_MAIL_CLIENT_ID'),
clientSecret: applicationEnvironment.get<string>('APP_ENV_MAIL_CLIENT_SECRET'),
refreshToken: applicationEnvironment.get<string>('APP_ENV_MAIL_REFRESH_TOKEN'),
},
},
}APP_ENV_MAIL_* is an app-level convention, not a framework-defined env var. applicationEnvironment.get() reads whatever variables your wrapper component chooses to look up. A matching .env:
APP_ENV_MAIL_HOST=smtp.gmail.com
APP_ENV_MAIL_PORT=465
APP_ENV_MAIL_SECURE=true
APP_ENV_MAIL_USER=your-email@gmail.com
APP_ENV_MAIL_CLIENT_ID=your-oauth2-client-id
APP_ENV_MAIL_CLIENT_SECRET=your-oauth2-client-secret
APP_ENV_MAIL_REFRESH_TOKEN=your-oauth2-refresh-tokenTIP
For Gmail OAuth2, follow Google's OAuth2 setup guide to obtain the client ID, secret, and refresh token.
Mailgun:
{
provider: MailProviders.MAILGUN,
from: 'noreply@example.com',
fromName: 'Example App',
config: {
username: 'api', // required -- mailgun.js client username
key: process.env.MAILGUN_API_KEY, // required -- Mailgun API key
domain: 'mg.example.com', // required
host: 'api.eu.mailgun.net', // optional -- EU region
},
}IMPORTANT
MailgunTransportHelper validates username, key, and domain on construction. It throws Invalid Mailgun configuration | Missing required keys: <keys> if any is missing. This check runs even though TMailgunConfig's only typed requirement is domain - username and key are checked at runtime, not by the type.
Amazon SES:
{
provider: MailProviders.AMAZON_SES,
from: 'noreply@example.com',
fromName: 'Example App',
config: {
region: 'ap-southeast-1', // required
credentials: { // optional -- omit to use the ambient AWS credential chain
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
endpoint: 'https://email.ap-southeast-1.amazonaws.com', // optional
},
}NOTE
This provider is the SES HTTPS API path, through the optional peer @aws-sdk/client-sesv2. SES over SMTP goes through MailProviders.NODEMAILER instead, with SES's SMTP host and credentials.
Custom transport:
{
provider: MailProviders.CUSTOM,
from: 'noreply@example.com',
config: myTransportImplementingIMailTransport, // must have send() and verify()
}Generic provider (extensibility escape hatch):
{
provider: 'sendgrid',
from: 'noreply@example.com',
config: { apiKey: process.env.SENDGRID_API_KEY },
}WARNING
IGenericMailOptions falls through to the default case in MailTransportProvider and throws Unsupported mail provider: <provider>. The framework's built-in provider does not handle it. IGenericMailOptions exists only so you can bind a custom MailTransportProvider that recognizes the provider string.
Queue executor options (IMailQueueExecutorConfig)
interface IMailQueueExecutorConfig {
type: TConstValue<typeof MailQueueExecutorTypes>; // 'direct' | 'internal-queue' | 'bullmq'
internalQueue?: {
identifier: string;
};
bullmq?: {
redis: IRedisSingleHelperOptions; // redis.module takes ioredis itself
queue: { identifier: string; name: string };
mode: TConstValue<typeof BullMQExecutorModes>; // REQUIRED -- no default
module?: typeof import('bullmq'); // bullmq itself, for a compiled binary
};
}// Direct (also the implicit default when MAIL_QUEUE_EXECUTOR_CONFIG is unbound)
{ type: 'direct' }
// Internal queue (in-memory)
{ type: 'internal-queue', internalQueue: { identifier: 'mail-internal-queue' } }
// BullMQ (Redis-backed)
{
type: 'bullmq',
bullmq: {
redis: { name: 'mail-redis', host: 'localhost', port: 6379, password: 'your-redis-password' },
queue: { identifier: 'mail-queue', name: 'mail-queue' },
mode: 'both', // 'queue-only' | 'worker-only' | 'both' -- required, no default
},
}Choosing an executor:
| Executor | Use when | Why |
|---|---|---|
direct | Development or low-volume apps | No queueing overhead |
internal-queue | Single-instance apps, moderate volume | In-memory, with retry |
bullmq | Distributed or high-volume systems | Redis-backed, configurable concurrency/priority/backoff |
Source: common/types/
Constants
| Constant | Value | Description |
|---|---|---|
MailDefaults.BATCH_CONCURRENCY | 5 | Default concurrent sends in sendBatch() |
MailDefaults.FALLBACK_FROM | 'noreply@example.com' | Used by getDefaultFrom() when options.from is unset |
MailDefaults.MAX_ATTACHMENT_BYTES | 25 * 1024 * 1024 | Default maxAttachmentBytes |
MailExecutorErrors.PROCESSOR_NOT_SET | 'Processor not set. Call setProcessor() first.' | Thrown by all three queue executors |
MailQueueExecutorTypes.DIRECT | 'direct' | Immediate execution |
MailQueueExecutorTypes.INTERNAL_QUEUE | 'internal-queue' | In-memory queue |
MailQueueExecutorTypes.BULLMQ | 'bullmq' | Redis-backed queue |
BullMQExecutorModes.QUEUE_ONLY | 'queue-only' | Producer only (enqueue) |
BullMQExecutorModes.WORKER_ONLY | 'worker-only' | Consumer only (process) |
BullMQExecutorModes.BOTH | 'both' | Full duplex (produce + consume) |
MailQueueExecutorTypes and BullMQExecutorModes both carry a SCHEME_SET/MODE_SET and a static isValid():
MailQueueExecutorTypes.isValid('bullmq'); // true
MailQueueExecutorTypes.isValid('unknown'); // false
BullMQExecutorModes.isValid('both'); // true
BullMQExecutorModes.isValid('invalid'); // falseMailErrorCodes
Built through MessageCode.build(), so every value is lower-case (ApplicationError lower-cases whatever it is handed):
| Constant | Value | Description |
|---|---|---|
MailErrorCodes.INVALID_CONFIGURATION | 'core.mail.invalid_configuration' | Invalid or missing configuration (transport, template engine, subject, body) |
MailErrorCodes.SEND_FAILED | 'core.mail.send_failed' | Single email send failed |
MailErrorCodes.VERIFICATION_FAILED | 'core.mail.verification_failed' | Transport connection verification failed |
MailErrorCodes.INVALID_RECIPIENT | 'core.mail.invalid_recipient' | Missing or empty recipient address |
MailErrorCodes.BATCH_SEND_FAILED | 'core.mail.batch_send_failed' | Batch email operation failed |
MailErrorCodes.TEMPLATE_NOT_FOUND | 'core.mail.template_not_found' | Template name not found in registry |
MailErrorCodes.ATTACHMENT_PATH_REFUSED | 'core.mail.attachment_path_refused' | An attachment path outside attachmentRoot, with no root set, or an href/raw source |
MailErrorCodes.BODY_SOURCE_REFUSED | 'core.mail.body_source_refused' | text or html is not a string |
MailErrorCodes.ATTACHMENT_TOO_LARGE | 'core.mail.attachment_too_large' | A message's attachments are over maxAttachmentBytes |
Source: common/constants.ts
IMailService interface
interface IMailService {
send(message: IMailMessage): Promise<IMailSendResult>;
sendBatch(
messages: IMailMessage[],
options?: { concurrency?: number },
): Promise<IMailSendResult[]>;
sendTemplate(opts: {
templateName: string;
data: Record<string, any>;
recipients: string | string[];
options?: Partial<IMailMessage>;
}): Promise<IMailSendResult>;
verify(): Promise<boolean>;
}send(message)
validateMessage()throws for missingto, missingsubject, or missing bothtext/html(400, see below).- Merges
message.fromwithgetDefaultFrom()if unset. - Delegates to
transport.send(). - Returns
{ success, messageId, error? }. - If the transport throws, and the error is not already an
ApplicationError, it is caught and re-thrown asMailErrorCodes.SEND_FAILED(500). - An
ApplicationErrorthrown earlier in the sametryblock - for example, fromvalidateMessage()- is re-thrown as-is, unchanged.
sendBatch(messages, options?)
Sends every message via send() with concurrency bounded by executePromiseWithLimit(), default MailDefaults.BATCH_CONCURRENCY (5). A send() that throws is caught per-message and downgraded to { success: false, error }; a failure in the batch operation itself throws MailErrorCodes.BATCH_SEND_FAILED.
sendTemplate(opts)
Renders a registered template and sends it. Subject resolution order: options.subject -> the template's own subject (rendered through the same engine) -> 'No Subject'. Throws MailErrorCodes.INVALID_CONFIGURATION ("Template engine not configured") if templateEngine was not injected. Re-throws any other error unchanged, including TEMPLATE_NOT_FOUND from the template engine.
verify()
Delegates to transport.verify(). If the transport throws, wraps it as MailErrorCodes.VERIFICATION_FAILED (500).
Protected methods (MailService)
validateMessage(message)
| Check | Error code | Status | Message |
|---|---|---|---|
to is falsy or an empty array | INVALID_RECIPIENT | 400 | Recipient email address is required |
subject is falsy | INVALID_CONFIGURATION | 400 | Email subject is required |
Both text and html are falsy | INVALID_CONFIGURATION | 400 | Email must have either text or html content |
getDefaultFrom()
- If
options.fromis unset, returnsMailDefaults.FALLBACK_FROM('noreply@example.com'). - Else if
options.fromNameis unset, returnsoptions.fromas-is. - Else returns
"${fromName}" <${from}>.
Source: services/mail.service.ts
IMailMessage interface
interface IMailMessage {
from?: string; // Uses getDefaultFrom() if not provided
to: string | string[];
cc?: string | string[];
bcc?: string | string[];
replyTo?: string;
subject: string;
text?: string;
html?: string;
attachments?: IMailAttachment[];
headers?: Record<string, string>;
requireValidate?: boolean; // Passed through to sendTemplate()'s render() call
[key: string]: any; // Open-ended -- provider-specific fields pass through
}| Field | Notes |
|---|---|
from | Falls back to getDefaultFrom(). Can be overridden per message (multi-tenant scenarios). |
to, cc, bcc | Single string or array. Nodemailer joins arrays with ', '; Mailgun passes to as-is (always coerced to an array), cc/bcc pass through unmodified. |
replyTo | Nodemailer passes it directly; Mailgun maps it to h:Reply-To. |
subject | Rendered through the template engine when set via sendTemplate(). |
text, html | At least one is required (validateMessage()). Each must be a string - an object such as { path } is refused with core.mail.body_source_refused, because Nodemailer would read it as a file or URL. |
attachments | See IMailAttachment below. |
headers | Nodemailer passes them directly; Mailgun prefixes every key with h:. |
requireValidate | true makes template rendering throw on missing placeholders instead of preserving them as literal text. Defaults to false. |
interface IMailAttachment {
filename?: string;
contentType?: string;
path?: string; // Read only under attachmentRoot
content?: string | Buffer | Uint8Array | Readable | ReadableStream<Uint8Array>;
encoding?: BufferEncoding; // How a string content decodes; default utf-8
cid?: string;
[key: string]: any;
}- Buffer:
{ filename: 'data.txt', content: Buffer.from('...') } - File under
attachmentRoot:{ filename: 'doc.pdf', path: 'invoices/doc.pdf' } - Inline image:
{ filename: 'logo.png', path: 'logo.png', cid: 'logo' }
send() resolves every attachment to content bytes before the transport sees it, and drops path, href, raw and encoding. The other fields pass through. An attachment read from path keeps the file's name as filename and gets a contentType from its extension, when neither is set. A content that is not bytes, text or a stream is refused with core.mail.invalid_configuration (400). A path without attachmentRoot, or one resolving outside it, is refused with core.mail.attachment_path_refused; attachments over maxAttachmentBytes are refused with core.mail.attachment_too_large. See Attachments.
WARNING
path names a file on your server. Never copy attachments from a request body - map an upload to content yourself.
Source: common/types/
IMailTemplateEngine interface
interface IMailTemplateEngine {
render(opts: {
templateData?: string;
templateName?: string;
data: Record<string, any>;
requireValidate?: boolean;
}): string;
registerTemplate(opts: { name: string; content: string }): void;
validateTemplateData(opts: { template: string; data: Record<string, any> }): {
isValid: boolean;
missingKeys: string[];
allKeys: string[];
};
getTemplate(name: string): ITemplate | undefined;
listTemplates(): ITemplate[];
hasTemplate(name: string): boolean;
removeTemplate(name: string): boolean;
}TemplateEngineService also exposes clearTemplates(): void. It resets the registry, but it is not part of the IMailTemplateEngine interface - you can only reach it when you hold the concrete class, not the interface type.
| Method | Behavior |
|---|---|
render(opts) | Renders by templateName (registry lookup) or raw templateData. Throws if neither is given. Throws TEMPLATE_NOT_FOUND (404) if templateName is not registered. Delegates to renderSimpleTemplate(). |
registerTemplate(opts) | options (on the class) accepts subject/description (Partial<ITemplate>). Overwrites an existing template with the same name. |
validateTemplateData(opts) | Extracts every unique {{key}} via /\{\{(\s*[\w.]+\s*)\}\}/g, deduplicates, resolves nested dot-notation values. Returns isValid, missingKeys, allKeys. |
getTemplate(name) | Returns undefined if not registered. |
listTemplates() | All templates, as an array of ITemplate. |
hasTemplate(name) | Registry membership check. |
removeTemplate(name) | Logs the removal, returns true/false. |
interface ITemplate {
name: string;
content?: string;
render?: (data: Record<string, AnyType>) => string;
subject?: string;
description?: string;
}The render field supports a custom per-template render function, though the built-in TemplateEngineService always uses content + renderSimpleTemplate() instead.
Source: services/template.service.ts
Transport layer
MailTransportProvider.value() returns a factory function that switches on TMailOptions.provider:
| Provider | Result |
|---|---|
'nodemailer' | NodemailerTransportHelper, backed by nodemailer |
'mailgun' | MailgunTransportHelper, backed by the Mailgun HTTP API (mailgun.js) |
'amazon-ses' | AmazonSesTransportHelper, backed by the SES v2 HTTPS API (@aws-sdk/client-sesv2) |
'custom' | The config value itself, validated to implement IMailTransport |
| Any other string | Throws Unsupported mail provider: <provider> (INVALID_CONFIGURATION, 500) |
Type narrowing uses four private guards, each checking provider === X && 'config' in options:
private isNodemailerOptions(options: TMailOptions): options is INodemailerMailOptions
private isMailgunOptions(options: TMailOptions): options is IMailgunMailOptions
private isAmazonSesOptions(options: TMailOptions): options is IAmazonSesMailOptions
private isCustomOptions(options: TMailOptions): options is ICustomMailOptionsA guard that does not match throws Invalid <Provider> configuration (INVALID_CONFIGURATION, 500) - Invalid Amazon SES configuration for this one.
For custom, an additional isMailTransport() utility check reports specifically which of send/verify is missing.
interface IMailTransport {
send(message: IMailMessage): Promise<IMailSendResult>;
verify(): Promise<boolean>;
close?(): Promise<void>;
}Peer dependency loading
Pass the peer yourself through module, or let the transport find it. With module set, the transport uses it as-is; without it, the transport falls back to ModuleUtility.loadSync({ module }) from the client-factory seam configure() calls. A missing package then throws the framework's install hint - [ModuleUtility.loadSync] nodemailer is required. Please install 'nodemailer' - not Node's raw Cannot find module.
That fallback keeps the specifier invisible to Bun.build. Importing @venizia/ignis/mail for the Nodemailer transport drags neither mailgun.js nor @aws-sdk/client-sesv2 into your bundle, and the same holds for every other pairing.
The BullMQ queue executor reaches bullmq and ioredis the same lazy way, when it builds its Redis connection, its queue and its workers. @venizia/ignis/mail therefore needs bullmq installed only when MAIL_QUEUE_EXECUTOR_CONFIG selects MailQueueExecutorTypes.BULLMQ. A compiled application passes both peers in the executor config:
import * as bullmq from 'bullmq';
import * as ioredis from 'ioredis';
this.bind({ key: MailKeys.MAIL_QUEUE_EXECUTOR_CONFIG }).toValue({
type: MailQueueExecutorTypes.BULLMQ,
bullmq: {
redis: { name: 'mail-redis', host, port, password, module: ioredis },
queue: { identifier: 'mail-queue', name: 'mail-queue' },
mode: BullMQExecutorModes.BOTH,
module: bullmq,
},
});Or register both once at the entrypoint: ModuleUtility.register({ modules: { ioredis, bullmq } }) - see Compiled binaries.
A compiled application must pass module. A bun build --compile binary ships without node_modules, so the runtime lookup has nothing to resolve against and the component throws that install hint at boot - with the peer sitting in package.json, which is no help because nothing put it inside the binary. The static import is what embeds it:
import * as nodemailer from 'nodemailer';
this.bind({ key: MailKeys.MAIL_OPTIONS }).toValue({
provider: MailProviders.NODEMAILER,
config: { host, port, secure, auth },
module: nodemailer,
});module is typed as the shape the transport calls - createTransport for Nodemailer, a constructor for Mailgun, and { SESv2Client, SendEmailCommand, GetAccountCommand } for Amazon SES. Handing over the wrong thing is a compile error rather than a boot crash. Prefer it over ModuleUtility.register: the dependency arrives where it is used and cannot be defeated by binding order. Every IGNIS component that reaches an optional peer now takes one of these options - the table in the module reference lists them.
Nodemailer (NodemailerTransportHelper, extends BaseHelper):
configure()builds the transporter vianodemailer.createTransport(config).send()mapsIMailMessagefields to Nodemailer's mail options, joining array recipients with', '. Attachments are resolved to bytes first, so Nodemailer is handed bytes, never apathto read. Atext/htmlthat is neither a string nornullis refused, for the same reason. Every send also sets Nodemailer'sdisableFileAccessanddisableUrlAccess, so no field can make Nodemailer open a file or fetch a URL. It catches transport errors and returns{ success: false, error }- it never throws.verify()delegates totransporter.verify()(SMTP handshake). It catches errors and returnsfalse- it never throws.close()callstransporter.close().
Mailgun (MailgunTransportHelper, extends BaseHelper):
configure()callsvalidateConfig()before building the client.username,key, anddomainmust all be present inconfig, or it throwsInvalid Mailgun configuration | Missing required keys: <keys>(INVALID_CONFIGURATION, 500). This check runs even thoughTMailgunConfig's type only requiresdomain.send()convertsIMailMessageto Mailgun's format:tois coerced to an array,replyTobecomesh:Reply-To, and every custom header is prefixedh:. Attachments are resolved to bytes first, then map to{ filename, data: content }.- It catches send errors and returns
{ success: false, error }- it never throws.
- It catches send errors and returns
verify()sends a test message toverify@<domain>witho:testmode: 'yes', since Mailgun has no dedicated verify endpoint. It catches errors and returnsfalse- it never throws.- No
close()- the HTTP API is stateless.
Amazon SES (AmazonSesTransportHelper, extends BaseHelper):
configure()callsvalidateConfig()first.regionmust be present, or it throwsInvalid Amazon SES Configuration | Missing region(INVALID_CONFIGURATION, 500).credentialsis optional - omit it and the AWS SDK falls back to its own credential chain.buildClient()constructsSESv2Clientfrommodule, or fromModuleUtility.loadSync({ module: '@aws-sdk/client-sesv2' }).send()builds a raw MIME message and issuesSendEmailCommandwithContent.Raw. The MIME builder reads attachments with no root, so apathreaching it is refused, never read.FromEmailAddressis RFC 2047-encoded separately, so a non-ASCII display name does not reach the API as raw UTF-8. It sets noReplyToAddresses- the raw content already carries one encodedReply-Toheader. It catches errors and returns{ success: false, error }- it never throws.verify()issuesGetAccountCommandand returnsresult.SendingEnabled === true. It catches errors and returnsfalse- it never throws.close()callsclient.destroy?.().
Called directly, outside MailService, a built-in transport still refuses a path - it has no attachmentRoot - and applies no size limit. Send through MailService to get both. A refused send destroys every attachment stream it was given.
Custom transport: set provider: MailProviders.CUSTOM and pass an object implementing IMailTransport as config. Reach for it when you need a provider IGNIS does not ship - SendGrid, Postmark, or a house SMTP relay with its own protocol. Amazon SES already has a first-class provider above; use that instead.
Source: helpers/transporters/
Queue executor implementations
All three implement:
interface IMailQueueExecutor {
enqueueVerificationEmail(email: string, options?: IMailQueueOptions): Promise<IMailQueueResult>;
setProcessor(processor: (email: string) => Promise<IMailProcessorResult>): void;
}DirectMailExecutorHelper extends BaseHelper. It calls the processor immediately, with no queue, and returns { queued: false, message: 'Email sent immediately (no queue)', result }. If enqueueVerificationEmail() runs before setProcessor(), it throws MailExecutorErrors.PROCESSOR_NOT_SET.
InternalQueueMailExecutorHelper extends BaseHelper (constructor takes IInternalQueueMailExecutorOpts). It wraps SequentialQueueHelper<IQueueJobPayload> from @venizia/ignis-helpers, with autoDispatch: true.
Job IDs:
job_<counter>_<timestamp>.options.delay > 0schedules the enqueue itself viasetTimeout, tracked in an internaldelayedJobs: Map<string, NodeJS.Timeout>.Retry: on a thrown error, or
{ success: false }from the processor, it retries up tooptions.attempts ?? 3times usingcalculateBackoff():backoffconfigDelay Not set 1000msfixedtype: 'fixed'the raw delaytype: 'exponential'delay * 2 ** (attempt - 1)close()clears every pending delayed/retrysetTimeout. Without that cleanup, a live timer keeps the event loop open past shutdown.No persistence across restarts.
BullMQMailExecutorHelper extends BaseHelper (constructor takes IBullMQMailExecutorOpts). It wraps BullMQHelper (@venizia/ignis-helpers/bullmq) and a RedisSingleHelper connection.
| Mode | Queue created | Workers created | Can enqueue | Can process |
|---|---|---|---|---|
'queue-only' | Yes | No (setProcessor() skips worker creation, logs a warning) | Yes - processor not required | No |
'worker-only' | No | Yes | No (throws) | Yes |
'both' | Yes | Yes | Yes - processor required | Yes |
setProcessor(processor, opts?) is async. It clears all existing workers, then creates opts?.numberOfWorkers ?? 1 workers, each with concurrencyPerWorker ?? 5 and lockDuration ?? 30000ms. In queue-only mode it stores the processor and returns without creating any worker.
enqueueVerificationEmail() throws in three cases:
| Condition | Throws |
|---|---|
Mode is worker-only | Cannot enqueue jobs in worker-only mode... |
| Queue was never created (should not happen in a queue-enabled mode) | Queue helper not initialized... |
No processor set, and mode !== 'queue-only' | PROCESSOR_NOT_SET |
Its enqueue options: attempts: options?.attempts ?? 3, backoff: { type: options?.backoff?.type ?? 'exponential', delay: options?.backoff?.delay ?? 1000 }, removeOnComplete: true, removeOnFail: false.
Dynamic workers - manage the worker pool without a restart:
| Method | Behavior |
|---|---|
addWorker(opts) | Requires a processor set first. Takes a unique identifier, default concurrency 5, default lock duration 30000ms |
removeWorker(index) | Closes the worker, then splices it out. Returns false if index is out of range |
clearWorkers() | Closes and empties every worker. Called internally by setProcessor() |
getWorkerCount() | Returns the current worker count |
getMode() | Returns the executor's BullMQExecutorModes value |
close() tears down workers, then the queue, then the Redis connection. Every step runs even if an earlier one failed, and every failure is collected into one thrown error at the end.
interface IBullMQMailExecutorOpts {
redis: IRedisSingleHelperOptions; // from @venizia/ignis-helpers; redis.module takes ioredis itself
queue: { identifier: string; name: string };
mode: TConstValue<typeof BullMQExecutorModes>; // REQUIRED, no default
module?: typeof import('bullmq'); // bullmq itself, for a compiled binary
}Source: helpers/executors/
Additional interfaces
interface IMailSendResult {
success: boolean;
messageId?: string;
response?: any;
error?: string;
}
interface IMailQueueOptions {
priority?: number;
delay?: number;
attempts?: number;
backoff?: { type: 'fixed' | 'exponential'; delay: number };
}
interface IMailQueueResult {
jobId?: string;
queued: boolean; // false for direct execution, true for internal-queue and bullmq
message: string;
result?: IMailProcessorResult; // populated only for direct execution (synchronous processor)
}
interface IMailProcessorResult {
success: boolean;
message: string;
expiresInMinutes: number;
nextResendAt?: string;
}
interface IVerificationData {
verificationCode: string;
codeGeneratedAt: string;
codeExpiresAt: string;
codeAttempts: number;
verificationToken: string;
tokenGeneratedAt: string;
tokenExpiresAt: string;
lastCodeSentAt: string;
}
interface IVerificationGenerationOptions {
codeLength: number; // all four fields required, no defaults
tokenBytes: number;
codeExpiryMinutes: number;
tokenExpiryHours: number;
}
interface IQueueJobPayload {
id: string;
email: string;
options?: IMailQueueOptions;
attempts: number;
scheduledAt: number;
}
interface IInternalQueueMailExecutorOpts {
identifier: string;
}Source: common/types/
Utility functions
// type.utility.ts
function isMailTransport(value: AnyType): value is IMailTransport;
function isValidMailOptions(options: AnyType): options is TMailOptions;
// verification.utility.ts
function getExpiryTime(minutes: number): Date; // Date `minutes` minutes from now
function getExpiryTimeInHours(hours: number): Date; // Date `hours` hours from nowisMailTransport()checks thatsendandverifyare functions, and thatcloseis either a function orundefined.isValidMailOptions()checks thatprovideris a string andconfigis truthy. It does not validate the shape ofconfigagainst the specific provider.
Source: utilities/
See also
- Overview -- quick start, imports, common configuration tasks
- Usage & Examples -- sending emails, templates, queue executors, verification generators
- Error Reference -- error codes and troubleshooting
- Queue Helper --
SequentialQueueHelperandBullMQHelper - Redis Helper --
RedisSingleHelper,IRedisSingleHelperOptions