@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,81 @@
|
|
|
1
|
+
// Export singleton instance
|
|
2
|
+
export declare const emailSDK: EmailSDK;
|
|
3
|
+
// Export convenience functions
|
|
4
|
+
export declare const sendEmail: (message: EmailMessage) => unknown;
|
|
5
|
+
export declare const getInbox: (mailbox: string, options?: { limit?: number; offset?: number }) => unknown;
|
|
6
|
+
export declare const searchEmails: (mailbox: string, options: EmailSearchOptions) => unknown;
|
|
7
|
+
export declare const deleteEmail: (mailbox: string, messageId: string) => unknown;
|
|
8
|
+
export declare interface EmailAddress {
|
|
9
|
+
name?: string
|
|
10
|
+
address: string
|
|
11
|
+
}
|
|
12
|
+
export declare interface EmailMessage {
|
|
13
|
+
from?: EmailAddress | string
|
|
14
|
+
to: string | string[] | EmailAddress[]
|
|
15
|
+
cc?: string | string[] | EmailAddress[]
|
|
16
|
+
bcc?: string | string[] | EmailAddress[]
|
|
17
|
+
replyTo?: string | EmailAddress
|
|
18
|
+
subject: string
|
|
19
|
+
text?: string
|
|
20
|
+
html?: string
|
|
21
|
+
attachments?: EmailAttachment[]
|
|
22
|
+
}
|
|
23
|
+
export declare interface EmailAttachment {
|
|
24
|
+
filename: string
|
|
25
|
+
content: string | Buffer
|
|
26
|
+
contentType?: string
|
|
27
|
+
encoding?: 'base64' | 'binary'
|
|
28
|
+
}
|
|
29
|
+
export declare interface InboxEmail {
|
|
30
|
+
messageId: string
|
|
31
|
+
from: string
|
|
32
|
+
fromName?: string
|
|
33
|
+
to: string
|
|
34
|
+
subject: string
|
|
35
|
+
date: string
|
|
36
|
+
read: boolean
|
|
37
|
+
preview?: string
|
|
38
|
+
hasAttachments?: boolean
|
|
39
|
+
path: string
|
|
40
|
+
}
|
|
41
|
+
export declare interface EmailSearchOptions {
|
|
42
|
+
from?: string
|
|
43
|
+
to?: string
|
|
44
|
+
subject?: string
|
|
45
|
+
after?: Date
|
|
46
|
+
before?: Date
|
|
47
|
+
hasAttachments?: boolean
|
|
48
|
+
limit?: number
|
|
49
|
+
offset?: number
|
|
50
|
+
}
|
|
51
|
+
export declare interface SendResult {
|
|
52
|
+
success: boolean
|
|
53
|
+
messageId?: string
|
|
54
|
+
error?: string
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Email SDK class for Stacks applications
|
|
58
|
+
*/
|
|
59
|
+
export declare class EmailSDK {
|
|
60
|
+
constructor(options?: { bucket?: string; region?: string; domain?: string });
|
|
61
|
+
send(message: EmailMessage): Promise<SendResult>;
|
|
62
|
+
sendTemplate(options: {
|
|
63
|
+
to: string | string[]
|
|
64
|
+
template: string
|
|
65
|
+
data: Record<string, any>
|
|
66
|
+
from?: EmailAddress | string
|
|
67
|
+
subject?: string
|
|
68
|
+
}): Promise<SendResult>;
|
|
69
|
+
getInbox(mailbox: string, options?: { limit?: number; offset?: number }): Promise<InboxEmail[]>;
|
|
70
|
+
getEmail(mailbox: string, messageId: string): Promise<{
|
|
71
|
+
metadata: Record<string, any>
|
|
72
|
+
html?: string
|
|
73
|
+
text?: string
|
|
74
|
+
raw?: string
|
|
75
|
+
} | null>;
|
|
76
|
+
search(mailbox: string, options: EmailSearchOptions): Promise<InboxEmail[]>;
|
|
77
|
+
delete(mailbox: string, messageId: string): Promise<boolean>;
|
|
78
|
+
markAsRead(mailbox: string, messageId: string): Promise<boolean>;
|
|
79
|
+
markAsUnread(mailbox: string, messageId: string): Promise<boolean>;
|
|
80
|
+
}
|
|
81
|
+
export default EmailSDK;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { email as emailConfig } from "@stacksjs/config";
|
|
2
|
+
import { getErrorMessage } from "@stacksjs/utils";
|
|
3
|
+
|
|
4
|
+
export class EmailSDK {
|
|
5
|
+
bucket;
|
|
6
|
+
region;
|
|
7
|
+
domain;
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.bucket = options?.bucket || `${process.env.APP_NAME?.toLowerCase() || "stacks"}-emails`;
|
|
10
|
+
this.region = options?.region || process.env.AWS_REGION || "us-east-1";
|
|
11
|
+
const fromAddress = emailConfig?.from?.address, parsedDomain = fromAddress?.includes("@") ? fromAddress.split("@")[1] : void 0;
|
|
12
|
+
this.domain = options?.domain || parsedDomain || "stacksjs.com";
|
|
13
|
+
}
|
|
14
|
+
async send(message) {
|
|
15
|
+
try {
|
|
16
|
+
const { SESClient } = await import("@stacksjs/ts-cloud"), ses = new SESClient(this.region), from = this.normalizeAddress(message.from || emailConfig?.from || { address: `noreply@${this.domain}` }), toAddresses = this.normalizeAddresses(message.to), ccAddresses = message.cc ? this.normalizeAddresses(message.cc) : void 0, bccAddresses = message.bcc ? this.normalizeAddresses(message.bcc) : void 0;
|
|
17
|
+
return {
|
|
18
|
+
success: !0,
|
|
19
|
+
messageId: (await ses.sendEmail({
|
|
20
|
+
FromEmailAddress: typeof from === "string" ? from : `${from.name} <${from.address}>`,
|
|
21
|
+
Destination: {
|
|
22
|
+
ToAddresses: toAddresses,
|
|
23
|
+
CcAddresses: ccAddresses,
|
|
24
|
+
BccAddresses: bccAddresses
|
|
25
|
+
},
|
|
26
|
+
ReplyToAddresses: message.replyTo ? [typeof message.replyTo === "string" ? message.replyTo : message.replyTo.address] : void 0,
|
|
27
|
+
Content: {
|
|
28
|
+
Simple: {
|
|
29
|
+
Subject: {
|
|
30
|
+
Data: message.subject,
|
|
31
|
+
Charset: "UTF-8"
|
|
32
|
+
},
|
|
33
|
+
Body: {
|
|
34
|
+
...message.html && {
|
|
35
|
+
Html: {
|
|
36
|
+
Data: message.html,
|
|
37
|
+
Charset: "UTF-8"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
...message.text && {
|
|
41
|
+
Text: {
|
|
42
|
+
Data: message.text,
|
|
43
|
+
Charset: "UTF-8"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
})).MessageId
|
|
50
|
+
};
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return {
|
|
53
|
+
success: !1,
|
|
54
|
+
error: getErrorMessage(error)
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async sendTemplate(options) {
|
|
59
|
+
const html = this.renderTemplate(options.template, options.data), subject = options.subject || options.template;
|
|
60
|
+
return this.send({
|
|
61
|
+
to: options.to,
|
|
62
|
+
from: options.from,
|
|
63
|
+
subject,
|
|
64
|
+
html
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async getInbox(mailbox, options) {
|
|
68
|
+
try {
|
|
69
|
+
const { S3Client } = await import("@stacksjs/ts-cloud"), s3 = new S3Client(this.region), [localPart, domain] = mailbox.includes("@") ? mailbox.split("@") : [mailbox, this.domain], indexKey = `mailboxes/${domain}/${localPart}/inbox.json`, result = await s3.getObject(this.bucket, indexKey);
|
|
70
|
+
if (!result)
|
|
71
|
+
return [];
|
|
72
|
+
let inbox = JSON.parse(result);
|
|
73
|
+
const offset = options?.offset || 0, limit = options?.limit || 50;
|
|
74
|
+
return inbox.slice(offset, offset + limit);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (getErrorMessage(error).includes("NoSuchKey") || getErrorMessage(error).includes("404"))
|
|
77
|
+
return [];
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async getEmail(mailbox, messageId) {
|
|
82
|
+
try {
|
|
83
|
+
const { S3Client } = await import("@stacksjs/ts-cloud"), s3 = new S3Client(this.region), [localPart, domain] = mailbox.includes("@") ? mailbox.split("@") : [mailbox, this.domain], email = (await this.getInbox(mailbox, { limit: 1000 })).find((e) => e.messageId === messageId);
|
|
84
|
+
if (!email)
|
|
85
|
+
return null;
|
|
86
|
+
const basePath = email.path, metaResult = await s3.getObject(this.bucket, `${basePath}/metadata.json`);
|
|
87
|
+
let metadata = {};
|
|
88
|
+
if (metaResult)
|
|
89
|
+
try {
|
|
90
|
+
metadata = JSON.parse(metaResult);
|
|
91
|
+
} catch (parseError) {
|
|
92
|
+
console.debug(`[email-sdk] Failed to parse email metadata: ${parseError.message}`);
|
|
93
|
+
}
|
|
94
|
+
let html;
|
|
95
|
+
try {
|
|
96
|
+
html = await s3.getObject(this.bucket, `${basePath}/body.html`) || void 0;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (!getErrorMessage(error)?.includes("NoSuchKey") && !getErrorMessage(error)?.includes("404"))
|
|
99
|
+
console.debug(`[email-sdk] Failed to fetch HTML body: ${getErrorMessage(error)}`);
|
|
100
|
+
}
|
|
101
|
+
let text;
|
|
102
|
+
try {
|
|
103
|
+
text = await s3.getObject(this.bucket, `${basePath}/body.txt`) || void 0;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (!getErrorMessage(error)?.includes("NoSuchKey") && !getErrorMessage(error)?.includes("404"))
|
|
106
|
+
console.debug(`[email-sdk] Failed to fetch text body: ${getErrorMessage(error)}`);
|
|
107
|
+
}
|
|
108
|
+
return { metadata, html, text };
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (getErrorMessage(error).includes("NoSuchKey") || getErrorMessage(error).includes("404"))
|
|
111
|
+
return null;
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async search(mailbox, options) {
|
|
116
|
+
let results = await this.getInbox(mailbox, { limit: 1000 });
|
|
117
|
+
if (options.from) {
|
|
118
|
+
const fromLower = options.from.toLowerCase();
|
|
119
|
+
results = results.filter((e) => e.from.toLowerCase().includes(fromLower));
|
|
120
|
+
}
|
|
121
|
+
if (options.subject) {
|
|
122
|
+
const subjectLower = options.subject.toLowerCase();
|
|
123
|
+
results = results.filter((e) => e.subject.toLowerCase().includes(subjectLower));
|
|
124
|
+
}
|
|
125
|
+
if (options.after)
|
|
126
|
+
results = results.filter((e) => new Date(e.date) >= options.after);
|
|
127
|
+
if (options.before)
|
|
128
|
+
results = results.filter((e) => new Date(e.date) <= options.before);
|
|
129
|
+
if (options.hasAttachments !== void 0)
|
|
130
|
+
results = results.filter((e) => e.hasAttachments === options.hasAttachments);
|
|
131
|
+
const offset = options.offset || 0, limit = options.limit || 50;
|
|
132
|
+
return results.slice(offset, offset + limit);
|
|
133
|
+
}
|
|
134
|
+
async delete(mailbox, messageId) {
|
|
135
|
+
try {
|
|
136
|
+
const { S3Client } = await import("@stacksjs/ts-cloud"), s3 = new S3Client(this.region), [localPart, domain] = mailbox.includes("@") ? mailbox.split("@") : [mailbox, this.domain], inbox = await this.getInbox(mailbox, { limit: 1000 }), emailIndex = inbox.findIndex((e) => e.messageId === messageId);
|
|
137
|
+
if (emailIndex === -1)
|
|
138
|
+
return !1;
|
|
139
|
+
const email = inbox[emailIndex];
|
|
140
|
+
if (!email)
|
|
141
|
+
return !1;
|
|
142
|
+
const basePath = email.path, keysToDelete = [
|
|
143
|
+
`${basePath}/metadata.json`,
|
|
144
|
+
`${basePath}/raw.eml`,
|
|
145
|
+
`${basePath}/body.html`,
|
|
146
|
+
`${basePath}/body.txt`,
|
|
147
|
+
`${basePath}/preview.txt`
|
|
148
|
+
];
|
|
149
|
+
for (const key of keysToDelete)
|
|
150
|
+
try {
|
|
151
|
+
await s3.deleteObject(this.bucket, key);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (!getErrorMessage(error)?.includes("NoSuchKey") && !getErrorMessage(error)?.includes("404"))
|
|
154
|
+
console.debug(`[email-sdk] Failed to delete ${key}: ${getErrorMessage(error)}`);
|
|
155
|
+
}
|
|
156
|
+
inbox.splice(emailIndex, 1);
|
|
157
|
+
await s3.putObject({
|
|
158
|
+
bucket: this.bucket,
|
|
159
|
+
key: `mailboxes/${domain}/${localPart}/inbox.json`,
|
|
160
|
+
body: JSON.stringify(inbox, null, 2),
|
|
161
|
+
contentType: "application/json"
|
|
162
|
+
});
|
|
163
|
+
return !0;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.debug(`[email-sdk] Failed to delete email ${messageId}: ${getErrorMessage(error)}`);
|
|
166
|
+
return !1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async markAsRead(mailbox, messageId) {
|
|
170
|
+
return this.updateEmailStatus(mailbox, messageId, { read: !0 });
|
|
171
|
+
}
|
|
172
|
+
async markAsUnread(mailbox, messageId) {
|
|
173
|
+
return this.updateEmailStatus(mailbox, messageId, { read: !1 });
|
|
174
|
+
}
|
|
175
|
+
async updateEmailStatus(mailbox, messageId, updates) {
|
|
176
|
+
try {
|
|
177
|
+
const { S3Client } = await import("@stacksjs/ts-cloud"), s3 = new S3Client(this.region), [localPart, domain] = mailbox.includes("@") ? mailbox.split("@") : [mailbox, this.domain], inbox = await this.getInbox(mailbox, { limit: 1000 }), emailIndex = inbox.findIndex((e) => e.messageId === messageId);
|
|
178
|
+
if (emailIndex === -1)
|
|
179
|
+
return !1;
|
|
180
|
+
Object.assign(inbox[emailIndex], updates);
|
|
181
|
+
await s3.putObject({
|
|
182
|
+
bucket: this.bucket,
|
|
183
|
+
key: `mailboxes/${domain}/${localPart}/inbox.json`,
|
|
184
|
+
body: JSON.stringify(inbox, null, 2),
|
|
185
|
+
contentType: "application/json"
|
|
186
|
+
});
|
|
187
|
+
return !0;
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.debug(`[email-sdk] Failed to update email status for ${messageId}: ${getErrorMessage(error)}`);
|
|
190
|
+
return !1;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
normalizeAddress(addr) {
|
|
194
|
+
if (typeof addr === "string") {
|
|
195
|
+
const match = addr.match(/^(.+?)\s*<(.+)>$/);
|
|
196
|
+
if (match)
|
|
197
|
+
return { name: match[1].trim(), address: match[2].trim() };
|
|
198
|
+
return { address: addr };
|
|
199
|
+
}
|
|
200
|
+
return addr;
|
|
201
|
+
}
|
|
202
|
+
normalizeAddresses(addrs) {
|
|
203
|
+
return (Array.isArray(addrs) ? addrs : [addrs]).map((a) => {
|
|
204
|
+
if (typeof a === "string")
|
|
205
|
+
return a;
|
|
206
|
+
return a.name ? `${a.name} <${a.address}>` : a.address;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
renderTemplate(template, data) {
|
|
210
|
+
let result = template;
|
|
211
|
+
for (const [key, value] of Object.entries(data)) {
|
|
212
|
+
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
213
|
+
result = result.replace(new RegExp(`{{\\s*${escapedKey}\\s*}}`, "g"), String(value));
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
export const emailSDK = new EmailSDK, sendEmail = (message) => emailSDK.send(message), getInbox = (mailbox, options) => emailSDK.getInbox(mailbox, options), searchEmails = (mailbox, options) => emailSDK.search(mailbox, options), deleteEmail = (mailbox, messageId) => emailSDK.delete(mailbox, messageId);
|
|
219
|
+
export default EmailSDK;
|
package/dist/send.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
package/dist/send.js
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does this error mean the backing table simply hasn't been migrated yet?
|
|
3
|
+
* Exported for direct unit coverage across dialects.
|
|
4
|
+
*
|
|
5
|
+
* Each supported database phrases "missing table" differently, and the
|
|
6
|
+
* suppression layer is fail-open — so a matcher that only knows sqlite/mysql
|
|
7
|
+
* would let Postgres's wording slip through and hard-fail every send on an
|
|
8
|
+
* un-migrated Postgres DB (stacksjs/stacks#1976). We scope the Postgres check
|
|
9
|
+
* to `undefined_table` specifically (SQLSTATE 42P01 / `relation "..." does not
|
|
10
|
+
* exist`) rather than a bare `does not exist`, so a genuine `column ... does
|
|
11
|
+
* not exist` schema bug still surfaces instead of being silently swallowed.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isMissingTableError(err: unknown): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Is the address suppressed? Pass a specific `type` to check only
|
|
16
|
+
* one kind (e.g. unsubscribe-only — bounces from the same address
|
|
17
|
+
* don't count). Omit to match any suppression.
|
|
18
|
+
*
|
|
19
|
+
* Returns `false` when the table doesn't exist yet — apps that
|
|
20
|
+
* haven't run the migration aren't broken by the new behavior, and
|
|
21
|
+
* a one-shot warn lets the operator know the table is missing.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isSuppressed(email: string, type?: SuppressionType): Promise<boolean>;
|
|
24
|
+
/**
|
|
25
|
+
* Look up the full suppression record(s) for an address — useful
|
|
26
|
+
* for admin tools that want to show the reason/timestamp alongside
|
|
27
|
+
* the suppression status. Returns an empty array on missing-table
|
|
28
|
+
* (same warn-once degrade).
|
|
29
|
+
*/
|
|
30
|
+
export declare function getSuppressions(email: string): Promise<SuppressionRecord[]>;
|
|
31
|
+
/**
|
|
32
|
+
* Record a suppression. Idempotent — a duplicate (email, type)
|
|
33
|
+
* pair silently no-ops (the unique constraint catches it).
|
|
34
|
+
*
|
|
35
|
+
* Called by:
|
|
36
|
+
* - the framework's bounce/complaint webhook handlers (#1881)
|
|
37
|
+
* - the unsubscribe route handler (this PR)
|
|
38
|
+
* - admin tooling (`SuppressionType: 'manual'`)
|
|
39
|
+
*/
|
|
40
|
+
export declare function suppress(email: string, type: SuppressionType, reason?: string): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Remove a suppression record (admin recovery, user-initiated
|
|
43
|
+
* resubscribe). Idempotent — removing something that isn't there
|
|
44
|
+
* is a no-op.
|
|
45
|
+
*/
|
|
46
|
+
export declare function unsuppress(email: string, type: SuppressionType): Promise<void>;
|
|
47
|
+
export declare function getSuppressionPolicy(): Promise<SuppressionPolicy>;
|
|
48
|
+
/**
|
|
49
|
+
* Decide whether a `mail.send()` should proceed for a given
|
|
50
|
+
* recipient. Returns `null` to indicate "allowed"; otherwise
|
|
51
|
+
* returns the matched suppression type so the caller can surface
|
|
52
|
+
* it in the error message.
|
|
53
|
+
*
|
|
54
|
+
* Called from `Mail.send()` after idempotency lookup, before the
|
|
55
|
+
* driver dispatch.
|
|
56
|
+
*/
|
|
57
|
+
export declare function checkSuppressionFor(email: string, tag: 'transactional' | 'broadcast' | undefined): Promise<SuppressionType | null>;
|
|
58
|
+
export declare interface SuppressionRecord {
|
|
59
|
+
email: string
|
|
60
|
+
type: SuppressionType
|
|
61
|
+
reason: string | null
|
|
62
|
+
created_at: string
|
|
63
|
+
}
|
|
64
|
+
export type SuppressionType = 'bounce' | 'complaint' | 'unsubscribe' | 'manual';
|
|
65
|
+
/**
|
|
66
|
+
* Suppression-policy resolution for `mail.send()` (stacksjs/stacks#1880).
|
|
67
|
+
*
|
|
68
|
+
* Reads the policy from `config.email.suppressionPolicy` with a
|
|
69
|
+
* sensible default and decides whether the message should be
|
|
70
|
+
* allowed through given its tag.
|
|
71
|
+
*
|
|
72
|
+
* Policy semantics:
|
|
73
|
+
* - `'strict'` — block all sends to suppressed addresses
|
|
74
|
+
* - `'transactional-allowed'` — block broadcasts; allow `tag: 'transactional'`
|
|
75
|
+
* - `'off'` — never block (table is only used for tracking)
|
|
76
|
+
*
|
|
77
|
+
* Default is `'strict'` — the safest behavior for compliance, and
|
|
78
|
+
* apps that don't run the migration are unaffected because the
|
|
79
|
+
* lookup falls through to "not suppressed" when the table is
|
|
80
|
+
* missing.
|
|
81
|
+
*/
|
|
82
|
+
export type SuppressionPolicy = 'strict' | 'transactional-allowed' | 'off';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { db } from "@stacksjs/database";
|
|
2
|
+
let warnedAboutMissingTable = !1;
|
|
3
|
+
function warnOnceAboutMissingTable() {
|
|
4
|
+
if (warnedAboutMissingTable)
|
|
5
|
+
return;
|
|
6
|
+
warnedAboutMissingTable = !0;
|
|
7
|
+
console.warn("[email/suppression] email_suppressions table missing \u2014 suppression checks accepted but NOT enforced. " + "Run migrations to enable enforcement.");
|
|
8
|
+
}
|
|
9
|
+
export function isMissingTableError(err) {
|
|
10
|
+
const e = err, msg = e?.message ?? "";
|
|
11
|
+
return e?.code === "42P01" || msg.includes("no such table") || msg.includes("doesn't exist") || /relation "[^"]*" does not exist/i.test(msg);
|
|
12
|
+
}
|
|
13
|
+
function isSuppressionStoreUnavailable(err) {
|
|
14
|
+
if (isMissingTableError(err))
|
|
15
|
+
return !0;
|
|
16
|
+
const msg = (err?.message ?? "").toLowerCase();
|
|
17
|
+
return 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");
|
|
18
|
+
}
|
|
19
|
+
function canonicalize(email) {
|
|
20
|
+
return String(email).trim().toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
export async function isSuppressed(email, type) {
|
|
23
|
+
const canon = canonicalize(email);
|
|
24
|
+
try {
|
|
25
|
+
let query = db.selectFrom("email_suppressions").where("email", "=", canon).select(["email"]);
|
|
26
|
+
if (type)
|
|
27
|
+
query = query.where("type", "=", type);
|
|
28
|
+
const row = await query.executeTakeFirst();
|
|
29
|
+
return Boolean(row);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (isSuppressionStoreUnavailable(err)) {
|
|
32
|
+
warnOnceAboutMissingTable();
|
|
33
|
+
return !1;
|
|
34
|
+
}
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function getSuppressions(email) {
|
|
39
|
+
const canon = canonicalize(email);
|
|
40
|
+
try {
|
|
41
|
+
return await db.selectFrom("email_suppressions").where("email", "=", canon).selectAll().execute() ?? [];
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (isSuppressionStoreUnavailable(err)) {
|
|
44
|
+
warnOnceAboutMissingTable();
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function suppress(email, type, reason) {
|
|
51
|
+
const canon = canonicalize(email), createdAt = new Date().toISOString().slice(0, 19).replace("T", " ");
|
|
52
|
+
try {
|
|
53
|
+
await db.insertInto("email_suppressions").values({
|
|
54
|
+
email: canon,
|
|
55
|
+
type,
|
|
56
|
+
reason: reason ?? null,
|
|
57
|
+
created_at: createdAt
|
|
58
|
+
}).execute();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (isSuppressionStoreUnavailable(err)) {
|
|
61
|
+
warnOnceAboutMissingTable();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const msg = err?.message ?? "";
|
|
65
|
+
if (msg.includes("UNIQUE constraint") || msg.includes("Duplicate entry"))
|
|
66
|
+
return;
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function unsuppress(email, type) {
|
|
71
|
+
const canon = canonicalize(email);
|
|
72
|
+
try {
|
|
73
|
+
await db.deleteFrom("email_suppressions").where("email", "=", canon).where("type", "=", type).execute();
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (isSuppressionStoreUnavailable(err)) {
|
|
76
|
+
warnOnceAboutMissingTable();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function getSuppressionPolicy() {
|
|
83
|
+
try {
|
|
84
|
+
const { config } = await import("@stacksjs/config"), policy = config?.email?.suppressionPolicy;
|
|
85
|
+
if (policy === "strict" || policy === "transactional-allowed" || policy === "off")
|
|
86
|
+
return policy;
|
|
87
|
+
} catch {}
|
|
88
|
+
return "strict";
|
|
89
|
+
}
|
|
90
|
+
export async function checkSuppressionFor(email, tag) {
|
|
91
|
+
const policy = await getSuppressionPolicy();
|
|
92
|
+
if (policy === "off")
|
|
93
|
+
return null;
|
|
94
|
+
if (policy === "transactional-allowed" && tag === "transactional")
|
|
95
|
+
return null;
|
|
96
|
+
const suppressions = await getSuppressions(email);
|
|
97
|
+
if (suppressions.length === 0)
|
|
98
|
+
return null;
|
|
99
|
+
const priority = ["unsubscribe", "complaint", "bounce", "manual"];
|
|
100
|
+
for (const p of priority)
|
|
101
|
+
if (suppressions.some((s) => s.type === p))
|
|
102
|
+
return p;
|
|
103
|
+
return suppressions[0].type;
|
|
104
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mark a string as pre-rendered HTML so {@link replaceVariables} splices
|
|
3
|
+
* it in verbatim instead of escaping. Use ONLY for content that you
|
|
4
|
+
* authored (or that came from a trusted renderer like the framework's
|
|
5
|
+
* own layout-slot resolution) — never for user input.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* mail.send({
|
|
10
|
+
* template: 'invoice',
|
|
11
|
+
* variables: {
|
|
12
|
+
* // User-supplied — escaped automatically (good)
|
|
13
|
+
* userName: req.input('name'),
|
|
14
|
+
* // Pre-rendered HTML you built yourself — opt out of escaping
|
|
15
|
+
* invoiceTable: safe(renderInvoiceTable(rows)),
|
|
16
|
+
* },
|
|
17
|
+
* })
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function safe(html: string): SafeHtml;
|
|
21
|
+
/**
|
|
22
|
+
* Render an email template with optional layout
|
|
23
|
+
*
|
|
24
|
+
* Supports both .stx and .html templates. When a .stx template
|
|
25
|
+
* is found, it uses the STX engine for rendering (with directives,
|
|
26
|
+
* server scripts, etc.). When an .html template is found, it uses
|
|
27
|
+
* simple {{ variable }} replacement with layout wrapping.
|
|
28
|
+
*
|
|
29
|
+
* Templates resolve from userland `resources/emails/` first, then
|
|
30
|
+
* fall back to the framework-shipped defaults in
|
|
31
|
+
* `storage/framework/defaults/resources/emails/` — so the prebaked
|
|
32
|
+
* mailers (password-reset, password-changed, email-verification)
|
|
33
|
+
* work out of the box on a default install while any userland file
|
|
34
|
+
* with the same name always wins (stacksjs/stacks#1944).
|
|
35
|
+
*
|
|
36
|
+
* Within each directory, .stx templates are preferred over .html
|
|
37
|
+
* when both exist.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* // STX template (resources/emails/welcome.stx)
|
|
42
|
+
* const { html, text } = await template('welcome', {
|
|
43
|
+
* variables: { userName: 'John' }
|
|
44
|
+
* })
|
|
45
|
+
*
|
|
46
|
+
* // HTML template with layout
|
|
47
|
+
* const { html, text } = await template('notification', {
|
|
48
|
+
* layout: 'base',
|
|
49
|
+
* variables: { message: 'Hello' }
|
|
50
|
+
* })
|
|
51
|
+
*
|
|
52
|
+
* // Without layout (HTML templates only)
|
|
53
|
+
* const { html, text } = await template('simple', {
|
|
54
|
+
* layout: false
|
|
55
|
+
* })
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export declare function template(templateName: string, options?: TemplateOptions): Promise<TemplateResult>;
|
|
59
|
+
/**
|
|
60
|
+
* Render a raw HTML string with variables (no file loading)
|
|
61
|
+
*/
|
|
62
|
+
export declare function renderHtml(htmlContent: string, variables?: TemplateVariables): TemplateResult;
|
|
63
|
+
/**
|
|
64
|
+
* Check if a template exists (.stx or .html)
|
|
65
|
+
*/
|
|
66
|
+
export declare function templateExists(templateName: string): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* List available templates (.stx and .html)
|
|
69
|
+
*/
|
|
70
|
+
export declare function listTemplates(): string[];
|
|
71
|
+
export declare interface TemplateResult {
|
|
72
|
+
html: string
|
|
73
|
+
text: string
|
|
74
|
+
}
|
|
75
|
+
export declare interface TemplateOptions {
|
|
76
|
+
variables?: TemplateVariables
|
|
77
|
+
layout?: string | false
|
|
78
|
+
subject?: string
|
|
79
|
+
inline?: boolean
|
|
80
|
+
}
|
|
81
|
+
/** Allowed types for email template variable values */
|
|
82
|
+
export type TemplateVariableValue = string | number | boolean | undefined | null | SafeHtml;
|
|
83
|
+
/** Map of variable names to their values for template replacement */
|
|
84
|
+
export type TemplateVariables = Record<string, TemplateVariableValue>;
|
|
85
|
+
/**
|
|
86
|
+
* Marker wrapper for variable values that contain pre-rendered HTML and
|
|
87
|
+
* should NOT be escaped during {@link replaceVariables}. Constructed via
|
|
88
|
+
* the {@link safe} helper.
|
|
89
|
+
*
|
|
90
|
+
* Anything that isn't a `SafeHtml` instance (or `safe`-marked) is treated
|
|
91
|
+
* as untrusted text and runs through HTML escaping — this is the M-1
|
|
92
|
+
* fix for stacksjs/stacks#1871 (template XSS via unescaped variable
|
|
93
|
+
* interpolation).
|
|
94
|
+
*/
|
|
95
|
+
export declare class SafeHtml {
|
|
96
|
+
readonly __safeHtml: true;
|
|
97
|
+
public readonly value: string;
|
|
98
|
+
constructor(value: string);
|
|
99
|
+
}
|