@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.
Files changed (132) hide show
  1. package/README.md +411 -78
  2. package/dist/core/enums/theme.enum.d.ts +11 -1
  3. package/dist/core/enums/theme.enum.d.ts.map +1 -1
  4. package/dist/core/enums/theme.enum.js +10 -0
  5. package/dist/core/interfaces/email.interface.d.ts +95 -5
  6. package/dist/core/interfaces/email.interface.d.ts.map +1 -1
  7. package/dist/core/interfaces/template.interface.d.ts +12 -1
  8. package/dist/core/interfaces/template.interface.d.ts.map +1 -1
  9. package/dist/factories/email-factory.d.ts +32 -6
  10. package/dist/factories/email-factory.d.ts.map +1 -1
  11. package/dist/factories/email-factory.js +110 -6
  12. package/dist/factories/template-factory.d.ts.map +1 -1
  13. package/dist/factories/template-factory.js +170 -60
  14. package/dist/index.d.ts +16 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +23 -1
  17. package/dist/providers/mailgun.transport.d.ts +10 -0
  18. package/dist/providers/mailgun.transport.d.ts.map +1 -0
  19. package/dist/providers/mailgun.transport.js +85 -0
  20. package/dist/providers/nodemailer.transport.d.ts +11 -0
  21. package/dist/providers/nodemailer.transport.d.ts.map +1 -0
  22. package/dist/providers/nodemailer.transport.js +23 -0
  23. package/dist/providers/provider-utils.d.ts +17 -0
  24. package/dist/providers/provider-utils.d.ts.map +1 -0
  25. package/dist/providers/provider-utils.js +89 -0
  26. package/dist/providers/provider.interface.d.ts +30 -0
  27. package/dist/providers/provider.interface.d.ts.map +1 -0
  28. package/dist/providers/provider.interface.js +2 -0
  29. package/dist/providers/sendgrid.transport.d.ts +10 -0
  30. package/dist/providers/sendgrid.transport.d.ts.map +1 -0
  31. package/dist/providers/sendgrid.transport.js +76 -0
  32. package/dist/queues/bullmq-email-queue.d.ts +153 -0
  33. package/dist/queues/bullmq-email-queue.d.ts.map +1 -0
  34. package/dist/queues/bullmq-email-queue.js +292 -0
  35. package/dist/services/attachment.service.d.ts +15 -37
  36. package/dist/services/attachment.service.d.ts.map +1 -1
  37. package/dist/services/attachment.service.js +58 -46
  38. package/dist/services/email.service.d.ts +22 -22
  39. package/dist/services/email.service.d.ts.map +1 -1
  40. package/dist/services/email.service.js +117 -33
  41. package/dist/services/template.service.d.ts +24 -111
  42. package/dist/services/template.service.d.ts.map +1 -1
  43. package/dist/services/template.service.js +273 -362
  44. package/dist/templates/base/color-accessibility.d.ts +4 -0
  45. package/dist/templates/base/color-accessibility.d.ts.map +1 -0
  46. package/dist/templates/base/color-accessibility.js +38 -0
  47. package/dist/templates/base/template-builder.d.ts +4 -1
  48. package/dist/templates/base/template-builder.d.ts.map +1 -1
  49. package/dist/templates/base/template-builder.js +276 -527
  50. package/dist/templates/themes/aurora.theme.d.ts +12 -0
  51. package/dist/templates/themes/aurora.theme.d.ts.map +1 -0
  52. package/dist/templates/themes/aurora.theme.js +88 -0
  53. package/dist/templates/themes/corporate.theme.d.ts +2 -1
  54. package/dist/templates/themes/corporate.theme.d.ts.map +1 -1
  55. package/dist/templates/themes/corporate.theme.js +139 -102
  56. package/dist/templates/themes/editorial.theme.d.ts +12 -0
  57. package/dist/templates/themes/editorial.theme.d.ts.map +1 -0
  58. package/dist/templates/themes/editorial.theme.js +90 -0
  59. package/dist/templates/themes/forest.theme.d.ts +12 -0
  60. package/dist/templates/themes/forest.theme.d.ts.map +1 -0
  61. package/dist/templates/themes/forest.theme.js +89 -0
  62. package/dist/templates/themes/glass.theme.d.ts +12 -0
  63. package/dist/templates/themes/glass.theme.d.ts.map +1 -0
  64. package/dist/templates/themes/glass.theme.js +88 -0
  65. package/dist/templates/themes/lavender.theme.d.ts +12 -0
  66. package/dist/templates/themes/lavender.theme.d.ts.map +1 -0
  67. package/dist/templates/themes/lavender.theme.js +91 -0
  68. package/dist/templates/themes/midnight.theme.d.ts +12 -0
  69. package/dist/templates/themes/midnight.theme.d.ts.map +1 -0
  70. package/dist/templates/themes/midnight.theme.js +91 -0
  71. package/dist/templates/themes/minimal.theme.d.ts +2 -1
  72. package/dist/templates/themes/minimal.theme.d.ts.map +1 -1
  73. package/dist/templates/themes/minimal.theme.js +142 -107
  74. package/dist/templates/themes/modern.theme.d.ts +2 -1
  75. package/dist/templates/themes/modern.theme.d.ts.map +1 -1
  76. package/dist/templates/themes/modern.theme.js +109 -74
  77. package/dist/templates/themes/monokai.theme.d.ts +2 -1
  78. package/dist/templates/themes/monokai.theme.d.ts.map +1 -1
  79. package/dist/templates/themes/monokai.theme.js +102 -66
  80. package/dist/templates/themes/neobrutal.theme.d.ts +12 -0
  81. package/dist/templates/themes/neobrutal.theme.d.ts.map +1 -0
  82. package/dist/templates/themes/neobrutal.theme.js +91 -0
  83. package/dist/templates/themes/ocean.theme.d.ts +12 -0
  84. package/dist/templates/themes/ocean.theme.d.ts.map +1 -0
  85. package/dist/templates/themes/ocean.theme.js +90 -0
  86. package/dist/templates/themes/pastel.theme.d.ts +12 -0
  87. package/dist/templates/themes/pastel.theme.d.ts.map +1 -0
  88. package/dist/templates/themes/pastel.theme.js +89 -0
  89. package/dist/templates/themes/sunset.theme.d.ts +12 -0
  90. package/dist/templates/themes/sunset.theme.d.ts.map +1 -0
  91. package/dist/templates/themes/sunset.theme.js +90 -0
  92. package/dist/templates/themes/system.theme.d.ts +2 -1
  93. package/dist/templates/themes/system.theme.d.ts.map +1 -1
  94. package/dist/templates/themes/system.theme.js +81 -47
  95. package/dist/templates/themes/theme.interface.d.ts +48 -0
  96. package/dist/templates/themes/theme.interface.d.ts.map +1 -1
  97. package/package.json +36 -14
  98. package/src/core/enums/template-part.enum.ts +7 -0
  99. package/src/core/enums/theme.enum.ts +18 -0
  100. package/src/core/interfaces/email.interface.ts +116 -0
  101. package/src/core/interfaces/template.interface.ts +71 -0
  102. package/src/factories/email-factory.ts +208 -0
  103. package/src/factories/template-factory.ts +201 -0
  104. package/src/index.ts +53 -0
  105. package/src/providers/mailgun.transport.ts +80 -0
  106. package/src/providers/nodemailer.transport.ts +20 -0
  107. package/src/providers/provider-utils.ts +91 -0
  108. package/src/providers/provider.interface.ts +26 -0
  109. package/src/providers/sendgrid.transport.ts +80 -0
  110. package/src/queues/bullmq-email-queue.ts +418 -0
  111. package/src/services/attachment.service.ts +98 -0
  112. package/src/services/email.service.ts +151 -0
  113. package/src/services/template.service.ts +431 -0
  114. package/src/templates/base/color-accessibility.ts +31 -0
  115. package/src/templates/base/template-builder.ts +438 -0
  116. package/src/templates/themes/aurora.theme.ts +91 -0
  117. package/src/templates/themes/corporate.theme.ts +156 -0
  118. package/src/templates/themes/editorial.theme.ts +93 -0
  119. package/src/templates/themes/forest.theme.ts +92 -0
  120. package/src/templates/themes/glass.theme.ts +91 -0
  121. package/src/templates/themes/lavender.theme.ts +94 -0
  122. package/src/templates/themes/midnight.theme.ts +94 -0
  123. package/src/templates/themes/minimal.theme.ts +158 -0
  124. package/src/templates/themes/modern.theme.ts +124 -0
  125. package/src/templates/themes/monokai.theme.ts +117 -0
  126. package/src/templates/themes/neobrutal.theme.ts +94 -0
  127. package/src/templates/themes/ocean.theme.ts +93 -0
  128. package/src/templates/themes/pastel.theme.ts +92 -0
  129. package/src/templates/themes/sunset.theme.ts +93 -0
  130. package/src/templates/themes/system.theme.ts +92 -0
  131. package/src/templates/themes/theme.interface.ts +95 -0
  132. package/src/tests/template.service.test.ts +1 -0
