@stacksjs/email 0.70.87 → 0.70.90
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/dist/css-inliner.js +172 -0
- package/dist/drivers/base.js +128 -0
- package/dist/drivers/capture.js +30 -0
- package/dist/drivers/index.js +6 -0
- package/dist/drivers/log.js +81 -0
- package/dist/drivers/mailgun.js +127 -0
- package/dist/drivers/mailtrap.js +129 -0
- package/dist/drivers/sendgrid.js +136 -0
- package/dist/drivers/ses.js +113 -0
- package/dist/drivers/smtp.js +225 -0
- package/dist/email.js +198 -0
- package/dist/idempotency.js +56 -0
- package/dist/index.js +19 -28
- package/dist/mailable.js +145 -0
- package/dist/mime.js +89 -0
- package/dist/preview-ui.js +132 -0
- package/dist/preview.js +97 -0
- package/dist/sdk/index.d.ts +81 -0
- package/dist/sdk/index.js +219 -0
- package/dist/send.d.ts +1 -0
- package/dist/send.js +0 -0
- package/dist/server/converter.d.ts +1 -0
- package/dist/server/converter.js +0 -0
- package/dist/server/inbound.d.ts +1 -0
- package/dist/server/inbound.js +0 -0
- package/dist/server/outbound.d.ts +1 -0
- package/dist/server/outbound.js +0 -0
- package/dist/suppression.js +104 -0
- package/dist/template.js +170 -0
- package/dist/types.js +0 -0
- package/dist/unsubscribe.js +65 -0
- package/dist/utils/config.d.ts +3 -0
- package/dist/utils/config.js +3 -0
- package/dist/validation.js +22 -0
- package/dist/webhook-dedup.js +33 -0
- package/dist/webhook-events.js +37 -0
- package/dist/webhook-handlers.js +264 -0
- package/dist/webhook-signatures.js +148 -0
- package/package.json +5 -5
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { config } from "@stacksjs/config";
|
|
3
|
+
import { log } from "@stacksjs/logging";
|
|
4
|
+
import { template } from "../template";
|
|
5
|
+
import { filterStringHeaders } from "../validation";
|
|
6
|
+
import { BaseEmailDriver } from "./base";
|
|
7
|
+
|
|
8
|
+
export class SendGridDriver extends BaseEmailDriver {
|
|
9
|
+
name = "sendgrid";
|
|
10
|
+
apiKey = null;
|
|
11
|
+
getApiKey() {
|
|
12
|
+
if (!this.apiKey)
|
|
13
|
+
this.apiKey = config.services.sendgrid?.apiKey ?? "";
|
|
14
|
+
return this.apiKey;
|
|
15
|
+
}
|
|
16
|
+
async send(message, options) {
|
|
17
|
+
const logContext = {
|
|
18
|
+
provider: this.name,
|
|
19
|
+
to: message.to,
|
|
20
|
+
subject: message.subject
|
|
21
|
+
};
|
|
22
|
+
log.info("Sending email via SendGrid...", logContext);
|
|
23
|
+
try {
|
|
24
|
+
this.validateMessage(message);
|
|
25
|
+
let htmlContent;
|
|
26
|
+
if (message.template) {
|
|
27
|
+
const templ = await template(message.template, options);
|
|
28
|
+
if (templ && "html" in templ)
|
|
29
|
+
htmlContent = templ.html;
|
|
30
|
+
}
|
|
31
|
+
const finalHtml = htmlContent || message.html, content = [];
|
|
32
|
+
if (finalHtml)
|
|
33
|
+
content.push({
|
|
34
|
+
type: "text/html",
|
|
35
|
+
value: finalHtml
|
|
36
|
+
});
|
|
37
|
+
if (message.text)
|
|
38
|
+
content.push({
|
|
39
|
+
type: "text/plain",
|
|
40
|
+
value: message.text
|
|
41
|
+
});
|
|
42
|
+
if (content.length === 0)
|
|
43
|
+
throw Error("Email must have either HTML or text content");
|
|
44
|
+
const replyTo = this.firstSendGridAddress(message.replyTo), customHeaders = filterStringHeaders(message.headers), sendgridPayload = {
|
|
45
|
+
personalizations: [
|
|
46
|
+
{
|
|
47
|
+
to: this.formatSendGridAddresses(message.to),
|
|
48
|
+
...message.cc && { cc: this.formatSendGridAddresses(message.cc) },
|
|
49
|
+
...message.bcc && { bcc: this.formatSendGridAddresses(message.bcc) },
|
|
50
|
+
subject: message.subject
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
from: {
|
|
54
|
+
email: message.from?.address || config.email.from?.address || "",
|
|
55
|
+
name: message.from?.name || config.email.from?.name
|
|
56
|
+
},
|
|
57
|
+
...replyTo ? { reply_to: replyTo } : {},
|
|
58
|
+
...customHeaders ? { headers: customHeaders } : {},
|
|
59
|
+
content,
|
|
60
|
+
...message.attachments && {
|
|
61
|
+
attachments: message.attachments.map((attachment) => ({
|
|
62
|
+
filename: attachment.filename,
|
|
63
|
+
content: typeof attachment.content === "string" ? attachment.content : this.arrayBufferToBase64(attachment.content),
|
|
64
|
+
type: attachment.contentType,
|
|
65
|
+
disposition: "attachment"
|
|
66
|
+
}))
|
|
67
|
+
}
|
|
68
|
+
}, response = await this.sendWithRetry(sendgridPayload);
|
|
69
|
+
return this.handleSuccess(message, response.headers?.get("x-message-id") ?? void 0);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return this.handleError(error, message);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
formatSendGridAddresses(addresses) {
|
|
75
|
+
if (!addresses)
|
|
76
|
+
return [];
|
|
77
|
+
if (typeof addresses === "string")
|
|
78
|
+
return [{ email: addresses }];
|
|
79
|
+
return addresses.map((addr) => {
|
|
80
|
+
if (typeof addr === "string")
|
|
81
|
+
return { email: addr };
|
|
82
|
+
return { email: addr.address, ...addr.name && { name: addr.name } };
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
firstSendGridAddress(value) {
|
|
86
|
+
if (!value)
|
|
87
|
+
return;
|
|
88
|
+
if (typeof value === "string")
|
|
89
|
+
return { email: value };
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
const first = value[0];
|
|
92
|
+
if (first === void 0)
|
|
93
|
+
return;
|
|
94
|
+
if (typeof first === "string")
|
|
95
|
+
return { email: first };
|
|
96
|
+
return { email: first.address, ...first.name && { name: first.name } };
|
|
97
|
+
}
|
|
98
|
+
return { email: value.address, ...value.name && { name: value.name } };
|
|
99
|
+
}
|
|
100
|
+
arrayBufferToBase64(buffer) {
|
|
101
|
+
let binary = "";
|
|
102
|
+
const bytes = new Uint8Array(buffer), len = bytes.byteLength;
|
|
103
|
+
for (let i = 0;i < len; i++)
|
|
104
|
+
binary += String.fromCharCode(bytes[i] ?? 0);
|
|
105
|
+
return typeof btoa === "function" ? btoa(binary) : Buffer.from(binary).toString("base64");
|
|
106
|
+
}
|
|
107
|
+
async sendWithRetry(payload, attempt = 1) {
|
|
108
|
+
try {
|
|
109
|
+
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: {
|
|
112
|
+
Authorization: `Bearer ${this.getApiKey()}`,
|
|
113
|
+
"Content-Type": "application/json"
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify(payload)
|
|
116
|
+
});
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
const errorData = await response.json(), err = Error(`SendGrid API error: ${response.status} - ${JSON.stringify(errorData)}`);
|
|
119
|
+
err.status = response.status;
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
log.info(`[${this.name}] Email sent successfully`, { attempt });
|
|
123
|
+
return response;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const status = error?.status;
|
|
126
|
+
if (!(typeof status === "number" && status >= 400 && status < 500 && status !== 429) && attempt < (config.services.sendgrid?.maxRetries ?? 3)) {
|
|
127
|
+
const retryTimeout = config.services.sendgrid?.retryTimeout ?? 1000;
|
|
128
|
+
log.warn(`[${this.name}] Email send failed, retrying (${attempt}/${config.services.sendgrid?.maxRetries ?? 3})`);
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, retryTimeout));
|
|
130
|
+
return this.sendWithRetry(payload, attempt + 1);
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export default SendGridDriver;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { config } from "@stacksjs/config";
|
|
2
|
+
import { SESClient } from "@stacksjs/ts-cloud";
|
|
3
|
+
import { template } from "../template";
|
|
4
|
+
import { buildMimeMessage } from "../mime";
|
|
5
|
+
import { filterStringHeaders } from "../validation";
|
|
6
|
+
import { BaseEmailDriver } from "./base";
|
|
7
|
+
|
|
8
|
+
export class SESDriver extends BaseEmailDriver {
|
|
9
|
+
name = "ses";
|
|
10
|
+
client = null;
|
|
11
|
+
getClient() {
|
|
12
|
+
if (!this.client) {
|
|
13
|
+
const sesConfig = config?.services?.ses, explicit = sesConfig?.credentials, hasExplicit = !!(explicit?.accessKeyId && explicit?.secretAccessKey);
|
|
14
|
+
this.client = new SESClient(sesConfig?.region || "us-east-1", hasExplicit ? {
|
|
15
|
+
accessKeyId: explicit.accessKeyId,
|
|
16
|
+
secretAccessKey: explicit.secretAccessKey,
|
|
17
|
+
sessionToken: explicit.sessionToken
|
|
18
|
+
} : void 0);
|
|
19
|
+
}
|
|
20
|
+
return this.client;
|
|
21
|
+
}
|
|
22
|
+
async send(message, options) {
|
|
23
|
+
try {
|
|
24
|
+
this.validateMessage(message);
|
|
25
|
+
let htmlContent;
|
|
26
|
+
if (message.template) {
|
|
27
|
+
const templ = await template(message.template, options);
|
|
28
|
+
if (templ && "html" in templ)
|
|
29
|
+
htmlContent = templ.html;
|
|
30
|
+
}
|
|
31
|
+
const finalHtml = htmlContent || message.html;
|
|
32
|
+
if (!finalHtml && !message.text)
|
|
33
|
+
throw Error("Email must have either HTML or text content");
|
|
34
|
+
const fromAddress = this.formatSourceAddress({
|
|
35
|
+
address: message.from?.address || config.email.from?.address || "",
|
|
36
|
+
name: message.from?.name || config.email.from?.name
|
|
37
|
+
}), toAddresses = this.formatAddresses(message.to), ccAddresses = this.formatAddresses(message.cc), bccAddresses = this.formatAddresses(message.bcc), replyToAddresses = message.replyTo ? this.formatAddressList(message.replyTo) : [], customHeaders = filterStringHeaders(message.headers);
|
|
38
|
+
if (!!(message.attachments && message.attachments.length > 0) || !!customHeaders) {
|
|
39
|
+
const raw = buildMimeMessage({
|
|
40
|
+
from: fromAddress,
|
|
41
|
+
to: toAddresses.join(", "),
|
|
42
|
+
cc: ccAddresses.length > 0 ? ccAddresses.join(", ") : void 0,
|
|
43
|
+
replyTo: replyToAddresses.length > 0 ? replyToAddresses.join(", ") : void 0,
|
|
44
|
+
customHeaders,
|
|
45
|
+
subject: message.subject,
|
|
46
|
+
text: message.text,
|
|
47
|
+
html: finalHtml,
|
|
48
|
+
attachments: message.attachments,
|
|
49
|
+
messageIdDomain: config.email.domain
|
|
50
|
+
}), result = await this.getClient().sendRawEmail({
|
|
51
|
+
source: fromAddress,
|
|
52
|
+
destinations: [...toAddresses, ...ccAddresses, ...bccAddresses],
|
|
53
|
+
rawMessage: raw
|
|
54
|
+
});
|
|
55
|
+
return this.handleSuccess(message, result.MessageId);
|
|
56
|
+
}
|
|
57
|
+
const body = {};
|
|
58
|
+
if (finalHtml)
|
|
59
|
+
body.Html = { Charset: config.email.charset || "UTF-8", Data: finalHtml };
|
|
60
|
+
if (message.text)
|
|
61
|
+
body.Text = { Charset: config.email.charset || "UTF-8", Data: message.text };
|
|
62
|
+
const result = await this.getClient().sendEmail({
|
|
63
|
+
FromEmailAddress: fromAddress,
|
|
64
|
+
Destination: {
|
|
65
|
+
ToAddresses: toAddresses,
|
|
66
|
+
CcAddresses: ccAddresses,
|
|
67
|
+
BccAddresses: bccAddresses
|
|
68
|
+
},
|
|
69
|
+
...replyToAddresses.length > 0 ? { ReplyToAddresses: replyToAddresses } : {},
|
|
70
|
+
Content: {
|
|
71
|
+
Simple: {
|
|
72
|
+
Subject: {
|
|
73
|
+
Charset: config.email.charset || "UTF-8",
|
|
74
|
+
Data: message.subject
|
|
75
|
+
},
|
|
76
|
+
Body: body
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return this.handleSuccess(message, result.MessageId);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return this.handleError(this.enrichSesError(error), message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
formatSourceAddress(from) {
|
|
86
|
+
if (!from.name)
|
|
87
|
+
return from.address;
|
|
88
|
+
return `${/[",()<>[\]:;@\\]/.test(from.name) ? `"${from.name.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"` : from.name} <${from.address}>`;
|
|
89
|
+
}
|
|
90
|
+
enrichSesError(error) {
|
|
91
|
+
const err = error instanceof Error ? error : Error(String(error)), text = `${err.message} ${err.name ?? ""}`.toLowerCase(), region = config?.services?.ses?.region || "us-east-1";
|
|
92
|
+
if (text.includes("email address is not verified") || text.includes("not authorized to send") || text.includes("messagerejected") && text.includes("verified")) {
|
|
93
|
+
err.message = `${err.message}
|
|
94
|
+
|
|
95
|
+
SES sandbox restriction: the From and (in sandbox) every To address must be verified. Verify identities in the SES console under "Verified identities" (region: ${region}), or request production access to lift the recipient restriction.`;
|
|
96
|
+
return err;
|
|
97
|
+
}
|
|
98
|
+
if (text.includes("signaturedoesnotmatch") || text.includes("invalidclienttokenid") || text.includes("unable to locate credentials") || text.includes("the security token included in the request is invalid")) {
|
|
99
|
+
err.message = `${err.message}
|
|
100
|
+
|
|
101
|
+
SES authentication failed. Check AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (or services.ses.credentials), and confirm the IAM principal has \`ses:SendEmail\` permission for the From identity.`;
|
|
102
|
+
return err;
|
|
103
|
+
}
|
|
104
|
+
if (text.includes("could not be reached") || text.includes("econnrefused") || text.includes("enotfound")) {
|
|
105
|
+
err.message = `${err.message}
|
|
106
|
+
|
|
107
|
+
SES endpoint unreachable. The configured region (\`${region}\`) must match the region where the From identity is verified.`;
|
|
108
|
+
return err;
|
|
109
|
+
}
|
|
110
|
+
return err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export default SESDriver;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import * as tls from "node:tls";
|
|
4
|
+
import * as net from "node:net";
|
|
5
|
+
import { config } from "@stacksjs/config";
|
|
6
|
+
import { log } from "@stacksjs/logging";
|
|
7
|
+
import { template } from "../template";
|
|
8
|
+
import { buildMimeMessage } from "../mime";
|
|
9
|
+
import { ENVELOPE_ADDRESS, filterStringHeaders } from "../validation";
|
|
10
|
+
import { BaseEmailDriver } from "./base";
|
|
11
|
+
function assertEnvelopeAddress(addr, role) {
|
|
12
|
+
if (typeof addr !== "string" || !ENVELOPE_ADDRESS.test(addr))
|
|
13
|
+
throw Error(`[smtp] Refusing to send: ${role} envelope address contains forbidden characters or is malformed: ${JSON.stringify(addr)}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class SMTPDriver extends BaseEmailDriver {
|
|
17
|
+
static SMTP_TIMEOUT = 30000;
|
|
18
|
+
name = "smtp";
|
|
19
|
+
getConfig() {
|
|
20
|
+
const smtp = config.services?.smtp, env = process.env, host = smtp?.host || env.MAIL_HOST || "127.0.0.1", port = smtp?.port || (env.MAIL_PORT ? Number(env.MAIL_PORT) : void 0) || 587, fromAddress = typeof config.email?.from?.address === "string" ? config.email.from.address : "", username = smtp?.username || env.MAIL_USERNAME || fromAddress || "", localPart = (username.includes("@") ? username.split("@")[0] : username).toUpperCase().replace(/[^A-Z0-9]/g, "_"), password = smtp?.password || env.MAIL_PASSWORD || (localPart ? env[`MAIL_PASSWORD_${localPart}`] : void 0) || "", rawEncryption = smtp?.encryption ?? env.MAIL_ENCRYPTION ?? null;
|
|
21
|
+
return {
|
|
22
|
+
host,
|
|
23
|
+
port,
|
|
24
|
+
username,
|
|
25
|
+
password,
|
|
26
|
+
encryption: rawEncryption === "tls" ? "starttls" : rawEncryption || null
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
async send(message, options) {
|
|
30
|
+
const smtpConfig = this.getConfig();
|
|
31
|
+
if (!smtpConfig.host || smtpConfig.host === "")
|
|
32
|
+
throw Error("[SMTP] Host is not configured. Set MAIL_HOST in your .env file.");
|
|
33
|
+
const logContext = {
|
|
34
|
+
provider: this.name,
|
|
35
|
+
to: message.to,
|
|
36
|
+
subject: message.subject,
|
|
37
|
+
host: smtpConfig.host,
|
|
38
|
+
port: smtpConfig.port
|
|
39
|
+
};
|
|
40
|
+
log.info("Sending email via SMTP...", logContext);
|
|
41
|
+
try {
|
|
42
|
+
this.validateMessage(message);
|
|
43
|
+
let htmlContent;
|
|
44
|
+
if (message.template) {
|
|
45
|
+
const templ = await template(message.template, options);
|
|
46
|
+
if (templ && "html" in templ)
|
|
47
|
+
htmlContent = templ.html;
|
|
48
|
+
}
|
|
49
|
+
const finalHtml = htmlContent || message.html, fromAddress = message.from?.address || config.email.from?.address || "", fromName = message.from?.name || config.email.from?.name || "", toAddresses = this.formatAddresses(message.to), replyToAddresses = this.formatAddressList(message.replyTo), emailContent = buildMimeMessage({
|
|
50
|
+
from: fromName ? `${fromName} <${fromAddress}>` : fromAddress,
|
|
51
|
+
to: toAddresses.join(", "),
|
|
52
|
+
cc: message.cc ? this.formatAddresses(message.cc).join(", ") : void 0,
|
|
53
|
+
replyTo: replyToAddresses.length > 0 ? replyToAddresses.join(", ") : void 0,
|
|
54
|
+
customHeaders: filterStringHeaders(message.headers),
|
|
55
|
+
subject: message.subject,
|
|
56
|
+
text: message.text,
|
|
57
|
+
html: finalHtml,
|
|
58
|
+
attachments: message.attachments,
|
|
59
|
+
messageIdDomain: config.email.domain
|
|
60
|
+
}), messageId = await this.sendViaSMTP(smtpConfig, fromAddress, toAddresses, emailContent);
|
|
61
|
+
return this.handleSuccess(message, messageId);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return this.handleError(error, message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async sendViaSMTP(smtpConfig, from, to, content) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const timeout = setTimeout(() => {
|
|
69
|
+
reject(Error(`SMTP connection timed out after ${SMTPDriver.SMTP_TIMEOUT}ms`));
|
|
70
|
+
}, SMTPDriver.SMTP_TIMEOUT), originalResolve = resolve, originalReject = reject;
|
|
71
|
+
resolve = (value) => {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
originalResolve(value);
|
|
74
|
+
};
|
|
75
|
+
reject = (reason) => {
|
|
76
|
+
clearTimeout(timeout);
|
|
77
|
+
originalReject(reason);
|
|
78
|
+
};
|
|
79
|
+
let socket, buffer = "";
|
|
80
|
+
const _currentCommand = "", commandQueue = [], _isProcessing = !1;
|
|
81
|
+
let completed = !1;
|
|
82
|
+
const processResponse = (response) => {
|
|
83
|
+
log.debug(`[SMTP] Server: ${response.trim()}`);
|
|
84
|
+
if (parseInt(response.substring(0, 3), 10) >= 400) {
|
|
85
|
+
const error = Error(`SMTP Error: ${response.trim()}`);
|
|
86
|
+
if (commandQueue.length > 0)
|
|
87
|
+
commandQueue.shift()?.reject(error);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (commandQueue.length > 0)
|
|
91
|
+
commandQueue.shift()?.resolve(response);
|
|
92
|
+
}, sendCommand = (cmd) => {
|
|
93
|
+
return new Promise((res, rej) => {
|
|
94
|
+
commandQueue.push({ cmd, resolve: res, reject: rej });
|
|
95
|
+
log.debug(`[SMTP] Client: ${cmd}`);
|
|
96
|
+
socket.write(`${cmd}\r
|
|
97
|
+
`);
|
|
98
|
+
});
|
|
99
|
+
}, handleData = (data) => {
|
|
100
|
+
buffer += data.toString();
|
|
101
|
+
const lines = buffer.split(`\r
|
|
102
|
+
`);
|
|
103
|
+
buffer = lines.pop() || "";
|
|
104
|
+
for (const line of lines)
|
|
105
|
+
if (line.length >= 3) {
|
|
106
|
+
if (line.length === 3 || line[3] === " ")
|
|
107
|
+
processResponse(line);
|
|
108
|
+
}
|
|
109
|
+
}, runSmtpSession = async () => {
|
|
110
|
+
try {
|
|
111
|
+
await new Promise((res, rej) => {
|
|
112
|
+
commandQueue.push({ cmd: "GREETING", resolve: res, reject: rej });
|
|
113
|
+
});
|
|
114
|
+
const _ehloResponse = await sendCommand(`EHLO ${config.email.domain || "localhost"}`);
|
|
115
|
+
if (smtpConfig.encryption === "starttls" && !(socket instanceof tls.TLSSocket)) {
|
|
116
|
+
await sendCommand("STARTTLS");
|
|
117
|
+
const plainSocket = socket;
|
|
118
|
+
plainSocket.removeAllListeners("data");
|
|
119
|
+
socket = await new Promise((res, rej) => {
|
|
120
|
+
const tlsSocket = tls.connect({
|
|
121
|
+
socket: plainSocket,
|
|
122
|
+
host: smtpConfig.host,
|
|
123
|
+
servername: smtpConfig.host
|
|
124
|
+
}, () => {
|
|
125
|
+
log.debug("[SMTP] TLS connection established");
|
|
126
|
+
res(tlsSocket);
|
|
127
|
+
});
|
|
128
|
+
tlsSocket.on("error", (err) => {
|
|
129
|
+
log.error("[SMTP] TLS socket error:", err);
|
|
130
|
+
rej(err);
|
|
131
|
+
});
|
|
132
|
+
tlsSocket.on("data", handleData);
|
|
133
|
+
tlsSocket.on("close", (hadError) => {
|
|
134
|
+
log.debug(`[SMTP] TLS socket closed (hadError: ${hadError})`);
|
|
135
|
+
while (commandQueue.length > 0)
|
|
136
|
+
commandQueue.shift()?.reject(Error("TLS connection closed unexpectedly"));
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
await sendCommand(`EHLO ${config.email.domain || "localhost"}`);
|
|
140
|
+
}
|
|
141
|
+
if (smtpConfig.username && smtpConfig.password) {
|
|
142
|
+
await sendCommand("AUTH LOGIN");
|
|
143
|
+
await sendCommand(Buffer.from(smtpConfig.username).toString("base64"));
|
|
144
|
+
await sendCommand(Buffer.from(smtpConfig.password).toString("base64"));
|
|
145
|
+
}
|
|
146
|
+
assertEnvelopeAddress(from, "MAIL FROM");
|
|
147
|
+
for (const recipient of to)
|
|
148
|
+
assertEnvelopeAddress(recipient, "RCPT TO");
|
|
149
|
+
await sendCommand(`MAIL FROM:<${from}>`);
|
|
150
|
+
for (const recipient of to)
|
|
151
|
+
await sendCommand(`RCPT TO:<${recipient}>`);
|
|
152
|
+
await sendCommand("DATA");
|
|
153
|
+
socket.write(`${content}\r
|
|
154
|
+
.\r
|
|
155
|
+
`);
|
|
156
|
+
await new Promise((res, rej) => {
|
|
157
|
+
commandQueue.push({ cmd: "DATA_END", resolve: res, reject: rej });
|
|
158
|
+
});
|
|
159
|
+
completed = !0;
|
|
160
|
+
const messageId = `${Date.now()}.${Math.random().toString(36).substring(2)}@${smtpConfig.host}`;
|
|
161
|
+
try {
|
|
162
|
+
socket.write(`QUIT\r
|
|
163
|
+
`);
|
|
164
|
+
} catch {}
|
|
165
|
+
socket.end();
|
|
166
|
+
resolve(messageId);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (completed) {
|
|
169
|
+
socket.end();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
socket.end();
|
|
173
|
+
reject(error);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
if (smtpConfig.encryption === "ssl")
|
|
177
|
+
socket = tls.connect({
|
|
178
|
+
host: smtpConfig.host,
|
|
179
|
+
port: smtpConfig.port,
|
|
180
|
+
servername: smtpConfig.host
|
|
181
|
+
}, () => {
|
|
182
|
+
log.debug(`[SMTP] TLS connected to ${smtpConfig.host}:${smtpConfig.port}`);
|
|
183
|
+
runSmtpSession();
|
|
184
|
+
});
|
|
185
|
+
else
|
|
186
|
+
socket = net.connect({
|
|
187
|
+
host: smtpConfig.host,
|
|
188
|
+
port: smtpConfig.port
|
|
189
|
+
}, () => {
|
|
190
|
+
log.debug(`[SMTP] Connected to ${smtpConfig.host}:${smtpConfig.port}`);
|
|
191
|
+
runSmtpSession();
|
|
192
|
+
});
|
|
193
|
+
socket.on("data", handleData);
|
|
194
|
+
socket.setTimeout(SMTPDriver.SMTP_TIMEOUT);
|
|
195
|
+
socket.on("timeout", () => {
|
|
196
|
+
socket.destroy(Error(`SMTP socket timed out after ${SMTPDriver.SMTP_TIMEOUT}ms`));
|
|
197
|
+
});
|
|
198
|
+
socket.on("error", (error) => {
|
|
199
|
+
if (completed)
|
|
200
|
+
return;
|
|
201
|
+
log.error(`[SMTP] Connection error to ${smtpConfig.host}:${smtpConfig.port}:`, error);
|
|
202
|
+
reject(error);
|
|
203
|
+
});
|
|
204
|
+
socket.on("close", (hadError) => {
|
|
205
|
+
log.debug(`[SMTP] Connection closed (hadError: ${hadError})`);
|
|
206
|
+
if (completed)
|
|
207
|
+
return;
|
|
208
|
+
while (commandQueue.length > 0)
|
|
209
|
+
commandQueue.shift()?.reject(Error("Connection closed unexpectedly"));
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
formatAddresses(addresses) {
|
|
214
|
+
if (!addresses)
|
|
215
|
+
return [];
|
|
216
|
+
if (typeof addresses === "string")
|
|
217
|
+
return [addresses];
|
|
218
|
+
return addresses.map((addr) => {
|
|
219
|
+
if (typeof addr === "string")
|
|
220
|
+
return addr;
|
|
221
|
+
return addr.address;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export default SMTPDriver;
|
package/dist/email.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { config } from "@stacksjs/config";
|
|
2
|
+
import { log } from "@stacksjs/logging";
|
|
3
|
+
import { CaptureEmailDriver } from "./drivers/capture";
|
|
4
|
+
import { LogEmailDriver } from "./drivers/log";
|
|
5
|
+
import { MailgunDriver } from "./drivers/mailgun";
|
|
6
|
+
import { MailtrapDriver } from "./drivers/mailtrap";
|
|
7
|
+
import { SendGridDriver } from "./drivers/sendgrid";
|
|
8
|
+
import { SESDriver } from "./drivers/ses";
|
|
9
|
+
import { SMTPDriver } from "./drivers/smtp";
|
|
10
|
+
import { findEmailByIdempotencyKey, recordEmailIdempotency } from "./idempotency";
|
|
11
|
+
import { checkSuppressionFor } from "./suppression";
|
|
12
|
+
|
|
13
|
+
export class Email {
|
|
14
|
+
name;
|
|
15
|
+
subject;
|
|
16
|
+
to;
|
|
17
|
+
from;
|
|
18
|
+
template;
|
|
19
|
+
handle;
|
|
20
|
+
onError;
|
|
21
|
+
onSuccess;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.name = options.name;
|
|
24
|
+
this.subject = options.subject;
|
|
25
|
+
this.to = options.to;
|
|
26
|
+
this.from = options.from;
|
|
27
|
+
this.template = options.template;
|
|
28
|
+
this.handle = options.handle;
|
|
29
|
+
this.onError = options.onError;
|
|
30
|
+
this.onSuccess = options.onSuccess;
|
|
31
|
+
}
|
|
32
|
+
async renderTemplate() {
|
|
33
|
+
if (!this.template)
|
|
34
|
+
return "";
|
|
35
|
+
try {
|
|
36
|
+
const { path: p } = await import("@stacksjs/path"), templatePath = p.resourcesPath(`views/emails/${this.template}.html`), file = Bun.file(templatePath);
|
|
37
|
+
if (await file.exists())
|
|
38
|
+
return await file.text();
|
|
39
|
+
} catch {}
|
|
40
|
+
if (this.template.includes("<"))
|
|
41
|
+
return this.template;
|
|
42
|
+
return `<p>${this.template}</p>`;
|
|
43
|
+
}
|
|
44
|
+
async send(to) {
|
|
45
|
+
const target = to ?? this.to, recipients = Array.isArray(target) ? target : target ? [target] : [];
|
|
46
|
+
if (recipients.length === 0)
|
|
47
|
+
throw Error("No recipient specified for email");
|
|
48
|
+
try {
|
|
49
|
+
await mail.send({
|
|
50
|
+
to: recipients,
|
|
51
|
+
from: this.from || {
|
|
52
|
+
name: config.email.from?.name || "Stacks",
|
|
53
|
+
address: config.email.from?.address || "no-reply@stacksjs.com"
|
|
54
|
+
},
|
|
55
|
+
subject: this.subject,
|
|
56
|
+
html: await this.renderTemplate()
|
|
57
|
+
});
|
|
58
|
+
if (this.onSuccess)
|
|
59
|
+
this.onSuccess();
|
|
60
|
+
if (this.handle)
|
|
61
|
+
return this.handle();
|
|
62
|
+
return { message: "Email sent" };
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (this.onError)
|
|
65
|
+
return this.onError(error instanceof Error ? error : Error(String(error)));
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class Mail {
|
|
72
|
+
drivers = new Map;
|
|
73
|
+
defaultDriver;
|
|
74
|
+
constructor(options = {}) {
|
|
75
|
+
this.defaultDriver = options.defaultDriver || config.email.default || "ses";
|
|
76
|
+
this.registerDefaultDrivers();
|
|
77
|
+
}
|
|
78
|
+
registerDefaultDrivers() {
|
|
79
|
+
this.drivers.set("log", new LogEmailDriver);
|
|
80
|
+
this.drivers.set("ses", new SESDriver);
|
|
81
|
+
this.drivers.set("sendgrid", new SendGridDriver);
|
|
82
|
+
this.drivers.set("mailgun", new MailgunDriver);
|
|
83
|
+
this.drivers.set("mailtrap", new MailtrapDriver);
|
|
84
|
+
this.drivers.set("smtp", new SMTPDriver);
|
|
85
|
+
this.drivers.set("capture", new CaptureEmailDriver);
|
|
86
|
+
}
|
|
87
|
+
async send(message) {
|
|
88
|
+
const driver = this.drivers.get(this.defaultDriver);
|
|
89
|
+
if (!driver) {
|
|
90
|
+
const available = [...this.drivers.keys()].sort().join(", ");
|
|
91
|
+
throw Error(`Email driver '${this.defaultDriver}' is not registered. Available drivers: [${available}]. Check config.email.default or the MAIL_MAILER environment variable.`);
|
|
92
|
+
}
|
|
93
|
+
if (message.idempotencyKey) {
|
|
94
|
+
const cached = await findEmailByIdempotencyKey(message.idempotencyKey);
|
|
95
|
+
if (cached)
|
|
96
|
+
return cached;
|
|
97
|
+
}
|
|
98
|
+
const suppressionType = await checkSuppressionForFirstRecipient(message);
|
|
99
|
+
if (suppressionType)
|
|
100
|
+
return {
|
|
101
|
+
success: !1,
|
|
102
|
+
message: `suppressed:${suppressionType}`,
|
|
103
|
+
provider: "suppression"
|
|
104
|
+
};
|
|
105
|
+
const defaultFrom = {
|
|
106
|
+
name: config.email.from?.name || "Stacks",
|
|
107
|
+
address: config.email.from?.address || "no-reply@stacksjs.com"
|
|
108
|
+
}, result = await driver.send({
|
|
109
|
+
...message,
|
|
110
|
+
from: message.from || defaultFrom
|
|
111
|
+
});
|
|
112
|
+
if (message.idempotencyKey)
|
|
113
|
+
await recordEmailIdempotency(message.idempotencyKey, message, result);
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
use(driver) {
|
|
117
|
+
if (!this.drivers.has(driver))
|
|
118
|
+
throw Error(`Email driver '${driver}' is not available`);
|
|
119
|
+
return new Mail({ defaultDriver: driver });
|
|
120
|
+
}
|
|
121
|
+
async queue(message) {
|
|
122
|
+
await this.dispatchOrFallback(message, async () => {
|
|
123
|
+
const { job } = await import("@stacksjs/queue");
|
|
124
|
+
await job("SendEmail", { message, driver: this.defaultDriver }).onQueue("emails").dispatch();
|
|
125
|
+
}, { context: "queue" });
|
|
126
|
+
}
|
|
127
|
+
async later(delaySeconds, message) {
|
|
128
|
+
await this.dispatchOrFallback(message, async () => {
|
|
129
|
+
const { job } = await import("@stacksjs/queue");
|
|
130
|
+
await job("SendEmail", { message, driver: this.defaultDriver }).onQueue("emails").delay(delaySeconds).dispatch();
|
|
131
|
+
}, { context: "later", delaySeconds });
|
|
132
|
+
}
|
|
133
|
+
async queueOn(queueName, message) {
|
|
134
|
+
await this.dispatchOrFallback(message, async () => {
|
|
135
|
+
const { job } = await import("@stacksjs/queue");
|
|
136
|
+
await job("SendEmail", { message, driver: this.defaultDriver }).onQueue(queueName).dispatch();
|
|
137
|
+
}, { context: "queueOn", queueName });
|
|
138
|
+
}
|
|
139
|
+
async dispatchOrFallback(message, dispatch, logExtra) {
|
|
140
|
+
try {
|
|
141
|
+
await dispatch();
|
|
142
|
+
return;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
145
|
+
log.warn("[email] Queue dispatch failed; falling back to synchronous send. " + "Background email pipeline is degraded \u2014 check the queue worker / broker.", { ...logExtra, reason });
|
|
146
|
+
}
|
|
147
|
+
await this.send(message);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function checkSuppressionForFirstRecipient(message) {
|
|
151
|
+
const recipients = collectRecipientAddresses(message);
|
|
152
|
+
if (recipients.length === 0)
|
|
153
|
+
return null;
|
|
154
|
+
for (const addr of recipients) {
|
|
155
|
+
const matched = await checkSuppressionFor(addr, message.tag);
|
|
156
|
+
if (matched)
|
|
157
|
+
return matched;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
function collectRecipientAddresses(message) {
|
|
162
|
+
const out = [], pushOne = (v) => {
|
|
163
|
+
if (typeof v === "string") {
|
|
164
|
+
out.push(v);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (v && typeof v === "object" && "address" in v && typeof v.address === "string")
|
|
168
|
+
out.push(v.address);
|
|
169
|
+
};
|
|
170
|
+
for (const field of ["to", "cc", "bcc"]) {
|
|
171
|
+
const v = message[field];
|
|
172
|
+
if (!v)
|
|
173
|
+
continue;
|
|
174
|
+
if (Array.isArray(v))
|
|
175
|
+
for (const item of v)
|
|
176
|
+
pushOne(item);
|
|
177
|
+
else
|
|
178
|
+
pushOne(v);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
let _mail;
|
|
183
|
+
function getMail() {
|
|
184
|
+
if (!_mail) {
|
|
185
|
+
const driver = config?.email?.default || process.env.MAIL_MAILER || "ses";
|
|
186
|
+
_mail = new Mail({ defaultDriver: driver });
|
|
187
|
+
}
|
|
188
|
+
return _mail;
|
|
189
|
+
}
|
|
190
|
+
export const mail = new Proxy({}, {
|
|
191
|
+
get(_t, prop) {
|
|
192
|
+
return getMail()[prop];
|
|
193
|
+
},
|
|
194
|
+
set(_t, prop, value) {
|
|
195
|
+
getMail()[prop] = value;
|
|
196
|
+
return !0;
|
|
197
|
+
}
|
|
198
|
+
});
|