@stacksjs/email 0.70.88 → 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.d.ts +26 -0
- package/dist/css-inliner.js +172 -0
- package/dist/drivers/base.d.ts +14 -0
- package/dist/drivers/base.js +128 -0
- package/dist/drivers/capture.d.ts +55 -0
- package/dist/drivers/capture.js +30 -0
- package/dist/drivers/index.d.ts +10 -0
- package/dist/drivers/index.js +6 -0
- package/dist/drivers/log.d.ts +30 -0
- package/dist/drivers/log.js +81 -0
- package/dist/drivers/mailgun.d.ts +8 -0
- package/dist/drivers/mailgun.js +127 -0
- package/dist/drivers/mailtrap.d.ts +8 -0
- package/dist/drivers/mailtrap.js +129 -0
- package/dist/drivers/sendgrid.d.ts +8 -0
- package/dist/drivers/sendgrid.js +136 -0
- package/dist/drivers/ses.d.ts +8 -0
- package/dist/drivers/ses.js +113 -0
- package/dist/drivers/smtp.d.ts +14 -0
- package/dist/drivers/smtp.js +225 -0
- package/dist/email.d.ts +39 -0
- package/dist/email.js +198 -0
- package/dist/idempotency.d.ts +20 -0
- package/dist/idempotency.js +56 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +19 -0
- package/dist/mailable.d.ts +116 -0
- package/dist/mailable.js +145 -0
- package/dist/mime.d.ts +37 -0
- package/dist/mime.js +89 -0
- package/dist/preview-ui.d.ts +11 -0
- package/dist/preview-ui.js +132 -0
- package/dist/preview.d.ts +50 -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.d.ts +82 -0
- package/dist/suppression.js +104 -0
- package/dist/template.d.ts +99 -0
- package/dist/template.js +170 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.js +0 -0
- package/dist/unsubscribe.d.ts +39 -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.d.ts +48 -0
- package/dist/validation.js +22 -0
- package/dist/webhook-dedup.d.ts +10 -0
- package/dist/webhook-dedup.js +33 -0
- package/dist/webhook-events.d.ts +34 -0
- package/dist/webhook-events.js +37 -0
- package/dist/webhook-handlers.d.ts +27 -0
- package/dist/webhook-handlers.js +264 -0
- package/dist/webhook-signatures.d.ts +91 -0
- package/dist/webhook-signatures.js +148 -0
- package/package.json +5 -5
|
@@ -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.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { EmailMessage, EmailResult } from '@stacksjs/types';
|
|
2
|
+
import type { Message } from './types';
|
|
3
|
+
export declare const mail: Mail;
|
|
4
|
+
/** Result returned by email handler callbacks */
|
|
5
|
+
declare interface EmailHandlerResult {
|
|
6
|
+
message: string
|
|
7
|
+
}
|
|
8
|
+
/** Configuration for the sender address */
|
|
9
|
+
declare interface EmailFromAddress {
|
|
10
|
+
name: string
|
|
11
|
+
address: string
|
|
12
|
+
}
|
|
13
|
+
/** Configuration for the Mail singleton */
|
|
14
|
+
declare interface MailConfig {
|
|
15
|
+
defaultDriver?: string
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Email notification class for defining email notifications
|
|
19
|
+
*/
|
|
20
|
+
export declare class Email {
|
|
21
|
+
name: string;
|
|
22
|
+
subject: string;
|
|
23
|
+
to: string | string[];
|
|
24
|
+
from?: EmailFromAddress;
|
|
25
|
+
template: string;
|
|
26
|
+
handle?: () => Promise<EmailHandlerResult>;
|
|
27
|
+
onError?: (error: Error) => Promise<EmailHandlerResult>;
|
|
28
|
+
onSuccess?: () => void;
|
|
29
|
+
constructor(options: Message);
|
|
30
|
+
send(to?: string | string[]): Promise<EmailHandlerResult>;
|
|
31
|
+
}
|
|
32
|
+
export declare class Mail {
|
|
33
|
+
constructor(options?: MailConfig);
|
|
34
|
+
send(message: EmailMessage): Promise<EmailResult>;
|
|
35
|
+
use(driver: string): Mail;
|
|
36
|
+
queue(message: EmailMessage): Promise<void>;
|
|
37
|
+
later(delaySeconds: number, message: EmailMessage): Promise<void>;
|
|
38
|
+
queueOn(queueName: string, message: EmailMessage): Promise<void>;
|
|
39
|
+
}
|
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
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { EmailMessage, EmailResult } from '@stacksjs/types';
|
|
2
|
+
/**
|
|
3
|
+
* Look up a cached EmailResult by idempotency key. Returns the
|
|
4
|
+
* reconstructed result when this key has been seen before, `null`
|
|
5
|
+
* when it hasn't. Degrades to "always null" with a startup warn
|
|
6
|
+
* when the `email_idempotency` dedup table isn't migrated yet.
|
|
7
|
+
*/
|
|
8
|
+
export declare function findEmailByIdempotencyKey(key: string): Promise<EmailResult | null>;
|
|
9
|
+
/**
|
|
10
|
+
* Record a successful send under its idempotency key. No-op when
|
|
11
|
+
* the table doesn't exist (warn-once already fired from the lookup
|
|
12
|
+
* path) and when the result reports failure (failed sends shouldn't
|
|
13
|
+
* lock out retries).
|
|
14
|
+
*
|
|
15
|
+
* Collision (same key inserted concurrently) is intentionally
|
|
16
|
+
* swallowed: the row already exists, which is exactly what the
|
|
17
|
+
* lookup will return next time. Throwing here would mask an
|
|
18
|
+
* otherwise successful send.
|
|
19
|
+
*/
|
|
20
|
+
export declare function recordEmailIdempotency(key: string, message: EmailMessage, result: EmailResult): Promise<void>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { db } from "@stacksjs/database";
|
|
2
|
+
let warnedAboutMissingEmailIdempotencyTable = !1;
|
|
3
|
+
function warnOnceAboutMissingTable() {
|
|
4
|
+
if (warnedAboutMissingEmailIdempotencyTable)
|
|
5
|
+
return;
|
|
6
|
+
warnedAboutMissingEmailIdempotencyTable = !0;
|
|
7
|
+
console.warn("[email/idempotency] email_idempotency table missing \u2014 idempotency keys are accepted but NOT enforced. " + "Run migrations to enable dedup.");
|
|
8
|
+
}
|
|
9
|
+
function isMissingTableError(err) {
|
|
10
|
+
const msg = (err?.message ?? "").toLowerCase();
|
|
11
|
+
return msg.includes("no such table") || msg.includes("doesn't exist") || msg.includes("connection closed") || msg.includes("unable to open database") || msg.includes("econnrefused") || msg.includes("connection terminated") || msg.includes("no database") || msg.includes("database connection");
|
|
12
|
+
}
|
|
13
|
+
export async function findEmailByIdempotencyKey(key) {
|
|
14
|
+
try {
|
|
15
|
+
const row = await db.selectFrom("email_idempotency").where("idempotency_key", "=", key).selectAll().executeTakeFirst();
|
|
16
|
+
if (!row)
|
|
17
|
+
return null;
|
|
18
|
+
return {
|
|
19
|
+
success: Boolean(row.success),
|
|
20
|
+
message: `Idempotent replay \u2014 original send recorded ${row.created_at}`,
|
|
21
|
+
provider: String(row.provider ?? "cache"),
|
|
22
|
+
messageId: row.message_id ?? void 0
|
|
23
|
+
};
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (isMissingTableError(err)) {
|
|
26
|
+
warnOnceAboutMissingTable();
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function recordEmailIdempotency(key, message, result) {
|
|
33
|
+
if (!result.success)
|
|
34
|
+
return;
|
|
35
|
+
const recipient = Array.isArray(message.to) ? message.to.map((t) => typeof t === "string" ? t : t.address).join(", ") : typeof message.to === "string" ? message.to : message.to.address;
|
|
36
|
+
try {
|
|
37
|
+
await db.insertInto("email_idempotency").values({
|
|
38
|
+
idempotency_key: key,
|
|
39
|
+
message_id: result.messageId ?? null,
|
|
40
|
+
recipient,
|
|
41
|
+
subject: message.subject,
|
|
42
|
+
provider: result.provider,
|
|
43
|
+
success: 1,
|
|
44
|
+
created_at: new Date().toISOString().slice(0, 19).replace("T", " ")
|
|
45
|
+
}).execute();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (isMissingTableError(err)) {
|
|
48
|
+
warnOnceAboutMissingTable();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const msg = err?.message ?? "";
|
|
52
|
+
if (msg.includes("UNIQUE constraint") || msg.includes("Duplicate entry"))
|
|
53
|
+
return;
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type { InlineCssOptions } from './css-inliner';
|
|
2
|
+
export type { DiscoveredMailable, MailablePreview } from './preview';
|
|
3
|
+
export * from './drivers/index';
|
|
4
|
+
export * from './email';
|
|
5
|
+
export * from './idempotency';
|
|
6
|
+
export * from './suppression';
|
|
7
|
+
export * from './unsubscribe';
|
|
8
|
+
export * from './webhook-dedup';
|
|
9
|
+
export * from './webhook-events';
|
|
10
|
+
export * from './webhook-handlers';
|
|
11
|
+
export * from './webhook-signatures';
|
|
12
|
+
export * from './mailable';
|
|
13
|
+
export * from './template';
|
|
14
|
+
export * from './types';
|
|
15
|
+
export { inlineCss, shouldInlineByDefault } from './css-inliner';
|
|
16
|
+
// Dev-only Mailable preview server (stacksjs/stacks#1900 A3).
|
|
17
|
+
export {
|
|
18
|
+
discoverMailables,
|
|
19
|
+
loadSampleProps,
|
|
20
|
+
renderMailablePreview,
|
|
21
|
+
} from './preview';
|
|
22
|
+
export { renderIndexHtml, renderPreviewHtml } from './preview-ui';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export * from "./drivers";
|
|
2
|
+
export * from "./email";
|
|
3
|
+
export * from "./idempotency";
|
|
4
|
+
export * from "./suppression";
|
|
5
|
+
export * from "./unsubscribe";
|
|
6
|
+
export * from "./webhook-dedup";
|
|
7
|
+
export * from "./webhook-events";
|
|
8
|
+
export * from "./webhook-handlers";
|
|
9
|
+
export * from "./webhook-signatures";
|
|
10
|
+
export * from "./mailable";
|
|
11
|
+
export * from "./template";
|
|
12
|
+
export * from "./types";
|
|
13
|
+
export { inlineCss, shouldInlineByDefault } from "./css-inliner";
|
|
14
|
+
export {
|
|
15
|
+
discoverMailables,
|
|
16
|
+
loadSampleProps,
|
|
17
|
+
renderMailablePreview
|
|
18
|
+
} from "./preview";
|
|
19
|
+
export { renderIndexHtml, renderPreviewHtml } from "./preview-ui";
|