@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
package/dist/preview.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { existsSync } from "@stacksjs/storage";
|
|
4
|
+
import { userEmailsPath, userMailPath } from "@stacksjs/path";
|
|
5
|
+
import { kebabCase } from "@stacksjs/strings";
|
|
6
|
+
import { Mailable } from "./mailable";
|
|
7
|
+
import { template } from "./template";
|
|
8
|
+
export function discoverMailables() {
|
|
9
|
+
const dir = userMailPath();
|
|
10
|
+
if (!existsSync(dir))
|
|
11
|
+
return [];
|
|
12
|
+
const entries = readdirSync(dir, { withFileTypes: !0 }), out = [];
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
if (!entry.isFile())
|
|
15
|
+
continue;
|
|
16
|
+
if (!entry.name.endsWith(".ts"))
|
|
17
|
+
continue;
|
|
18
|
+
if (entry.name.endsWith(".d.ts"))
|
|
19
|
+
continue;
|
|
20
|
+
if (entry.name.endsWith(".test.ts"))
|
|
21
|
+
continue;
|
|
22
|
+
if (entry.name.endsWith(".spec.ts"))
|
|
23
|
+
continue;
|
|
24
|
+
const base = entry.name.replace(/\.ts$/, "");
|
|
25
|
+
out.push({
|
|
26
|
+
name: base,
|
|
27
|
+
path: join(dir, entry.name),
|
|
28
|
+
slug: kebabCase(base)
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
export async function loadSampleProps(slug) {
|
|
35
|
+
const samplePath = userEmailsPath(`_previews/${slug}.ts`);
|
|
36
|
+
if (!existsSync(samplePath))
|
|
37
|
+
return null;
|
|
38
|
+
try {
|
|
39
|
+
const mod = await import(samplePath), props = mod.default ?? mod.props ?? null;
|
|
40
|
+
return props && typeof props === "object" ? props : null;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return { __preview_error__: `Failed to load sample props: ${err instanceof Error ? err.message : String(err)}` };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function renderMailablePreview(mailable) {
|
|
46
|
+
const empty = {
|
|
47
|
+
inspection: { to: [], cc: [], bcc: [], attachments: [] },
|
|
48
|
+
html: "",
|
|
49
|
+
text: "",
|
|
50
|
+
sampleProps: null
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
const mod = await import(mailable.path), Cls = pickMailableConstructor(mod);
|
|
54
|
+
if (!Cls)
|
|
55
|
+
return { ...empty, error: `No subclass of \`Mailable\` exported from ${mailable.path}.` };
|
|
56
|
+
const sampleProps = await loadSampleProps(mailable.slug), instance = new Cls(sampleProps ?? {});
|
|
57
|
+
if (!(instance instanceof Mailable))
|
|
58
|
+
return { ...empty, sampleProps, error: "Constructor did not produce a Mailable instance." };
|
|
59
|
+
await instance.build();
|
|
60
|
+
const inspection = instance.inspect();
|
|
61
|
+
if (!inspection.template)
|
|
62
|
+
return {
|
|
63
|
+
inspection,
|
|
64
|
+
html: inspection.html ?? "",
|
|
65
|
+
text: inspection.text ?? "",
|
|
66
|
+
sampleProps
|
|
67
|
+
};
|
|
68
|
+
const rendered = await template(inspection.template.name, {
|
|
69
|
+
variables: inspection.template.props,
|
|
70
|
+
subject: inspection.subject
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
inspection,
|
|
74
|
+
html: rendered.html,
|
|
75
|
+
text: rendered.text,
|
|
76
|
+
sampleProps
|
|
77
|
+
};
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return { ...empty, error: err instanceof Error ? `${err.name}: ${err.message}` : String(err) };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function pickMailableConstructor(mod) {
|
|
83
|
+
const candidates = [];
|
|
84
|
+
if (mod.default)
|
|
85
|
+
candidates.push(mod.default);
|
|
86
|
+
for (const key of Object.keys(mod))
|
|
87
|
+
if (key !== "default")
|
|
88
|
+
candidates.push(mod[key]);
|
|
89
|
+
for (const candidate of candidates) {
|
|
90
|
+
if (typeof candidate !== "function")
|
|
91
|
+
continue;
|
|
92
|
+
if (!(candidate.prototype instanceof Mailable))
|
|
93
|
+
continue;
|
|
94
|
+
return candidate;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
@@ -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,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
|
+
}
|
package/dist/template.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { config } from "@stacksjs/config";
|
|
2
|
+
import { log } from "@stacksjs/logging";
|
|
3
|
+
import { fs } from "@stacksjs/storage";
|
|
4
|
+
import { defaultsResourcesPath, resourcesPath } from "@stacksjs/path";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { inlineCss, shouldInlineByDefault } from "./css-inliner";
|
|
7
|
+
|
|
8
|
+
export class SafeHtml {
|
|
9
|
+
value;
|
|
10
|
+
__safeHtml = !0;
|
|
11
|
+
constructor(value) {
|
|
12
|
+
this.value = value;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function safe(html) {
|
|
16
|
+
return new SafeHtml(html);
|
|
17
|
+
}
|
|
18
|
+
function getDefaultVariables() {
|
|
19
|
+
const primaryColor = config.app.primaryColor || "#3b82f6";
|
|
20
|
+
return {
|
|
21
|
+
appName: config.app.name || "Stacks",
|
|
22
|
+
appUrl: config.app.url || "https://localhost",
|
|
23
|
+
primaryColor,
|
|
24
|
+
primaryColorDark: darkenColor(primaryColor, 15),
|
|
25
|
+
year: new Date().getFullYear()
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function darkenColor(hex, percent) {
|
|
29
|
+
const num = Number.parseInt(hex.replace("#", ""), 16), amt = Math.round(2.55 * percent), R = Math.max(0, (num >> 16) - amt), G = Math.max(0, (num >> 8 & 255) - amt), B = Math.max(0, (num & 255) - amt);
|
|
30
|
+
return `#${(16777216 + R * 65536 + G * 256 + B).toString(16).slice(1)}`;
|
|
31
|
+
}
|
|
32
|
+
function escapeHtml(input) {
|
|
33
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
34
|
+
}
|
|
35
|
+
function replaceVariables(html, variables) {
|
|
36
|
+
let result = html;
|
|
37
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
38
|
+
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), regex = new RegExp(`\\{\\{\\s*${escapedKey}\\s*\\}\\}`, "g");
|
|
39
|
+
result = result.replace(regex, renderTemplateValue(value));
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
function renderTemplateValue(value) {
|
|
44
|
+
if (value === null || value === void 0)
|
|
45
|
+
return "";
|
|
46
|
+
if (value instanceof SafeHtml)
|
|
47
|
+
return value.value;
|
|
48
|
+
return escapeHtml(String(value));
|
|
49
|
+
}
|
|
50
|
+
const templateRoots = [
|
|
51
|
+
(relativePath) => resourcesPath(join("emails", relativePath)),
|
|
52
|
+
(relativePath) => defaultsResourcesPath(join("emails", relativePath))
|
|
53
|
+
];
|
|
54
|
+
function resolveTemplatePath(templateName) {
|
|
55
|
+
for (const root of templateRoots) {
|
|
56
|
+
if (templateName.endsWith(".stx")) {
|
|
57
|
+
const fullPath = root(templateName);
|
|
58
|
+
if (fs.existsSync(fullPath))
|
|
59
|
+
return { path: fullPath, type: "stx" };
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (templateName.endsWith(".html")) {
|
|
63
|
+
const fullPath = root(templateName);
|
|
64
|
+
if (fs.existsSync(fullPath))
|
|
65
|
+
return { path: fullPath, type: "html" };
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const stxPath = root(`${templateName}.stx`);
|
|
69
|
+
if (fs.existsSync(stxPath))
|
|
70
|
+
return { path: stxPath, type: "stx" };
|
|
71
|
+
const htmlPath = root(`${templateName}.html`);
|
|
72
|
+
if (fs.existsSync(htmlPath))
|
|
73
|
+
return { path: htmlPath, type: "html" };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function loadHtmlTemplate(templatePath) {
|
|
78
|
+
const path = templatePath.endsWith(".html") ? templatePath : `${templatePath}.html`;
|
|
79
|
+
for (const root of templateRoots) {
|
|
80
|
+
const fullPath = root(path);
|
|
81
|
+
if (fs.existsSync(fullPath))
|
|
82
|
+
return fs.readFileSync(fullPath, "utf-8");
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
function loadLayout(layoutName) {
|
|
87
|
+
return loadHtmlTemplate(`layouts/${layoutName}`);
|
|
88
|
+
}
|
|
89
|
+
function htmlToText(html) {
|
|
90
|
+
return html.replace(/<br\s*\/?>/gi, `
|
|
91
|
+
`).replace(/<\/(p|div|h[1-6]|li|tr)>/gi, `
|
|
92
|
+
`).replace(/<\/td>/gi, "\t").replace(/<[^>]*>/g, "").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/©/g, "(c)").replace(/\n\s*\n\s*\n/g, `
|
|
93
|
+
|
|
94
|
+
`).trim();
|
|
95
|
+
}
|
|
96
|
+
export async function template(templateName, options = {}) {
|
|
97
|
+
const {
|
|
98
|
+
variables = {},
|
|
99
|
+
layout = "base",
|
|
100
|
+
subject = "",
|
|
101
|
+
inline = shouldInlineByDefault()
|
|
102
|
+
} = options, allVariables = {
|
|
103
|
+
...getDefaultVariables(),
|
|
104
|
+
subject,
|
|
105
|
+
...variables
|
|
106
|
+
}, resolved = resolveTemplatePath(templateName);
|
|
107
|
+
if (!resolved) {
|
|
108
|
+
console.warn(`[Email Template] Template "${templateName}" not found`);
|
|
109
|
+
return { html: "", text: "" };
|
|
110
|
+
}
|
|
111
|
+
if (resolved.type === "stx")
|
|
112
|
+
try {
|
|
113
|
+
const { renderEmail } = await import("@stacksjs/stx"), result = await renderEmail(resolved.path, allVariables, {
|
|
114
|
+
componentsDir: defaultsResourcesPath("components/Email")
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
...result,
|
|
118
|
+
html: inlineCss(result.html, { inline })
|
|
119
|
+
};
|
|
120
|
+
} catch (error) {
|
|
121
|
+
log.warn(`[email] STX template rendering failed for ${templateName}: ${error instanceof Error ? error.message : String(error)}`);
|
|
122
|
+
return { html: "", text: "" };
|
|
123
|
+
}
|
|
124
|
+
let content = fs.readFileSync(resolved.path, "utf-8");
|
|
125
|
+
content = replaceVariables(content, allVariables);
|
|
126
|
+
let html;
|
|
127
|
+
if (layout !== !1) {
|
|
128
|
+
const layoutHtml = loadLayout(layout);
|
|
129
|
+
if (!layoutHtml) {
|
|
130
|
+
console.warn(`[Email Template] Layout "${layout}" not found, using content only`);
|
|
131
|
+
html = content;
|
|
132
|
+
} else {
|
|
133
|
+
allVariables.content = safe(content);
|
|
134
|
+
html = replaceVariables(layoutHtml, allVariables);
|
|
135
|
+
}
|
|
136
|
+
} else
|
|
137
|
+
html = content;
|
|
138
|
+
html = inlineCss(html, { inline });
|
|
139
|
+
const text = htmlToText(html);
|
|
140
|
+
return { html, text };
|
|
141
|
+
}
|
|
142
|
+
export function renderHtml(htmlContent, variables = {}) {
|
|
143
|
+
const allVariables = {
|
|
144
|
+
...getDefaultVariables(),
|
|
145
|
+
...variables
|
|
146
|
+
}, html = replaceVariables(htmlContent, allVariables), text = htmlToText(html);
|
|
147
|
+
return { html, text };
|
|
148
|
+
}
|
|
149
|
+
export function templateExists(templateName) {
|
|
150
|
+
return resolveTemplatePath(templateName) !== null;
|
|
151
|
+
}
|
|
152
|
+
export function listTemplates() {
|
|
153
|
+
const emailsPath = resourcesPath("emails");
|
|
154
|
+
if (!fs.existsSync(emailsPath))
|
|
155
|
+
return [];
|
|
156
|
+
const templates = [];
|
|
157
|
+
function scanDir(dir, prefix = "") {
|
|
158
|
+
const entries = fs.readdirSync(dir, { withFileTypes: !0 });
|
|
159
|
+
for (const entry of entries)
|
|
160
|
+
if (entry.isDirectory() && entry.name !== "layouts")
|
|
161
|
+
scanDir(join(dir, entry.name), `${prefix}${entry.name}/`);
|
|
162
|
+
else if (entry.isFile() && (entry.name.endsWith(".html") || entry.name.endsWith(".stx"))) {
|
|
163
|
+
const name = entry.name.replace(/\.(html|stx)$/, "");
|
|
164
|
+
if (!templates.includes(`${prefix}${name}`))
|
|
165
|
+
templates.push(`${prefix}${name}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
scanDir(emailsPath);
|
|
169
|
+
return templates;
|
|
170
|
+
}
|