@objectstack/plugin-email 4.0.1
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/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +202 -0
- package/dist/index.d.mts +256 -0
- package/dist/index.d.ts +256 -0
- package/dist/index.js +877 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +835 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +35 -0
- package/src/email-plugin.ts +361 -0
- package/src/email-service.test.ts +142 -0
- package/src/email-service.ts +366 -0
- package/src/index.ts +32 -0
- package/src/send-template.test.ts +273 -0
- package/src/template-engine.test.ts +76 -0
- package/src/template-engine.ts +94 -0
- package/src/templates/auth-templates.ts +177 -0
- package/src/transports/index.ts +39 -0
- package/src/transports/postmark.ts +94 -0
- package/src/transports/resend.ts +89 -0
- package/tsconfig.json +10 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
// src/email-plugin.ts
|
|
2
|
+
import { SysEmail, SysEmailTemplate } from "@objectstack/platform-objects/audit";
|
|
3
|
+
|
|
4
|
+
// src/template-engine.ts
|
|
5
|
+
var PLACEHOLDER = /(\{\{\{?)\s*([\w.]+)\s*(\}?\}\})/g;
|
|
6
|
+
function lookup(data, path) {
|
|
7
|
+
if (!path) return void 0;
|
|
8
|
+
const parts = path.split(".");
|
|
9
|
+
let cur = data;
|
|
10
|
+
for (const p of parts) {
|
|
11
|
+
if (cur == null) return void 0;
|
|
12
|
+
cur = cur[p];
|
|
13
|
+
}
|
|
14
|
+
return cur;
|
|
15
|
+
}
|
|
16
|
+
function escapeHtml(s) {
|
|
17
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
18
|
+
}
|
|
19
|
+
function renderTemplate(template, data) {
|
|
20
|
+
if (!template) return "";
|
|
21
|
+
return template.replace(PLACEHOLDER, (_match, open, path, close) => {
|
|
22
|
+
const isUnescaped = open === "{{{" && close === "}}}";
|
|
23
|
+
const raw = lookup(data, path);
|
|
24
|
+
if (raw == null) return "";
|
|
25
|
+
const str = typeof raw === "string" ? raw : String(raw);
|
|
26
|
+
return isUnescaped ? str : escapeHtml(str);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function requireVars(data, required) {
|
|
30
|
+
const missing = required.filter((name) => lookup(data, name) == null);
|
|
31
|
+
if (missing.length > 0) {
|
|
32
|
+
throw new Error(`MISSING_VARIABLES: ${missing.join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function htmlToText(html) {
|
|
36
|
+
if (!html) return "";
|
|
37
|
+
return html.replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|div|h[1-6]|li|tr)>/gi, "\n").replace(/<[^>]+>/g, "").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/[ \t]+/g, " ").replace(/\n[ \t]+/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/email-service.ts
|
|
41
|
+
var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
42
|
+
function formatAddress(addr) {
|
|
43
|
+
const obj = typeof addr === "string" ? { address: addr } : addr;
|
|
44
|
+
const address = String(obj.address ?? "").trim();
|
|
45
|
+
if (!EMAIL_REGEX.test(address)) {
|
|
46
|
+
throw new Error(`Invalid email address: ${address || "(empty)"}`);
|
|
47
|
+
}
|
|
48
|
+
const name = obj.name?.trim();
|
|
49
|
+
if (!name) return address;
|
|
50
|
+
const needsQuote = /[",()<>@:;.\\\[\]]/.test(name);
|
|
51
|
+
const quoted = needsQuote ? `"${name.replace(/"/g, '\\"')}"` : name;
|
|
52
|
+
return `${quoted} <${address}>`;
|
|
53
|
+
}
|
|
54
|
+
function listToArray(v) {
|
|
55
|
+
if (v === void 0) return void 0;
|
|
56
|
+
const arr = Array.isArray(v) ? v : [v];
|
|
57
|
+
return arr.map(formatAddress);
|
|
58
|
+
}
|
|
59
|
+
function normalizeMessage(input, defaultFrom) {
|
|
60
|
+
if (!input || typeof input !== "object") {
|
|
61
|
+
throw new Error("VALIDATION_FAILED: input must be an object");
|
|
62
|
+
}
|
|
63
|
+
const subject = String(input.subject ?? "").trim();
|
|
64
|
+
if (!subject) throw new Error("VALIDATION_FAILED: subject is required");
|
|
65
|
+
if (!input.text && !input.html) {
|
|
66
|
+
throw new Error("VALIDATION_FAILED: at least one of text or html is required");
|
|
67
|
+
}
|
|
68
|
+
const toArr = listToArray(input.to);
|
|
69
|
+
if (!toArr || toArr.length === 0) {
|
|
70
|
+
throw new Error("VALIDATION_FAILED: at least one recipient (to) is required");
|
|
71
|
+
}
|
|
72
|
+
const fromCandidate = input.from ?? defaultFrom;
|
|
73
|
+
if (!fromCandidate) {
|
|
74
|
+
throw new Error("VALIDATION_FAILED: from address required (set options.defaultFrom or pass input.from)");
|
|
75
|
+
}
|
|
76
|
+
const from = formatAddress(fromCandidate);
|
|
77
|
+
const msg = {
|
|
78
|
+
to: toArr,
|
|
79
|
+
from,
|
|
80
|
+
subject,
|
|
81
|
+
...input.text !== void 0 ? { text: input.text } : {},
|
|
82
|
+
...input.html !== void 0 ? { html: input.html } : {}
|
|
83
|
+
};
|
|
84
|
+
const cc = listToArray(input.cc);
|
|
85
|
+
if (cc && cc.length > 0) msg.cc = cc;
|
|
86
|
+
const bcc = listToArray(input.bcc);
|
|
87
|
+
if (bcc && bcc.length > 0) msg.bcc = bcc;
|
|
88
|
+
if (input.replyTo) msg.replyTo = formatAddress(input.replyTo);
|
|
89
|
+
if (input.attachments && input.attachments.length > 0) msg.attachments = input.attachments;
|
|
90
|
+
if (input.headers && Object.keys(input.headers).length > 0) msg.headers = input.headers;
|
|
91
|
+
return msg;
|
|
92
|
+
}
|
|
93
|
+
var LogTransport = class {
|
|
94
|
+
constructor(logger) {
|
|
95
|
+
this.logger = logger;
|
|
96
|
+
this.counter = 0;
|
|
97
|
+
}
|
|
98
|
+
async send(message) {
|
|
99
|
+
const messageId = `<dev-${Date.now()}-${++this.counter}@objectstack.local>`;
|
|
100
|
+
this.logger?.info("[LogTransport] would send email", {
|
|
101
|
+
messageId,
|
|
102
|
+
to: message.to,
|
|
103
|
+
from: message.from,
|
|
104
|
+
subject: message.subject,
|
|
105
|
+
hasText: !!message.text,
|
|
106
|
+
hasHtml: !!message.html,
|
|
107
|
+
attachments: message.attachments?.length ?? 0
|
|
108
|
+
});
|
|
109
|
+
return { messageId, response: "logged" };
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
function newId() {
|
|
113
|
+
try {
|
|
114
|
+
const g = globalThis.crypto;
|
|
115
|
+
if (g?.randomUUID) return g.randomUUID();
|
|
116
|
+
} catch {
|
|
117
|
+
}
|
|
118
|
+
const hex = (n) => Math.floor(Math.random() * 16 ** n).toString(16).padStart(n, "0");
|
|
119
|
+
return `${hex(8)}-${hex(4)}-4${hex(3)}-a${hex(3)}-${hex(12)}`;
|
|
120
|
+
}
|
|
121
|
+
var EmailService = class {
|
|
122
|
+
constructor(options) {
|
|
123
|
+
this.options = options;
|
|
124
|
+
if (!options.transport) throw new Error("EmailService: transport is required");
|
|
125
|
+
}
|
|
126
|
+
/** Wire (or replace) the template loader after construction. */
|
|
127
|
+
setTemplateLoader(loader) {
|
|
128
|
+
this.options.templateLoader = loader;
|
|
129
|
+
}
|
|
130
|
+
/** Wire (or replace) persistence after construction. */
|
|
131
|
+
setPersistence(persistence) {
|
|
132
|
+
this.options.persistence = persistence;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Hot-swap the underlying transport. Used by EmailServicePlugin when
|
|
136
|
+
* the `mail` settings namespace changes (e.g. SMTP host updated in
|
|
137
|
+
* the admin UI) so subsequent `send()` calls go through the new
|
|
138
|
+
* transport without restarting the process.
|
|
139
|
+
*/
|
|
140
|
+
setTransport(transport) {
|
|
141
|
+
this.options.transport = transport;
|
|
142
|
+
}
|
|
143
|
+
/** Replace the default `from` address used when callers omit `input.from`. */
|
|
144
|
+
setDefaultFrom(from) {
|
|
145
|
+
this.options.defaultFrom = from;
|
|
146
|
+
}
|
|
147
|
+
async send(input) {
|
|
148
|
+
let normalized;
|
|
149
|
+
try {
|
|
150
|
+
normalized = normalizeMessage(input, this.options.defaultFrom);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
const id = newId();
|
|
155
|
+
const baseRow = {
|
|
156
|
+
id,
|
|
157
|
+
from_address: normalized.from,
|
|
158
|
+
to_addresses: normalized.to.join(", "),
|
|
159
|
+
...normalized.cc?.length ? { cc_addresses: normalized.cc.join(", ") } : {},
|
|
160
|
+
...normalized.bcc?.length ? { bcc_addresses: normalized.bcc.join(", ") } : {},
|
|
161
|
+
...normalized.replyTo ? { reply_to: normalized.replyTo } : {},
|
|
162
|
+
subject: normalized.subject,
|
|
163
|
+
...normalized.text !== void 0 ? { body_text: normalized.text } : {},
|
|
164
|
+
...normalized.html !== void 0 ? { body_html: normalized.html } : {},
|
|
165
|
+
...input.relatedObject ? { related_object: input.relatedObject } : {},
|
|
166
|
+
...input.relatedId ? { related_id: input.relatedId } : {},
|
|
167
|
+
...input.sentBy ? { sent_by: input.sentBy } : {},
|
|
168
|
+
status: "queued",
|
|
169
|
+
attempt_count: 0
|
|
170
|
+
};
|
|
171
|
+
let persistedId;
|
|
172
|
+
if (this.options.persistence) {
|
|
173
|
+
try {
|
|
174
|
+
const res = await this.options.persistence.insert(baseRow);
|
|
175
|
+
persistedId = typeof res === "string" ? res : res?.id ?? id;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
this.options.logger?.warn("EmailService: sys_email persist failed (non-fatal)", { error: err?.message });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const rowId = persistedId ?? id;
|
|
181
|
+
const maxAttempts = (this.options.retries ?? 0) + 1;
|
|
182
|
+
let lastError;
|
|
183
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
184
|
+
try {
|
|
185
|
+
const result = await this.options.transport.send(normalized);
|
|
186
|
+
const messageId = result.messageId;
|
|
187
|
+
const status = "sent";
|
|
188
|
+
await this.updateRow(rowId, {
|
|
189
|
+
status,
|
|
190
|
+
message_id: messageId,
|
|
191
|
+
sent_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
192
|
+
attempt_count: attempt
|
|
193
|
+
});
|
|
194
|
+
return { id: rowId, status, messageId };
|
|
195
|
+
} catch (err) {
|
|
196
|
+
lastError = err;
|
|
197
|
+
if (attempt < maxAttempts) {
|
|
198
|
+
await new Promise((r) => setTimeout(r, Math.min(2e3, 100 * 2 ** (attempt - 1))));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const errMessage = String(lastError?.message ?? lastError ?? "send failed").slice(0, 1e3);
|
|
203
|
+
await this.updateRow(rowId, {
|
|
204
|
+
status: "failed",
|
|
205
|
+
error: errMessage,
|
|
206
|
+
attempt_count: maxAttempts
|
|
207
|
+
});
|
|
208
|
+
return { id: rowId, status: "failed", error: errMessage };
|
|
209
|
+
}
|
|
210
|
+
async updateRow(id, patch) {
|
|
211
|
+
if (!this.options.persistence?.update) return;
|
|
212
|
+
try {
|
|
213
|
+
await this.options.persistence.update(id, patch);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
this.options.logger?.warn("EmailService: sys_email update failed (non-fatal)", { id, error: err?.message });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Render a named template from sys_email_template and deliver via
|
|
220
|
+
* send(). Looks up `(name, locale)` then falls back to `(name, 'en-US')`.
|
|
221
|
+
*/
|
|
222
|
+
async sendTemplate(input) {
|
|
223
|
+
if (!input?.template) {
|
|
224
|
+
throw new Error("VALIDATION_FAILED: template name is required");
|
|
225
|
+
}
|
|
226
|
+
const loader = this.options.templateLoader;
|
|
227
|
+
if (!loader) {
|
|
228
|
+
throw new Error("TEMPLATE_NOT_FOUND: no templateLoader configured on EmailService");
|
|
229
|
+
}
|
|
230
|
+
const preferred = input.locale && String(input.locale).trim();
|
|
231
|
+
let row = await loader.load(input.template, preferred || void 0);
|
|
232
|
+
if (!row && preferred && preferred !== "en-US") {
|
|
233
|
+
row = await loader.load(input.template, "en-US");
|
|
234
|
+
}
|
|
235
|
+
if (!row) {
|
|
236
|
+
throw new Error(`TEMPLATE_NOT_FOUND: ${input.template} (locale=${preferred || "en-US"})`);
|
|
237
|
+
}
|
|
238
|
+
if (row.active === false) {
|
|
239
|
+
throw new Error(`TEMPLATE_INACTIVE: ${input.template}`);
|
|
240
|
+
}
|
|
241
|
+
const data = {
|
|
242
|
+
...this.options.defaultTemplateContext || {},
|
|
243
|
+
...input.data || {}
|
|
244
|
+
};
|
|
245
|
+
if (row.variables_json) {
|
|
246
|
+
try {
|
|
247
|
+
const decl = JSON.parse(String(row.variables_json));
|
|
248
|
+
const required = decl.filter((v) => v?.required).map((v) => v.name);
|
|
249
|
+
if (required.length) requireVars(data, required);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
if (String(err?.message).startsWith("MISSING_VARIABLES")) throw err;
|
|
252
|
+
this.options.logger?.warn("EmailService: variables_json parse failed (ignored)", { template: input.template });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const subject = renderTemplate(row.subject, data);
|
|
256
|
+
const html = renderTemplate(row.body_html, data);
|
|
257
|
+
const text = row.body_text ? renderTemplate(row.body_text, data) : htmlToText(html);
|
|
258
|
+
const from = input.from ?? (row.from_address ? { address: row.from_address, ...row.from_name ? { name: row.from_name } : {} } : void 0);
|
|
259
|
+
const sendInput = {
|
|
260
|
+
to: input.to,
|
|
261
|
+
subject,
|
|
262
|
+
html,
|
|
263
|
+
text,
|
|
264
|
+
...from ? { from } : {},
|
|
265
|
+
...input.cc ? { cc: input.cc } : {},
|
|
266
|
+
...input.bcc ? { bcc: input.bcc } : {},
|
|
267
|
+
...input.replyTo ?? row.reply_to ? { replyTo: input.replyTo ?? row.reply_to } : {},
|
|
268
|
+
...input.attachments ? { attachments: input.attachments } : {},
|
|
269
|
+
...input.headers ? { headers: input.headers } : {},
|
|
270
|
+
...input.relatedObject ? { relatedObject: input.relatedObject } : {},
|
|
271
|
+
...input.relatedId ? { relatedId: input.relatedId } : {},
|
|
272
|
+
...input.sentBy ? { sentBy: input.sentBy } : {}
|
|
273
|
+
};
|
|
274
|
+
return this.send(sendInput);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// src/transports/resend.ts
|
|
279
|
+
var ResendTransport = class {
|
|
280
|
+
constructor(apiKeyOrOptions) {
|
|
281
|
+
if (typeof apiKeyOrOptions === "string") {
|
|
282
|
+
this.apiKey = apiKeyOrOptions;
|
|
283
|
+
this.endpoint = "https://api.resend.com/emails";
|
|
284
|
+
} else {
|
|
285
|
+
this.apiKey = apiKeyOrOptions.apiKey;
|
|
286
|
+
this.endpoint = apiKeyOrOptions.endpoint || "https://api.resend.com/emails";
|
|
287
|
+
}
|
|
288
|
+
if (!this.apiKey) {
|
|
289
|
+
throw new Error("ResendTransport: apiKey is required");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async send(message) {
|
|
293
|
+
const body = {
|
|
294
|
+
from: message.from,
|
|
295
|
+
to: message.to,
|
|
296
|
+
subject: message.subject
|
|
297
|
+
};
|
|
298
|
+
if (message.html !== void 0) body.html = message.html;
|
|
299
|
+
if (message.text !== void 0) body.text = message.text;
|
|
300
|
+
if (message.cc?.length) body.cc = message.cc;
|
|
301
|
+
if (message.bcc?.length) body.bcc = message.bcc;
|
|
302
|
+
if (message.replyTo) body.reply_to = message.replyTo;
|
|
303
|
+
if (message.headers && Object.keys(message.headers).length > 0) body.headers = message.headers;
|
|
304
|
+
if (message.attachments?.length) {
|
|
305
|
+
body.attachments = message.attachments.map((a) => ({
|
|
306
|
+
filename: a.filename,
|
|
307
|
+
content: typeof a.content === "string" ? a.content : a.content?.toString?.("base64") ?? String(a.content),
|
|
308
|
+
...a.contentType ? { content_type: a.contentType } : {}
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
const res = await fetch(this.endpoint, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
headers: {
|
|
314
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
315
|
+
"Content-Type": "application/json"
|
|
316
|
+
},
|
|
317
|
+
body: JSON.stringify(body)
|
|
318
|
+
});
|
|
319
|
+
if (!res.ok) {
|
|
320
|
+
const errText = await res.text().catch(() => "");
|
|
321
|
+
throw new Error(`Resend ${res.status}: ${errText.slice(0, 500)}`);
|
|
322
|
+
}
|
|
323
|
+
const json = await res.json().catch(() => ({}));
|
|
324
|
+
const messageId = String(json?.id ?? "");
|
|
325
|
+
if (!messageId) {
|
|
326
|
+
throw new Error("Resend: response missing `id` field");
|
|
327
|
+
}
|
|
328
|
+
return { messageId, response: "resend:ok" };
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// src/transports/postmark.ts
|
|
333
|
+
var PostmarkTransport = class {
|
|
334
|
+
constructor(opts) {
|
|
335
|
+
if (!opts?.apiKey) throw new Error("PostmarkTransport: apiKey is required");
|
|
336
|
+
this.apiKey = opts.apiKey;
|
|
337
|
+
this.endpoint = opts.endpoint || "https://api.postmarkapp.com/email";
|
|
338
|
+
this.messageStream = opts.messageStream || "outbound";
|
|
339
|
+
}
|
|
340
|
+
async send(message) {
|
|
341
|
+
const body = {
|
|
342
|
+
From: message.from,
|
|
343
|
+
To: message.to.join(", "),
|
|
344
|
+
Subject: message.subject,
|
|
345
|
+
MessageStream: this.messageStream
|
|
346
|
+
};
|
|
347
|
+
if (message.html !== void 0) body.HtmlBody = message.html;
|
|
348
|
+
if (message.text !== void 0) body.TextBody = message.text;
|
|
349
|
+
if (message.cc?.length) body.Cc = message.cc.join(", ");
|
|
350
|
+
if (message.bcc?.length) body.Bcc = message.bcc.join(", ");
|
|
351
|
+
if (message.replyTo) body.ReplyTo = message.replyTo;
|
|
352
|
+
if (message.headers && Object.keys(message.headers).length > 0) {
|
|
353
|
+
body.Headers = Object.entries(message.headers).map(([Name, Value]) => ({ Name, Value }));
|
|
354
|
+
}
|
|
355
|
+
if (message.attachments?.length) {
|
|
356
|
+
body.Attachments = message.attachments.map((a) => ({
|
|
357
|
+
Name: a.filename,
|
|
358
|
+
Content: typeof a.content === "string" ? a.content : a.content?.toString?.("base64") ?? String(a.content),
|
|
359
|
+
ContentType: a.contentType || "application/octet-stream",
|
|
360
|
+
...a.cid ? { ContentID: `cid:${a.cid}` } : {}
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
const res = await fetch(this.endpoint, {
|
|
364
|
+
method: "POST",
|
|
365
|
+
headers: {
|
|
366
|
+
"X-Postmark-Server-Token": this.apiKey,
|
|
367
|
+
"Content-Type": "application/json",
|
|
368
|
+
Accept: "application/json"
|
|
369
|
+
},
|
|
370
|
+
body: JSON.stringify(body)
|
|
371
|
+
});
|
|
372
|
+
if (!res.ok) {
|
|
373
|
+
const errText = await res.text().catch(() => "");
|
|
374
|
+
throw new Error(`Postmark ${res.status}: ${errText.slice(0, 500)}`);
|
|
375
|
+
}
|
|
376
|
+
const json = await res.json().catch(() => ({}));
|
|
377
|
+
const messageId = String(json?.MessageID ?? "");
|
|
378
|
+
if (!messageId) {
|
|
379
|
+
throw new Error("Postmark: response missing `MessageID` field");
|
|
380
|
+
}
|
|
381
|
+
return { messageId, response: "postmark:ok" };
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// src/transports/index.ts
|
|
386
|
+
function makeTransport(opts) {
|
|
387
|
+
const { provider, apiKey, options = {}, logger } = opts;
|
|
388
|
+
switch (provider) {
|
|
389
|
+
case "log":
|
|
390
|
+
return new LogTransport(logger);
|
|
391
|
+
case "resend":
|
|
392
|
+
if (!apiKey) throw new Error("makeTransport: provider='resend' requires apiKey (OS_EMAIL_API_KEY)");
|
|
393
|
+
return new ResendTransport({ apiKey, ...options });
|
|
394
|
+
case "postmark":
|
|
395
|
+
if (!apiKey) throw new Error("makeTransport: provider='postmark' requires apiKey (OS_EMAIL_API_KEY)");
|
|
396
|
+
return new PostmarkTransport({ apiKey, ...options });
|
|
397
|
+
default:
|
|
398
|
+
throw new Error(`makeTransport: unknown provider '${provider}'`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// src/templates/auth-templates.ts
|
|
403
|
+
var baseStyles = "font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5;color:#1f2937";
|
|
404
|
+
var buttonStyles = "display:inline-block;padding:12px 24px;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600";
|
|
405
|
+
var footerStyles = "margin-top:32px;padding-top:16px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:12px";
|
|
406
|
+
function wrap(title, bodyHtml) {
|
|
407
|
+
return `<!doctype html><html><body style="${baseStyles};margin:0;padding:24px;background:#f9fafb">
|
|
408
|
+
<div style="max-width:560px;margin:0 auto;background:#ffffff;padding:32px;border-radius:8px;border:1px solid #e5e7eb">
|
|
409
|
+
<h1 style="margin:0 0 16px 0;font-size:20px;font-weight:600">${title}</h1>
|
|
410
|
+
${bodyHtml}
|
|
411
|
+
<div style="${footerStyles}">
|
|
412
|
+
You received this email because of activity on your {{appName}} account.<br>
|
|
413
|
+
If this wasn't you, you can safely ignore this message.
|
|
414
|
+
</div>
|
|
415
|
+
</div></body></html>`;
|
|
416
|
+
}
|
|
417
|
+
var AUTH_PASSWORD_RESET_TEMPLATE = {
|
|
418
|
+
name: "auth.password_reset",
|
|
419
|
+
label: "Password Reset",
|
|
420
|
+
category: "auth",
|
|
421
|
+
locale: "en-US",
|
|
422
|
+
subject: "Reset your {{appName}} password",
|
|
423
|
+
bodyHtml: wrap("Reset your password", `
|
|
424
|
+
<p>Hi {{user.name}},</p>
|
|
425
|
+
<p>We received a request to reset the password for the account associated with <strong>{{user.email}}</strong>.</p>
|
|
426
|
+
<p>Click the button below to choose a new password. This link expires in {{expiresInMinutes}} minutes.</p>
|
|
427
|
+
<p style="margin:24px 0"><a href="{{{resetUrl}}}" style="${buttonStyles}">Reset password</a></p>
|
|
428
|
+
<p style="font-size:13px;color:#6b7280">Or copy and paste this URL into your browser:<br><span style="word-break:break-all">{{resetUrl}}</span></p>
|
|
429
|
+
<p>If you didn't request this, no action is needed \u2014 your password stays the same.</p>
|
|
430
|
+
`),
|
|
431
|
+
bodyText: `Hi {{user.name}},
|
|
432
|
+
|
|
433
|
+
We received a request to reset the password for {{user.email}}.
|
|
434
|
+
|
|
435
|
+
Reset your password (link expires in {{expiresInMinutes}} minutes):
|
|
436
|
+
{{resetUrl}}
|
|
437
|
+
|
|
438
|
+
If you didn't request this, ignore this email.`,
|
|
439
|
+
variables: [
|
|
440
|
+
{ name: "user.name", type: "string", required: false, description: "Recipient display name" },
|
|
441
|
+
{ name: "user.email", type: "string", required: true, description: "Recipient email" },
|
|
442
|
+
{ name: "resetUrl", type: "url", required: true, description: "Password reset URL" },
|
|
443
|
+
{ name: "expiresInMinutes", type: "number", required: false, description: "Link TTL in minutes" },
|
|
444
|
+
{ name: "appName", type: "string", required: false, description: "Product/app name (brand override)" }
|
|
445
|
+
],
|
|
446
|
+
active: true,
|
|
447
|
+
isSystem: true,
|
|
448
|
+
description: "Sent when a user requests a password reset via better-auth."
|
|
449
|
+
};
|
|
450
|
+
var AUTH_VERIFY_EMAIL_TEMPLATE = {
|
|
451
|
+
name: "auth.verify_email",
|
|
452
|
+
label: "Verify Email Address",
|
|
453
|
+
category: "auth",
|
|
454
|
+
locale: "en-US",
|
|
455
|
+
subject: "Verify your {{appName}} email address",
|
|
456
|
+
bodyHtml: wrap("Verify your email", `
|
|
457
|
+
<p>Hi {{user.name}},</p>
|
|
458
|
+
<p>Thanks for signing up for {{appName}}! Please confirm <strong>{{user.email}}</strong> belongs to you.</p>
|
|
459
|
+
<p style="margin:24px 0"><a href="{{{verificationUrl}}}" style="${buttonStyles}">Verify email</a></p>
|
|
460
|
+
<p style="font-size:13px;color:#6b7280">Or copy and paste this URL into your browser:<br><span style="word-break:break-all">{{verificationUrl}}</span></p>
|
|
461
|
+
`),
|
|
462
|
+
bodyText: `Hi {{user.name}},
|
|
463
|
+
|
|
464
|
+
Please verify your email ({{user.email}}) by opening this link:
|
|
465
|
+
{{verificationUrl}}`,
|
|
466
|
+
variables: [
|
|
467
|
+
{ name: "user.name", type: "string", required: false },
|
|
468
|
+
{ name: "user.email", type: "string", required: true },
|
|
469
|
+
{ name: "verificationUrl", type: "url", required: true },
|
|
470
|
+
{ name: "appName", type: "string", required: false }
|
|
471
|
+
],
|
|
472
|
+
active: true,
|
|
473
|
+
isSystem: true,
|
|
474
|
+
description: "Sent when better-auth needs to verify a newly-registered email address."
|
|
475
|
+
};
|
|
476
|
+
var AUTH_MAGIC_LINK_TEMPLATE = {
|
|
477
|
+
name: "auth.magic_link",
|
|
478
|
+
label: "Magic Link Sign-In",
|
|
479
|
+
category: "auth",
|
|
480
|
+
locale: "en-US",
|
|
481
|
+
subject: "Your {{appName}} sign-in link",
|
|
482
|
+
bodyHtml: wrap("Sign in to {{appName}}", `
|
|
483
|
+
<p>Click the button below to sign in. This link expires in {{expiresInMinutes}} minutes and may only be used once.</p>
|
|
484
|
+
<p style="margin:24px 0"><a href="{{{magicLinkUrl}}}" style="${buttonStyles}">Sign in</a></p>
|
|
485
|
+
<p style="font-size:13px;color:#6b7280">Or paste:<br><span style="word-break:break-all">{{magicLinkUrl}}</span></p>
|
|
486
|
+
`),
|
|
487
|
+
bodyText: `Sign in to {{appName}} (expires in {{expiresInMinutes}} min):
|
|
488
|
+
{{magicLinkUrl}}`,
|
|
489
|
+
variables: [
|
|
490
|
+
{ name: "magicLinkUrl", type: "url", required: true },
|
|
491
|
+
{ name: "expiresInMinutes", type: "number", required: false },
|
|
492
|
+
{ name: "appName", type: "string", required: false }
|
|
493
|
+
],
|
|
494
|
+
active: true,
|
|
495
|
+
isSystem: true,
|
|
496
|
+
description: "Passwordless sign-in link sent by the magic-link plugin."
|
|
497
|
+
};
|
|
498
|
+
var AUTH_INVITATION_TEMPLATE = {
|
|
499
|
+
name: "auth.invitation",
|
|
500
|
+
label: "Organization Invitation",
|
|
501
|
+
category: "auth",
|
|
502
|
+
locale: "en-US",
|
|
503
|
+
subject: "{{inviter.name}} invited you to {{organization.name}}",
|
|
504
|
+
bodyHtml: wrap("You have been invited", `
|
|
505
|
+
<p><strong>{{inviter.name}}</strong> ({{inviter.email}}) has invited you to join <strong>{{organization.name}}</strong> on {{appName}} as <em>{{role}}</em>.</p>
|
|
506
|
+
<p style="margin:24px 0"><a href="{{{acceptUrl}}}" style="${buttonStyles}">Accept invitation</a></p>
|
|
507
|
+
<p style="font-size:13px;color:#6b7280">Or paste:<br><span style="word-break:break-all">{{acceptUrl}}</span></p>
|
|
508
|
+
`),
|
|
509
|
+
bodyText: `{{inviter.name}} ({{inviter.email}}) invited you to join {{organization.name}} on {{appName}}.
|
|
510
|
+
|
|
511
|
+
Accept: {{acceptUrl}}`,
|
|
512
|
+
variables: [
|
|
513
|
+
{ name: "inviter.name", type: "string", required: false },
|
|
514
|
+
{ name: "inviter.email", type: "string", required: false },
|
|
515
|
+
{ name: "organization.name", type: "string", required: true },
|
|
516
|
+
{ name: "role", type: "string", required: false },
|
|
517
|
+
{ name: "acceptUrl", type: "url", required: true },
|
|
518
|
+
{ name: "appName", type: "string", required: false }
|
|
519
|
+
],
|
|
520
|
+
active: true,
|
|
521
|
+
isSystem: true,
|
|
522
|
+
description: "Sent by better-auth organization plugin when a user is invited to an org."
|
|
523
|
+
};
|
|
524
|
+
var AUTH_TWO_FACTOR_OTP_TEMPLATE = {
|
|
525
|
+
name: "auth.two_factor_otp",
|
|
526
|
+
label: "Two-Factor Verification Code",
|
|
527
|
+
category: "auth",
|
|
528
|
+
locale: "en-US",
|
|
529
|
+
subject: "Your {{appName}} verification code",
|
|
530
|
+
bodyHtml: wrap("Your verification code", `
|
|
531
|
+
<p>Use this code to complete sign-in:</p>
|
|
532
|
+
<p style="font-size:32px;font-weight:700;letter-spacing:6px;background:#f3f4f6;padding:16px;text-align:center;border-radius:6px;margin:24px 0">{{otp}}</p>
|
|
533
|
+
<p style="color:#6b7280;font-size:13px">This code expires in {{expiresInMinutes}} minutes. If you didn't try to sign in, change your password \u2014 your account may be at risk.</p>
|
|
534
|
+
`),
|
|
535
|
+
bodyText: `Your {{appName}} verification code: {{otp}}
|
|
536
|
+
(expires in {{expiresInMinutes}} minutes)`,
|
|
537
|
+
variables: [
|
|
538
|
+
{ name: "otp", type: "string", required: true },
|
|
539
|
+
{ name: "expiresInMinutes", type: "number", required: false },
|
|
540
|
+
{ name: "appName", type: "string", required: false }
|
|
541
|
+
],
|
|
542
|
+
active: true,
|
|
543
|
+
isSystem: true,
|
|
544
|
+
description: "Time-based OTP delivered for two-factor / email-OTP login."
|
|
545
|
+
};
|
|
546
|
+
var BUILTIN_AUTH_TEMPLATES = [
|
|
547
|
+
AUTH_PASSWORD_RESET_TEMPLATE,
|
|
548
|
+
AUTH_VERIFY_EMAIL_TEMPLATE,
|
|
549
|
+
AUTH_MAGIC_LINK_TEMPLATE,
|
|
550
|
+
AUTH_INVITATION_TEMPLATE,
|
|
551
|
+
AUTH_TWO_FACTOR_OTP_TEMPLATE
|
|
552
|
+
];
|
|
553
|
+
|
|
554
|
+
// src/email-plugin.ts
|
|
555
|
+
var SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
556
|
+
var EmailServicePlugin = class {
|
|
557
|
+
constructor(options = {}) {
|
|
558
|
+
this.name = "com.objectstack.service.email";
|
|
559
|
+
this.version = "1.0.0";
|
|
560
|
+
this.type = "standard";
|
|
561
|
+
this.dependencies = ["com.objectstack.engine.objectql"];
|
|
562
|
+
this.options = options;
|
|
563
|
+
}
|
|
564
|
+
resolveTransport(ctx) {
|
|
565
|
+
if (this.options.transport) return this.options.transport;
|
|
566
|
+
const provider = this.options.provider ?? "log";
|
|
567
|
+
if (provider === "log") return new LogTransport(ctx.logger);
|
|
568
|
+
return makeTransport({
|
|
569
|
+
provider,
|
|
570
|
+
apiKey: this.options.apiKey,
|
|
571
|
+
options: this.options.providerOptions,
|
|
572
|
+
logger: ctx.logger
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
async init(ctx) {
|
|
576
|
+
ctx.getService("manifest").register({
|
|
577
|
+
id: "com.objectstack.service.email",
|
|
578
|
+
name: "Email Service",
|
|
579
|
+
version: "1.0.0",
|
|
580
|
+
type: "plugin",
|
|
581
|
+
scope: "system",
|
|
582
|
+
defaultDatasource: "cloud",
|
|
583
|
+
namespace: "sys",
|
|
584
|
+
objects: [SysEmail, SysEmailTemplate]
|
|
585
|
+
});
|
|
586
|
+
const transport = this.resolveTransport(ctx);
|
|
587
|
+
if (!this.options.transport && (this.options.provider ?? "log") === "log") {
|
|
588
|
+
ctx.logger.info(
|
|
589
|
+
"EmailServicePlugin: no transport configured \u2014 using LogTransport (mail will NOT be sent)"
|
|
590
|
+
);
|
|
591
|
+
} else {
|
|
592
|
+
ctx.logger.info(
|
|
593
|
+
`EmailServicePlugin: using '${this.options.provider ?? "log"}' provider`
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
this.service = new EmailService({
|
|
597
|
+
transport,
|
|
598
|
+
defaultFrom: this.options.defaultFrom,
|
|
599
|
+
retries: this.options.retries,
|
|
600
|
+
defaultTemplateContext: this.options.defaultTemplateContext,
|
|
601
|
+
logger: ctx.logger
|
|
602
|
+
});
|
|
603
|
+
ctx.registerService("email", this.service);
|
|
604
|
+
ctx.logger.info("EmailServicePlugin: email service registered");
|
|
605
|
+
}
|
|
606
|
+
async start(ctx) {
|
|
607
|
+
ctx.hook("kernel:ready", async () => {
|
|
608
|
+
let engine = null;
|
|
609
|
+
try {
|
|
610
|
+
engine = ctx.getService("objectql");
|
|
611
|
+
} catch {
|
|
612
|
+
try {
|
|
613
|
+
engine = ctx.getService("data");
|
|
614
|
+
} catch {
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (!engine || !this.service) return;
|
|
618
|
+
try {
|
|
619
|
+
const settings = ctx.getService("settings");
|
|
620
|
+
if (settings && typeof settings.createClient === "function") {
|
|
621
|
+
const applySettings = async () => {
|
|
622
|
+
try {
|
|
623
|
+
const payload = await settings.getNamespace("mail");
|
|
624
|
+
const values = {};
|
|
625
|
+
for (const [k, v] of Object.entries(payload.values)) {
|
|
626
|
+
values[k] = v?.value;
|
|
627
|
+
}
|
|
628
|
+
this.applyMailSettings(values, ctx);
|
|
629
|
+
} catch (err) {
|
|
630
|
+
ctx.logger.warn("EmailServicePlugin: failed to apply mail settings: " + (err?.message ?? err));
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
await applySettings();
|
|
634
|
+
if (typeof settings.subscribe === "function") {
|
|
635
|
+
settings.subscribe("mail", () => {
|
|
636
|
+
void applySettings();
|
|
637
|
+
});
|
|
638
|
+
ctx.logger.info("EmailServicePlugin: bound to settings:changed for namespace=mail");
|
|
639
|
+
}
|
|
640
|
+
if (typeof settings.registerAction === "function") {
|
|
641
|
+
const svc = this.service;
|
|
642
|
+
settings.registerAction("mail", "test", async ({ values, ctx: actionCtx }) => {
|
|
643
|
+
const to = actionCtx?.body?.to ?? values.from_email;
|
|
644
|
+
if (!to) {
|
|
645
|
+
return { ok: false, severity: "error", message: 'Provide a "to" address (or set from_email).' };
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
const result = await svc.send({
|
|
649
|
+
to,
|
|
650
|
+
from: values.from_email ? {
|
|
651
|
+
address: String(values.from_email),
|
|
652
|
+
name: values.from_name ? String(values.from_name) : void 0
|
|
653
|
+
} : void 0,
|
|
654
|
+
subject: "ObjectStack mail test",
|
|
655
|
+
text: "This is a test email from the ObjectStack settings page."
|
|
656
|
+
});
|
|
657
|
+
if (result.status === "failed") {
|
|
658
|
+
return { ok: false, severity: "error", message: result.error ?? "Send failed." };
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
ok: true,
|
|
662
|
+
severity: "info",
|
|
663
|
+
message: `Sent test email to ${to} (id=${result.id}).`
|
|
664
|
+
};
|
|
665
|
+
} catch (err) {
|
|
666
|
+
return { ok: false, severity: "error", message: err?.message ?? String(err) };
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
} catch {
|
|
672
|
+
}
|
|
673
|
+
const persistence = this.options.persist === false ? void 0 : {
|
|
674
|
+
async insert(row) {
|
|
675
|
+
const created = await engine.insert("sys_email", row, {
|
|
676
|
+
context: SYSTEM_CTX
|
|
677
|
+
});
|
|
678
|
+
return created?.id ? { id: String(created.id) } : { id: String(row.id) };
|
|
679
|
+
},
|
|
680
|
+
async update(id, patch) {
|
|
681
|
+
await engine.update("sys_email", { id, ...patch }, {
|
|
682
|
+
context: SYSTEM_CTX
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
const templateLoader = {
|
|
687
|
+
async load(name, locale) {
|
|
688
|
+
const where = { name };
|
|
689
|
+
if (locale) where.locale = locale;
|
|
690
|
+
const rows = await engine.find("sys_email_template", {
|
|
691
|
+
where,
|
|
692
|
+
limit: 1,
|
|
693
|
+
context: SYSTEM_CTX
|
|
694
|
+
});
|
|
695
|
+
const row = Array.isArray(rows) ? rows[0] : rows?.data?.[0];
|
|
696
|
+
return row || null;
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
if (persistence) this.service.setPersistence(persistence);
|
|
700
|
+
this.service.setTemplateLoader(templateLoader);
|
|
701
|
+
ctx.logger.info("EmailServicePlugin: sys_email persistence + template loader enabled");
|
|
702
|
+
try {
|
|
703
|
+
const queue = ctx.getService("queue");
|
|
704
|
+
if (queue && typeof queue.subscribe === "function" && this.service) {
|
|
705
|
+
const svc = this.service;
|
|
706
|
+
await queue.subscribe("email.send.async", async (msg) => {
|
|
707
|
+
const result = await svc.send(msg.data);
|
|
708
|
+
if (result.status === "failed") {
|
|
709
|
+
throw new Error(result.error ?? "email send failed");
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
ctx.logger.info("EmailServicePlugin: subscribed to email.send.async queue");
|
|
713
|
+
}
|
|
714
|
+
} catch (err) {
|
|
715
|
+
ctx.logger.warn("EmailServicePlugin: email.send.async subscription failed", err);
|
|
716
|
+
}
|
|
717
|
+
if (this.options.seedTemplates !== false) {
|
|
718
|
+
const all = [
|
|
719
|
+
...BUILTIN_AUTH_TEMPLATES,
|
|
720
|
+
...this.options.templates ?? []
|
|
721
|
+
];
|
|
722
|
+
for (const tpl of all) {
|
|
723
|
+
try {
|
|
724
|
+
await this.upsertTemplate(engine, tpl);
|
|
725
|
+
} catch (err) {
|
|
726
|
+
ctx.logger.warn(`EmailServicePlugin: seed template failed: ${tpl.name} ${tpl.locale}`, err?.message || err);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
ctx.logger.info(`EmailServicePlugin: seeded ${all.length} template row(s)`);
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Translate the `mail` settings namespace snapshot into a transport
|
|
735
|
+
* and `defaultFrom`, then hot-swap them on the running EmailService.
|
|
736
|
+
*
|
|
737
|
+
* Behaviour:
|
|
738
|
+
* - `provider = 'log' | 'smtp'` keeps the LogTransport (real SMTP
|
|
739
|
+
* delivery requires `@objectstack/plugin-mail-smtp`, which is not
|
|
740
|
+
* a dependency of this package). The from-address is still applied.
|
|
741
|
+
* - `provider = 'resend' | 'postmark'` rebuilds the transport using
|
|
742
|
+
* `api_key` from settings. If `api_key` is missing the swap is
|
|
743
|
+
* skipped and a warning is logged — the previous transport stays.
|
|
744
|
+
*
|
|
745
|
+
* Env-locked fields (handled in SettingsService.get) still resolve
|
|
746
|
+
* before this method ever sees them, so an env override transparently
|
|
747
|
+
* wins.
|
|
748
|
+
*/
|
|
749
|
+
applyMailSettings(values, ctx) {
|
|
750
|
+
if (!this.service) return;
|
|
751
|
+
const fromEmail = typeof values.from_email === "string" ? values.from_email : void 0;
|
|
752
|
+
const fromName = typeof values.from_name === "string" ? values.from_name : void 0;
|
|
753
|
+
if (fromEmail) this.service.setDefaultFrom({ address: fromEmail, name: fromName });
|
|
754
|
+
const provider = String(values.provider ?? "smtp");
|
|
755
|
+
if (provider === "smtp" || provider === "log") {
|
|
756
|
+
ctx.logger.info(
|
|
757
|
+
`EmailServicePlugin: mail settings applied (provider=${provider}, from=${fromEmail ?? "\u2205"}); transport unchanged.`
|
|
758
|
+
);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const apiKey = typeof values.api_key === "string" ? values.api_key : void 0;
|
|
762
|
+
if (!apiKey) {
|
|
763
|
+
ctx.logger.warn(
|
|
764
|
+
`EmailServicePlugin: provider='${provider}' selected but api_key is empty \u2014 transport NOT rebuilt.`
|
|
765
|
+
);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
const transport = makeTransport({
|
|
770
|
+
provider,
|
|
771
|
+
apiKey,
|
|
772
|
+
logger: ctx.logger
|
|
773
|
+
});
|
|
774
|
+
this.service.setTransport(transport);
|
|
775
|
+
ctx.logger.info(`EmailServicePlugin: transport rebuilt from settings (provider=${provider}).`);
|
|
776
|
+
} catch (err) {
|
|
777
|
+
ctx.logger.warn("EmailServicePlugin: failed to rebuild transport: " + (err?.message ?? err));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
async upsertTemplate(engine, tpl) {
|
|
781
|
+
const row = {
|
|
782
|
+
name: tpl.name,
|
|
783
|
+
label: tpl.label,
|
|
784
|
+
category: tpl.category,
|
|
785
|
+
locale: tpl.locale,
|
|
786
|
+
subject: tpl.subject,
|
|
787
|
+
body_html: tpl.bodyHtml,
|
|
788
|
+
...tpl.bodyText ? { body_text: tpl.bodyText } : {},
|
|
789
|
+
...tpl.fromOverride?.address ? {
|
|
790
|
+
from_address: tpl.fromOverride.address,
|
|
791
|
+
...tpl.fromOverride.name ? { from_name: tpl.fromOverride.name } : {}
|
|
792
|
+
} : {},
|
|
793
|
+
...tpl.replyTo ? { reply_to: tpl.replyTo } : {},
|
|
794
|
+
active: tpl.active,
|
|
795
|
+
is_system: tpl.isSystem,
|
|
796
|
+
...tpl.description ? { description: tpl.description } : {},
|
|
797
|
+
...tpl.variables?.length ? { variables_json: JSON.stringify(tpl.variables) } : {}
|
|
798
|
+
};
|
|
799
|
+
const existing = await engine.find("sys_email_template", {
|
|
800
|
+
where: { name: tpl.name, locale: tpl.locale },
|
|
801
|
+
limit: 1,
|
|
802
|
+
context: SYSTEM_CTX
|
|
803
|
+
});
|
|
804
|
+
const existingRow = Array.isArray(existing) ? existing[0] : existing?.data?.[0];
|
|
805
|
+
if (existingRow?.id) {
|
|
806
|
+
if (existingRow.is_system === false) return;
|
|
807
|
+
await engine.update("sys_email_template", { id: existingRow.id, ...row }, {
|
|
808
|
+
context: SYSTEM_CTX
|
|
809
|
+
});
|
|
810
|
+
} else {
|
|
811
|
+
await engine.insert("sys_email_template", row, {
|
|
812
|
+
context: SYSTEM_CTX
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
export {
|
|
818
|
+
AUTH_INVITATION_TEMPLATE,
|
|
819
|
+
AUTH_MAGIC_LINK_TEMPLATE,
|
|
820
|
+
AUTH_PASSWORD_RESET_TEMPLATE,
|
|
821
|
+
AUTH_TWO_FACTOR_OTP_TEMPLATE,
|
|
822
|
+
AUTH_VERIFY_EMAIL_TEMPLATE,
|
|
823
|
+
BUILTIN_AUTH_TEMPLATES,
|
|
824
|
+
EmailServicePlugin,
|
|
825
|
+
LogTransport,
|
|
826
|
+
PostmarkTransport,
|
|
827
|
+
ResendTransport,
|
|
828
|
+
formatAddress,
|
|
829
|
+
htmlToText,
|
|
830
|
+
makeTransport,
|
|
831
|
+
normalizeMessage,
|
|
832
|
+
renderTemplate,
|
|
833
|
+
requireVars
|
|
834
|
+
};
|
|
835
|
+
//# sourceMappingURL=index.mjs.map
|