@@ -0,0 +1,91 @@
1
+ import { readFile, stat } from 'fs/promises';
2
+ import { IAttachment } from '../core/interfaces/email.interface';
3
+
4
+ export interface IProviderMailbox {
5
+ email: string;
6
+ name?: string;
7
+ }
8
+
9
+ /** Splits a header address list without splitting commas inside quotes or angle brackets. */
10
+ export function splitAddresses(value: string | string[] | undefined): string[] {
11
+ if (value === undefined) return [];
12
+ const entries = Array.isArray(value) ? value : [value];
13
+ const result: string[] = [];
14
+
15
+ for (const entry of entries) {
16
+ let start = 0;
17
+ let quoted = false;
18
+ let angleDepth = 0;
19
+ for (let index = 0; index < entry.length; index += 1) {
20
+ const char = entry[index];
21
+ if (char === '"' && entry[index - 1] !== '\\') quoted = !quoted;
22
+ if (!quoted && char === '<') angleDepth += 1;
23
+ if (!quoted && char === '>' && angleDepth > 0) angleDepth -= 1;
24
+ if (!quoted && angleDepth === 0 && char === ',') {
25
+ const address = entry.slice(start, index).trim();
26
+ if (address) result.push(address);
27
+ start = index + 1;
28
+ }
29
+ }
30
+ const address = entry.slice(start).trim();
31
+ if (address) result.push(address);
32
+ }
33
+
34
+ return result;
35
+ }
36
+
37
+ export function parseMailbox(value: string): IProviderMailbox {
38
+ const normalized = value.trim();
39
+ const match = /^(.*?)\s*<([^<>]+)>$/.exec(normalized);
40
+ if (!match) return { email: normalized };
41
+
42
+ const name = match[1].trim().replace(/^"|"$/g, '');
43
+ return name ? { email: match[2].trim(), name } : { email: match[2].trim() };
44
+ }
45
+
46
+ export function parseMailboxes(value: string | string[] | undefined): IProviderMailbox[] {
47
+ return splitAddresses(value).map(parseMailbox);
48
+ }
49
+
50
+ export async function attachmentToBuffer(attachment: IAttachment, maxAttachmentBytes?: number): Promise<Buffer> {
51
+ let content: Buffer;
52
+ if (attachment.content !== undefined) {
53
+ content = Buffer.isBuffer(attachment.content)
54
+ ? attachment.content
55
+ : Buffer.from(attachment.content);
56
+ } else if (attachment.path) {
57
+ if (maxAttachmentBytes !== undefined) {
58
+ const fileInfo = await stat(attachment.path);
59
+ if (fileInfo.size > maxAttachmentBytes) {
60
+ throw new Error(`Attachment exceeds configured maxAttachmentBytes (${maxAttachmentBytes})`);
61
+ }
62
+ }
63
+ content = await readFile(attachment.path);
64
+ } else {
65
+ throw new Error(`Attachment "${attachment.filename}" requires content or path`);
66
+ }
67
+
68
+ if (maxAttachmentBytes !== undefined && content.length > maxAttachmentBytes) {
69
+ throw new Error(`Attachment exceeds configured maxAttachmentBytes (${maxAttachmentBytes})`);
70
+ }
71
+ if (!attachment.filename || /[\u0000-\u001f\u007f]/.test(attachment.filename)) {
72
+ throw new Error('Attachment filename must be non-empty and contain no control characters');
73
+ }
74
+ return content;
75
+ }
76
+
77
+ export function validateApiConfig(options: {
78
+ apiKey: string;
79
+ defaultFrom: string;
80
+ requestTimeoutMs?: number;
81
+ maxAttachmentBytes?: number;
82
+ }): void {
83
+ if (!options.apiKey.trim()) throw new Error('Provider apiKey is required');
84
+ if (!options.defaultFrom.trim()) throw new Error('Provider defaultFrom is required');
85
+ if (options.requestTimeoutMs !== undefined && (!Number.isSafeInteger(options.requestTimeoutMs) || options.requestTimeoutMs <= 0)) {
86
+ throw new Error('requestTimeoutMs must be a positive safe integer');
87
+ }
88
+ if (options.maxAttachmentBytes !== undefined && (!Number.isSafeInteger(options.maxAttachmentBytes) || options.maxAttachmentBytes <= 0)) {
89
+ throw new Error('maxAttachmentBytes must be a positive safe integer');
90
+ }
91
+ }
@@ -0,0 +1,26 @@
1
+ import { IAttachment } from '../core/interfaces/email.interface';
2
+
3
+ export interface ITransportMessage {
4
+ from?: string;
5
+ to: string | string[];
6
+ cc?: string | string[];
7
+ bcc?: string | string[];
8
+ subject: string;
9
+ text?: string;
10
+ html?: string;
11
+ attachments?: IAttachment[];
12
+ }
13
+
14
+ export interface ITransportSendInfo {
15
+ messageId?: string;
16
+ response?: string;
17
+ accepted?: Array<string | { address: string; name: string }>;
18
+ rejected?: Array<string | { address: string; name: string }>;
19
+ }
20
+
21
+ /** Small common contract; provider transports may omit verify when their API has no safe health check. */
22
+ export interface IEmailTransport {
23
+ sendMail(message: ITransportMessage): Promise<ITransportSendInfo>;
24
+ verify?(): Promise<true>;
25
+ close?(): void;
26
+ }
@@ -0,0 +1,80 @@
1
+ import { ISendGridConfig } from '../core/interfaces/email.interface';
2
+ import { ITransportMessage, ITransportSendInfo, IEmailTransport } from './provider.interface';
3
+ import { attachmentToBuffer, parseMailboxes, validateApiConfig } from './provider-utils';
4
+
5
+ export class SendGridTransport implements IEmailTransport {
6
+ private readonly apiUrl: string;
7
+ private readonly fetchImpl: typeof fetch;
8
+
9
+ constructor(private readonly config: ISendGridConfig, fetchImpl: typeof fetch = fetch) {
10
+ validateApiConfig(config);
11
+ this.apiUrl = (config.apiUrl ?? 'https://api.sendgrid.com').replace(/\/+$/, '');
12
+ const parsedUrl = new URL(this.apiUrl);
13
+ if (parsedUrl.protocol !== 'https:' && parsedUrl.hostname !== 'localhost' && parsedUrl.hostname !== '127.0.0.1') {
14
+ throw new Error('SendGrid apiUrl must use HTTPS');
15
+ }
16
+ this.fetchImpl = fetchImpl;
17
+ }
18
+
19
+ async sendMail(message: ITransportMessage): Promise<ITransportSendInfo> {
20
+ const recipients = parseMailboxes(message.to);
21
+ if (recipients.length === 0) throw new Error('SendGrid requires at least one recipient');
22
+ const from = parseMailboxes(message.from ?? this.config.defaultFrom)[0];
23
+ if (!from) throw new Error('SendGrid requires a sender');
24
+
25
+ const content = [
26
+ ...(message.text !== undefined ? [{ type: 'text/plain', value: message.text }] : []),
27
+ ...(message.html !== undefined ? [{ type: 'text/html', value: message.html }] : [])
28
+ ];
29
+ if (content.length === 0) throw new Error('SendGrid requires a text or HTML body');
30
+
31
+ const attachments = await Promise.all((message.attachments ?? []).map(async (attachment) => ({
32
+ content: (await attachmentToBuffer(attachment, this.config.maxAttachmentBytes)).toString('base64'),
33
+ filename: attachment.filename,
34
+ type: attachment.contentType ?? 'application/octet-stream',
35
+ disposition: attachment.cid ? 'inline' : 'attachment',
36
+ ...(attachment.cid ? { content_id: attachment.cid } : {})
37
+ })));
38
+
39
+ const payload = {
40
+ personalizations: [{
41
+ to: recipients,
42
+ ...(message.cc ? { cc: parseMailboxes(message.cc) } : {}),
43
+ ...(message.bcc ? { bcc: parseMailboxes(message.bcc) } : {})
44
+ }],
45
+ from,
46
+ subject: message.subject,
47
+ content,
48
+ ...(attachments.length > 0 ? { attachments } : {})
49
+ };
50
+
51
+ const response = await this.fetchImpl(`${this.apiUrl}/v3/mail/send`, {
52
+ method: 'POST',
53
+ headers: {
54
+ authorization: `Bearer ${this.config.apiKey}`,
55
+ 'content-type': 'application/json'
56
+ },
57
+ body: JSON.stringify(payload),
58
+ redirect: 'error',
59
+ signal: AbortSignal.timeout(this.config.requestTimeoutMs ?? 30_000)
60
+ });
61
+
62
+ if (response.status !== 202) {
63
+ const responseBody = await response.text();
64
+ const error = new Error(`SendGrid API returned HTTP ${response.status}${responseBody ? `: ${responseBody}` : ''}`);
65
+ Object.assign(error, { statusCode: response.status, responseBody });
66
+ throw error;
67
+ }
68
+
69
+ const accepted = [...recipients, ...parseMailboxes(message.cc), ...parseMailboxes(message.bcc)].map(({ email, name }) =>
70
+ name ? { address: email, name } : email
71
+ );
72
+ const messageId = response.headers.get('x-message-id') ?? undefined;
73
+ return {
74
+ ...(messageId ? { messageId } : {}),
75
+ response: '202 Accepted',
76
+ accepted,
77
+ rejected: []
78
+ };
79
+ }
80
+ }
@@ -0,0 +1,418 @@
1
+ import { createHash } from 'crypto';
2
+ import { createRequire } from 'module';
3
+ import { IAttachment, IEmailOptions, IEmailSendResult } from '../core/interfaces/email.interface';
4
+
5
+ const loadOptionalModule = createRequire(__filename);
6
+
7
+ export interface IBullMQBackoffOptions {
8
+ type: 'fixed' | 'exponential';
9
+ delay: number;
10
+ jitter?: number;
11
+ }
12
+
13
+ export interface IEmailQueueJobOptions {
14
+ /** Stable business key. It is hashed before being used as a Redis job ID. */
15
+ idempotencyKey?: string;
16
+ delay?: number;
17
+ priority?: number;
18
+ attempts?: number;
19
+ backoff?: IBullMQBackoffOptions;
20
+ }
21
+
22
+ export interface IBullMQRateLimiterOptions {
23
+ max: number;
24
+ duration: number;
25
+ }
26
+
27
+ export interface IBullMQWorkerMetricsOptions {
28
+ /** Number of one-minute data points retained in Redis for completed/failed jobs. */
29
+ maxDataPoints: number;
30
+ }
31
+
32
+ export interface IBullMQEmailQueueConfig {
33
+ /** Redis/ioredis connection options. Keep credentials in a secret manager. */
34
+ connection: Record<string, unknown>;
35
+ queueName?: string;
36
+ prefix?: string;
37
+ concurrency?: number;
38
+ limiter?: IBullMQRateLimiterOptions;
39
+ workerMetrics?: IBullMQWorkerMetricsOptions;
40
+ maxPayloadBytes?: number;
41
+ maxBulkSize?: number;
42
+ defaultJobOptions?: {
43
+ attempts?: number;
44
+ backoff?: IBullMQBackoffOptions;
45
+ removeOnComplete?: boolean | number | { age?: number; count?: number };
46
+ removeOnFail?: boolean | number | { age?: number; count?: number };
47
+ };
48
+ }
49
+
50
+ export interface IEmailQueueSender {
51
+ sendEmail(options: IEmailOptions): Promise<IEmailSendResult>;
52
+ }
53
+
54
+ export interface IQueuedEmailJob {
55
+ id?: string;
56
+ }
57
+
58
+ export type ISerializedEmailAttachment = Omit<IAttachment, 'content'> & { contentBase64?: string };
59
+
60
+ export interface IEmailQueueJobData {
61
+ version: 1;
62
+ email: Omit<IEmailOptions, 'template' | 'templateData' | 'attachments'> & {
63
+ attachments?: ISerializedEmailAttachment[];
64
+ };
65
+ }
66
+
67
+ export interface IBullMQJobLike {
68
+ id?: string | number;
69
+ name: string;
70
+ data: IEmailQueueJobData;
71
+ }
72
+
73
+ export interface IBullMQQueueMetrics {
74
+ data: number[];
75
+ count: number;
76
+ meta: { count: number; prevTS: number; prevCount: number };
77
+ }
78
+
79
+ export interface IBullMQQueueEventPayload {
80
+ jobId?: string;
81
+ prev?: string;
82
+ failedReason?: string;
83
+ returnvalue?: unknown;
84
+ data?: unknown;
85
+ [key: string]: unknown;
86
+ }
87
+
88
+ export interface IBullMQQueueEventsLike {
89
+ on(event: string, listener: (payload: IBullMQQueueEventPayload) => void): this;
90
+ close(): Promise<void>;
91
+ }
92
+
93
+ interface IBullMQQueueLike {
94
+ add(name: string, data: IEmailQueueJobData, options?: Record<string, unknown>): Promise<IQueuedEmailJob>;
95
+ addBulk(jobs: Array<{ name: string; data: IEmailQueueJobData; opts?: Record<string, unknown> }>): Promise<IQueuedEmailJob[]>;
96
+ close(): Promise<void>;
97
+ pause(): Promise<void>;
98
+ resume(): Promise<void>;
99
+ getJobCounts(...types: string[]): Promise<Record<string, number>>;
100
+ exportPrometheusMetrics?(globalVariables?: Record<string, string>): Promise<string>;
101
+ getMetrics?(type: 'completed' | 'failed', start?: number, end?: number): Promise<IBullMQQueueMetrics>;
102
+ }
103
+
104
+ interface IBullMQWorkerLike {
105
+ close(): Promise<void>;
106
+ }
107
+
108
+ export interface IBullMQRuntime {
109
+ Queue: new (name: string, options: Record<string, unknown>) => IBullMQQueueLike;
110
+ Worker: new (
111
+ name: string,
112
+ processor: (job: IBullMQJobLike) => Promise<IEmailSendResult>,
113
+ options: Record<string, unknown>
114
+ ) => IBullMQWorkerLike;
115
+ UnrecoverableError: new (message: string) => Error;
116
+ QueueEvents?: new (name: string, options: Record<string, unknown>) => IBullMQQueueEventsLike;
117
+ }
118
+
119
+ function loadBullMQRuntime(): IBullMQRuntime {
120
+ try {
121
+ return loadOptionalModule('bullmq') as IBullMQRuntime;
122
+ } catch (error) {
123
+ throw new Error('BullMQ support requires the optional package bullmq. Install it with: npm install bullmq', { cause: error });
124
+ }
125
+ }
126
+
127
+ function assertPositiveInteger(name: string, value: number | undefined): void {
128
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) {
129
+ throw new Error(`${name} must be a positive safe integer`);
130
+ }
131
+ }
132
+
133
+ function assertJobOptions(options: IEmailQueueJobOptions): void {
134
+ assertPositiveInteger('attempts', options.attempts);
135
+ if (options.delay !== undefined && (!Number.isSafeInteger(options.delay) || options.delay < 0)) {
136
+ throw new Error('delay must be a non-negative safe integer');
137
+ }
138
+ if (options.priority !== undefined && (!Number.isSafeInteger(options.priority) || options.priority < 0)) {
139
+ throw new Error('priority must be a non-negative safe integer');
140
+ }
141
+ if (options.backoff) {
142
+ assertPositiveInteger('backoff.delay', options.backoff.delay);
143
+ if (options.backoff.jitter !== undefined && (options.backoff.jitter < 0 || options.backoff.jitter > 1)) {
144
+ throw new Error('backoff.jitter must be between 0 and 1');
145
+ }
146
+ }
147
+ if (options.idempotencyKey !== undefined && !options.idempotencyKey.trim()) {
148
+ throw new Error('idempotencyKey must not be empty');
149
+ }
150
+ }
151
+
152
+ function jobOptions(options: IEmailQueueJobOptions): Record<string, unknown> {
153
+ const { idempotencyKey, ...bullOptions } = options;
154
+ return {
155
+ ...bullOptions,
156
+ ...(idempotencyKey ? { jobId: `email-${createHash('sha256').update(idempotencyKey).digest('hex')}` } : {})
157
+ };
158
+ }
159
+
160
+ function serializeAttachment(attachment: IAttachment): ISerializedEmailAttachment {
161
+ const { content, ...metadata } = attachment;
162
+ if (content === undefined) return metadata;
163
+ const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
164
+ return { ...metadata, contentBase64: buffer.toString('base64') };
165
+ }
166
+
167
+ function deserializeEmail(data: IEmailQueueJobData): IEmailOptions {
168
+ if (data?.version !== 1 || !data.email || typeof data.email !== 'object') {
169
+ throw new Error('Unsupported or malformed tzmail queue job payload');
170
+ }
171
+ const { attachments, ...message } = data.email;
172
+ return {
173
+ ...message,
174
+ ...(attachments ? {
175
+ attachments: attachments.map(({ contentBase64, ...attachment }) => ({
176
+ ...attachment,
177
+ ...(contentBase64 !== undefined ? { content: Buffer.from(contentBase64, 'base64') } : {})
178
+ }))
179
+ } : {})
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Redis-backed asynchronous email delivery using BullMQ. This class may be
185
+ * instantiated in producer and worker processes with the same queue settings.
186
+ */
187
+ export class BullMQEmailQueue {
188
+ readonly queueName: string;
189
+ private readonly runtime: IBullMQRuntime;
190
+ private readonly queue: IBullMQQueueLike;
191
+ private readonly workers = new Set<IBullMQWorkerLike>();
192
+ private readonly queueEvents = new Set<IBullMQQueueEventsLike>();
193
+ private readonly maxPayloadBytes: number;
194
+ private readonly maxBulkSize: number;
195
+ private closed = false;
196
+
197
+ constructor(private readonly config: IBullMQEmailQueueConfig, runtime?: IBullMQRuntime) {
198
+ if (!config.connection || typeof config.connection !== 'object') {
199
+ throw new Error('BullMQ Redis connection options are required');
200
+ }
201
+ this.queueName = config.queueName ?? 'tzmail-email';
202
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(this.queueName)) {
203
+ throw new Error('queueName must contain only letters, numbers, underscores or hyphens, with a maximum length of 128');
204
+ }
205
+ assertPositiveInteger('concurrency', config.concurrency);
206
+ assertPositiveInteger('maxPayloadBytes', config.maxPayloadBytes);
207
+ assertPositiveInteger('maxBulkSize', config.maxBulkSize);
208
+ assertPositiveInteger('defaultJobOptions.attempts', config.defaultJobOptions?.attempts);
209
+ assertPositiveInteger('workerMetrics.maxDataPoints', config.workerMetrics?.maxDataPoints);
210
+ if (config.defaultJobOptions?.backoff) assertJobOptions({ backoff: config.defaultJobOptions.backoff });
211
+ if (config.limiter) {
212
+ assertPositiveInteger('limiter.max', config.limiter.max);
213
+ assertPositiveInteger('limiter.duration', config.limiter.duration);
214
+ }
215
+ this.maxPayloadBytes = config.maxPayloadBytes ?? 10 * 1024 * 1024;
216
+ this.maxBulkSize = config.maxBulkSize ?? 1_000;
217
+ this.runtime = runtime ?? loadBullMQRuntime();
218
+ this.queue = new this.runtime.Queue(this.queueName, {
219
+ connection: config.connection,
220
+ ...(config.prefix ? { prefix: config.prefix } : {}),
221
+ defaultJobOptions: {
222
+ attempts: 5,
223
+ backoff: { type: 'exponential', delay: 1_000, jitter: 0.2 },
224
+ removeOnComplete: { age: 86_400, count: 10_000 },
225
+ removeOnFail: { age: 604_800, count: 50_000 },
226
+ ...config.defaultJobOptions
227
+ }
228
+ });
229
+ }
230
+
231
+ /** Add one email job. Template rendering happens before enqueue so jobs stay serializable. */
232
+ async enqueueEmail(options: IEmailOptions, job: IEmailQueueJobOptions = {}): Promise<IQueuedEmailJob> {
233
+ this.assertOpen();
234
+ assertJobOptions(job);
235
+ const data = await this.serializeEmail(options);
236
+ return this.queue.add('send-email', data, jobOptions(job));
237
+ }
238
+
239
+ /** Add a bounded batch with one Redis bulk operation. BullMQ/Redis bulk calls can partially add jobs on failure. */
240
+ async enqueueBulk(
241
+ items: Array<{ email: IEmailOptions; job?: IEmailQueueJobOptions }>
242
+ ): Promise<IQueuedEmailJob[]> {
243
+ this.assertOpen();
244
+ if (!Array.isArray(items)) throw new Error('items must be an array');
245
+ if (items.length === 0) return [];
246
+ if (items.length > this.maxBulkSize) {
247
+ throw new Error(`Bulk exceeds configured maxBulkSize (${this.maxBulkSize})`);
248
+ }
249
+ if (items.some((item) => !item || !item.email)) {
250
+ throw new Error('Each bulk item requires an email message');
251
+ }
252
+ const keys = items.map((item) => item.job?.idempotencyKey).filter((key): key is string => key !== undefined);
253
+ if (new Set(keys).size !== keys.length) {
254
+ throw new Error('idempotencyKey values must be unique within one bulk request');
255
+ }
256
+
257
+ const jobs = await Promise.all(items.map(async (item) => {
258
+ const options = item.job ?? {};
259
+ assertJobOptions(options);
260
+ return {
261
+ name: 'send-email',
262
+ data: await this.serializeEmail(item.email),
263
+ opts: jobOptions(options)
264
+ };
265
+ }));
266
+ return this.queue.addBulk(jobs);
267
+ }
268
+
269
+ /** Start a BullMQ Worker. Run this in dedicated worker processes for horizontal scaling. */
270
+ startWorker(sender: IEmailQueueSender, options: {
271
+ concurrency?: number;
272
+ limiter?: IBullMQRateLimiterOptions;
273
+ metrics?: IBullMQWorkerMetricsOptions;
274
+ } = {}): IBullMQWorkerLike {
275
+ this.assertOpen();
276
+ const concurrency = options.concurrency ?? this.config.concurrency ?? 5;
277
+ assertPositiveInteger('concurrency', concurrency);
278
+ const limiter = options.limiter ?? this.config.limiter;
279
+ const metrics = options.metrics ?? this.config.workerMetrics;
280
+ if (limiter) {
281
+ assertPositiveInteger('limiter.max', limiter.max);
282
+ assertPositiveInteger('limiter.duration', limiter.duration);
283
+ }
284
+ assertPositiveInteger('metrics.maxDataPoints', metrics?.maxDataPoints);
285
+
286
+ const worker = new this.runtime.Worker(this.queueName, async (job) => {
287
+ let email: IEmailOptions;
288
+ try {
289
+ email = deserializeEmail(job.data);
290
+ } catch (error) {
291
+ throw new this.runtime.UnrecoverableError(error instanceof Error ? error.message : 'Malformed email job payload');
292
+ }
293
+
294
+ const result = await sender.sendEmail(email);
295
+ if (result.status === 'success') return result;
296
+ if (result.status === 'partial') {
297
+ throw new this.runtime.UnrecoverableError(
298
+ 'Email was accepted for only some recipients; automatic retry is disabled to avoid duplicate delivery.'
299
+ );
300
+ }
301
+ if (result.errorStage === 'validation' || result.errorStage === 'render') {
302
+ throw new this.runtime.UnrecoverableError(`Email job failed at ${result.errorStage} stage.`);
303
+ }
304
+ if (result.error instanceof Error) throw result.error;
305
+ throw new Error(`Email job failed at ${result.errorStage ?? 'unknown'} stage.`);
306
+ }, {
307
+ connection: this.config.connection,
308
+ ...(this.config.prefix ? { prefix: this.config.prefix } : {}),
309
+ concurrency,
310
+ ...(limiter ? { limiter } : {}),
311
+ ...(metrics ? { metrics } : {})
312
+ });
313
+ this.workers.add(worker);
314
+ return worker;
315
+ }
316
+
317
+ pause(): Promise<void> {
318
+ this.assertOpen();
319
+ return this.queue.pause();
320
+ }
321
+
322
+ resume(): Promise<void> {
323
+ this.assertOpen();
324
+ return this.queue.resume();
325
+ }
326
+
327
+ getJobCounts(): Promise<Record<string, number>> {
328
+ this.assertOpen();
329
+ return this.queue.getJobCounts('wait', 'active', 'completed', 'failed', 'delayed', 'paused');
330
+ }
331
+
332
+ /** Export queue counts in Prometheus text exposition format. */
333
+ async exportPrometheusMetrics(globalVariables?: Record<string, string>): Promise<string> {
334
+ this.assertOpen();
335
+ if (!this.queue.exportPrometheusMetrics) {
336
+ throw new Error('The installed BullMQ version does not support exportPrometheusMetrics().');
337
+ }
338
+ return this.queue.exportPrometheusMetrics(globalVariables);
339
+ }
340
+
341
+ /** Read BullMQ's per-minute completed/failed worker metrics from Redis. */
342
+ async getMetrics(type: 'completed' | 'failed', start = 0, end = -1): Promise<IBullMQQueueMetrics> {
343
+ this.assertOpen();
344
+ if (!this.queue.getMetrics) {
345
+ throw new Error('The installed BullMQ version does not support getMetrics().');
346
+ }
347
+ return this.queue.getMetrics(type, start, end);
348
+ }
349
+
350
+ /** Listen to lifecycle events across all workers using BullMQ QueueEvents. */
351
+ createQueueEvents(): IBullMQQueueEventsLike {
352
+ this.assertOpen();
353
+ if (!this.runtime.QueueEvents) {
354
+ throw new Error('The installed BullMQ runtime does not expose QueueEvents.');
355
+ }
356
+ const events = new this.runtime.QueueEvents(this.queueName, {
357
+ connection: this.config.connection,
358
+ ...(this.config.prefix ? { prefix: this.config.prefix } : {})
359
+ });
360
+ this.queueEvents.add(events);
361
+ return events;
362
+ }
363
+
364
+ /** Gracefully wait for active workers and release the BullMQ queue connection. */
365
+ async close(): Promise<void> {
366
+ if (this.closed) return;
367
+ this.closed = true;
368
+ const workerResults = await Promise.allSettled([
369
+ ...[...this.workers].map((worker) => worker.close()),
370
+ ...[...this.queueEvents].map((events) => events.close())
371
+ ]);
372
+ this.workers.clear();
373
+ this.queueEvents.clear();
374
+ const errors = workerResults.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
375
+ try {
376
+ await this.queue.close();
377
+ } catch (error) {
378
+ errors.push(error);
379
+ }
380
+ if (errors.length > 0) {
381
+ throw new AggregateError(errors, 'One or more BullMQ resources failed to close cleanly.');
382
+ }
383
+ }
384
+
385
+ private async serializeEmail(options: IEmailOptions): Promise<IEmailQueueJobData> {
386
+ if (!options || typeof options !== 'object') throw new Error('Email options are required');
387
+ const template = options.template;
388
+ const attachments = options.attachments;
389
+ const message = { ...options };
390
+ delete message.template;
391
+ delete message.templateData;
392
+ delete message.attachments;
393
+ let html = message.html;
394
+ if (html === undefined && template) {
395
+ html = await template.render(options);
396
+ }
397
+ const serializedAttachments = attachments?.map(serializeAttachment);
398
+ const email = {
399
+ ...message,
400
+ ...(html !== undefined ? { html } : {}),
401
+ ...(serializedAttachments ? { attachments: serializedAttachments } : {})
402
+ };
403
+ let payload: string;
404
+ try {
405
+ payload = JSON.stringify({ version: 1, email });
406
+ } catch (error) {
407
+ throw new Error('Email job payload must be JSON serializable; template functions are rendered before enqueue.', { cause: error });
408
+ }
409
+ if (Buffer.byteLength(payload, 'utf8') > this.maxPayloadBytes) {
410
+ throw new Error(`Email job payload exceeds configured maxPayloadBytes (${this.maxPayloadBytes})`);
411
+ }
412
+ return JSON.parse(payload) as IEmailQueueJobData;
413
+ }
414
+
415
+ private assertOpen(): void {
416
+ if (this.closed) throw new Error('BullMQEmailQueue is closed. Create a new instance to resume queue operations.');
417
+ }
418
+ }