@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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ThemeType } from "../enums/theme.enum";
|
|
2
|
+
|
|
3
|
+
export interface ITemplate {
|
|
4
|
+
name: string;
|
|
5
|
+
theme: ThemeType;
|
|
6
|
+
variant: 'light' | 'dark';
|
|
7
|
+
config: ITemplateConfig;
|
|
8
|
+
render(data: any): Promise<string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ITemplateConfig {
|
|
12
|
+
/** BCP 47 language tag written to the generated document; defaults to en. */
|
|
13
|
+
language?: string;
|
|
14
|
+
/** Text direction for right-to-left or automatic-script content; defaults to ltr. */
|
|
15
|
+
direction?: 'ltr' | 'rtl' | 'auto';
|
|
16
|
+
/** Hidden inbox preview text shown beside the subject; limited to 500 characters. */
|
|
17
|
+
preheader?: string;
|
|
18
|
+
/** Decoration for generated footer links; defaults to none. Set to underline when desired. */
|
|
19
|
+
linkDecoration?: 'none' | 'underline';
|
|
20
|
+
header?: IHeaderConfig;
|
|
21
|
+
body?: IBodyConfig;
|
|
22
|
+
footer?: IFooterConfig;
|
|
23
|
+
layout?: 'full' | 'minimal';
|
|
24
|
+
spacing?: 'compact' | 'normal' | 'relaxed';
|
|
25
|
+
borderRadius?: 'none' | 'small' | 'medium' | 'large';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IBodyConfig {
|
|
29
|
+
title?: string;
|
|
30
|
+
message?: string;
|
|
31
|
+
/** HTML intencionalmente confiável; os demais campos textuais são escapados pelo renderer. */
|
|
32
|
+
content?: string;
|
|
33
|
+
buttonText?: string;
|
|
34
|
+
buttonUrl?: string;
|
|
35
|
+
buttonVariant?: 'primary' | 'secondary' | 'success' | 'danger';
|
|
36
|
+
alignment?: 'left' | 'center' | 'right';
|
|
37
|
+
backgroundColor?: string;
|
|
38
|
+
textColor?: string;
|
|
39
|
+
fontSize?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface IHeaderConfig {
|
|
43
|
+
show: boolean;
|
|
44
|
+
logo?: {
|
|
45
|
+
type: 'text' | 'image';
|
|
46
|
+
text?: string;
|
|
47
|
+
imageUrl?: string;
|
|
48
|
+
alt?: string;
|
|
49
|
+
size?: 'small' | 'medium' | 'large';
|
|
50
|
+
};
|
|
51
|
+
backgroundColor?: string;
|
|
52
|
+
textColor?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface IFooterConfig {
|
|
56
|
+
show: boolean;
|
|
57
|
+
links?: Array<{
|
|
58
|
+
text: string;
|
|
59
|
+
url: string;
|
|
60
|
+
}>;
|
|
61
|
+
socialLinks?: Array<{
|
|
62
|
+
platform: 'facebook' | 'twitter' | 'linkedin' | 'github' | 'instagram' | 'youtube' | 'tiktok' | 'pinterest' | 'reddit' | 'gitlab' | 'stackoverflow' | 'medium' | 'dribbble' | 'behance' | 'telegram';
|
|
63
|
+
url: string;
|
|
64
|
+
}>;
|
|
65
|
+
copyrightText?: string;
|
|
66
|
+
unsubscribeText?: string;
|
|
67
|
+
/** URL opcional do link de cancelamento; sem ela, o texto é exibido sem href fictício. */
|
|
68
|
+
unsubscribeUrl?: string;
|
|
69
|
+
backgroundColor?: string;
|
|
70
|
+
textColor?: string;
|
|
71
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
import nodemailer from 'nodemailer';
|
|
4
|
+
import {
|
|
5
|
+
IAmazonSesConfig,
|
|
6
|
+
IEmailConfig,
|
|
7
|
+
IEmailOptions,
|
|
8
|
+
IEmailSendResult,
|
|
9
|
+
IMailgunConfig,
|
|
10
|
+
ISendGridConfig
|
|
11
|
+
} from '../core/interfaces/email.interface';
|
|
12
|
+
import { EmailService } from '../services/email.service';
|
|
13
|
+
import { ITemplateServiceOptions, TemplateService } from '../services/template.service';
|
|
14
|
+
import { AttachmentService, IAttachmentServiceOptions } from '../services/attachment.service';
|
|
15
|
+
import { ThemeType } from '../core/enums/theme.enum';
|
|
16
|
+
import { ITemplateConfig } from '../core/interfaces/template.interface';
|
|
17
|
+
import { IEmailTransport } from '../providers/provider.interface';
|
|
18
|
+
import { NodemailerTransport } from '../providers/nodemailer.transport';
|
|
19
|
+
import { SendGridTransport } from '../providers/sendgrid.transport';
|
|
20
|
+
import { MailgunTransport } from '../providers/mailgun.transport';
|
|
21
|
+
import { BullMQEmailQueue, IBullMQEmailQueueConfig } from '../queues/bullmq-email-queue';
|
|
22
|
+
|
|
23
|
+
const loadOptionalModule = createRequire(__filename);
|
|
24
|
+
|
|
25
|
+
export class EmailFactory {
|
|
26
|
+
private static instance?: EmailFactory;
|
|
27
|
+
private static singletonFingerprint?: string;
|
|
28
|
+
private emailService: EmailService;
|
|
29
|
+
private templateService: TemplateService;
|
|
30
|
+
private attachmentService: AttachmentService;
|
|
31
|
+
|
|
32
|
+
private constructor(
|
|
33
|
+
transport: IEmailTransport,
|
|
34
|
+
defaultFrom?: string,
|
|
35
|
+
templateOptions?: ITemplateServiceOptions,
|
|
36
|
+
attachmentOptions?: IAttachmentServiceOptions,
|
|
37
|
+
errorStage: 'smtp' | 'provider' = 'smtp'
|
|
38
|
+
) {
|
|
39
|
+
this.emailService = new EmailService(transport, defaultFrom, errorStage);
|
|
40
|
+
this.templateService = new TemplateService(templateOptions);
|
|
41
|
+
this.attachmentService = new AttachmentService(attachmentOptions);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns the process singleton. A later call with different settings fails
|
|
46
|
+
* explicitly rather than silently continuing with the first configuration.
|
|
47
|
+
*/
|
|
48
|
+
static initialize(config: IEmailConfig, templateOptions?: ITemplateServiceOptions, attachmentOptions?: IAttachmentServiceOptions): EmailFactory {
|
|
49
|
+
const fingerprint = this.fingerprint(config, templateOptions, attachmentOptions);
|
|
50
|
+
if (!this.instance) {
|
|
51
|
+
this.instance = new EmailFactory(
|
|
52
|
+
new NodemailerTransport(nodemailer.createTransport(config)),
|
|
53
|
+
config.defaultFrom,
|
|
54
|
+
templateOptions,
|
|
55
|
+
attachmentOptions
|
|
56
|
+
);
|
|
57
|
+
this.singletonFingerprint = fingerprint;
|
|
58
|
+
} else if (this.singletonFingerprint !== fingerprint) {
|
|
59
|
+
throw new Error('EmailFactory is already initialized with a different configuration; use EmailFactory.create() for an independent instance.');
|
|
60
|
+
}
|
|
61
|
+
return this.instance;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Creates an independent factory instance without changing the singleton. */
|
|
65
|
+
static create(config: IEmailConfig, templateOptions?: ITemplateServiceOptions, attachmentOptions?: IAttachmentServiceOptions): EmailFactory {
|
|
66
|
+
return new EmailFactory(
|
|
67
|
+
new NodemailerTransport(nodemailer.createTransport(config)),
|
|
68
|
+
config.defaultFrom,
|
|
69
|
+
templateOptions,
|
|
70
|
+
attachmentOptions
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Creates an independent SendGrid Web API v3 client; no provider state is shared between instances. */
|
|
75
|
+
static createSendGrid(config: ISendGridConfig, templateOptions?: ITemplateServiceOptions, attachmentOptions?: IAttachmentServiceOptions): EmailFactory {
|
|
76
|
+
return new EmailFactory(new SendGridTransport(config), config.defaultFrom, templateOptions, attachmentOptions, 'provider');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Creates an independent Mailgun Messages API client. */
|
|
80
|
+
static createMailgun(config: IMailgunConfig, templateOptions?: ITemplateServiceOptions, attachmentOptions?: IAttachmentServiceOptions): EmailFactory {
|
|
81
|
+
return new EmailFactory(new MailgunTransport(config), config.defaultFrom, templateOptions, attachmentOptions, 'provider');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Creates an Amazon SES v2 transport using the AWS SDK credential provider chain unless credentials are supplied. */
|
|
85
|
+
static createSES(config: IAmazonSesConfig, templateOptions?: ITemplateServiceOptions, attachmentOptions?: IAttachmentServiceOptions): EmailFactory {
|
|
86
|
+
if (!config.region.trim()) throw new Error('Amazon SES region is required');
|
|
87
|
+
let awsSes: {
|
|
88
|
+
SESv2Client: new (options: Record<string, unknown>) => unknown;
|
|
89
|
+
SendEmailCommand: unknown;
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
awsSes = loadOptionalModule('@aws-sdk/client-sesv2') as typeof awsSes;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new Error('Amazon SES support requires optional package @aws-sdk/client-sesv2. Install it with: npm install @aws-sdk/client-sesv2', { cause: error });
|
|
95
|
+
}
|
|
96
|
+
const sesClient = new awsSes.SESv2Client({
|
|
97
|
+
region: config.region,
|
|
98
|
+
...(config.credentials ? { credentials: config.credentials } : {})
|
|
99
|
+
});
|
|
100
|
+
const transporter = nodemailer.createTransport({
|
|
101
|
+
SES: { sesClient, SendEmailCommand: awsSes.SendEmailCommand }
|
|
102
|
+
} as never);
|
|
103
|
+
return new EmailFactory(
|
|
104
|
+
new NodemailerTransport(transporter, () => (sesClient as { destroy?: () => void }).destroy?.()),
|
|
105
|
+
config.defaultFrom,
|
|
106
|
+
templateOptions,
|
|
107
|
+
attachmentOptions,
|
|
108
|
+
'provider'
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Creates an instance around a user-supplied transport adapter. */
|
|
113
|
+
static createWithTransport(
|
|
114
|
+
transport: IEmailTransport,
|
|
115
|
+
defaultFrom?: string,
|
|
116
|
+
templateOptions?: ITemplateServiceOptions,
|
|
117
|
+
attachmentOptions?: IAttachmentServiceOptions
|
|
118
|
+
): EmailFactory {
|
|
119
|
+
return new EmailFactory(transport, defaultFrom, templateOptions, attachmentOptions, 'provider');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Creates an optional Redis-backed BullMQ producer/worker integration. */
|
|
123
|
+
static createBullMQQueue(config: IBullMQEmailQueueConfig): BullMQEmailQueue {
|
|
124
|
+
return new BullMQEmailQueue(config);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static getInstance(): EmailFactory {
|
|
128
|
+
if (!this.instance) {
|
|
129
|
+
throw new Error('EmailFactory not initialized. Call initialize() first.');
|
|
130
|
+
}
|
|
131
|
+
return this.instance;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async sendEmail(options: IEmailOptions): Promise<IEmailSendResult> {
|
|
135
|
+
return this.emailService.send(options);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getTemplateService(): TemplateService {
|
|
139
|
+
return this.templateService;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getAttachmentService(): AttachmentService {
|
|
143
|
+
return this.attachmentService;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Verify connectivity when supported by the selected transport; API transports may not provide a safe check. */
|
|
147
|
+
async verifyTransport(): Promise<true> {
|
|
148
|
+
return this.emailService.verify();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Release pooled SMTP connections and the template cache cleanup timer. */
|
|
152
|
+
close(): void {
|
|
153
|
+
this.emailService.close();
|
|
154
|
+
this.templateService.dispose();
|
|
155
|
+
if (EmailFactory.instance === this) {
|
|
156
|
+
EmailFactory.instance = undefined;
|
|
157
|
+
EmailFactory.singletonFingerprint = undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async previewTemplate(
|
|
162
|
+
themeType: ThemeType,
|
|
163
|
+
variant: 'light' | 'dark',
|
|
164
|
+
config: ITemplateConfig,
|
|
165
|
+
data: any
|
|
166
|
+
): Promise<string> {
|
|
167
|
+
return this.templateService.previewTemplate(themeType, variant, config, data);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
getThemeInfo(themeType: ThemeType) {
|
|
171
|
+
return this.templateService.getThemeInfo(themeType);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
listThemes() {
|
|
175
|
+
return this.templateService.listThemes();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
getTemplateStats() {
|
|
179
|
+
return this.templateService.getTemplateStats();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Hashes canonicalized settings so credentials are never exposed in errors/logs. */
|
|
183
|
+
private static fingerprint(config: IEmailConfig, templateOptions?: ITemplateServiceOptions, attachmentOptions?: IAttachmentServiceOptions): string {
|
|
184
|
+
const canonicalize = (value: any, ancestors = new Set<object>()): string => {
|
|
185
|
+
if (value === undefined) return 'undefined';
|
|
186
|
+
if (value === null || typeof value !== 'object') {
|
|
187
|
+
if (typeof value === 'function') return `function:${value.toString()}`;
|
|
188
|
+
if (typeof value === 'bigint') return `bigint:${value.toString()}`;
|
|
189
|
+
return JSON.stringify(value) ?? String(value);
|
|
190
|
+
}
|
|
191
|
+
if (ancestors.has(value)) return '[Circular]';
|
|
192
|
+
ancestors.add(value);
|
|
193
|
+
let result: string;
|
|
194
|
+
if (Array.isArray(value)) {
|
|
195
|
+
result = `[${value.map((item) => canonicalize(item, ancestors)).join(',')}]`;
|
|
196
|
+
} else if (value instanceof Date) {
|
|
197
|
+
result = `date:${value.toISOString()}`;
|
|
198
|
+
} else {
|
|
199
|
+
result = `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key], ancestors)}`).join(',')}}`;
|
|
200
|
+
}
|
|
201
|
+
ancestors.delete(value);
|
|
202
|
+
return result;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const serialized = `${canonicalize(config)}|${canonicalize(templateOptions)}|${canonicalize(attachmentOptions)}`;
|
|
206
|
+
return createHash('sha256').update(serialized).digest('hex');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { ThemeType } from '../core/enums/theme.enum';
|
|
2
|
+
import { ITemplate, ITemplateConfig } from '../core/interfaces/template.interface';
|
|
3
|
+
import { ITheme } from '../templates/themes/theme.interface';
|
|
4
|
+
import { SystemTheme } from '../templates/themes/system.theme';
|
|
5
|
+
import { MonokaiTheme } from '../templates/themes/monokai.theme';
|
|
6
|
+
import { ModernTheme } from '../templates/themes/modern.theme';
|
|
7
|
+
import { CorporateTheme } from '../templates/themes/corporate.theme';
|
|
8
|
+
import { MinimalTheme } from '../templates/themes/minimal.theme';
|
|
9
|
+
import { AuroraTheme } from '../templates/themes/aurora.theme';
|
|
10
|
+
import { OceanTheme } from '../templates/themes/ocean.theme';
|
|
11
|
+
import { SunsetTheme } from '../templates/themes/sunset.theme';
|
|
12
|
+
import { LavenderTheme } from '../templates/themes/lavender.theme';
|
|
13
|
+
import { ForestTheme } from '../templates/themes/forest.theme';
|
|
14
|
+
import { EditorialTheme } from '../templates/themes/editorial.theme';
|
|
15
|
+
import { PastelTheme } from '../templates/themes/pastel.theme';
|
|
16
|
+
import { NeoBrutalTheme } from '../templates/themes/neobrutal.theme';
|
|
17
|
+
import { GlassTheme } from '../templates/themes/glass.theme';
|
|
18
|
+
import { MidnightTheme } from '../templates/themes/midnight.theme';
|
|
19
|
+
import { TemplateBuilder } from '../templates/base/template-builder';
|
|
20
|
+
import { readableTextColor } from '../templates/base/color-accessibility';
|
|
21
|
+
|
|
22
|
+
const SAFE_COLOR = /^(#[\da-f]{3,8}|[a-z]{1,24}|(?:rgb|rgba|hsl|hsla)\([\d.%\s,+/-]+\))$/i;
|
|
23
|
+
const SAFE_LANGUAGE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/;
|
|
24
|
+
const SAFE_NAMED_COLORS = new Set(('transparent inherit currentcolor aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgrey darkgreen darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray grey green greenyellow honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgrey lightgreen lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen').split(' '));
|
|
25
|
+
function cssColor(value: string | undefined, fallback: string): string {
|
|
26
|
+
if (value === undefined) return fallback;
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
if (!SAFE_COLOR.test(trimmed) || (!trimmed.startsWith('#') && !/^(rgb|rgba|hsl|hsla)\(/i.test(trimmed) && !SAFE_NAMED_COLORS.has(trimmed.toLowerCase()))) {
|
|
29
|
+
throw new Error('Invalid template color value');
|
|
30
|
+
}
|
|
31
|
+
return trimmed;
|
|
32
|
+
}
|
|
33
|
+
function escapeHtml(value: unknown): string {
|
|
34
|
+
return String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character]!));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function interpolate(value: string | undefined, variables: Record<string, unknown>, escapeValues = false): string | undefined {
|
|
38
|
+
if (value === undefined) return undefined;
|
|
39
|
+
return value.replace(/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (_placeholder, path: string) => {
|
|
40
|
+
let resolved: unknown = variables;
|
|
41
|
+
for (const segment of path.split('.')) {
|
|
42
|
+
if (resolved === null || typeof resolved !== 'object') throw new Error(`Missing template variable: ${path}`);
|
|
43
|
+
resolved = (resolved as Record<string, unknown>)[segment];
|
|
44
|
+
}
|
|
45
|
+
if (resolved === undefined) throw new Error(`Missing template variable: ${path}`);
|
|
46
|
+
if (resolved !== null && !['string', 'number', 'boolean'].includes(typeof resolved)) {
|
|
47
|
+
throw new Error(`Template variable ${path} must be a string, number, boolean, or null`);
|
|
48
|
+
}
|
|
49
|
+
const text = resolved === null ? '' : String(resolved);
|
|
50
|
+
return escapeValues ? escapeHtml(text) : text;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveTemplateConfig(config: ITemplateConfig, variables: Record<string, unknown>): ITemplateConfig {
|
|
55
|
+
return {
|
|
56
|
+
...config,
|
|
57
|
+
preheader: interpolate(config.preheader, variables),
|
|
58
|
+
header: config.header ? {
|
|
59
|
+
...config.header,
|
|
60
|
+
logo: config.header.logo ? {
|
|
61
|
+
...config.header.logo,
|
|
62
|
+
text: interpolate(config.header.logo.text, variables),
|
|
63
|
+
imageUrl: interpolate(config.header.logo.imageUrl, variables),
|
|
64
|
+
alt: interpolate(config.header.logo.alt, variables)
|
|
65
|
+
} : undefined
|
|
66
|
+
} : undefined,
|
|
67
|
+
body: config.body ? {
|
|
68
|
+
...config.body,
|
|
69
|
+
title: interpolate(config.body.title, variables),
|
|
70
|
+
message: interpolate(config.body.message, variables),
|
|
71
|
+
// Substitutions inside trusted HTML are escaped before insertion.
|
|
72
|
+
content: interpolate(config.body.content, variables, true),
|
|
73
|
+
buttonText: interpolate(config.body.buttonText, variables),
|
|
74
|
+
buttonUrl: interpolate(config.body.buttonUrl, variables)
|
|
75
|
+
} : undefined,
|
|
76
|
+
footer: config.footer ? {
|
|
77
|
+
...config.footer,
|
|
78
|
+
links: config.footer.links?.map(link => ({
|
|
79
|
+
text: interpolate(link.text, variables)!,
|
|
80
|
+
url: interpolate(link.url, variables)!
|
|
81
|
+
})),
|
|
82
|
+
socialLinks: config.footer.socialLinks?.map(link => ({
|
|
83
|
+
...link,
|
|
84
|
+
url: interpolate(link.url, variables)!
|
|
85
|
+
})),
|
|
86
|
+
copyrightText: interpolate(config.footer.copyrightText, variables),
|
|
87
|
+
unsubscribeText: interpolate(config.footer.unsubscribeText, variables),
|
|
88
|
+
unsubscribeUrl: interpolate(config.footer.unsubscribeUrl, variables)
|
|
89
|
+
} : undefined
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class TemplateFactory {
|
|
94
|
+
private static themes: Map<ThemeType, ITheme> = new Map([
|
|
95
|
+
[ThemeType.SYSTEM, new SystemTheme()],
|
|
96
|
+
[ThemeType.MONOKAI, new MonokaiTheme()],
|
|
97
|
+
[ThemeType.MODERN, new ModernTheme()],
|
|
98
|
+
[ThemeType.CORPORATE, new CorporateTheme()],
|
|
99
|
+
[ThemeType.MINIMAL, new MinimalTheme()],
|
|
100
|
+
[ThemeType.AURORA, new AuroraTheme()],
|
|
101
|
+
[ThemeType.OCEAN, new OceanTheme()],
|
|
102
|
+
[ThemeType.SUNSET, new SunsetTheme()],
|
|
103
|
+
[ThemeType.LAVENDER, new LavenderTheme()],
|
|
104
|
+
[ThemeType.FOREST, new ForestTheme()],
|
|
105
|
+
[ThemeType.EDITORIAL, new EditorialTheme()],
|
|
106
|
+
[ThemeType.PASTEL, new PastelTheme()],
|
|
107
|
+
[ThemeType.NEOBRUTAL, new NeoBrutalTheme()],
|
|
108
|
+
[ThemeType.GLASS, new GlassTheme()],
|
|
109
|
+
[ThemeType.MIDNIGHT, new MidnightTheme()]
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
static createTemplate(themeType: ThemeType, variant: 'light' | 'dark', config: ITemplateConfig): ITemplate {
|
|
113
|
+
const theme = this.themes.get(themeType);
|
|
114
|
+
if (!theme) throw new Error(`Theme ${themeType} not found`);
|
|
115
|
+
if (variant !== 'light' && variant !== 'dark') throw new Error(`Invalid template variant: ${variant}`);
|
|
116
|
+
if (!config || typeof config !== 'object') throw new Error('Template config must be an object');
|
|
117
|
+
if (config.language !== undefined && (typeof config.language !== 'string' || config.language.length > 63 || !SAFE_LANGUAGE_TAG.test(config.language))) {
|
|
118
|
+
throw new Error('Invalid template language tag');
|
|
119
|
+
}
|
|
120
|
+
if (config.direction !== undefined && !['ltr', 'rtl', 'auto'].includes(config.direction)) throw new Error('Invalid template text direction');
|
|
121
|
+
if (config.linkDecoration !== undefined && !['none', 'underline'].includes(config.linkDecoration)) throw new Error('Invalid template linkDecoration');
|
|
122
|
+
if (config.preheader !== undefined && (typeof config.preheader !== 'string' || config.preheader.length > 500 || /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(config.preheader))) {
|
|
123
|
+
throw new Error('preheader must be a string of at most 500 printable characters');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
name: `${themeType}_${variant}`,
|
|
128
|
+
theme: themeType,
|
|
129
|
+
variant,
|
|
130
|
+
config,
|
|
131
|
+
render: async (data: any) => {
|
|
132
|
+
const templateData = data?.templateData && typeof data.templateData === 'object' && !Array.isArray(data.templateData)
|
|
133
|
+
? data.templateData as Record<string, unknown>
|
|
134
|
+
: {};
|
|
135
|
+
const renderConfig = resolveTemplateConfig(config, templateData);
|
|
136
|
+
const builder = new TemplateBuilder(theme, renderConfig, variant);
|
|
137
|
+
// Renderer usa sempre a configuração capturada. `data` é somente conteúdo variável,
|
|
138
|
+
// nunca uma fonte alternativa de configuração/template.
|
|
139
|
+
const body = renderConfig.body || {};
|
|
140
|
+
const alignment = body.alignment || 'left';
|
|
141
|
+
if (!['left', 'center', 'right'].includes(alignment)) throw new Error('Invalid body alignment');
|
|
142
|
+
const fontSize = body.fontSize ?? 14;
|
|
143
|
+
if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize > 72) throw new Error('Invalid body font size');
|
|
144
|
+
const background = cssColor(body.backgroundColor, theme[variant].background);
|
|
145
|
+
const requestedTextColor = cssColor(body.textColor, theme[variant].text);
|
|
146
|
+
const textColor = readableTextColor(requestedTextColor, background, theme[variant].text);
|
|
147
|
+
cssColor(renderConfig.header?.backgroundColor, 'transparent');
|
|
148
|
+
cssColor(renderConfig.header?.textColor, 'inherit');
|
|
149
|
+
cssColor(renderConfig.footer?.backgroundColor, 'transparent');
|
|
150
|
+
cssColor(renderConfig.footer?.textColor, 'inherit');
|
|
151
|
+
const titleFontSize = fontSize + 7;
|
|
152
|
+
const bodyCellStyle = `padding: 0; text-align: ${alignment}; background-color: ${background}; color: ${textColor}; font-size: ${fontSize}px; font-family: ${theme.typography.fontFamily};`;
|
|
153
|
+
const titleStyle = `margin: 0 0 16px 0; color: ${textColor}; font-family: ${theme.typography.fontFamily}; font-size: ${titleFontSize}px; font-weight: ${theme.typography.fontWeights.bold}; line-height: 1.3; text-align: ${alignment};`;
|
|
154
|
+
const messageStyle = `margin: 0; color: ${textColor}; font-family: ${theme.typography.fontFamily}; font-size: ${fontSize}px; line-height: 1.6; text-align: ${alignment};`;
|
|
155
|
+
// body.content é o único campo da configuração intencionalmente tratado como HTML confiável.
|
|
156
|
+
// Título, mensagem e valores de estilo são sempre escapados/validados e não viram HTML.
|
|
157
|
+
const bodyContent = body.content ?? `
|
|
158
|
+
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
|
159
|
+
<tr>
|
|
160
|
+
<td align="${alignment}" style="${escapeHtml(bodyCellStyle)}">
|
|
161
|
+
<h2 style="${escapeHtml(titleStyle)}">${escapeHtml(body.title ?? 'Hello!')}</h2>
|
|
162
|
+
<p style="${escapeHtml(messageStyle)}">${escapeHtml(body.message ?? 'This is an email generated with tzMail.')}</p>
|
|
163
|
+
</td>
|
|
164
|
+
</tr>
|
|
165
|
+
</table>`;
|
|
166
|
+
|
|
167
|
+
builder.buildPreheader().buildHeader(typeof data?.headerContent === 'string' ? data.headerContent : '').buildBody(bodyContent);
|
|
168
|
+
if (body.buttonText && body.buttonUrl) {
|
|
169
|
+
builder.buildButton(body.buttonText, body.buttonUrl, body.buttonVariant || 'primary');
|
|
170
|
+
}
|
|
171
|
+
return builder.buildFooter().build();
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
static getTheme(themeType: ThemeType): ITheme | undefined { return this.themes.get(themeType); }
|
|
177
|
+
static listThemes(): ThemeType[] { return Array.from(this.themes.keys()); }
|
|
178
|
+
|
|
179
|
+
static getThemeInfo(themeType: ThemeType): { name: string; description: string; features: string[] } {
|
|
180
|
+
const themeInfo: Record<ThemeType, { name: string; description: string; features: string[] }> = {
|
|
181
|
+
[ThemeType.SYSTEM]: { name: 'System', description: 'Clean, professional palette with adaptive light and dark variants', features: ['Minimal design', 'Accessible contrast', 'Email-client friendly'] },
|
|
182
|
+
[ThemeType.MONOKAI]: { name: 'Monokai', description: 'Vibrant, code-inspired colors for technical content', features: ['Vivid accents', 'Developer aesthetic', 'Dark-mode variant'] },
|
|
183
|
+
[ThemeType.MODERN]: { name: 'Modern', description: 'Contemporary layout with refined colors and typography', features: ['Modern palette', 'Clean hierarchy', 'Responsive structure'] },
|
|
184
|
+
[ThemeType.CORPORATE]: { name: 'Corporate', description: 'Professional, structured styling for business communication', features: ['Serif typography', 'Gold details', 'Formal hierarchy'] },
|
|
185
|
+
[ThemeType.MINIMAL]: { name: 'Minimal', description: 'Content-first design with generous whitespace', features: ['Low visual noise', 'Generous spacing', 'Clean typography'] },
|
|
186
|
+
[ThemeType.AURORA]: { name: 'Aurora', description: 'Cool northern-lights colors with teal and violet accents', features: ['Teal and violet palette', 'Balanced light and dark modes', 'System-font typography'] },
|
|
187
|
+
[ThemeType.OCEAN]: { name: 'Ocean', description: 'Calm marine blues and cyan for clear, trustworthy messages', features: ['Navy and cyan palette', 'Readable contrast', 'Email-safe fonts'] },
|
|
188
|
+
[ThemeType.SUNSET]: { name: 'Sunset', description: 'Warm coral, terracotta, and amber for an energetic impression', features: ['Warm accents', 'Polished surfaces', 'Light and dark modes'] },
|
|
189
|
+
[ThemeType.LAVENDER]: { name: 'Lavender', description: 'Elegant violet and rose accents on soft, readable surfaces', features: ['Lavender palette', 'Soft visual tone', 'Accessible contrast'] },
|
|
190
|
+
[ThemeType.FOREST]: { name: 'Forest', description: 'Natural evergreen, moss, sage, and warm cream colors', features: ['Nature-inspired palette', 'Calm serif typography', 'Light and dark modes'] },
|
|
191
|
+
[ThemeType.EDITORIAL]: { name: 'Editorial', description: 'Magazine-inspired typography with warm paper and ink tones', features: ['Serif hierarchy', 'Brick-red accent', 'Content-led layout'] },
|
|
192
|
+
[ThemeType.PASTEL]: { name: 'Pastel', description: 'Soft peach, mint, and lavender accents with readable contrast', features: ['Gentle pastel palette', 'Bright, friendly tone', 'Accessible text colors'] },
|
|
193
|
+
[ThemeType.NEOBRUTAL]: { name: 'Neo Brutal', description: 'Bold black-and-white styling with electric color accents', features: ['High contrast', 'Strong typography', 'Distinctive cobalt and yellow'] },
|
|
194
|
+
[ThemeType.GLASS]: { name: 'Glass', description: 'Cool, glass-inspired colors implemented with solid email-safe surfaces', features: ['Cool blue-gray palette', 'Solid client-compatible colors', 'Clean typography'] },
|
|
195
|
+
[ThemeType.MIDNIGHT]: { name: 'Midnight', description: 'Premium midnight navy and champagne-gold accents', features: ['Luxe dark palette', 'Warm paper light mode', 'Serif typography'] }
|
|
196
|
+
};
|
|
197
|
+
const info = themeInfo[themeType];
|
|
198
|
+
if (!info) throw new Error(`Theme ${themeType} not found`);
|
|
199
|
+
return info;
|
|
200
|
+
}
|
|
201
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
|
|
2
|
+
export { EmailFactory } from './factories/email-factory';
|
|
3
|
+
export { TemplateFactory } from './factories/template-factory';
|
|
4
|
+
export { ITemplate, ITemplateConfig } from './core/interfaces/template.interface';
|
|
5
|
+
export { ThemeType } from './core/enums/theme.enum';
|
|
6
|
+
export { ITheme, IThemeColorToken, IThemeEmailStyles, IThemeTextTransform } from './templates/themes/theme.interface';
|
|
7
|
+
export { TemplateService, ITemplateServiceOptions } from './services/template.service';
|
|
8
|
+
export { EmailService } from './services/email.service';
|
|
9
|
+
export { AttachmentService, IAttachmentServiceOptions } from './services/attachment.service';
|
|
10
|
+
export {
|
|
11
|
+
IAmazonSesConfig,
|
|
12
|
+
IAttachment,
|
|
13
|
+
IEmailAuth,
|
|
14
|
+
IEmailConfig,
|
|
15
|
+
IEmailOptions,
|
|
16
|
+
IEmailSendResult,
|
|
17
|
+
IMailgunConfig,
|
|
18
|
+
ISendGridConfig
|
|
19
|
+
} from './core/interfaces/email.interface';
|
|
20
|
+
export { IEmailTransport, ITransportMessage, ITransportSendInfo } from './providers/provider.interface';
|
|
21
|
+
export {
|
|
22
|
+
BullMQEmailQueue,
|
|
23
|
+
IBullMQBackoffOptions,
|
|
24
|
+
IBullMQEmailQueueConfig,
|
|
25
|
+
IBullMQJobLike,
|
|
26
|
+
IBullMQQueueEventPayload,
|
|
27
|
+
IBullMQQueueEventsLike,
|
|
28
|
+
IBullMQQueueMetrics,
|
|
29
|
+
IBullMQRateLimiterOptions,
|
|
30
|
+
IBullMQRuntime,
|
|
31
|
+
IBullMQWorkerMetricsOptions,
|
|
32
|
+
IEmailQueueJobData,
|
|
33
|
+
IEmailQueueJobOptions,
|
|
34
|
+
IEmailQueueSender,
|
|
35
|
+
ISerializedEmailAttachment,
|
|
36
|
+
IQueuedEmailJob
|
|
37
|
+
} from './queues/bullmq-email-queue';
|
|
38
|
+
export { TemplateBuilder } from './templates/base/template-builder';
|
|
39
|
+
export { CorporateTheme } from './templates/themes/corporate.theme';
|
|
40
|
+
export { MinimalTheme } from './templates/themes/minimal.theme';
|
|
41
|
+
export { ModernTheme } from './templates/themes/modern.theme';
|
|
42
|
+
export { MonokaiTheme } from './templates/themes/monokai.theme';
|
|
43
|
+
export { SystemTheme } from './templates/themes/system.theme';
|
|
44
|
+
export { AuroraTheme } from './templates/themes/aurora.theme';
|
|
45
|
+
export { OceanTheme } from './templates/themes/ocean.theme';
|
|
46
|
+
export { SunsetTheme } from './templates/themes/sunset.theme';
|
|
47
|
+
export { LavenderTheme } from './templates/themes/lavender.theme';
|
|
48
|
+
export { ForestTheme } from './templates/themes/forest.theme';
|
|
49
|
+
export { EditorialTheme } from './templates/themes/editorial.theme';
|
|
50
|
+
export { PastelTheme } from './templates/themes/pastel.theme';
|
|
51
|
+
export { NeoBrutalTheme } from './templates/themes/neobrutal.theme';
|
|
52
|
+
export { GlassTheme } from './templates/themes/glass.theme';
|
|
53
|
+
export { MidnightTheme } from './templates/themes/midnight.theme';
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { IMailgunConfig } from '../core/interfaces/email.interface';
|
|
2
|
+
import { ITransportMessage, ITransportSendInfo, IEmailTransport } from './provider.interface';
|
|
3
|
+
import { attachmentToBuffer, splitAddresses, validateApiConfig } from './provider-utils';
|
|
4
|
+
|
|
5
|
+
export class MailgunTransport implements IEmailTransport {
|
|
6
|
+
private readonly apiUrl: string;
|
|
7
|
+
private readonly fetchImpl: typeof fetch;
|
|
8
|
+
|
|
9
|
+
constructor(private readonly config: IMailgunConfig, fetchImpl: typeof fetch = fetch) {
|
|
10
|
+
validateApiConfig(config);
|
|
11
|
+
if (!config.domain.trim() || /[\u0000-\u001f\u007f]/.test(config.domain)) {
|
|
12
|
+
throw new Error('Mailgun domain is required and must not contain control characters');
|
|
13
|
+
}
|
|
14
|
+
const defaultApiUrl = config.region === 'eu' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net';
|
|
15
|
+
this.apiUrl = (config.apiUrl ?? defaultApiUrl).replace(/\/+$/, '');
|
|
16
|
+
const parsedUrl = new URL(this.apiUrl);
|
|
17
|
+
if (parsedUrl.protocol !== 'https:' && parsedUrl.hostname !== 'localhost' && parsedUrl.hostname !== '127.0.0.1') {
|
|
18
|
+
throw new Error('Mailgun apiUrl must use HTTPS');
|
|
19
|
+
}
|
|
20
|
+
this.fetchImpl = fetchImpl;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async sendMail(message: ITransportMessage): Promise<ITransportSendInfo> {
|
|
24
|
+
const recipients = splitAddresses(message.to);
|
|
25
|
+
if (recipients.length === 0) throw new Error('Mailgun requires at least one recipient');
|
|
26
|
+
const from = message.from ?? this.config.defaultFrom;
|
|
27
|
+
const body = new FormData();
|
|
28
|
+
body.set('from', from);
|
|
29
|
+
for (const recipient of recipients) body.append('to', recipient);
|
|
30
|
+
for (const recipient of splitAddresses(message.cc)) body.append('cc', recipient);
|
|
31
|
+
for (const recipient of splitAddresses(message.bcc)) body.append('bcc', recipient);
|
|
32
|
+
body.set('subject', message.subject);
|
|
33
|
+
if (message.text !== undefined) body.set('text', message.text);
|
|
34
|
+
if (message.text === undefined && message.html === undefined) {
|
|
35
|
+
throw new Error('Mailgun requires a text or HTML body');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let html = message.html;
|
|
39
|
+
for (const attachment of message.attachments ?? []) {
|
|
40
|
+
const data = await attachmentToBuffer(attachment, this.config.maxAttachmentBytes);
|
|
41
|
+
const file = new Blob([new Uint8Array(data)], { type: attachment.contentType ?? 'application/octet-stream' });
|
|
42
|
+
body.append(attachment.cid ? 'inline' : 'attachment', file, attachment.filename);
|
|
43
|
+
if (attachment.cid && html !== undefined) {
|
|
44
|
+
html = html.split(`cid:${attachment.cid}`).join(`cid:${attachment.filename}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (html !== undefined) body.set('html', html);
|
|
48
|
+
|
|
49
|
+
const url = `${this.apiUrl}/v3/${encodeURIComponent(this.config.domain)}/messages`;
|
|
50
|
+
const response = await this.fetchImpl(url, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
authorization: `Basic ${Buffer.from(`api:${this.config.apiKey}`).toString('base64')}`
|
|
54
|
+
},
|
|
55
|
+
body,
|
|
56
|
+
redirect: 'error',
|
|
57
|
+
signal: AbortSignal.timeout(this.config.requestTimeoutMs ?? 30_000)
|
|
58
|
+
});
|
|
59
|
+
const responseBody = await response.text();
|
|
60
|
+
if (response.status !== 200) {
|
|
61
|
+
const error = new Error(`Mailgun API returned HTTP ${response.status}${responseBody ? `: ${responseBody}` : ''}`);
|
|
62
|
+
Object.assign(error, { statusCode: response.status, responseBody });
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let result: { id?: string; message?: string };
|
|
67
|
+
try {
|
|
68
|
+
result = responseBody ? JSON.parse(responseBody) as { id?: string; message?: string } : {};
|
|
69
|
+
} catch {
|
|
70
|
+
throw new Error('Mailgun API returned an invalid JSON response');
|
|
71
|
+
}
|
|
72
|
+
const accepted = [...recipients, ...splitAddresses(message.cc), ...splitAddresses(message.bcc)];
|
|
73
|
+
return {
|
|
74
|
+
...(result.id ? { messageId: result.id } : {}),
|
|
75
|
+
response: result.message ?? '200 Accepted',
|
|
76
|
+
accepted,
|
|
77
|
+
rejected: []
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Transporter } from 'nodemailer';
|
|
2
|
+
import { IEmailTransport, ITransportMessage, ITransportSendInfo } from './provider.interface';
|
|
3
|
+
|
|
4
|
+
export class NodemailerTransport implements IEmailTransport {
|
|
5
|
+
constructor(private readonly transporter: Transporter, private readonly closeExtra?: () => void) {}
|
|
6
|
+
|
|
7
|
+
async sendMail(message: ITransportMessage): Promise<ITransportSendInfo> {
|
|
8
|
+
const info = await this.transporter.sendMail(message as never);
|
|
9
|
+
return info as ITransportSendInfo;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
verify(): Promise<true> {
|
|
13
|
+
return this.transporter.verify();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
close(): void {
|
|
17
|
+
this.transporter.close();
|
|
18
|
+
this.closeExtra?.();
|
|
19
|
+
}
|
|
20
|
+
}
|