@zyther/tzmail 0.1.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +411 -78
- package/dist/core/enums/theme.enum.d.ts +11 -1
- package/dist/core/enums/theme.enum.d.ts.map +1 -1
- package/dist/core/enums/theme.enum.js +10 -0
- package/dist/core/interfaces/email.interface.d.ts +95 -5
- package/dist/core/interfaces/email.interface.d.ts.map +1 -1
- package/dist/core/interfaces/template.interface.d.ts +12 -1
- package/dist/core/interfaces/template.interface.d.ts.map +1 -1
- package/dist/factories/email-factory.d.ts +32 -6
- package/dist/factories/email-factory.d.ts.map +1 -1
- package/dist/factories/email-factory.js +110 -6
- package/dist/factories/template-factory.d.ts.map +1 -1
- package/dist/factories/template-factory.js +170 -60
- package/dist/index.d.ts +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -1
- package/dist/providers/mailgun.transport.d.ts +10 -0
- package/dist/providers/mailgun.transport.d.ts.map +1 -0
- package/dist/providers/mailgun.transport.js +85 -0
- package/dist/providers/nodemailer.transport.d.ts +11 -0
- package/dist/providers/nodemailer.transport.d.ts.map +1 -0
- package/dist/providers/nodemailer.transport.js +23 -0
- package/dist/providers/provider-utils.d.ts +17 -0
- package/dist/providers/provider-utils.d.ts.map +1 -0
- package/dist/providers/provider-utils.js +89 -0
- package/dist/providers/provider.interface.d.ts +30 -0
- package/dist/providers/provider.interface.d.ts.map +1 -0
- package/dist/providers/provider.interface.js +2 -0
- package/dist/providers/sendgrid.transport.d.ts +10 -0
- package/dist/providers/sendgrid.transport.d.ts.map +1 -0
- package/dist/providers/sendgrid.transport.js +76 -0
- package/dist/queues/bullmq-email-queue.d.ts +153 -0
- package/dist/queues/bullmq-email-queue.d.ts.map +1 -0
- package/dist/queues/bullmq-email-queue.js +292 -0
- package/dist/services/attachment.service.d.ts +15 -37
- package/dist/services/attachment.service.d.ts.map +1 -1
- package/dist/services/attachment.service.js +58 -46
- package/dist/services/email.service.d.ts +22 -22
- package/dist/services/email.service.d.ts.map +1 -1
- package/dist/services/email.service.js +117 -33
- package/dist/services/template.service.d.ts +24 -111
- package/dist/services/template.service.d.ts.map +1 -1
- package/dist/services/template.service.js +273 -362
- package/dist/templates/base/color-accessibility.d.ts +4 -0
- package/dist/templates/base/color-accessibility.d.ts.map +1 -0
- package/dist/templates/base/color-accessibility.js +38 -0
- package/dist/templates/base/template-builder.d.ts +4 -1
- package/dist/templates/base/template-builder.d.ts.map +1 -1
- package/dist/templates/base/template-builder.js +276 -527
- package/dist/templates/themes/aurora.theme.d.ts +12 -0
- package/dist/templates/themes/aurora.theme.d.ts.map +1 -0
- package/dist/templates/themes/aurora.theme.js +88 -0
- package/dist/templates/themes/corporate.theme.d.ts +2 -1
- package/dist/templates/themes/corporate.theme.d.ts.map +1 -1
- package/dist/templates/themes/corporate.theme.js +139 -102
- package/dist/templates/themes/editorial.theme.d.ts +12 -0
- package/dist/templates/themes/editorial.theme.d.ts.map +1 -0
- package/dist/templates/themes/editorial.theme.js +90 -0
- package/dist/templates/themes/forest.theme.d.ts +12 -0
- package/dist/templates/themes/forest.theme.d.ts.map +1 -0
- package/dist/templates/themes/forest.theme.js +89 -0
- package/dist/templates/themes/glass.theme.d.ts +12 -0
- package/dist/templates/themes/glass.theme.d.ts.map +1 -0
- package/dist/templates/themes/glass.theme.js +88 -0
- package/dist/templates/themes/lavender.theme.d.ts +12 -0
- package/dist/templates/themes/lavender.theme.d.ts.map +1 -0
- package/dist/templates/themes/lavender.theme.js +91 -0
- package/dist/templates/themes/midnight.theme.d.ts +12 -0
- package/dist/templates/themes/midnight.theme.d.ts.map +1 -0
- package/dist/templates/themes/midnight.theme.js +91 -0
- package/dist/templates/themes/minimal.theme.d.ts +2 -1
- package/dist/templates/themes/minimal.theme.d.ts.map +1 -1
- package/dist/templates/themes/minimal.theme.js +142 -107
- package/dist/templates/themes/modern.theme.d.ts +2 -1
- package/dist/templates/themes/modern.theme.d.ts.map +1 -1
- package/dist/templates/themes/modern.theme.js +109 -74
- package/dist/templates/themes/monokai.theme.d.ts +2 -1
- package/dist/templates/themes/monokai.theme.d.ts.map +1 -1
- package/dist/templates/themes/monokai.theme.js +102 -66
- package/dist/templates/themes/neobrutal.theme.d.ts +12 -0
- package/dist/templates/themes/neobrutal.theme.d.ts.map +1 -0
- package/dist/templates/themes/neobrutal.theme.js +91 -0
- package/dist/templates/themes/ocean.theme.d.ts +12 -0
- package/dist/templates/themes/ocean.theme.d.ts.map +1 -0
- package/dist/templates/themes/ocean.theme.js +90 -0
- package/dist/templates/themes/pastel.theme.d.ts +12 -0
- package/dist/templates/themes/pastel.theme.d.ts.map +1 -0
- package/dist/templates/themes/pastel.theme.js +89 -0
- package/dist/templates/themes/sunset.theme.d.ts +12 -0
- package/dist/templates/themes/sunset.theme.d.ts.map +1 -0
- package/dist/templates/themes/sunset.theme.js +90 -0
- package/dist/templates/themes/system.theme.d.ts +2 -1
- package/dist/templates/themes/system.theme.d.ts.map +1 -1
- package/dist/templates/themes/system.theme.js +81 -47
- package/dist/templates/themes/theme.interface.d.ts +48 -0
- package/dist/templates/themes/theme.interface.d.ts.map +1 -1
- package/package.json +36 -14
- package/src/core/enums/template-part.enum.ts +7 -0
- package/src/core/enums/theme.enum.ts +18 -0
- package/src/core/interfaces/email.interface.ts +116 -0
- package/src/core/interfaces/template.interface.ts +71 -0
- package/src/factories/email-factory.ts +208 -0
- package/src/factories/template-factory.ts +201 -0
- package/src/index.ts +53 -0
- package/src/providers/mailgun.transport.ts +80 -0
- package/src/providers/nodemailer.transport.ts +20 -0
- package/src/providers/provider-utils.ts +91 -0
- package/src/providers/provider.interface.ts +26 -0
- package/src/providers/sendgrid.transport.ts +80 -0
- package/src/queues/bullmq-email-queue.ts +418 -0
- package/src/services/attachment.service.ts +98 -0
- package/src/services/email.service.ts +151 -0
- package/src/services/template.service.ts +431 -0
- package/src/templates/base/color-accessibility.ts +31 -0
- package/src/templates/base/template-builder.ts +438 -0
- package/src/templates/themes/aurora.theme.ts +91 -0
- package/src/templates/themes/corporate.theme.ts +156 -0
- package/src/templates/themes/editorial.theme.ts +93 -0
- package/src/templates/themes/forest.theme.ts +92 -0
- package/src/templates/themes/glass.theme.ts +91 -0
- package/src/templates/themes/lavender.theme.ts +94 -0
- package/src/templates/themes/midnight.theme.ts +94 -0
- package/src/templates/themes/minimal.theme.ts +158 -0
- package/src/templates/themes/modern.theme.ts +124 -0
- package/src/templates/themes/monokai.theme.ts +117 -0
- package/src/templates/themes/neobrutal.theme.ts +94 -0
- package/src/templates/themes/ocean.theme.ts +93 -0
- package/src/templates/themes/pastel.theme.ts +92 -0
- package/src/templates/themes/sunset.theme.ts +93 -0
- package/src/templates/themes/system.theme.ts +92 -0
- package/src/templates/themes/theme.interface.ts +95 -0
- package/src/tests/template.service.test.ts +1 -0
package/README.md
CHANGED
|
@@ -1,133 +1,466 @@
|
|
|
1
1
|
# tzmail
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@zyther/tzmail` is a TypeScript/Node.js library for generating templates and sending messages via SMTP, SendGrid, Amazon SES, or Mailgun. It includes ready-made themes, basic validation, bounded caching, attachments, and independent provider factories.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> **Scope:** tzmail is a templates, transports, and sending queue layer, not a complete campaign platform. For high volume, use a durable queue, define idempotency and retries, and coordinate limits with the provider. SMTP or an API accepting the message does not guarantee inbox delivery.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Requirements and installation
|
|
8
8
|
|
|
9
|
-
-
|
|
9
|
+
- Node.js 20 or later.
|
|
10
|
+
- `nodemailer` 6.9, 7, or 8, installed as a peer dependency.
|
|
11
|
+
- To use Amazon SES, also install the optional peer `@aws-sdk/client-sesv2`; SendGrid and Mailgun use Node's native `fetch` and do not need the vendor SDKs.
|
|
12
|
+
- To use the asynchronous queue, install the optional peer `bullmq` and provide a compatible Redis instance.
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
```bash
|
|
15
|
+
npm install @zyther/tzmail nodemailer
|
|
16
|
+
# Optional, only if using the queue:
|
|
17
|
+
npm install bullmq
|
|
18
|
+
```
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
## Quick start
|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
Use environment variables or a secret manager for credentials; do not store SMTP passwords in source code.
|
|
16
23
|
|
|
17
|
-
|
|
24
|
+
```typescript
|
|
25
|
+
import {
|
|
26
|
+
EmailFactory,
|
|
27
|
+
ThemeType,
|
|
28
|
+
type IEmailConfig,
|
|
29
|
+
type ITemplateConfig
|
|
30
|
+
} from '@zyther/tzmail';
|
|
31
|
+
|
|
32
|
+
const smtp: IEmailConfig = {
|
|
33
|
+
host: process.env.SMTP_HOST!,
|
|
34
|
+
port: Number(process.env.SMTP_PORT ?? 587),
|
|
35
|
+
secure: false, // Implicit TLS usually uses 465; 587 usually negotiates STARTTLS
|
|
36
|
+
auth: {
|
|
37
|
+
user: process.env.SMTP_USER!,
|
|
38
|
+
pass: process.env.SMTP_PASSWORD!
|
|
39
|
+
},
|
|
40
|
+
defaultFrom: 'Acme <noreply@example.com>'
|
|
41
|
+
};
|
|
18
42
|
|
|
19
|
-
|
|
43
|
+
// create() is suitable for services/tenants with isolated configurations.
|
|
44
|
+
const mailer = EmailFactory.create(smtp);
|
|
45
|
+
const templateConfig: ITemplateConfig = {
|
|
46
|
+
header: { show: true, logo: { type: 'text', text: 'Acme' } },
|
|
47
|
+
body: {
|
|
48
|
+
title: 'Welcome!',
|
|
49
|
+
message: 'Your account is ready.',
|
|
50
|
+
buttonText: 'Open account',
|
|
51
|
+
buttonUrl: 'https://example.com/account'
|
|
52
|
+
},
|
|
53
|
+
footer: { show: true, unsubscribeText: 'Unsubscribe' }
|
|
54
|
+
};
|
|
20
55
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
56
|
+
const template = mailer.getTemplateService().createTemplate(
|
|
57
|
+
ThemeType.MODERN,
|
|
58
|
+
'light',
|
|
59
|
+
templateConfig
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const result = await mailer.sendEmail({
|
|
63
|
+
to: 'pessoa@example.com',
|
|
64
|
+
subject: 'Your account is ready',
|
|
65
|
+
template
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (result.status !== 'success') {
|
|
69
|
+
// Log operational metadata according to the application's policy.
|
|
70
|
+
// Avoid sending message bodies, credentials, or personal data to logs.
|
|
71
|
+
console.error('Email send failed', result.status, result.errorStage);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// During graceful process/worker shutdown:
|
|
75
|
+
mailer.close();
|
|
25
76
|
```
|
|
26
77
|
|
|
27
|
-
##
|
|
78
|
+
## SMTP and throughput
|
|
28
79
|
|
|
29
|
-
|
|
80
|
+
`IEmailConfig` exposes the main SMTP and Nodemailer pool options. `pool`, `maxConnections`, `maxMessages`, `rateDelta`, and `rateLimit` control the pool and sending rate **for a single instance/process**:
|
|
30
81
|
|
|
31
82
|
```typescript
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const smtpConfig = {
|
|
35
|
-
host: 'smtp.example.com',
|
|
83
|
+
const smtp: IEmailConfig = {
|
|
84
|
+
host: process.env.SMTP_HOST!,
|
|
36
85
|
port: 587,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
86
|
+
secure: false,
|
|
87
|
+
auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASSWORD! },
|
|
88
|
+
defaultFrom: 'Acme <noreply@example.com>',
|
|
89
|
+
pool: true,
|
|
90
|
+
maxConnections: 5,
|
|
91
|
+
maxMessages: 100,
|
|
92
|
+
rateDelta: 1_000,
|
|
93
|
+
rateLimit: 50,
|
|
94
|
+
connectionTimeout: 30_000,
|
|
95
|
+
greetingTimeout: 30_000,
|
|
96
|
+
socketTimeout: 60_000
|
|
42
97
|
};
|
|
43
98
|
|
|
44
|
-
const
|
|
99
|
+
const mailer = EmailFactory.create(smtp);
|
|
100
|
+
await mailer.verifyTransport(); // validates the connection/credentials; does not send email
|
|
45
101
|
```
|
|
46
102
|
|
|
47
|
-
|
|
103
|
+
`auth` supports `login` credentials (username/password), `oauth2` credentials (for example, `user`, `clientId`, `clientSecret`, `refreshToken`, or `accessToken`), and Nodemailer's `custom` mechanism. Store tokens, callbacks, and secrets in a secret manager; the password example above is not required for OAuth2.
|
|
104
|
+
|
|
105
|
+
Choose limits according to your SMTP provider. Adding replicas multiplies connections and local limits; apply distributed control/a queue and quotas in the system that coordinates the workers.
|
|
106
|
+
|
|
107
|
+
`EmailFactory.create(config, templateOptions?, attachmentOptions?)` creates an isolated instance. `EmailFactory.initialize(...)`/`getInstance()` remain available for a process-wide singleton; a second initialization with different settings throws an error instead of silently reusing the first SMTP configuration. Close each created instance with `close()` during shutdown.
|
|
108
|
+
|
|
109
|
+
## Asynchronous queue with BullMQ
|
|
110
|
+
|
|
111
|
+
BullMQ is an optional peer. Install it and configure Redis before creating the queue. Producer processes only need to add jobs; run `startWorker()` in dedicated workers and use the same `queueName`, `prefix`, and Redis connection. Workers can be scaled horizontally. `concurrency` controls simultaneous jobs per worker, while `limiter` applies a shared per-queue rate across BullMQ workers.
|
|
48
112
|
|
|
49
113
|
```typescript
|
|
50
|
-
|
|
114
|
+
import { EmailFactory, type IEmailConfig } from '@zyther/tzmail';
|
|
115
|
+
|
|
116
|
+
const queue = EmailFactory.createBullMQQueue({
|
|
117
|
+
connection: {
|
|
118
|
+
host: process.env.REDIS_HOST!,
|
|
119
|
+
port: Number(process.env.REDIS_PORT ?? 6379),
|
|
120
|
+
password: process.env.REDIS_PASSWORD,
|
|
121
|
+
tls: process.env.REDIS_TLS === 'true' ? {} : undefined
|
|
122
|
+
},
|
|
123
|
+
queueName: 'transactional-email',
|
|
124
|
+
concurrency: 10,
|
|
125
|
+
limiter: { max: 100, duration: 1_000 },
|
|
126
|
+
// BullMQ data points per minute: 14 days = 14 * 24 * 60 points.
|
|
127
|
+
workerMetrics: { maxDataPoints: 20_160 },
|
|
128
|
+
defaultJobOptions: {
|
|
129
|
+
attempts: 5,
|
|
130
|
+
backoff: { type: 'exponential', delay: 1_000, jitter: 0.2 }
|
|
131
|
+
}
|
|
132
|
+
});
|
|
51
133
|
|
|
52
|
-
//
|
|
53
|
-
const
|
|
54
|
-
'
|
|
55
|
-
'
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
134
|
+
// Producer/API process:
|
|
135
|
+
const job = await queue.enqueueEmail({
|
|
136
|
+
to: 'ana@example.com',
|
|
137
|
+
subject: 'Receipt',
|
|
138
|
+
text: 'Payment confirmed.'
|
|
139
|
+
}, { idempotencyKey: 'receipt-order-123' });
|
|
140
|
+
console.log('Job ID:', job.id);
|
|
141
|
+
|
|
142
|
+
// Or enqueue a batch, sent to Redis via addBulk:
|
|
143
|
+
await queue.enqueueBulk([
|
|
144
|
+
{ email: { to: 'a@example.com', subject: 'Hello', text: 'A' }, job: { idempotencyKey: 'batch-a' } },
|
|
145
|
+
{ email: { to: 'b@example.com', subject: 'Hello', text: 'B' }, job: { idempotencyKey: 'batch-b' } }
|
|
146
|
+
]);
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Expose `exportPrometheusMetrics()` on an internal `/metrics` endpoint; it returns the text format expected by Prometheus and can accept low-cardinality global labels:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
import { createServer } from 'node:http';
|
|
153
|
+
|
|
154
|
+
const metricsServer = createServer(async (req, res) => {
|
|
155
|
+
if (req.method !== 'GET' || req.url !== '/metrics') {
|
|
156
|
+
res.writeHead(404).end();
|
|
157
|
+
return;
|
|
63
158
|
}
|
|
64
|
-
|
|
159
|
+
try {
|
|
160
|
+
const body = await queue.exportPrometheusMetrics({
|
|
161
|
+
env: process.env.NODE_ENV ?? 'production',
|
|
162
|
+
service: 'tzmail'
|
|
163
|
+
});
|
|
164
|
+
res.writeHead(200, {
|
|
165
|
+
'content-type': 'text/plain; version=0.0.4; charset=utf-8',
|
|
166
|
+
'content-length': String(Buffer.byteLength(body))
|
|
167
|
+
});
|
|
168
|
+
res.end(body);
|
|
169
|
+
} catch {
|
|
170
|
+
res.writeHead(500).end('Metrics unavailable');
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
metricsServer.listen(9464, '0.0.0.0');
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Configure Prometheus to scrape `mail-metrics:9464` every 15 seconds. A dedicated exporter per queue avoids duplicate count series when multiple replicas query the same Redis instance. In Grafana, create dashboards for waiting, active, and failed jobs, and alerts for growing backlogs/failures. Do not use email addresses, job IDs, or other unbounded values as labels.
|
|
177
|
+
|
|
178
|
+
In a worker process, create the transport as usual and start the consumer with the same queue configuration:
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
const mailer = EmailFactory.createSendGrid({
|
|
182
|
+
apiKey: process.env.SENDGRID_API_KEY!,
|
|
183
|
+
defaultFrom: 'Acme <noreply@example.com>'
|
|
184
|
+
});
|
|
185
|
+
queue.startWorker(mailer, { concurrency: 20 }); // inherits workerMetrics from the configuration
|
|
186
|
+
|
|
187
|
+
// Global events from all workers; use them for real-time logging/alerts.
|
|
188
|
+
const events = queue.createQueueEvents();
|
|
189
|
+
events.on('completed', ({ jobId }) => logger.info({ jobId }, 'email job completed'));
|
|
190
|
+
events.on('failed', ({ jobId, failedReason }) => logger.error({ jobId, failedReason }, 'email job failed'));
|
|
191
|
+
|
|
192
|
+
// During graceful shutdown, wait for active jobs before closing the transport:
|
|
193
|
+
await queue.close();
|
|
194
|
+
mailer.close();
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`enqueueEmail()` accepts `idempotencyKey` to deduplicate jobs while the previous job is still retained in the queue; the key is transformed into a Redis ID without exposing the original value. Jobs removed after completion/failure are no longer deduplicated. `enqueueBulk()` accepts up to 1,000 messages by default; a partial failure during bulk insertion may leave some jobs enqueued, so use idempotency keys and check the queue before retrying the batch. `getJobCounts()` returns counts of waiting, active, completed, failed, delayed, and paused jobs. `getMetrics('completed' | 'failed')` reads throughput series in one-minute intervals stored in Redis; all workers in the queue must use the same `workerMetrics.maxDataPoints`.
|
|
198
|
+
|
|
199
|
+
The default options are 5 attempts, exponential backoff with jitter, retention of completed jobs for up to 24 hours/10,000 items, and failed jobs for up to 7 days/50,000 items. These can be changed in `defaultJobOptions` or per job. BullMQ retries worker failures; a transport failure after remote acceptance but before the response can therefore duplicate a send. Job idempotency prevents duplicate enqueueing while a job is retained, but does not provide exactly-once delivery by the provider. Validation/rendering errors and partial deliveries are marked as unrecoverable. Partial deliveries are not retried automatically, to avoid resending to recipients who have already been accepted; review these failed jobs and handle rejected recipients through a dedicated flow.
|
|
200
|
+
|
|
201
|
+
The template is rendered before entering the queue, to avoid serializing closures/functions. `Buffer` content is converted to Base64 and stored in Redis. Attachment `path` remains a local path that must be accessible to all workers. The default maximum serialized size is 10 MiB per job (`maxPayloadBytes`); increase it cautiously, since large attachments consume Redis and memory. The payload contains email addresses and content: protect Redis with TLS, authentication, network controls, and appropriate retention. `close()` waits for active workers and closes the queue; call it during graceful shutdown.
|
|
202
|
+
|
|
203
|
+
See also the official BullMQ documentation on [retries and backoff](https://docs.bullmq.io/guide/retrying-failing-jobs), [job IDs](https://docs.bullmq.io/guide/jobs/job-ids), [bulk insertion](https://docs.bullmq.io/guide/queues/adding-bulks), [rate limiting](https://docs.bullmq.io/guide/rate-limiting), and [graceful worker shutdown](https://docs.bullmq.io/guide/workers/graceful-shutdown).
|
|
204
|
+
|
|
205
|
+
## Email providers
|
|
206
|
+
|
|
207
|
+
Provider factories create independent instances, so you can, for example, maintain a transactional sender through SES and a marketing sender through SendGrid in the same process. There is no shared singleton state across keys or tenants.
|
|
208
|
+
|
|
209
|
+
### SendGrid Web API
|
|
210
|
+
|
|
211
|
+
The adapter uses the official v3 API via `fetch` (it does not require `@sendgrid/mail`). The sender must be verified in SendGrid. API attachments are Base64-encoded and held in memory during the call; configure `maxAttachmentBytes` to limit the size of each file.
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
const sendgrid = EmailFactory.createSendGrid({
|
|
215
|
+
apiKey: process.env.SENDGRID_API_KEY!,
|
|
216
|
+
defaultFrom: 'Acme <noreply@example.com>',
|
|
217
|
+
requestTimeoutMs: 30_000,
|
|
218
|
+
maxAttachmentBytes: 10 * 1024 * 1024
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const result = await sendgrid.sendEmail({
|
|
222
|
+
to: ['ana@example.com', 'bruno@example.com'],
|
|
223
|
+
subject: 'Account update',
|
|
224
|
+
text: 'Your account has been updated.'
|
|
225
|
+
});
|
|
226
|
+
sendgrid.close();
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Use `apiUrl` if the account is configured for a regional endpoint. The default is `https://api.sendgrid.com`. References: [Mail Send API](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send) and [attachments in the official SDK](https://github.com/sendgrid/sendgrid-nodejs/blob/main/docs/use-cases/attachments.md).
|
|
230
|
+
|
|
231
|
+
### Mailgun Messages API
|
|
65
232
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
233
|
+
The adapter uses the Messages API via `fetch` and multipart/form-data; it does not require `mailgun.js`. Set the sending domain and account region. `apiUrl` should only be customized for a custom or controlled endpoint.
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
const mailgun = EmailFactory.createMailgun({
|
|
237
|
+
apiKey: process.env.MAILGUN_API_KEY!,
|
|
238
|
+
domain: process.env.MAILGUN_DOMAIN!,
|
|
239
|
+
region: 'eu', // omit or use 'us' for the US API
|
|
240
|
+
defaultFrom: 'Acme <noreply@example.com>',
|
|
241
|
+
requestTimeoutMs: 30_000,
|
|
242
|
+
maxAttachmentBytes: 10 * 1024 * 1024
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await mailgun.sendEmail({
|
|
246
|
+
to: 'ana@example.com',
|
|
247
|
+
subject: 'Hello',
|
|
248
|
+
html: '<p>Message sent via Mailgun.</p>'
|
|
71
249
|
});
|
|
250
|
+
mailgun.close();
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The adapter uses `https://api.mailgun.net` in the US and `https://api.eu.mailgun.net` for `region: 'eu'`. Attachments are prepared in memory before the POST; set an appropriate limit. For inline images, Mailgun derives the Content-ID from the file name; when `cid` is provided, the adapter converts `cid:<value>` references in the HTML to `cid:<filename>`. See the [official Messages API](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages).
|
|
254
|
+
|
|
255
|
+
### Amazon SES v2
|
|
256
|
+
|
|
257
|
+
Install the official SDK only if you are going to use SES:
|
|
72
258
|
|
|
73
|
-
|
|
259
|
+
```bash
|
|
260
|
+
npm install @aws-sdk/client-sesv2
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
const ses = EmailFactory.createSES({
|
|
265
|
+
region: process.env.AWS_REGION ?? 'us-east-1',
|
|
266
|
+
defaultFrom: 'Acme <noreply@example.com>'
|
|
267
|
+
// Without explicit credentials, the AWS SDK uses the default credential chain,
|
|
268
|
+
// such as an IAM role, environment variables, or local credentials.
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await ses.sendEmail({
|
|
272
|
+
to: 'ana@example.com',
|
|
273
|
+
subject: 'Receipt',
|
|
274
|
+
text: 'Payment confirmed.'
|
|
275
|
+
});
|
|
276
|
+
ses.close();
|
|
74
277
|
```
|
|
75
278
|
|
|
76
|
-
|
|
279
|
+
The adapter uses Nodemailer's SESv2 transport. The sender identity must be verified and the role/credential must have the `ses:SendEmail` permission. The SES transport does not enforce rate limits on its own; use the BullMQ queue or your own coordination to respect the account quota. Nodemailer documents that the SES transport builds the full message in memory, including attachments. See [SES transport](https://nodemailer.com/transports/ses) and [AWS SDK credentials](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/ses-examples-sending-email.html).
|
|
280
|
+
|
|
281
|
+
### Custom transports and verification
|
|
282
|
+
|
|
283
|
+
`EmailFactory.createWithTransport(transport, defaultFrom?, templateOptions?, attachmentOptions?)` lets you connect a custom adapter that implements `IEmailTransport`. `verifyTransport()` works when the transport offers a non-destructive check; SendGrid and Mailgun do not send test requests because the sending API does not provide a safe health check with minimal permissions. For these providers, validate the key and permissions in the deployment environment and monitor actual send responses.
|
|
284
|
+
|
|
285
|
+
All factories accept the same optional template and attachment options. The default `requestTimeoutMs` for the SendGrid and Mailgun APIs is 30 seconds. `maxAttachmentBytes` is optional and limits each individual attachment; SendGrid/Mailgun files and SES messages may be buffered in memory. For large files, use conservative limits and SMTP transport when appropriate.
|
|
286
|
+
|
|
287
|
+
## Templates and themes
|
|
288
|
+
|
|
289
|
+
The library includes 15 themes, each with `light` and `dark` variants. The original options are `ThemeType.MODERN`, `CORPORATE`, `MINIMAL`, `MONOKAI`, and `SYSTEM`; ten new palettes are also available:
|
|
290
|
+
|
|
291
|
+
| Theme | Style |
|
|
292
|
+
| --- | --- |
|
|
293
|
+
| `AURORA` | Cool teal and violet, inspired by the northern lights. |
|
|
294
|
+
| `OCEAN` | Calm navy, marine blue, and cyan. |
|
|
295
|
+
| `SUNSET` | Warm coral, terracotta, amber, and plum. |
|
|
296
|
+
| `LAVENDER` | Elegant violet and rose accents on soft surfaces. |
|
|
297
|
+
| `FOREST` | Evergreen, moss, sage, and warm cream. |
|
|
298
|
+
| `EDITORIAL` | Serif-led magazine styling with warm paper and ink tones. |
|
|
299
|
+
| `PASTEL` | Friendly peach, mint, and lavender accents with readable contrast. |
|
|
300
|
+
| `NEOBRUTAL` | Bold black and white with electric yellow and cobalt accents. |
|
|
301
|
+
| `GLASS` | Cool blue-gray colors on solid, email-client-friendly surfaces. |
|
|
302
|
+
| `MIDNIGHT` | Premium midnight navy with champagne-gold accents. |
|
|
303
|
+
|
|
304
|
+
All themes use fluid, table-based email markup. On screens up to 600px wide, calls to action expand to the available width and footer links stack vertically. Use `TemplateFactory.listThemes()` to enumerate available themes and `TemplateFactory.getThemeInfo(theme)` to read each theme's description and features.
|
|
305
|
+
|
|
306
|
+
Open the standalone [interactive theme gallery](examples/theme-gallery.html) in a browser to compare all 15 themes, switch between light and dark variants, search by theme name, and inspect expanded previews. It embeds sample emails rendered by the current `TemplateFactory` and requires no server or external assets.
|
|
307
|
+
|
|
308
|
+
Each built-in `ITheme` now includes an `emailStyles` profile for desktop width, container surfaces, header/body/footer accents, spacing, and solid or outline CTAs. Color accents are palette references, so the same profile adapts to light and dark variants. Custom `ITheme` implementations can omit `emailStyles` and receive conservative email-safe defaults. The renderer avoids CSS gradients, hover-only behavior, animation, and glass effects that are unreliable in email clients.
|
|
309
|
+
|
|
310
|
+
Theme-selected or caller-supplied logo, header, body, and footer text colors are checked against their effective backgrounds. If a requested foreground would be unreadable, the renderer substitutes a WCAG AA-safe fallback while preserving the theme accent on borders and fills.
|
|
311
|
+
|
|
312
|
+
Body copy and its CTA share the configured `body.alignment` (`left`, `center`, or `right`); `body.backgroundColor` and `body.textColor` can override the theme's body palette. Footer navigation and unsubscribe links have no underline by default. Set `linkDecoration: 'underline'` when underlined links are preferred; the setting applies consistently to both.
|
|
313
|
+
|
|
314
|
+
Templates also support `language` (default `en`), `direction` (`ltr`, `rtl`, or `auto`; default `ltr`), and a hidden `preheader` (up to 500 printable characters) for inbox previews. Preheaders support the same escaped per-recipient placeholders as other text fields. After changing a theme or the renderer, refresh the gallery's embedded samples with `npm run gallery:build`.
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
const html = await mailer.getTemplateService().previewTemplate(
|
|
318
|
+
ThemeType.MIDNIGHT,
|
|
319
|
+
'dark',
|
|
320
|
+
{
|
|
321
|
+
language: 'ar',
|
|
322
|
+
direction: 'rtl',
|
|
323
|
+
preheader: 'مرحبًا، {{user.firstName}} — your account update is ready.',
|
|
324
|
+
body: { title: 'Welcome, {{user.firstName}}', message: 'Your update is available.' }
|
|
325
|
+
},
|
|
326
|
+
{ templateData: { user: { firstName: 'Samira' } } }
|
|
327
|
+
);
|
|
328
|
+
```
|
|
77
329
|
|
|
78
|
-
|
|
330
|
+
```typescript
|
|
331
|
+
const service = mailer.getTemplateService();
|
|
79
332
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
333
|
+
const html = await service.previewTemplate(
|
|
334
|
+
ThemeType.CORPORATE,
|
|
335
|
+
'light',
|
|
336
|
+
{
|
|
337
|
+
header: { show: true, logo: { type: 'text', text: 'Acme' } },
|
|
338
|
+
body: { title: 'Hello, Ana', message: 'See what is new.' },
|
|
339
|
+
footer: {
|
|
340
|
+
show: true,
|
|
341
|
+
links: [{ text: 'Privacy', url: 'https://example.com/privacy' }],
|
|
342
|
+
unsubscribeText: 'Unsubscribe',
|
|
343
|
+
unsubscribeUrl: 'https://example.com/unsubscribe?token=...'
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
{}
|
|
347
|
+
);
|
|
348
|
+
```
|
|
87
349
|
|
|
88
|
-
|
|
350
|
+
`ITemplateServiceOptions` options include:
|
|
89
351
|
|
|
90
|
-
|
|
352
|
+
| Option | Default | Description |
|
|
353
|
+
| --- | ---: | --- |
|
|
354
|
+
| `cache` | `true` | In-memory cache by configuration. Can be disabled per call with `{ cache: false }`. |
|
|
355
|
+
| `cacheTTL` | `3600` | TTL in seconds; `0` disables storage. |
|
|
356
|
+
| `maxCacheEntries` | `100` | Positive limit on cached entries; approximate LRU eviction. |
|
|
357
|
+
| `maxHistoryEntries` | `100` | Limit on IDs in the history. |
|
|
358
|
+
| `maxVersionsPerTemplate` | `10` | Versions retained per template. |
|
|
359
|
+
| `validateConfig` | `true` | Validates template options before rendering. |
|
|
91
360
|
|
|
92
361
|
```typescript
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
html: '<p>Please find the report attached.</p>'
|
|
362
|
+
import { TemplateService } from '@zyther/tzmail';
|
|
363
|
+
|
|
364
|
+
const service = new TemplateService({
|
|
365
|
+
cache: true,
|
|
366
|
+
cacheTTL: 900,
|
|
367
|
+
maxCacheEntries: 500,
|
|
368
|
+
maxHistoryEntries: 200
|
|
101
369
|
});
|
|
102
370
|
```
|
|
103
371
|
|
|
104
|
-
|
|
372
|
+
The cache and history are local to the process, not distributed. Call `service.dispose()` when you create `TemplateService` directly and no longer need it. `EmailFactory.close()` stops the associated service timer.
|
|
373
|
+
`getTemplateStats()` returns `totalTemplates`, `cachedTemplates`, `totalHits`, `cacheRequests`, `cacheHitRate`, and counts by theme; requests that bypass/disable the cache are not included in the hit rate.
|
|
105
374
|
|
|
106
|
-
###
|
|
375
|
+
### HTML and URL policy
|
|
107
376
|
|
|
108
|
-
|
|
377
|
+
- `title`, `message`, logo name, button/link/footer text, and textual metadata are escaped by the renderer.
|
|
378
|
+
- `body.content` is the only configuration field treated as **trusted HTML** to allow custom markup. Do not pass user-provided HTML without sanitizing it with a policy appropriate to the application.
|
|
379
|
+
- `TemplateBuilder.buildBody()` is a low-level API and also expects trusted HTML; prefer `TemplateService` for validation and interpolation.
|
|
380
|
+
- `IEmailOptions.html` is also passed through as HTML supplied by the caller; treat it as trusted content or sanitize it before sending.
|
|
381
|
+
- Action and link URLs accept absolute `https:`, `http:`, or `mailto:` URLs; images/logos and social links accept `https:`/`http:`. Schemes such as `javascript:` and `data:` and attributes containing injection characters are rejected.
|
|
382
|
+
- Colors and some style values are validated. The library's validation does not replace business validation or sanitization of custom HTML.
|
|
109
383
|
|
|
110
|
-
-
|
|
384
|
+
For per-recipient customization, use `{{placeholder}}` in template text and pass `templateData` in the message. Nested keys are supported (`{{user.firstName}}`); leaf values must be primitives (`string`, `number`, `boolean`, or `null`), and a placeholder without a value causes rendering to fail instead of sending incomplete text. Values inserted into text are escaped; insertions into `body.content` are escaped before being added to the trusted HTML; URLs are validated after interpolation.
|
|
111
385
|
|
|
112
|
-
|
|
386
|
+
```typescript
|
|
387
|
+
await mailer.sendEmail({
|
|
388
|
+
to: 'ana@example.com',
|
|
389
|
+
subject: 'Welcome',
|
|
390
|
+
template,
|
|
391
|
+
templateData: {
|
|
392
|
+
user: { firstName: 'Ana' },
|
|
393
|
+
accountId: 'acct-123',
|
|
394
|
+
actionUrl: 'https://example.com/account/acct-123'
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
```
|
|
113
398
|
|
|
114
|
-
|
|
399
|
+
For example, use `title: 'Hello, {{user.firstName}}'` and `buttonUrl: '{{actionUrl}}'` in the template configuration. The dynamic URL must still comply with the allowed schemes.
|
|
115
400
|
|
|
116
|
-
|
|
401
|
+
## Attachments
|
|
117
402
|
|
|
118
|
-
|
|
403
|
+
Attachments specified by path remain as paths so Nodemailer can stream them without loading entire large files into memory. In production, prefer restricting the allowed root and enforcing a size limit:
|
|
119
404
|
|
|
120
|
-
|
|
405
|
+
```typescript
|
|
406
|
+
const mailer = EmailFactory.create(
|
|
407
|
+
smtp,
|
|
408
|
+
{ cache: true, cacheTTL: 900 },
|
|
409
|
+
{ allowedRoot: '/srv/app/mail-attachments', maxSizeBytes: 10 * 1024 * 1024 }
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const attachment = await mailer.getAttachmentService().addFromPath(
|
|
413
|
+
'/srv/app/mail-attachments/report.pdf'
|
|
414
|
+
);
|
|
415
|
+
await mailer.sendEmail({
|
|
416
|
+
to: 'finance@example.com',
|
|
417
|
+
subject: 'Report',
|
|
418
|
+
text: 'Please find the report attached.',
|
|
419
|
+
attachments: [attachment]
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
`allowedRoot` resolves real paths and rejects paths outside the root, including symlink escapes at validation time. Keep the root and its directories unwritable by untrusted users to reduce the risk of the file being swapped between validation and reading. If it is not set, the application is responsible for ensuring that paths are trusted. `maxSizeBytes` is optional; buffer attachments are also checked. `addFromUrl()` does not download URLs and remains unimplemented.
|
|
121
424
|
|
|
122
|
-
|
|
425
|
+
## Results and error handling
|
|
426
|
+
|
|
427
|
+
`sendEmail()` always returns an `IEmailSendResult`; validation, rendering, and transport are identified by `errorStage`:
|
|
123
428
|
|
|
124
429
|
```typescript
|
|
125
|
-
{
|
|
430
|
+
type IEmailSendResult = {
|
|
126
431
|
success: boolean;
|
|
432
|
+
status: 'success' | 'partial' | 'failure';
|
|
127
433
|
messageId?: string;
|
|
128
434
|
response?: string;
|
|
129
|
-
|
|
130
|
-
}
|
|
435
|
+
accepted?: Array<string | { address: string; name: string }>;
|
|
436
|
+
rejected?: Array<string | { address: string; name: string }>;
|
|
437
|
+
error?: unknown;
|
|
438
|
+
errorStage?: 'validation' | 'render' | 'smtp' | 'provider';
|
|
439
|
+
};
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
`success` is `true` only when the transport reports no rejected recipients. `partial` means partial acceptance and rejection; route these cases to explicit handling/retry in the application. An API or server accepting the message does not prove final delivery, that it was read, or that it avoided spam.
|
|
443
|
+
|
|
444
|
+
## Development and validation
|
|
445
|
+
|
|
446
|
+
```bash
|
|
447
|
+
npm ci
|
|
448
|
+
npm run lint
|
|
449
|
+
npm run typecheck
|
|
450
|
+
npm test
|
|
451
|
+
npm pack --dry-run
|
|
131
452
|
```
|
|
132
453
|
|
|
133
|
-
|
|
454
|
+
Tests use the native `node:test` runner and fake transports; they do not send real emails. The GitHub Actions pipeline runs lint, typecheck, build/tests, and package inspection on pushes, pull requests, and before publishing an npm release.
|
|
455
|
+
|
|
456
|
+
## Production considerations
|
|
457
|
+
|
|
458
|
+
1. Use `BullMQEmailQueue` or another durable queue/outbox with workers, idempotency, and retries with backoff; do not automatically retry sends without a duplicate-prevention strategy.
|
|
459
|
+
2. Configure SPF, DKIM, DMARC, sending domain/reputation, quotas, and bounce/unsubscribe handling with the provider and in the application.
|
|
460
|
+
3. Keep secrets out of the repository and rotate them; do not log message content or link tokens.
|
|
461
|
+
4. Size `maxConnections`/`rateLimit` according to the provider and the number of replicas. Nodemailer pool limits are local; the BullMQ limiter is shared by the queue.
|
|
462
|
+
5. Monitor `status`, `errorStage`, `accepted`/`rejected`, latency, and queues with redacted logs and custom metrics.
|
|
463
|
+
|
|
464
|
+
## License
|
|
465
|
+
|
|
466
|
+
ISC. See `LICENSE`/repository metadata for the applicable terms.
|
|
@@ -3,6 +3,16 @@ export declare enum ThemeType {
|
|
|
3
3
|
MONOKAI = "monokai",
|
|
4
4
|
MODERN = "modern",
|
|
5
5
|
CORPORATE = "corporate",
|
|
6
|
-
MINIMAL = "minimal"
|
|
6
|
+
MINIMAL = "minimal",
|
|
7
|
+
AURORA = "aurora",
|
|
8
|
+
OCEAN = "ocean",
|
|
9
|
+
SUNSET = "sunset",
|
|
10
|
+
LAVENDER = "lavender",
|
|
11
|
+
FOREST = "forest",
|
|
12
|
+
EDITORIAL = "editorial",
|
|
13
|
+
PASTEL = "pastel",
|
|
14
|
+
NEOBRUTAL = "neobrutal",
|
|
15
|
+
GLASS = "glass",
|
|
16
|
+
MIDNIGHT = "midnight"
|
|
7
17
|
}
|
|
8
18
|
//# sourceMappingURL=theme.enum.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"theme.enum.d.ts","sourceRoot":"","sources":["../../../src/core/enums/theme.enum.ts"],"names":[],"mappings":"AACA,oBAAY,SAAS;IACnB,MAAM,WAAW;IACjB,OAAO,YAAY;IACnB,MAAM,WAAW;IACjB,SAAS,cAAc;IACvB,OAAO,YAAY;
|
|
1
|
+
{"version":3,"file":"theme.enum.d.ts","sourceRoot":"","sources":["../../../src/core/enums/theme.enum.ts"],"names":[],"mappings":"AACA,oBAAY,SAAS;IACnB,MAAM,WAAW;IACjB,OAAO,YAAY;IACnB,MAAM,WAAW;IACjB,SAAS,cAAc;IACvB,OAAO,YAAY;IACnB,MAAM,WAAW;IACjB,KAAK,UAAU;IACf,MAAM,WAAW;IACjB,QAAQ,aAAa;IACrB,MAAM,WAAW;IACjB,SAAS,cAAc;IACvB,MAAM,WAAW;IACjB,SAAS,cAAc;IACvB,KAAK,UAAU;IACf,QAAQ,aAAa;CACtB"}
|
|
@@ -8,4 +8,14 @@ var ThemeType;
|
|
|
8
8
|
ThemeType["MODERN"] = "modern";
|
|
9
9
|
ThemeType["CORPORATE"] = "corporate";
|
|
10
10
|
ThemeType["MINIMAL"] = "minimal";
|
|
11
|
+
ThemeType["AURORA"] = "aurora";
|
|
12
|
+
ThemeType["OCEAN"] = "ocean";
|
|
13
|
+
ThemeType["SUNSET"] = "sunset";
|
|
14
|
+
ThemeType["LAVENDER"] = "lavender";
|
|
15
|
+
ThemeType["FOREST"] = "forest";
|
|
16
|
+
ThemeType["EDITORIAL"] = "editorial";
|
|
17
|
+
ThemeType["PASTEL"] = "pastel";
|
|
18
|
+
ThemeType["NEOBRUTAL"] = "neobrutal";
|
|
19
|
+
ThemeType["GLASS"] = "glass";
|
|
20
|
+
ThemeType["MIDNIGHT"] = "midnight";
|
|
11
21
|
})(ThemeType || (exports.ThemeType = ThemeType = {}));
|