@stacksjs/email 0.70.257 → 0.70.259

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/email.js CHANGED
@@ -1,198 +1 @@
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
- });
1
+ import{config}from"@stacksjs/config";import{log}from"@stacksjs/logging";import{CaptureEmailDriver}from"./drivers/capture";import{LogEmailDriver}from"./drivers/log";import{MailgunDriver}from"./drivers/mailgun";import{MailtrapDriver}from"./drivers/mailtrap";import{SendGridDriver}from"./drivers/sendgrid";import{SESDriver}from"./drivers/ses";import{SMTPDriver}from"./drivers/smtp";import{findEmailByIdempotencyKey,recordEmailIdempotency}from"./idempotency";import{checkSuppressionFor}from"./suppression";export class Email{name;subject;to;from;template;handle;onError;onSuccess;constructor(options){this.name=options.name;this.subject=options.subject;this.to=options.to;this.from=options.from;this.template=options.template;this.handle=options.handle;this.onError=options.onError;this.onSuccess=options.onSuccess}async renderTemplate(){if(!this.template)return"";try{const{path:p}=await import("@stacksjs/path"),templatePath=p.resourcesPath(`views/emails/${this.template}.html`),file=Bun.file(templatePath);if(await file.exists())return await file.text()}catch{}if(this.template.includes("<"))return this.template;return`<p>${this.template}</p>`}async send(to){const target=to??this.to,recipients=Array.isArray(target)?target:target?[target]:[];if(recipients.length===0)throw Error("No recipient specified for email");try{await mail.send({to:recipients,from:this.from||{name:config.email.from?.name||"Stacks",address:config.email.from?.address||"no-reply@stacksjs.com"},subject:this.subject,html:await this.renderTemplate()});if(this.onSuccess)this.onSuccess();if(this.handle)return this.handle();return{message:"Email sent"}}catch(error){if(this.onError)return this.onError(error instanceof Error?error:Error(String(error)));throw error}}}export class Mail{drivers=new Map;defaultDriver;constructor(options={}){this.defaultDriver=options.defaultDriver||config.email.default||"ses";this.registerDefaultDrivers()}registerDefaultDrivers(){this.drivers.set("log",new LogEmailDriver);this.drivers.set("ses",new SESDriver);this.drivers.set("sendgrid",new SendGridDriver);this.drivers.set("mailgun",new MailgunDriver);this.drivers.set("mailtrap",new MailtrapDriver);this.drivers.set("smtp",new SMTPDriver);this.drivers.set("capture",new CaptureEmailDriver)}async send(message){const driver=this.drivers.get(this.defaultDriver);if(!driver){const available=[...this.drivers.keys()].sort().join(", ");throw Error(`Email driver '${this.defaultDriver}' is not registered. Available drivers: [${available}]. Check config.email.default or the MAIL_MAILER environment variable.`)}if(message.idempotencyKey){const cached=await findEmailByIdempotencyKey(message.idempotencyKey);if(cached)return cached}const suppressionType=await checkSuppressionForFirstRecipient(message);if(suppressionType)return{success:!1,message:`suppressed:${suppressionType}`,provider:"suppression"};const defaultFrom={name:config.email.from?.name||"Stacks",address:config.email.from?.address||"no-reply@stacksjs.com"},result=await driver.send({...message,from:message.from||defaultFrom});if(message.idempotencyKey)await recordEmailIdempotency(message.idempotencyKey,message,result);return result}use(driver){if(!this.drivers.has(driver))throw Error(`Email driver '${driver}' is not available`);return new Mail({defaultDriver:driver})}async queue(message){await this.dispatchOrFallback(message,async()=>{const{job}=await import("@stacksjs/queue");await job("SendEmail",{message,driver:this.defaultDriver}).onQueue("emails").dispatch()},{context:"queue"})}async later(delaySeconds,message){await this.dispatchOrFallback(message,async()=>{const{job}=await import("@stacksjs/queue");await job("SendEmail",{message,driver:this.defaultDriver}).onQueue("emails").delay(delaySeconds).dispatch()},{context:"later",delaySeconds})}async queueOn(queueName,message){await this.dispatchOrFallback(message,async()=>{const{job}=await import("@stacksjs/queue");await job("SendEmail",{message,driver:this.defaultDriver}).onQueue(queueName).dispatch()},{context:"queueOn",queueName})}async dispatchOrFallback(message,dispatch,logExtra){try{await dispatch();return}catch(error){const reason=error instanceof Error?error.message:String(error);log.warn("[email] Queue dispatch failed; falling back to synchronous send. "+"Background email pipeline is degraded \u2014 check the queue worker / broker.",{...logExtra,reason})}await this.send(message)}}async function checkSuppressionForFirstRecipient(message){const recipients=collectRecipientAddresses(message);if(recipients.length===0)return null;for(const addr of recipients){const matched=await checkSuppressionFor(addr,message.tag);if(matched)return matched}return null}function collectRecipientAddresses(message){const out=[],pushOne=(v)=>{if(typeof v==="string"){out.push(v);return}if(v&&typeof v==="object"&&"address"in v&&typeof v.address==="string")out.push(v.address)};for(const field of["to","cc","bcc"]){const v=message[field];if(!v)continue;if(Array.isArray(v))for(const item of v)pushOne(item);else pushOne(v)}return out}let _mail;function getMail(){if(!_mail){const driver=config?.email?.default||process.env.MAIL_MAILER||"ses";_mail=new Mail({defaultDriver:driver})}return _mail}export const mail=new Proxy({},{get(_t,prop){return getMail()[prop]},set(_t,prop,value){getMail()[prop]=value;return!0}});
@@ -1,56 +1 @@
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
- }
1
+ import{db}from"@stacksjs/database";let warnedAboutMissingEmailIdempotencyTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingEmailIdempotencyTable)return;warnedAboutMissingEmailIdempotencyTable=!0;console.warn("[email/idempotency] email_idempotency table missing \u2014 idempotency keys are accepted but NOT enforced. "+"Run migrations to enable dedup.")}function isMissingTableError(err){const msg=(err?.message??"").toLowerCase();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")}export async function findEmailByIdempotencyKey(key){try{const row=await db.selectFrom("email_idempotency").where("idempotency_key","=",key).selectAll().executeTakeFirst();if(!row)return null;return{success:Boolean(row.success),message:`Idempotent replay \u2014 original send recorded ${row.created_at}`,provider:String(row.provider??"cache"),messageId:row.message_id??void 0}}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return null}throw err}}export async function recordEmailIdempotency(key,message,result){if(!result.success)return;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;try{await db.insertInto("email_idempotency").values({idempotency_key:key,message_id:result.messageId??null,recipient,subject:message.subject,provider:result.provider,success:1,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))return;throw err}}
package/dist/index.js CHANGED
@@ -1,21 +1 @@
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 "./mime-preview";
12
- export * from "./sdk";
13
- export * from "./template";
14
- export * from "./types";
15
- export { inlineCss, shouldInlineByDefault } from "./css-inliner";
16
- export {
17
- discoverMailables,
18
- loadSampleProps,
19
- renderMailablePreview
20
- } from "./preview";
21
- export { renderIndexHtml, renderPreviewHtml } from "./preview-ui";
1
+ export*from"./drivers";export*from"./email";export*from"./idempotency";export*from"./suppression";export*from"./unsubscribe";export*from"./webhook-dedup";export*from"./webhook-events";export*from"./webhook-handlers";export*from"./webhook-signatures";export*from"./mailable";export*from"./mime-preview";export*from"./sdk";export*from"./template";export*from"./types";export{inlineCss,shouldInlineByDefault}from"./css-inliner";export{discoverMailables,loadSampleProps,renderMailablePreview}from"./preview";export{renderIndexHtml,renderPreviewHtml}from"./preview-ui";
package/dist/mailable.js CHANGED
@@ -1,145 +1 @@
1
- import { config } from "@stacksjs/config";
2
- import { mail } from "./email";
3
- import { template as renderTemplate } from "./template";
4
-
5
- export class Mailable {
6
- _to = [];
7
- _cc = [];
8
- _bcc = [];
9
- _replyTo;
10
- _from;
11
- _subject;
12
- _text;
13
- _html;
14
- _template;
15
- _attachments = [];
16
- to(address) {
17
- this._to = normalizeAddresses(address);
18
- return this;
19
- }
20
- cc(address) {
21
- this._cc = normalizeAddresses(address);
22
- return this;
23
- }
24
- bcc(address) {
25
- this._bcc = normalizeAddresses(address);
26
- return this;
27
- }
28
- replyTo(address) {
29
- this._replyTo = typeof address === "string" ? { address } : address;
30
- return this;
31
- }
32
- from(addr) {
33
- this._from = addr;
34
- return this;
35
- }
36
- subject(s) {
37
- this._subject = s;
38
- return this;
39
- }
40
- text(body) {
41
- this._text = body;
42
- return this;
43
- }
44
- html(body) {
45
- this._html = body;
46
- return this;
47
- }
48
- template(name, ...rest) {
49
- const props = rest[0] ?? {};
50
- this._template = { name, props };
51
- return this;
52
- }
53
- inspect() {
54
- return {
55
- to: this._to,
56
- cc: this._cc,
57
- bcc: this._bcc,
58
- from: this._from,
59
- replyTo: this._replyTo,
60
- subject: this._subject,
61
- text: this._text,
62
- html: this._html,
63
- template: this._template,
64
- attachments: this._attachments
65
- };
66
- }
67
- attach(path, name) {
68
- this._attachments.push({
69
- filename: name || basename(path),
70
- content: `__file__:${path}`,
71
- encoding: "binary"
72
- });
73
- return this;
74
- }
75
- attachData(buffer, name, mime) {
76
- this._attachments.push({
77
- filename: name,
78
- content: buffer,
79
- contentType: mime,
80
- encoding: typeof buffer === "string" ? "utf8" : "binary"
81
- });
82
- return this;
83
- }
84
- async send(options = {}) {
85
- await Promise.resolve(this.build());
86
- if (this._to.length === 0)
87
- throw Error("[Mailable] no recipients \u2014 call this.to(...) inside build()");
88
- if (!this._subject)
89
- throw Error("[Mailable] no subject \u2014 call this.subject(...) inside build()");
90
- let html = this._html, text = this._text;
91
- if (this._template) {
92
- const rendered = await renderTemplate(this._template.name, {
93
- variables: this._template.props,
94
- subject: this._subject
95
- });
96
- if (!html)
97
- html = rendered.html;
98
- if (!text)
99
- text = rendered.text;
100
- }
101
- const message = {
102
- to: this._to,
103
- subject: this._subject,
104
- from: this._from || resolveDefaultFrom(),
105
- ...this._cc.length ? { cc: this._cc } : {},
106
- ...this._bcc.length ? { bcc: this._bcc } : {},
107
- ...html ? { html } : {},
108
- ...text ? { text } : {},
109
- ...this._attachments.length ? { attachments: await materializeAttachments(this._attachments) } : {}
110
- };
111
- if (this._replyTo)
112
- message.replyTo = this._replyTo;
113
- return (options.driver ? mail.use(options.driver) : mail).send(message);
114
- }
115
- }
116
- function normalizeAddresses(input) {
117
- const arr = Array.isArray(input) ? input : [input];
118
- if (arr.some((a) => typeof a === "object" && a !== null))
119
- return arr.map((a) => typeof a === "string" ? { address: a } : a);
120
- return arr;
121
- }
122
- function resolveDefaultFrom() {
123
- return {
124
- name: config.email.from?.name || "Stacks",
125
- address: config.email.from?.address || "no-reply@stacksjs.com"
126
- };
127
- }
128
- function basename(p) {
129
- const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
130
- return idx === -1 ? p : p.slice(idx + 1);
131
- }
132
- async function materializeAttachments(atts) {
133
- return Promise.all(atts.map(async (att) => {
134
- if (typeof att.content === "string" && att.content.startsWith("__file__:")) {
135
- const filePath = att.content.slice(9), file = Bun.file(filePath), buf = new Uint8Array(await file.arrayBuffer());
136
- return {
137
- ...att,
138
- content: buf,
139
- contentType: att.contentType || file.type || void 0,
140
- encoding: "binary"
141
- };
142
- }
143
- return att;
144
- }));
145
- }
1
+ import{config}from"@stacksjs/config";import{mail}from"./email";import{template as renderTemplate}from"./template";export class Mailable{_to=[];_cc=[];_bcc=[];_replyTo;_from;_subject;_text;_html;_template;_attachments=[];to(address){this._to=normalizeAddresses(address);return this}cc(address){this._cc=normalizeAddresses(address);return this}bcc(address){this._bcc=normalizeAddresses(address);return this}replyTo(address){this._replyTo=typeof address==="string"?{address}:address;return this}from(addr){this._from=addr;return this}subject(s){this._subject=s;return this}text(body){this._text=body;return this}html(body){this._html=body;return this}template(name,...rest){const props=rest[0]??{};this._template={name,props};return this}inspect(){return{to:this._to,cc:this._cc,bcc:this._bcc,from:this._from,replyTo:this._replyTo,subject:this._subject,text:this._text,html:this._html,template:this._template,attachments:this._attachments}}attach(path,name){this._attachments.push({filename:name||basename(path),content:`__file__:${path}`,encoding:"binary"});return this}attachData(buffer,name,mime){this._attachments.push({filename:name,content:buffer,contentType:mime,encoding:typeof buffer==="string"?"utf8":"binary"});return this}async send(options={}){await Promise.resolve(this.build());if(this._to.length===0)throw Error("[Mailable] no recipients \u2014 call this.to(...) inside build()");if(!this._subject)throw Error("[Mailable] no subject \u2014 call this.subject(...) inside build()");let html=this._html,text=this._text;if(this._template){const rendered=await renderTemplate(this._template.name,{variables:this._template.props,subject:this._subject});if(!html)html=rendered.html;if(!text)text=rendered.text}const message={to:this._to,subject:this._subject,from:this._from||resolveDefaultFrom(),...this._cc.length?{cc:this._cc}:{},...this._bcc.length?{bcc:this._bcc}:{},...html?{html}:{},...text?{text}:{},...this._attachments.length?{attachments:await materializeAttachments(this._attachments)}:{}};if(this._replyTo)message.replyTo=this._replyTo;return(options.driver?mail.use(options.driver):mail).send(message)}}function normalizeAddresses(input){const arr=Array.isArray(input)?input:[input];if(arr.some((a)=>typeof a==="object"&&a!==null))return arr.map((a)=>typeof a==="string"?{address:a}:a);return arr}function resolveDefaultFrom(){return{name:config.email.from?.name||"Stacks",address:config.email.from?.address||"no-reply@stacksjs.com"}}function basename(p){const idx=Math.max(p.lastIndexOf("/"),p.lastIndexOf("\\"));return idx===-1?p:p.slice(idx+1)}async function materializeAttachments(atts){return Promise.all(atts.map(async(att)=>{if(typeof att.content==="string"&&att.content.startsWith("__file__:")){const filePath=att.content.slice(9),file=Bun.file(filePath),buf=new Uint8Array(await file.arrayBuffer());return{...att,content:buf,contentType:att.contentType||file.type||void 0,encoding:"binary"}}return att}))}
@@ -1,70 +1,5 @@
1
- function splitHeaders(value) {
2
- const normalized = value.replace(/\r\n/g, `
3
- `), separator = normalized.indexOf(`
1
+ function splitHeaders(value){const normalized=value.replace(/\r\n/g,`
2
+ `),separator=normalized.indexOf(`
4
3
 
5
- `);
6
- if (separator < 0)
7
- return { headers: {}, body: normalized };
8
- const headerLines = normalized.slice(0, separator).replace(/\n[ \t]+/g, " ").split(`
9
- `), headers = {};
10
- for (const line of headerLines) {
11
- const match = line.match(/^([^:]+):\s*(.*)$/);
12
- if (match)
13
- headers[match[1].toLowerCase()] = match[2] || "";
14
- }
15
- return { headers, body: normalized.slice(separator + 2) };
16
- }
17
- function decodeQuotedPrintable(value) {
18
- const input = value.replace(/=\n/g, ""), bytes = [], encoder = new TextEncoder;
19
- for (let index = 0;index < input.length; index++) {
20
- const encoded = input.slice(index, index + 3);
21
- if (/^=[0-9a-f]{2}$/i.test(encoded)) {
22
- bytes.push(Number.parseInt(encoded.slice(1), 16));
23
- index += 2;
24
- } else
25
- bytes.push(...encoder.encode(input[index]));
26
- }
27
- return new TextDecoder().decode(Uint8Array.from(bytes));
28
- }
29
- function decodeBody(body, encoding) {
30
- if (encoding === "base64")
31
- try {
32
- return Buffer.from(body.replace(/\s+/g, ""), "base64").toString("utf8");
33
- } catch {
34
- return body;
35
- }
36
- if (encoding === "quoted-printable")
37
- return decodeQuotedPrintable(body);
38
- return body;
39
- }
40
- function mimeParts(value) {
41
- const { headers, body } = splitHeaders(value), contentType = (headers["content-type"] || "text/plain").toLowerCase(), boundary = headers["content-type"]?.match(/boundary\s*=\s*(?:"([^"]+)"|([^;\s]+))/i)?.slice(1).find(Boolean);
42
- if (contentType.startsWith("multipart/") && boundary)
43
- return body.split(`--${boundary}`).slice(1).filter((part) => !part.trimStart().startsWith("--")).flatMap(mimeParts);
44
- return [{
45
- contentType,
46
- encoding: (headers["content-transfer-encoding"] || "").toLowerCase(),
47
- body
48
- }];
49
- }
50
- function stripLegacyMimePreamble(value) {
51
- let result = value.trim();
52
- result = result.replace(/^[-=_]{2,}[^\s]*\s+/, "");
53
- for (let pass = 0;pass < 6; pass++) {
54
- const next = result.replace(/^content-transfer-encoding:\s*[^\s]+\s*/i, "").replace(/^mime-version:\s*[^\s]+\s*/i, "").replace(/^content-description:\s*[^\s]+\s*/i, "").replace(/^content-type:\s*[^\s;]+(?:;\s*(?:charset|boundary|name)\s*=\s*(?:"[^"]*"|[^\s]+))*\s*/i, "");
55
- if (next === result)
56
- break;
57
- result = next;
58
- }
59
- return result.replace(/^[-=_]{4,}[^\s]*\s*/, "");
60
- }
61
- function plainText(value) {
62
- return decodeQuotedPrintable(stripLegacyMimePreamble(value)).replace(/<style\b[\s\S]*?<\/style>/gi, " ").replace(/<script\b[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/&nbsp;/gi, " ").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/&#39;/gi, "'").replace(/\s+/g, " ").trim();
63
- }
64
- export function extractEmailPreview(rawEmail, maxLength = 200) {
65
- const parts = mimeParts(rawEmail), selected = parts.find((part) => part.contentType.startsWith("text/plain")) || parts.find((part) => part.contentType.startsWith("text/html"));
66
- return plainText(selected ? decodeBody(selected.body, selected.encoding) : rawEmail).slice(0, maxLength);
67
- }
68
- export function normalizeEmailPreview(preview, maxLength = 200) {
69
- return plainText(preview).slice(0, maxLength);
70
- }
4
+ `);if(separator<0)return{headers:{},body:normalized};const headerLines=normalized.slice(0,separator).replace(/\n[ \t]+/g," ").split(`
5
+ `),headers={};for(const line of headerLines){const match=line.match(/^([^:]+):\s*(.*)$/);if(match)headers[match[1].toLowerCase()]=match[2]||""}return{headers,body:normalized.slice(separator+2)}}function decodeQuotedPrintable(value){const input=value.replace(/=\n/g,""),bytes=[],encoder=new TextEncoder;for(let index=0;index<input.length;index++){const encoded=input.slice(index,index+3);if(/^=[0-9a-f]{2}$/i.test(encoded)){bytes.push(Number.parseInt(encoded.slice(1),16));index+=2}else bytes.push(...encoder.encode(input[index]))}return new TextDecoder().decode(Uint8Array.from(bytes))}function decodeBody(body,encoding){if(encoding==="base64")try{return Buffer.from(body.replace(/\s+/g,""),"base64").toString("utf8")}catch{return body}if(encoding==="quoted-printable")return decodeQuotedPrintable(body);return body}function mimeParts(value){const{headers,body}=splitHeaders(value),contentType=(headers["content-type"]||"text/plain").toLowerCase(),boundary=headers["content-type"]?.match(/boundary\s*=\s*(?:"([^"]+)"|([^;\s]+))/i)?.slice(1).find(Boolean);if(contentType.startsWith("multipart/")&&boundary)return body.split(`--${boundary}`).slice(1).filter((part)=>!part.trimStart().startsWith("--")).flatMap(mimeParts);return[{contentType,encoding:(headers["content-transfer-encoding"]||"").toLowerCase(),body}]}function stripLegacyMimePreamble(value){let result=value.trim();result=result.replace(/^[-=_]{2,}[^\s]*\s+/,"");for(let pass=0;pass<6;pass++){const next=result.replace(/^content-transfer-encoding:\s*[^\s]+\s*/i,"").replace(/^mime-version:\s*[^\s]+\s*/i,"").replace(/^content-description:\s*[^\s]+\s*/i,"").replace(/^content-type:\s*[^\s;]+(?:;\s*(?:charset|boundary|name)\s*=\s*(?:"[^"]*"|[^\s]+))*\s*/i,"");if(next===result)break;result=next}return result.replace(/^[-=_]{4,}[^\s]*\s*/,"")}function plainText(value){return decodeQuotedPrintable(stripLegacyMimePreamble(value)).replace(/<style\b[\s\S]*?<\/style>/gi," ").replace(/<script\b[\s\S]*?<\/script>/gi," ").replace(/<[^>]+>/g," ").replace(/&nbsp;/gi," ").replace(/&amp;/gi,"&").replace(/&lt;/gi,"<").replace(/&gt;/gi,">").replace(/&quot;/gi,'"').replace(/&#39;/gi,"'").replace(/\s+/g," ").trim()}export function extractEmailPreview(rawEmail,maxLength=200){const parts=mimeParts(rawEmail),selected=parts.find((part)=>part.contentType.startsWith("text/plain"))||parts.find((part)=>part.contentType.startsWith("text/html"));return plainText(selected?decodeBody(selected.body,selected.encoding):rawEmail).slice(0,maxLength)}export function normalizeEmailPreview(preview,maxLength=200){return plainText(preview).slice(0,maxLength)}
package/dist/mime.js CHANGED
@@ -1,89 +1,3 @@
1
- import { Buffer } from "node:buffer";
2
- export function encodeRfc2047IfNeeded(value) {
3
- if (/^[\x00-\x7F]*$/.test(value))
4
- return value;
5
- return `=?UTF-8?B?${Buffer.from(value, "utf-8").toString("base64")}?=`;
6
- }
7
- export function buildMimeMessage(options) {
8
- const {
9
- from,
10
- to,
11
- cc,
12
- replyTo,
13
- subject,
14
- text,
15
- html,
16
- attachments,
17
- messageIdDomain,
18
- customHeaders
19
- } = options, lines = [], hasAttachments = !!(attachments && attachments.length > 0), boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`;
20
- lines.push(`From: ${from}`);
21
- lines.push(`To: ${to}`);
22
- if (cc)
23
- lines.push(`Cc: ${cc}`);
24
- if (replyTo)
25
- lines.push(`Reply-To: ${replyTo}`);
26
- lines.push(`Subject: ${encodeRfc2047IfNeeded(subject)}`);
27
- lines.push("MIME-Version: 1.0");
28
- lines.push(`Date: ${new Date().toUTCString()}`);
29
- lines.push(`Message-ID: <${Date.now()}.${Math.random().toString(36).substring(2)}@${messageIdDomain || "localhost"}>`);
30
- if (customHeaders)
31
- for (const [k, v] of Object.entries(customHeaders))
32
- lines.push(`${k}: ${v}`);
33
- if (hasAttachments) {
34
- const mixedBoundary = boundary, altBoundary = `${boundary}_alt`;
35
- lines.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
36
- lines.push("");
37
- lines.push(`--${mixedBoundary}`);
38
- pushBodyParts(lines, { text, html, altBoundary });
39
- for (const attachment of attachments) {
40
- lines.push(`--${mixedBoundary}`);
41
- pushAttachmentPart(lines, attachment);
42
- }
43
- lines.push(`--${mixedBoundary}--`);
44
- } else
45
- pushBodyParts(lines, { text, html, altBoundary: boundary });
46
- return lines.join(`\r
47
- `);
48
- }
49
- function pushBodyParts(lines, options) {
50
- const { text, html, altBoundary } = options;
51
- if (html && text) {
52
- lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
53
- lines.push("");
54
- lines.push(`--${altBoundary}`);
55
- lines.push("Content-Type: text/plain; charset=UTF-8");
56
- lines.push("Content-Transfer-Encoding: 7bit");
57
- lines.push("");
58
- lines.push(text);
59
- lines.push("");
60
- lines.push(`--${altBoundary}`);
61
- lines.push("Content-Type: text/html; charset=UTF-8");
62
- lines.push("Content-Transfer-Encoding: 7bit");
63
- lines.push("");
64
- lines.push(html);
65
- lines.push("");
66
- lines.push(`--${altBoundary}--`);
67
- } else if (html) {
68
- lines.push("Content-Type: text/html; charset=UTF-8");
69
- lines.push("Content-Transfer-Encoding: 7bit");
70
- lines.push("");
71
- lines.push(html);
72
- } else if (text) {
73
- lines.push("Content-Type: text/plain; charset=UTF-8");
74
- lines.push("Content-Transfer-Encoding: 7bit");
75
- lines.push("");
76
- lines.push(text);
77
- }
78
- }
79
- function pushAttachmentPart(lines, attachment) {
80
- const contentType = attachment.contentType || "application/octet-stream", safeFilename = encodeRfc2047IfNeeded(attachment.filename.replace(/\\/g, "\\\\").replace(/"/g, "\\\""));
81
- lines.push(`Content-Type: ${contentType}; name="${safeFilename}"`);
82
- lines.push(`Content-Disposition: attachment; filename="${safeFilename}"`);
83
- lines.push("Content-Transfer-Encoding: base64");
84
- lines.push("");
85
- const raw = typeof attachment.content === "string" ? Buffer.from(attachment.content, "utf-8").toString("base64") : Buffer.from(attachment.content).toString("base64"), wrapped = raw.match(/.{1,76}/g)?.join(`\r
86
- `) ?? raw;
87
- lines.push(wrapped);
88
- lines.push("");
89
- }
1
+ import{Buffer}from"node:buffer";export function encodeRfc2047IfNeeded(value){if(/^[\x00-\x7F]*$/.test(value))return value;return`=?UTF-8?B?${Buffer.from(value,"utf-8").toString("base64")}?=`}export function buildMimeMessage(options){const{from,to,cc,replyTo,subject,text,html,attachments,messageIdDomain,customHeaders}=options,lines=[],hasAttachments=!!(attachments&&attachments.length>0),boundary=`----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`;lines.push(`From: ${from}`);lines.push(`To: ${to}`);if(cc)lines.push(`Cc: ${cc}`);if(replyTo)lines.push(`Reply-To: ${replyTo}`);lines.push(`Subject: ${encodeRfc2047IfNeeded(subject)}`);lines.push("MIME-Version: 1.0");lines.push(`Date: ${new Date().toUTCString()}`);lines.push(`Message-ID: <${Date.now()}.${Math.random().toString(36).substring(2)}@${messageIdDomain||"localhost"}>`);if(customHeaders)for(const[k,v]of Object.entries(customHeaders))lines.push(`${k}: ${v}`);if(hasAttachments){const mixedBoundary=boundary,altBoundary=`${boundary}_alt`;lines.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);lines.push("");lines.push(`--${mixedBoundary}`);pushBodyParts(lines,{text,html,altBoundary});for(const attachment of attachments){lines.push(`--${mixedBoundary}`);pushAttachmentPart(lines,attachment)}lines.push(`--${mixedBoundary}--`)}else pushBodyParts(lines,{text,html,altBoundary:boundary});return lines.join(`\r
2
+ `)}function pushBodyParts(lines,options){const{text,html,altBoundary}=options;if(html&&text){lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);lines.push("");lines.push(`--${altBoundary}`);lines.push("Content-Type: text/plain; charset=UTF-8");lines.push("Content-Transfer-Encoding: 7bit");lines.push("");lines.push(text);lines.push("");lines.push(`--${altBoundary}`);lines.push("Content-Type: text/html; charset=UTF-8");lines.push("Content-Transfer-Encoding: 7bit");lines.push("");lines.push(html);lines.push("");lines.push(`--${altBoundary}--`)}else if(html){lines.push("Content-Type: text/html; charset=UTF-8");lines.push("Content-Transfer-Encoding: 7bit");lines.push("");lines.push(html)}else if(text){lines.push("Content-Type: text/plain; charset=UTF-8");lines.push("Content-Transfer-Encoding: 7bit");lines.push("");lines.push(text)}}function pushAttachmentPart(lines,attachment){const contentType=attachment.contentType||"application/octet-stream",safeFilename=encodeRfc2047IfNeeded(attachment.filename.replace(/\\/g,"\\\\").replace(/"/g,"\\\""));lines.push(`Content-Type: ${contentType}; name="${safeFilename}"`);lines.push(`Content-Disposition: attachment; filename="${safeFilename}"`);lines.push("Content-Transfer-Encoding: base64");lines.push("");const raw=typeof attachment.content==="string"?Buffer.from(attachment.content,"utf-8").toString("base64"):Buffer.from(attachment.content).toString("base64"),wrapped=raw.match(/.{1,76}/g)?.join(`\r
3
+ `)??raw;lines.push(wrapped);lines.push("")}