emdash-smtp-core 0.2.0

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/index.mjs ADDED
@@ -0,0 +1,2153 @@
1
+ //#region src/storage.ts
2
+ const GLOBAL_SETTINGS_KEY = "settings:global";
3
+ const SELECTED_PROVIDER_KEY = "state:selectedProviderId";
4
+ const LAST_TEST_RESULT_KEY = "state:lastTestResult";
5
+ function providerSettingsKey(providerId) {
6
+ return `settings:provider:${providerId}`;
7
+ }
8
+ function trimString(value) {
9
+ if (typeof value !== "string") return void 0;
10
+ const next = value.trim();
11
+ return next === "" ? void 0 : next;
12
+ }
13
+ function numberValue$2(value) {
14
+ if (typeof value === "number" && Number.isFinite(value)) return value;
15
+ if (typeof value === "string" && value.trim() !== "") {
16
+ const next = Number(value);
17
+ if (Number.isFinite(next)) return next;
18
+ }
19
+ }
20
+ function booleanValue$1(value) {
21
+ if (typeof value === "boolean") return value;
22
+ if (typeof value === "string") return value === "true" || value === "1" || value === "on";
23
+ return Boolean(value);
24
+ }
25
+ function normalizeLogLevel(value) {
26
+ if (value === "errors" || value === "off") return value;
27
+ return "all";
28
+ }
29
+ function cleanRecord(record) {
30
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
31
+ }
32
+ async function getGlobalSettings(ctx) {
33
+ const saved = await ctx.kv.get(GLOBAL_SETTINGS_KEY) ?? {};
34
+ return {
35
+ primaryProviderId: trimString(saved.primaryProviderId),
36
+ fallbackProviderId: trimString(saved.fallbackProviderId),
37
+ fromEmail: trimString(saved.fromEmail),
38
+ fromName: trimString(saved.fromName),
39
+ replyTo: trimString(saved.replyTo),
40
+ logLevel: normalizeLogLevel(saved.logLevel)
41
+ };
42
+ }
43
+ async function saveGlobalSettingsFromValues(ctx, values) {
44
+ const next = {
45
+ primaryProviderId: trimString(values.primaryProviderId),
46
+ fallbackProviderId: trimString(values.fallbackProviderId),
47
+ fromEmail: trimString(values.fromEmail),
48
+ fromName: trimString(values.fromName),
49
+ replyTo: trimString(values.replyTo),
50
+ logLevel: normalizeLogLevel(values.logLevel)
51
+ };
52
+ await ctx.kv.set(GLOBAL_SETTINGS_KEY, next);
53
+ return next;
54
+ }
55
+ async function getProviderSettings(ctx, providerId) {
56
+ return await ctx.kv.get(providerSettingsKey(providerId)) ?? {};
57
+ }
58
+ async function patchProviderSettings(ctx, providerId, patch) {
59
+ const cleaned = cleanRecord({
60
+ ...await getProviderSettings(ctx, providerId),
61
+ ...patch
62
+ });
63
+ await ctx.kv.set(providerSettingsKey(providerId), cleaned);
64
+ return cleaned;
65
+ }
66
+ async function saveProviderSettingsFromValues(ctx, provider, values) {
67
+ const existing = await getProviderSettings(ctx, provider.id);
68
+ const next = {};
69
+ for (const field of provider.fields) {
70
+ const raw = values[field.key];
71
+ if (field.type === "secret") {
72
+ const secret = trimString(raw);
73
+ if (secret !== void 0) next[field.key] = secret;
74
+ else if (existing[field.key] !== void 0) next[field.key] = existing[field.key];
75
+ continue;
76
+ }
77
+ if (field.type === "number") {
78
+ const numeric = numberValue$2(raw);
79
+ if (numeric !== void 0) next[field.key] = numeric;
80
+ continue;
81
+ }
82
+ if (field.type === "toggle") {
83
+ next[field.key] = booleanValue$1(raw);
84
+ continue;
85
+ }
86
+ const text = trimString(raw);
87
+ if (text !== void 0) next[field.key] = text;
88
+ }
89
+ const cleaned = cleanRecord(next);
90
+ await ctx.kv.set(providerSettingsKey(provider.id), cleaned);
91
+ return cleaned;
92
+ }
93
+ async function clearProviderSecret(ctx, providerId, fieldKey) {
94
+ const next = { ...await getProviderSettings(ctx, providerId) };
95
+ delete next[fieldKey];
96
+ await ctx.kv.set(providerSettingsKey(providerId), cleanRecord(next));
97
+ }
98
+ async function getSelectedProviderId(ctx) {
99
+ return trimString(await ctx.kv.get(SELECTED_PROVIDER_KEY));
100
+ }
101
+ async function setSelectedProviderId(ctx, providerId) {
102
+ await ctx.kv.set(SELECTED_PROVIDER_KEY, providerId);
103
+ }
104
+ async function getLastTestResult(ctx) {
105
+ return await ctx.kv.get(LAST_TEST_RESULT_KEY);
106
+ }
107
+ async function setLastTestResult(ctx, result) {
108
+ await ctx.kv.set(LAST_TEST_RESULT_KEY, result);
109
+ }
110
+ function createDeliveryLogRecord(input) {
111
+ return {
112
+ ...input,
113
+ createdAt: input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
114
+ };
115
+ }
116
+ function createLogId(record) {
117
+ const base = record.createdAt.replace(/[^0-9]/g, "").slice(0, 14);
118
+ const random = Math.random().toString(36).slice(2, 10);
119
+ return `${base}-${record.providerId}-${random}`;
120
+ }
121
+ async function writeDeliveryLog(ctx, record) {
122
+ const collection = ctx.storage?.deliveryLogs;
123
+ if (!collection?.put) return;
124
+ const logLevel = (await getGlobalSettings(ctx)).logLevel ?? "all";
125
+ if (logLevel === "off") return;
126
+ if (logLevel === "errors" && record.status !== "failed") return;
127
+ const id = record.id ?? createLogId(record);
128
+ await collection.put(id, {
129
+ ...record,
130
+ id
131
+ });
132
+ }
133
+ async function queryRecentDeliveryLogs(ctx, limit = 25) {
134
+ const collection = ctx.storage?.deliveryLogs;
135
+ if (!collection?.query) return [];
136
+ return (await collection.query({
137
+ orderBy: { createdAt: "desc" },
138
+ limit
139
+ })).items ?? [];
140
+ }
141
+ async function countDeliveryLogs(ctx, status) {
142
+ const collection = ctx.storage?.deliveryLogs;
143
+ if (collection?.count) return collection.count({ status });
144
+ if (!collection?.query) return 0;
145
+ return (await collection.query({
146
+ where: { status },
147
+ limit: 1e3
148
+ })).items.length;
149
+ }
150
+
151
+ //#endregion
152
+ //#region src/providers.ts
153
+ function unique(values) {
154
+ return [...new Set(values.filter(Boolean))].sort();
155
+ }
156
+ function stringValue$1(settings, key) {
157
+ const value = settings[key];
158
+ if (typeof value !== "string") return void 0;
159
+ const trimmed = value.trim();
160
+ return trimmed === "" ? void 0 : trimmed;
161
+ }
162
+ function numberValue$1(settings, key, fallback) {
163
+ const value = settings[key];
164
+ if (typeof value === "number" && Number.isFinite(value)) return value;
165
+ if (typeof value === "string" && value.trim() !== "") {
166
+ const parsed = Number(value);
167
+ if (Number.isFinite(parsed)) return parsed;
168
+ }
169
+ return fallback;
170
+ }
171
+ function requireString(settings, key, label) {
172
+ const value = stringValue$1(settings, key);
173
+ if (!value) throw new Error(`${label} is required.`);
174
+ return value;
175
+ }
176
+ function sanitizeHeaderText(value) {
177
+ return value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
178
+ }
179
+ function sanitizeEmailAddress(value, label) {
180
+ const trimmed = value.trim();
181
+ if (!trimmed) throw new Error(`${label} is required.`);
182
+ if (/[\r\n]/.test(trimmed)) throw new Error(`${label} contains invalid newline characters.`);
183
+ return trimmed;
184
+ }
185
+ function encodeHeaderValue(value) {
186
+ const sanitized = sanitizeHeaderText(value);
187
+ if (/^[\x20-\x7E]*$/.test(sanitized) && !/[",]/.test(sanitized)) return sanitized;
188
+ return `=?UTF-8?B?${toBase64(new TextEncoder().encode(sanitized))}?=`;
189
+ }
190
+ function formatAddress(email, name) {
191
+ const safeEmail = sanitizeEmailAddress(email, "Email address");
192
+ const safeName = name ? encodeHeaderValue(name) : void 0;
193
+ return safeName ? `${safeName} <${safeEmail}>` : safeEmail;
194
+ }
195
+ function ensureFetch(runtime) {
196
+ if (!runtime.fetch) throw new Error("This provider requires network fetch support in the current runtime.");
197
+ return runtime.fetch;
198
+ }
199
+ function ensureSmtp(runtime) {
200
+ if (!runtime.smtpSend) throw new Error("Custom SMTP is only available in the trusted package.");
201
+ return runtime.smtpSend;
202
+ }
203
+ function ensureSendmail(runtime) {
204
+ if (!runtime.sendmailSend) throw new Error("Local sendmail is only available in the trusted package.");
205
+ return runtime.sendmailSend;
206
+ }
207
+ async function readResponse(response) {
208
+ const text = await response.text();
209
+ try {
210
+ return {
211
+ text,
212
+ json: text ? JSON.parse(text) : void 0
213
+ };
214
+ } catch {
215
+ return { text };
216
+ }
217
+ }
218
+ function asRecord(value) {
219
+ return typeof value === "object" && value !== null ? value : void 0;
220
+ }
221
+ function asArray(value) {
222
+ return Array.isArray(value) ? value : void 0;
223
+ }
224
+ function asString(value) {
225
+ return typeof value === "string" ? value : void 0;
226
+ }
227
+ function getPath(value, ...path) {
228
+ let current = value;
229
+ for (const segment of path) {
230
+ if (typeof segment === "number") {
231
+ const items = asArray(current);
232
+ if (!items) return void 0;
233
+ current = items[segment];
234
+ continue;
235
+ }
236
+ const record = asRecord(current);
237
+ if (!record) return void 0;
238
+ current = record[segment];
239
+ }
240
+ return current;
241
+ }
242
+ var HttpError = class extends Error {
243
+ status;
244
+ bodyText;
245
+ bodyJson;
246
+ constructor(status, bodyText, bodyJson) {
247
+ super(bodyText || `HTTP ${status}`);
248
+ this.name = "HttpError";
249
+ this.status = status;
250
+ this.bodyText = bodyText;
251
+ this.bodyJson = bodyJson;
252
+ }
253
+ };
254
+ function hasAllSettings(settings, keys) {
255
+ return keys.every((key) => Boolean(stringValue$1(settings, key)));
256
+ }
257
+ function shouldRetryAfterRefresh(error) {
258
+ return error instanceof HttpError && (error.status === 401 || error.status === 403);
259
+ }
260
+ async function refreshProviderAccessToken(args, tokenUrl, body, label) {
261
+ const { json } = await requestJson({
262
+ url: tokenUrl,
263
+ runtime: args.runtime,
264
+ ok: [200],
265
+ init: {
266
+ method: "POST",
267
+ headers: {
268
+ accept: "application/json",
269
+ "content-type": "application/x-www-form-urlencoded"
270
+ },
271
+ body: body.toString()
272
+ }
273
+ });
274
+ const accessToken = asString(getPath(json, "access_token"));
275
+ if (!accessToken) throw new Error(`${label} refresh did not return an access token.`);
276
+ const refreshToken = asString(getPath(json, "refresh_token"));
277
+ await patchProviderSettings(args.ctx, args.providerId, {
278
+ accessToken,
279
+ ...refreshToken ? { refreshToken } : {}
280
+ });
281
+ return accessToken;
282
+ }
283
+ async function requestJson(opts) {
284
+ const response = await ensureFetch(opts.runtime)(opts.url, opts.init);
285
+ const result = await readResponse(response);
286
+ if (!(opts.ok ?? [
287
+ 200,
288
+ 201,
289
+ 202
290
+ ]).includes(response.status)) throw new HttpError(response.status, result.text || `HTTP ${response.status}`, result.json);
291
+ return {
292
+ response,
293
+ ...result
294
+ };
295
+ }
296
+ function toBase64(bytes) {
297
+ if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
298
+ let binary = "";
299
+ for (const byte of bytes) binary += String.fromCharCode(byte);
300
+ return btoa(binary);
301
+ }
302
+ function toBase64Url(text) {
303
+ return toBase64(new TextEncoder().encode(text)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
304
+ }
305
+ function toHex(bytes) {
306
+ return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, "0")).join("");
307
+ }
308
+ async function sha256Hex(payload) {
309
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload));
310
+ return toHex(new Uint8Array(digest));
311
+ }
312
+ async function hmacSha256Raw(key, data) {
313
+ const rawKey = typeof key === "string" ? new TextEncoder().encode(key) : Uint8Array.from(key);
314
+ const imported = await crypto.subtle.importKey("raw", rawKey.buffer, {
315
+ name: "HMAC",
316
+ hash: "SHA-256"
317
+ }, false, ["sign"]);
318
+ const signature = await crypto.subtle.sign("HMAC", imported, new TextEncoder().encode(data));
319
+ return new Uint8Array(signature);
320
+ }
321
+ function stripHtml$1(html) {
322
+ return html.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
323
+ }
324
+ function buildMimeMessage(message) {
325
+ const from = formatAddress(message.fromEmail || "", message.fromName);
326
+ const to = message.to.map((email) => sanitizeEmailAddress(email, "Recipient email address")).join(", ");
327
+ const replyTo = message.replyTo?.length ? message.replyTo.map((email) => sanitizeEmailAddress(email, "Reply-to email address")).join(", ") : void 0;
328
+ const subject = encodeHeaderValue(message.subject);
329
+ const headers = [
330
+ `From: ${from}`,
331
+ `To: ${to}`,
332
+ `Subject: ${subject}`,
333
+ ...replyTo ? [`Reply-To: ${replyTo}`] : [],
334
+ "MIME-Version: 1.0"
335
+ ];
336
+ if (message.html) {
337
+ const boundary = `emdash-${Math.random().toString(36).slice(2, 12)}`;
338
+ return [
339
+ ...headers,
340
+ `Content-Type: multipart/alternative; boundary="${boundary}"`,
341
+ "",
342
+ `--${boundary}`,
343
+ "Content-Type: text/plain; charset=\"UTF-8\"",
344
+ "",
345
+ message.text,
346
+ "",
347
+ `--${boundary}`,
348
+ "Content-Type: text/html; charset=\"UTF-8\"",
349
+ "",
350
+ message.html,
351
+ "",
352
+ `--${boundary}--`,
353
+ ""
354
+ ].join("\r\n");
355
+ }
356
+ return [
357
+ ...headers,
358
+ "Content-Type: text/plain; charset=\"UTF-8\"",
359
+ "",
360
+ message.text,
361
+ ""
362
+ ].join("\r\n");
363
+ }
364
+ async function signAwsRequest(opts) {
365
+ const amzDate = (/* @__PURE__ */ new Date()).toISOString().replace(/[:-]|\.\d{3}/g, "");
366
+ const dateStamp = amzDate.slice(0, 8);
367
+ const service = "ses";
368
+ const payloadHash = await sha256Hex(opts.payload);
369
+ const baseHeaders = {
370
+ "content-type": "application/x-www-form-urlencoded; charset=utf-8",
371
+ "host": opts.url.host,
372
+ "x-amz-content-sha256": payloadHash,
373
+ "x-amz-date": amzDate,
374
+ ...opts.headers ?? {}
375
+ };
376
+ const sortedHeaderKeys = Object.keys(baseHeaders).sort();
377
+ const canonicalHeaders = sortedHeaderKeys.map((key) => `${key}:${(baseHeaders[key] ?? "").trim()}\n`).join("");
378
+ const signedHeaders = sortedHeaderKeys.join(";");
379
+ const canonicalRequest = [
380
+ opts.method,
381
+ opts.url.pathname || "/",
382
+ opts.url.search.startsWith("?") ? opts.url.search.slice(1) : opts.url.search,
383
+ canonicalHeaders,
384
+ signedHeaders,
385
+ payloadHash
386
+ ].join("\n");
387
+ const credentialScope = `${dateStamp}/${opts.region}/${service}/aws4_request`;
388
+ const stringToSign = [
389
+ "AWS4-HMAC-SHA256",
390
+ amzDate,
391
+ credentialScope,
392
+ await sha256Hex(canonicalRequest)
393
+ ].join("\n");
394
+ const signature = toHex(await hmacSha256Raw(await hmacSha256Raw(await hmacSha256Raw(await hmacSha256Raw(await hmacSha256Raw(`AWS4${opts.secretAccessKey}`, dateStamp), opts.region), service), "aws4_request"), stringToSign));
395
+ return {
396
+ ...baseHeaders,
397
+ Authorization: `AWS4-HMAC-SHA256 Credential=${opts.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
398
+ };
399
+ }
400
+ function getMessageText(message) {
401
+ return message.text || (message.html ? stripHtml$1(message.html) : "");
402
+ }
403
+ async function sendViaAmazon(args) {
404
+ const accessKeyId = requireString(args.settings, "accessKeyId", "Access Key ID");
405
+ const secretAccessKey = requireString(args.settings, "secretAccessKey", "Secret Access Key");
406
+ const region = requireString(args.settings, "region", "Region");
407
+ const payload = new URLSearchParams({
408
+ Action: "SendRawEmail",
409
+ Version: "2010-12-01",
410
+ "RawMessage.Data": toBase64(new TextEncoder().encode(buildMimeMessage(args.message)))
411
+ }).toString();
412
+ const url = new URL(`https://email.${region}.amazonaws.com/`);
413
+ const headers = await signAwsRequest({
414
+ accessKeyId,
415
+ secretAccessKey,
416
+ region,
417
+ url,
418
+ method: "POST",
419
+ payload
420
+ });
421
+ const { text } = await requestJson({
422
+ url: url.toString(),
423
+ runtime: args.runtime,
424
+ ok: [200],
425
+ init: {
426
+ method: "POST",
427
+ headers,
428
+ body: payload
429
+ }
430
+ });
431
+ return { remoteMessageId: text.match(/<MessageId>([^<]+)<\/MessageId>/i)?.[1] };
432
+ }
433
+ async function sendViaBrevo(args) {
434
+ const apiKey = requireString(args.settings, "apiKey", "API Key");
435
+ const body = {
436
+ sender: {
437
+ email: args.message.fromEmail,
438
+ ...args.message.fromName ? { name: args.message.fromName } : {}
439
+ },
440
+ to: args.message.to.map((email) => ({ email })),
441
+ subject: args.message.subject,
442
+ ...args.message.html ? { htmlContent: args.message.html } : { textContent: args.message.text },
443
+ ...args.message.replyTo?.[0] ? { replyTo: { email: args.message.replyTo[0] } } : {}
444
+ };
445
+ const { json } = await requestJson({
446
+ url: "https://api.brevo.com/v3/smtp/email",
447
+ runtime: args.runtime,
448
+ init: {
449
+ method: "POST",
450
+ headers: {
451
+ accept: "application/json",
452
+ "content-type": "application/json",
453
+ "api-key": apiKey
454
+ },
455
+ body: JSON.stringify(body)
456
+ }
457
+ });
458
+ return { remoteMessageId: asString(asRecord(json)?.messageId) };
459
+ }
460
+ async function sendViaElasticEmail(args) {
461
+ const apiKey = requireString(args.settings, "apiKey", "API Key");
462
+ const body = {
463
+ Recipients: { To: [...args.message.to] },
464
+ Content: {
465
+ From: formatAddress(args.message.fromEmail || "", args.message.fromName),
466
+ Subject: args.message.subject,
467
+ Body: [{
468
+ Charset: "utf-8",
469
+ Content: args.message.html || args.message.text,
470
+ ContentType: args.message.html ? "HTML" : "PlainText"
471
+ }],
472
+ ...args.message.replyTo?.[0] ? { ReplyTo: args.message.replyTo[0] } : {}
473
+ }
474
+ };
475
+ const { json } = await requestJson({
476
+ url: "https://api.elasticemail.com/v4/emails/transactional",
477
+ runtime: args.runtime,
478
+ init: {
479
+ method: "POST",
480
+ headers: {
481
+ accept: "application/json",
482
+ "content-type": "application/json",
483
+ "X-ElasticEmail-ApiKey": apiKey
484
+ },
485
+ body: JSON.stringify(body)
486
+ }
487
+ });
488
+ const record = asRecord(json);
489
+ return { remoteMessageId: asString(record?.TransactionID) ?? asString(record?.MessageID) };
490
+ }
491
+ async function sendViaEmailit(args) {
492
+ const apiKey = requireString(args.settings, "apiKey", "API Key");
493
+ const body = {
494
+ to: args.message.to.join(", "),
495
+ from: formatAddress(args.message.fromEmail || "", args.message.fromName),
496
+ subject: args.message.subject,
497
+ headers: { ...args.message.replyTo?.[0] ? { "reply-to": args.message.replyTo[0] } : {} },
498
+ ...args.message.html ? {
499
+ html: args.message.html,
500
+ text: getMessageText(args.message)
501
+ } : { text: args.message.text }
502
+ };
503
+ const { json } = await requestJson({
504
+ url: "https://api.emailit.com/v1/emails",
505
+ runtime: args.runtime,
506
+ init: {
507
+ method: "POST",
508
+ headers: {
509
+ accept: "application/json",
510
+ "content-type": "application/json",
511
+ Authorization: `Bearer ${apiKey}`
512
+ },
513
+ body: JSON.stringify(body)
514
+ }
515
+ });
516
+ const record = asRecord(json);
517
+ return { remoteMessageId: asString(record?.id) ?? asString(record?.message_id) };
518
+ }
519
+ async function sendViaGenericSmtp(args) {
520
+ return ensureSmtp(args.runtime)({
521
+ host: requireString(args.settings, "host", "SMTP Hostname"),
522
+ port: numberValue$1(args.settings, "port", 587) ?? 587,
523
+ secure: (stringValue$1(args.settings, "security") ?? "starttls") === "ssl",
524
+ username: stringValue$1(args.settings, "username"),
525
+ password: stringValue$1(args.settings, "password")
526
+ }, args.message);
527
+ }
528
+ async function resolveGoogleAccessToken(args, forceRefresh = false) {
529
+ const storedAccessToken = forceRefresh ? void 0 : stringValue$1(args.settings, "accessToken");
530
+ if (storedAccessToken) return storedAccessToken;
531
+ if (!hasAllSettings(args.settings, [
532
+ "clientId",
533
+ "clientSecret",
534
+ "refreshToken"
535
+ ])) throw new Error("Google requires an access token or client ID, client secret, and refresh token.");
536
+ return refreshProviderAccessToken(args, "https://oauth2.googleapis.com/token", new URLSearchParams({
537
+ client_id: requireString(args.settings, "clientId", "Client ID"),
538
+ client_secret: requireString(args.settings, "clientSecret", "Client Secret"),
539
+ refresh_token: requireString(args.settings, "refreshToken", "Refresh Token"),
540
+ grant_type: "refresh_token"
541
+ }), "Google");
542
+ }
543
+ async function resolveMicrosoftAccessToken(args, forceRefresh = false) {
544
+ const storedAccessToken = forceRefresh ? void 0 : stringValue$1(args.settings, "accessToken");
545
+ if (storedAccessToken) return storedAccessToken;
546
+ if (!hasAllSettings(args.settings, [
547
+ "clientId",
548
+ "clientSecret",
549
+ "refreshToken"
550
+ ])) throw new Error("Microsoft requires an access token or application ID, client secret, and refresh token.");
551
+ return refreshProviderAccessToken(args, `https://login.microsoftonline.com/${stringValue$1(args.settings, "tenantId") ?? "common"}/oauth2/v2.0/token`, new URLSearchParams({
552
+ client_id: requireString(args.settings, "clientId", "Application ID"),
553
+ client_secret: requireString(args.settings, "clientSecret", "Client Secret"),
554
+ refresh_token: requireString(args.settings, "refreshToken", "Refresh Token"),
555
+ grant_type: "refresh_token",
556
+ scope: "email Mail.Send User.Read profile openid offline_access"
557
+ }), "Microsoft");
558
+ }
559
+ async function resolveZohoAccessToken(args, forceRefresh = false) {
560
+ const storedAccessToken = forceRefresh ? void 0 : stringValue$1(args.settings, "accessToken");
561
+ if (storedAccessToken) return storedAccessToken;
562
+ if (!hasAllSettings(args.settings, [
563
+ "clientId",
564
+ "clientSecret",
565
+ "refreshToken"
566
+ ])) throw new Error("Zoho requires an access token or client ID, client secret, and refresh token.");
567
+ const body = new URLSearchParams({
568
+ client_id: requireString(args.settings, "clientId", "Client ID"),
569
+ client_secret: requireString(args.settings, "clientSecret", "Client Secret"),
570
+ refresh_token: requireString(args.settings, "refreshToken", "Refresh Token"),
571
+ grant_type: "refresh_token"
572
+ });
573
+ const redirectUri = stringValue$1(args.settings, "redirectUri");
574
+ if (redirectUri) body.set("redirect_uri", redirectUri);
575
+ return refreshProviderAccessToken(args, "https://accounts.zoho.com/oauth/v2/token", body, "Zoho");
576
+ }
577
+ async function sendViaGoogle(args) {
578
+ const raw = toBase64Url(buildMimeMessage(args.message));
579
+ const sendRequest = async (accessToken) => {
580
+ const { json } = await requestJson({
581
+ url: "https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
582
+ runtime: args.runtime,
583
+ init: {
584
+ method: "POST",
585
+ headers: {
586
+ Authorization: `Bearer ${accessToken}`,
587
+ "Content-Type": "application/json"
588
+ },
589
+ body: JSON.stringify({ raw })
590
+ },
591
+ ok: [200]
592
+ });
593
+ return { remoteMessageId: asString(asRecord(json)?.id) };
594
+ };
595
+ const canRefresh = hasAllSettings(args.settings, [
596
+ "clientId",
597
+ "clientSecret",
598
+ "refreshToken"
599
+ ]);
600
+ try {
601
+ return await sendRequest(await resolveGoogleAccessToken(args));
602
+ } catch (error) {
603
+ if (!canRefresh || !shouldRetryAfterRefresh(error)) throw error;
604
+ return sendRequest(await resolveGoogleAccessToken(args, true));
605
+ }
606
+ }
607
+ async function sendViaMailchimp(args) {
608
+ const body = {
609
+ key: requireString(args.settings, "apiKey", "API Key"),
610
+ message: {
611
+ subject: args.message.subject,
612
+ from_email: args.message.fromEmail,
613
+ ...args.message.fromName ? { from_name: args.message.fromName } : {},
614
+ to: args.message.to.map((email) => ({
615
+ email,
616
+ type: "to"
617
+ })),
618
+ ...args.message.html ? {
619
+ html: args.message.html,
620
+ text: getMessageText(args.message)
621
+ } : { text: args.message.text },
622
+ ...args.message.replyTo?.[0] ? { headers: { "reply-to": args.message.replyTo[0] } } : {}
623
+ }
624
+ };
625
+ const { json } = await requestJson({
626
+ url: "https://mandrillapp.com/api/1.0/messages/send",
627
+ runtime: args.runtime,
628
+ init: {
629
+ method: "POST",
630
+ headers: { "Content-Type": "application/json" },
631
+ body: JSON.stringify(body)
632
+ }
633
+ });
634
+ const record = asRecord(Array.isArray(json) ? json[0] : json);
635
+ return { remoteMessageId: asString(record?._id) ?? asString(record?.id) };
636
+ }
637
+ async function sendViaMailerSend(args) {
638
+ const apiKey = requireString(args.settings, "apiKey", "API Key");
639
+ const body = {
640
+ subject: args.message.subject,
641
+ from: {
642
+ email: args.message.fromEmail,
643
+ ...args.message.fromName ? { name: args.message.fromName } : {}
644
+ },
645
+ to: args.message.to.map((email) => ({ email })),
646
+ ...args.message.html ? { html: args.message.html } : { text: args.message.text },
647
+ ...args.message.replyTo?.[0] ? { reply_to: { email: args.message.replyTo[0] } } : {}
648
+ };
649
+ const { json } = await requestJson({
650
+ url: "https://api.mailersend.com/v1/email",
651
+ runtime: args.runtime,
652
+ init: {
653
+ method: "POST",
654
+ headers: {
655
+ accept: "application/json",
656
+ "content-type": "application/json",
657
+ Authorization: `Bearer ${apiKey}`
658
+ },
659
+ body: JSON.stringify(body)
660
+ }
661
+ });
662
+ const record = asRecord(json);
663
+ return { remoteMessageId: asString(record?.message_id) ?? asString(record?.id) };
664
+ }
665
+ async function sendViaMailgun(args) {
666
+ const apiKey = requireString(args.settings, "apiKey", "Mailgun API Key");
667
+ const domain = requireString(args.settings, "domain", "Sending Domain");
668
+ const base = (stringValue$1(args.settings, "region") ?? "us") === "eu" ? "https://api.eu.mailgun.net/v3" : "https://api.mailgun.net/v3";
669
+ const body = new URLSearchParams({
670
+ from: formatAddress(args.message.fromEmail || "", args.message.fromName),
671
+ to: args.message.to.join(", "),
672
+ subject: args.message.subject,
673
+ ...args.message.html ? { html: args.message.html } : {},
674
+ text: getMessageText(args.message)
675
+ });
676
+ if (args.message.replyTo?.[0]) body.set("h:Reply-To", args.message.replyTo[0]);
677
+ const basic = toBase64(new TextEncoder().encode(`api:${apiKey}`));
678
+ const { json } = await requestJson({
679
+ url: `${base}/${domain}/messages`,
680
+ runtime: args.runtime,
681
+ init: {
682
+ method: "POST",
683
+ headers: { Authorization: `Basic ${basic}` },
684
+ body
685
+ },
686
+ ok: [200]
687
+ });
688
+ return { remoteMessageId: asString(asRecord(json)?.id) };
689
+ }
690
+ async function sendViaMailjet(args) {
691
+ const apiKey = requireString(args.settings, "apiKey", "API Key");
692
+ const apiSecret = requireString(args.settings, "apiSecret", "API Secret Key");
693
+ const body = { Messages: [{
694
+ To: args.message.to.map((email) => ({ Email: email })),
695
+ From: {
696
+ Email: args.message.fromEmail,
697
+ ...args.message.fromName ? { Name: args.message.fromName } : {}
698
+ },
699
+ Subject: args.message.subject,
700
+ ...args.message.html ? {
701
+ HTMLPart: args.message.html,
702
+ TextPart: getMessageText(args.message)
703
+ } : { TextPart: args.message.text },
704
+ ...args.message.replyTo?.[0] ? { Headers: { "Reply-To": args.message.replyTo[0] } } : {}
705
+ }] };
706
+ const basic = toBase64(new TextEncoder().encode(`${apiKey}:${apiSecret}`));
707
+ const { json } = await requestJson({
708
+ url: "https://api.mailjet.com/v3.1/send",
709
+ runtime: args.runtime,
710
+ init: {
711
+ method: "POST",
712
+ headers: {
713
+ accept: "application/json",
714
+ "content-type": "application/json",
715
+ Authorization: `Basic ${basic}`
716
+ },
717
+ body: JSON.stringify(body)
718
+ }
719
+ });
720
+ return { remoteMessageId: asString(getPath(asRecord(json), "Messages", 0, "To", 0, "MessageUUID")) };
721
+ }
722
+ async function sendViaMicrosoft(args) {
723
+ const sendRequest = async (accessToken) => {
724
+ await requestJson({
725
+ url: "https://graph.microsoft.com/v1.0/me/sendMail",
726
+ runtime: args.runtime,
727
+ ok: [202],
728
+ init: {
729
+ method: "POST",
730
+ headers: {
731
+ Authorization: `Bearer ${accessToken}`,
732
+ "content-type": "application/json"
733
+ },
734
+ body: JSON.stringify({
735
+ message: {
736
+ subject: args.message.subject,
737
+ body: {
738
+ contentType: args.message.html ? "HTML" : "Text",
739
+ content: args.message.html || args.message.text
740
+ },
741
+ toRecipients: args.message.to.map((email) => ({ emailAddress: { address: email } })),
742
+ ...args.message.replyTo?.[0] ? { replyTo: [{ emailAddress: { address: args.message.replyTo[0] } }] } : {}
743
+ },
744
+ saveToSentItems: false
745
+ })
746
+ }
747
+ });
748
+ return {};
749
+ };
750
+ const canRefresh = hasAllSettings(args.settings, [
751
+ "clientId",
752
+ "clientSecret",
753
+ "refreshToken"
754
+ ]);
755
+ try {
756
+ return await sendRequest(await resolveMicrosoftAccessToken(args));
757
+ } catch (error) {
758
+ if (!canRefresh || !shouldRetryAfterRefresh(error)) throw error;
759
+ return sendRequest(await resolveMicrosoftAccessToken(args, true));
760
+ }
761
+ }
762
+ async function sendViaPhpMail(args) {
763
+ return ensureSendmail(args.runtime)({
764
+ sendmailPath: stringValue$1(args.settings, "sendmailPath") ?? "sendmail",
765
+ fromEmail: args.message.fromEmail || "",
766
+ fromName: args.message.fromName
767
+ }, args.message);
768
+ }
769
+ async function sendViaPostmark(args) {
770
+ const serverApiToken = requireString(args.settings, "serverApiToken", "Server API Token");
771
+ const body = {
772
+ from: formatAddress(args.message.fromEmail || "", args.message.fromName),
773
+ to: args.message.to.join(","),
774
+ subject: args.message.subject,
775
+ textBody: getMessageText(args.message),
776
+ ...args.message.html ? { htmlBody: args.message.html } : {},
777
+ ...args.message.replyTo?.[0] ? { ReplyTo: args.message.replyTo[0] } : {}
778
+ };
779
+ const { json } = await requestJson({
780
+ url: "https://api.postmarkapp.com/email",
781
+ runtime: args.runtime,
782
+ ok: [200],
783
+ init: {
784
+ method: "POST",
785
+ headers: {
786
+ Accept: "application/json",
787
+ "Content-Type": "application/json",
788
+ "X-Postmark-Server-Token": serverApiToken
789
+ },
790
+ body: JSON.stringify(body)
791
+ }
792
+ });
793
+ return { remoteMessageId: asString(asRecord(json)?.MessageID) };
794
+ }
795
+ async function sendViaResend(args) {
796
+ const apiKey = requireString(args.settings, "apiKey", "API Key");
797
+ const body = {
798
+ to: [...args.message.to],
799
+ from: formatAddress(args.message.fromEmail || "", args.message.fromName),
800
+ subject: args.message.subject,
801
+ ...args.message.html ? {
802
+ html: args.message.html,
803
+ text: getMessageText(args.message)
804
+ } : { text: args.message.text },
805
+ ...args.message.replyTo?.[0] ? { reply_to: args.message.replyTo[0] } : {}
806
+ };
807
+ const { json } = await requestJson({
808
+ url: "https://api.resend.com/emails",
809
+ runtime: args.runtime,
810
+ init: {
811
+ method: "POST",
812
+ headers: {
813
+ accept: "application/json",
814
+ "content-type": "application/json",
815
+ Authorization: `Bearer ${apiKey}`
816
+ },
817
+ body: JSON.stringify(body)
818
+ }
819
+ });
820
+ return { remoteMessageId: asString(asRecord(json)?.id) };
821
+ }
822
+ async function sendViaSendgrid(args) {
823
+ const apiKey = requireString(args.settings, "apiKey", "SendGrid API Key");
824
+ await requestJson({
825
+ url: "https://api.sendgrid.com/v3/mail/send",
826
+ runtime: args.runtime,
827
+ ok: [202],
828
+ init: {
829
+ method: "POST",
830
+ headers: {
831
+ Authorization: `Bearer ${apiKey}`,
832
+ "Content-Type": "application/json"
833
+ },
834
+ body: JSON.stringify({
835
+ from: {
836
+ email: args.message.fromEmail,
837
+ ...args.message.fromName ? { name: args.message.fromName } : {}
838
+ },
839
+ personalizations: [{ to: args.message.to.map((email) => ({ email })) }],
840
+ subject: args.message.subject,
841
+ content: [{
842
+ type: args.message.html ? "text/html" : "text/plain",
843
+ value: args.message.html || args.message.text
844
+ }],
845
+ ...args.message.replyTo?.length ? { reply_to_list: args.message.replyTo.map((email) => ({ email })) } : {}
846
+ })
847
+ }
848
+ });
849
+ return {};
850
+ }
851
+ async function sendViaSmtp2go(args) {
852
+ const apiKey = requireString(args.settings, "apiKey", "API Key");
853
+ const body = {
854
+ sender: formatAddress(args.message.fromEmail || "", args.message.fromName),
855
+ subject: args.message.subject,
856
+ to: [...args.message.to],
857
+ ...args.message.html ? { html_body: args.message.html } : { text_body: args.message.text }
858
+ };
859
+ if (args.message.replyTo?.[0]) body.custom_headers = [{
860
+ header: "Reply-To",
861
+ value: args.message.replyTo[0]
862
+ }];
863
+ const { json } = await requestJson({
864
+ url: "https://api.smtp2go.com/v3/email/send",
865
+ runtime: args.runtime,
866
+ init: {
867
+ method: "POST",
868
+ headers: {
869
+ accept: "application/json",
870
+ "content-type": "application/json",
871
+ "X-Smtp2go-Api-Key": apiKey
872
+ },
873
+ body: JSON.stringify(body)
874
+ }
875
+ });
876
+ const record = asRecord(json);
877
+ return { remoteMessageId: asString(getPath(record, "data", "email_id")) ?? asString(record?.request_id) };
878
+ }
879
+ async function sendViaSparkpost(args) {
880
+ const apiKey = requireString(args.settings, "apiKey", "API Key");
881
+ const base = (stringValue$1(args.settings, "accountLocation") ?? "us") === "eu" ? "https://api.eu.sparkpost.com/api/v1" : "https://api.sparkpost.com/api/v1";
882
+ const body = {
883
+ recipients: args.message.to.map((email) => ({ address: { email } })),
884
+ content: {
885
+ from: {
886
+ email: args.message.fromEmail,
887
+ ...args.message.fromName ? { name: args.message.fromName } : {}
888
+ },
889
+ subject: args.message.subject,
890
+ ...args.message.html ? { html: args.message.html } : { text: args.message.text },
891
+ ...args.message.replyTo?.[0] ? { reply_to: args.message.replyTo[0] } : {}
892
+ },
893
+ options: { transactional: true }
894
+ };
895
+ const { json } = await requestJson({
896
+ url: `${base}/transmissions/`,
897
+ runtime: args.runtime,
898
+ init: {
899
+ method: "POST",
900
+ headers: {
901
+ accept: "application/json",
902
+ "content-type": "application/json",
903
+ Authorization: apiKey
904
+ },
905
+ body: JSON.stringify(body)
906
+ }
907
+ });
908
+ return { remoteMessageId: asString(getPath(asRecord(json), "results", "id")) };
909
+ }
910
+ function getZohoBaseUrl(region) {
911
+ switch (region) {
912
+ case "eu": return "https://mail.zoho.eu";
913
+ case "in": return "https://mail.zoho.in";
914
+ case "com.au": return "https://mail.zoho.com.au";
915
+ case "jp": return "https://mail.zoho.jp";
916
+ case "sa": return "https://mail.zoho.sa";
917
+ case "ca": return "https://mail.zohocloud.ca";
918
+ default: return "https://mail.zoho.com";
919
+ }
920
+ }
921
+ async function sendViaZoho(args) {
922
+ const accountId = requireString(args.settings, "accountId", "Account ID");
923
+ const region = stringValue$1(args.settings, "dataCenterRegion") ?? "us";
924
+ const body = {
925
+ fromAddress: args.message.fromEmail,
926
+ toAddress: args.message.to.join(","),
927
+ subject: args.message.subject,
928
+ content: args.message.html || args.message.text,
929
+ encoding: "UTF-8",
930
+ mailFormat: args.message.html ? "html" : "plaintext",
931
+ ...args.message.replyTo?.[0] ? { replyToAddress: args.message.replyTo[0] } : {}
932
+ };
933
+ const sendRequest = async (accessToken) => {
934
+ const { json } = await requestJson({
935
+ url: `${getZohoBaseUrl(region)}/api/accounts/${accountId}/messages`,
936
+ runtime: args.runtime,
937
+ init: {
938
+ method: "POST",
939
+ headers: {
940
+ Authorization: `Zoho-oauthtoken ${accessToken}`,
941
+ "Content-Type": "application/json"
942
+ },
943
+ body: JSON.stringify(body)
944
+ }
945
+ });
946
+ const record = asRecord(json);
947
+ return { remoteMessageId: asString(getPath(record, "data", "messageId")) ?? asString(getPath(record, "data", "message_id")) };
948
+ };
949
+ const canRefresh = hasAllSettings(args.settings, [
950
+ "clientId",
951
+ "clientSecret",
952
+ "refreshToken"
953
+ ]);
954
+ try {
955
+ return await sendRequest(await resolveZohoAccessToken(args));
956
+ } catch (error) {
957
+ if (!canRefresh || !shouldRetryAfterRefresh(error)) throw error;
958
+ return sendRequest(await resolveZohoAccessToken(args, true));
959
+ }
960
+ }
961
+ function option(label, value) {
962
+ return {
963
+ label,
964
+ value
965
+ };
966
+ }
967
+ function fields(...defs) {
968
+ return defs;
969
+ }
970
+ const SMTP_PROVIDER_DEFINITIONS = [
971
+ {
972
+ id: "amazon",
973
+ label: "Amazon SES",
974
+ description: "Amazon Simple Email Service using signed AWS API requests.",
975
+ availability: {
976
+ trusted: true,
977
+ marketplace: true
978
+ },
979
+ allowedHosts: ["*.amazonaws.com"],
980
+ fields: fields({
981
+ key: "accessKeyId",
982
+ label: "Access Key ID",
983
+ type: "secret",
984
+ required: true
985
+ }, {
986
+ key: "secretAccessKey",
987
+ label: "Secret Access Key",
988
+ type: "secret",
989
+ required: true
990
+ }, {
991
+ key: "region",
992
+ label: "Region",
993
+ type: "select",
994
+ required: true,
995
+ defaultValue: "us-east-1",
996
+ options: [
997
+ option("US East (N. Virginia)", "us-east-1"),
998
+ option("US East (Ohio)", "us-east-2"),
999
+ option("US West (N. California)", "us-west-1"),
1000
+ option("US West (Oregon)", "us-west-2"),
1001
+ option("Europe (Ireland)", "eu-west-1"),
1002
+ option("Europe (Frankfurt)", "eu-central-1"),
1003
+ option("Europe (London)", "eu-west-2"),
1004
+ option("Asia Pacific (Sydney)", "ap-southeast-2"),
1005
+ option("Asia Pacific (Singapore)", "ap-southeast-1")
1006
+ ]
1007
+ }),
1008
+ send: sendViaAmazon
1009
+ },
1010
+ {
1011
+ id: "brevo",
1012
+ label: "Brevo",
1013
+ description: "Transactional email via the Brevo SMTP API.",
1014
+ availability: {
1015
+ trusted: true,
1016
+ marketplace: true
1017
+ },
1018
+ allowedHosts: ["api.brevo.com"],
1019
+ fields: fields({
1020
+ key: "apiKey",
1021
+ label: "API Key",
1022
+ type: "secret",
1023
+ required: true
1024
+ }),
1025
+ send: sendViaBrevo
1026
+ },
1027
+ {
1028
+ id: "elastic_email",
1029
+ label: "Elastic Email",
1030
+ description: "Transactional email via the Elastic Email v4 API.",
1031
+ availability: {
1032
+ trusted: true,
1033
+ marketplace: true
1034
+ },
1035
+ allowedHosts: ["api.elasticemail.com"],
1036
+ fields: fields({
1037
+ key: "apiKey",
1038
+ label: "API Key",
1039
+ type: "secret",
1040
+ required: true
1041
+ }),
1042
+ send: sendViaElasticEmail
1043
+ },
1044
+ {
1045
+ id: "emailit",
1046
+ label: "Emailit",
1047
+ description: "Transactional email via the Emailit API.",
1048
+ availability: {
1049
+ trusted: true,
1050
+ marketplace: true
1051
+ },
1052
+ allowedHosts: ["api.emailit.com"],
1053
+ fields: fields({
1054
+ key: "apiKey",
1055
+ label: "API Key",
1056
+ type: "secret",
1057
+ required: true
1058
+ }),
1059
+ send: sendViaEmailit
1060
+ },
1061
+ {
1062
+ id: "generic",
1063
+ label: "Generic SMTP",
1064
+ description: "Custom SMTP server using Nodemailer in the trusted package.",
1065
+ availability: {
1066
+ trusted: true,
1067
+ marketplace: false
1068
+ },
1069
+ allowedHosts: [],
1070
+ fields: fields({
1071
+ key: "host",
1072
+ label: "SMTP Hostname",
1073
+ type: "text",
1074
+ required: true,
1075
+ placeholder: "smtp.example.com"
1076
+ }, {
1077
+ key: "port",
1078
+ label: "SMTP Port",
1079
+ type: "number",
1080
+ required: true,
1081
+ defaultValue: 587
1082
+ }, {
1083
+ key: "security",
1084
+ label: "Encryption",
1085
+ type: "select",
1086
+ defaultValue: "starttls",
1087
+ options: [
1088
+ option("STARTTLS / Auto", "starttls"),
1089
+ option("SSL/TLS", "ssl"),
1090
+ option("None", "none")
1091
+ ]
1092
+ }, {
1093
+ key: "username",
1094
+ label: "Authentication Username",
1095
+ type: "text"
1096
+ }, {
1097
+ key: "password",
1098
+ label: "Authentication Password",
1099
+ type: "secret"
1100
+ }),
1101
+ send: sendViaGenericSmtp
1102
+ },
1103
+ {
1104
+ id: "google",
1105
+ label: "Google / Gmail",
1106
+ description: "Gmail API delivery using an access token or refresh-token credentials.",
1107
+ availability: {
1108
+ trusted: true,
1109
+ marketplace: true
1110
+ },
1111
+ allowedHosts: ["gmail.googleapis.com", "oauth2.googleapis.com"],
1112
+ fields: fields({
1113
+ key: "accessToken",
1114
+ label: "Access Token",
1115
+ type: "secret"
1116
+ }, {
1117
+ key: "refreshToken",
1118
+ label: "Refresh Token",
1119
+ type: "secret"
1120
+ }, {
1121
+ key: "clientId",
1122
+ label: "Client ID",
1123
+ type: "text"
1124
+ }, {
1125
+ key: "clientSecret",
1126
+ label: "Client Secret",
1127
+ type: "secret"
1128
+ }),
1129
+ isConfigured: (settings) => Boolean(stringValue$1(settings, "accessToken")) || hasAllSettings(settings, [
1130
+ "clientId",
1131
+ "clientSecret",
1132
+ "refreshToken"
1133
+ ]),
1134
+ send: sendViaGoogle
1135
+ },
1136
+ {
1137
+ id: "mailchimp",
1138
+ label: "Mailchimp Transactional",
1139
+ description: "Mandrill/Mailchimp Transactional email delivery.",
1140
+ availability: {
1141
+ trusted: true,
1142
+ marketplace: true
1143
+ },
1144
+ allowedHosts: ["mandrillapp.com"],
1145
+ fields: fields({
1146
+ key: "apiKey",
1147
+ label: "API Key",
1148
+ type: "secret",
1149
+ required: true
1150
+ }),
1151
+ send: sendViaMailchimp
1152
+ },
1153
+ {
1154
+ id: "mailersend",
1155
+ label: "MailerSend",
1156
+ description: "Transactional email via the MailerSend API.",
1157
+ availability: {
1158
+ trusted: true,
1159
+ marketplace: true
1160
+ },
1161
+ allowedHosts: ["api.mailersend.com"],
1162
+ fields: fields({
1163
+ key: "apiKey",
1164
+ label: "API Key",
1165
+ type: "secret",
1166
+ required: true
1167
+ }),
1168
+ send: sendViaMailerSend
1169
+ },
1170
+ {
1171
+ id: "mailgun",
1172
+ label: "Mailgun",
1173
+ description: "Transactional email via the Mailgun Messages API.",
1174
+ availability: {
1175
+ trusted: true,
1176
+ marketplace: true
1177
+ },
1178
+ allowedHosts: ["api.mailgun.net", "api.eu.mailgun.net"],
1179
+ fields: fields({
1180
+ key: "apiKey",
1181
+ label: "Mailgun API Key",
1182
+ type: "secret",
1183
+ required: true
1184
+ }, {
1185
+ key: "domain",
1186
+ label: "Sending Domain",
1187
+ type: "text",
1188
+ required: true,
1189
+ placeholder: "mg.example.com"
1190
+ }, {
1191
+ key: "region",
1192
+ label: "Region",
1193
+ type: "select",
1194
+ defaultValue: "us",
1195
+ options: [option("US", "us"), option("EU", "eu")]
1196
+ }),
1197
+ send: sendViaMailgun
1198
+ },
1199
+ {
1200
+ id: "mailjet",
1201
+ label: "Mailjet",
1202
+ description: "Transactional email via the Mailjet v3.1 API.",
1203
+ availability: {
1204
+ trusted: true,
1205
+ marketplace: true
1206
+ },
1207
+ allowedHosts: ["api.mailjet.com"],
1208
+ fields: fields({
1209
+ key: "apiKey",
1210
+ label: "API Key",
1211
+ type: "secret",
1212
+ required: true
1213
+ }, {
1214
+ key: "apiSecret",
1215
+ label: "API Secret Key",
1216
+ type: "secret",
1217
+ required: true
1218
+ }),
1219
+ send: sendViaMailjet
1220
+ },
1221
+ {
1222
+ id: "microsoft",
1223
+ label: "365 / Outlook",
1224
+ description: "Microsoft Graph delivery using an access token or refresh-token credentials.",
1225
+ availability: {
1226
+ trusted: true,
1227
+ marketplace: true
1228
+ },
1229
+ allowedHosts: ["graph.microsoft.com", "login.microsoftonline.com"],
1230
+ fields: fields({
1231
+ key: "accessToken",
1232
+ label: "Access Token",
1233
+ type: "secret"
1234
+ }, {
1235
+ key: "refreshToken",
1236
+ label: "Refresh Token",
1237
+ type: "secret"
1238
+ }, {
1239
+ key: "clientId",
1240
+ label: "Application ID",
1241
+ type: "text"
1242
+ }, {
1243
+ key: "clientSecret",
1244
+ label: "Client Secret",
1245
+ type: "secret"
1246
+ }, {
1247
+ key: "tenantId",
1248
+ label: "Tenant ID",
1249
+ type: "text",
1250
+ defaultValue: "common",
1251
+ placeholder: "common"
1252
+ }),
1253
+ isConfigured: (settings) => Boolean(stringValue$1(settings, "accessToken")) || hasAllSettings(settings, [
1254
+ "clientId",
1255
+ "clientSecret",
1256
+ "refreshToken"
1257
+ ]),
1258
+ send: sendViaMicrosoft
1259
+ },
1260
+ {
1261
+ id: "phpmail",
1262
+ label: "PHP Mail / local sendmail",
1263
+ description: "Local sendmail transport for trusted installs.",
1264
+ availability: {
1265
+ trusted: true,
1266
+ marketplace: false
1267
+ },
1268
+ allowedHosts: [],
1269
+ fields: fields({
1270
+ key: "sendmailPath",
1271
+ label: "Sendmail Path",
1272
+ type: "text",
1273
+ defaultValue: "sendmail"
1274
+ }),
1275
+ send: sendViaPhpMail
1276
+ },
1277
+ {
1278
+ id: "postmark",
1279
+ label: "Postmark",
1280
+ description: "Transactional email via the Postmark send email endpoint.",
1281
+ availability: {
1282
+ trusted: true,
1283
+ marketplace: true
1284
+ },
1285
+ allowedHosts: ["api.postmarkapp.com"],
1286
+ fields: fields({
1287
+ key: "serverApiToken",
1288
+ label: "Server API Token",
1289
+ type: "secret",
1290
+ required: true
1291
+ }),
1292
+ send: sendViaPostmark
1293
+ },
1294
+ {
1295
+ id: "resend",
1296
+ label: "Resend",
1297
+ description: "Transactional email via the Resend API.",
1298
+ availability: {
1299
+ trusted: true,
1300
+ marketplace: true
1301
+ },
1302
+ allowedHosts: ["api.resend.com"],
1303
+ fields: fields({
1304
+ key: "apiKey",
1305
+ label: "API Key",
1306
+ type: "secret",
1307
+ required: true
1308
+ }),
1309
+ send: sendViaResend
1310
+ },
1311
+ {
1312
+ id: "sendgrid",
1313
+ label: "SendGrid",
1314
+ description: "Transactional email via Twilio SendGrid.",
1315
+ availability: {
1316
+ trusted: true,
1317
+ marketplace: true
1318
+ },
1319
+ allowedHosts: ["api.sendgrid.com"],
1320
+ fields: fields({
1321
+ key: "apiKey",
1322
+ label: "SendGrid API Key",
1323
+ type: "secret",
1324
+ required: true
1325
+ }),
1326
+ send: sendViaSendgrid
1327
+ },
1328
+ {
1329
+ id: "smtp2go",
1330
+ label: "SMTP2GO",
1331
+ description: "Transactional email via SMTP2GO's HTTP API.",
1332
+ availability: {
1333
+ trusted: true,
1334
+ marketplace: true
1335
+ },
1336
+ allowedHosts: ["api.smtp2go.com"],
1337
+ fields: fields({
1338
+ key: "apiKey",
1339
+ label: "API Key",
1340
+ type: "secret",
1341
+ required: true
1342
+ }),
1343
+ send: sendViaSmtp2go
1344
+ },
1345
+ {
1346
+ id: "sparkpost",
1347
+ label: "SparkPost",
1348
+ description: "Transactional email via the SparkPost Transmissions API.",
1349
+ availability: {
1350
+ trusted: true,
1351
+ marketplace: true
1352
+ },
1353
+ allowedHosts: ["api.sparkpost.com", "api.eu.sparkpost.com"],
1354
+ fields: fields({
1355
+ key: "accountLocation",
1356
+ label: "Account Location",
1357
+ type: "select",
1358
+ defaultValue: "us",
1359
+ options: [option("United States", "us"), option("Europe", "eu")]
1360
+ }, {
1361
+ key: "apiKey",
1362
+ label: "API Key",
1363
+ type: "secret",
1364
+ required: true
1365
+ }),
1366
+ send: sendViaSparkpost
1367
+ },
1368
+ {
1369
+ id: "zoho",
1370
+ label: "Zoho Mail",
1371
+ description: "Zoho Mail API delivery using an access token or refresh-token credentials.",
1372
+ availability: {
1373
+ trusted: true,
1374
+ marketplace: true
1375
+ },
1376
+ allowedHosts: [
1377
+ "mail.zoho.com",
1378
+ "mail.zoho.eu",
1379
+ "mail.zoho.in",
1380
+ "mail.zoho.com.au",
1381
+ "mail.zoho.jp",
1382
+ "mail.zoho.sa",
1383
+ "mail.zohocloud.ca",
1384
+ "accounts.zoho.com"
1385
+ ],
1386
+ fields: fields({
1387
+ key: "dataCenterRegion",
1388
+ label: "Datacenter Region",
1389
+ type: "select",
1390
+ defaultValue: "us",
1391
+ options: [
1392
+ option("United States", "us"),
1393
+ option("Europe", "eu"),
1394
+ option("India", "in"),
1395
+ option("Australia", "com.au"),
1396
+ option("Japan", "jp"),
1397
+ option("Saudi Arabia", "sa"),
1398
+ option("Canada", "ca")
1399
+ ]
1400
+ }, {
1401
+ key: "clientId",
1402
+ label: "Client ID",
1403
+ type: "text"
1404
+ }, {
1405
+ key: "clientSecret",
1406
+ label: "Client Secret",
1407
+ type: "secret"
1408
+ }, {
1409
+ key: "refreshToken",
1410
+ label: "Refresh Token",
1411
+ type: "secret"
1412
+ }, {
1413
+ key: "accessToken",
1414
+ label: "Access Token",
1415
+ type: "secret"
1416
+ }, {
1417
+ key: "redirectUri",
1418
+ label: "Redirect URI",
1419
+ type: "text",
1420
+ placeholder: "Optional unless required by your token setup"
1421
+ }, {
1422
+ key: "accountId",
1423
+ label: "Account ID",
1424
+ type: "text",
1425
+ required: true
1426
+ }),
1427
+ isConfigured: (settings) => Boolean(stringValue$1(settings, "accountId")) && (Boolean(stringValue$1(settings, "accessToken")) || hasAllSettings(settings, [
1428
+ "clientId",
1429
+ "clientSecret",
1430
+ "refreshToken"
1431
+ ])),
1432
+ send: sendViaZoho
1433
+ }
1434
+ ];
1435
+ function getProviderById(providerId) {
1436
+ return SMTP_PROVIDER_DEFINITIONS.find((provider) => provider.id === providerId);
1437
+ }
1438
+ function isProviderAvailable(provider, variant) {
1439
+ return provider.availability[variant];
1440
+ }
1441
+ function isProviderConfigured(provider, settings) {
1442
+ if (provider.isConfigured) return provider.isConfigured(settings);
1443
+ return provider.fields.every((field) => {
1444
+ if (!field.required) return true;
1445
+ if (field.type === "number") return numberValue$1(settings, field.key) !== void 0;
1446
+ if (field.type === "toggle") return settings[field.key] !== void 0;
1447
+ return Boolean(stringValue$1(settings, field.key));
1448
+ });
1449
+ }
1450
+ function getProviderLabel(providerId) {
1451
+ if (!providerId) return "Not configured";
1452
+ return getProviderById(providerId)?.label ?? providerId;
1453
+ }
1454
+ function getAvailableProviderSelectOptions(variant) {
1455
+ return SMTP_PROVIDER_DEFINITIONS.filter((provider) => isProviderAvailable(provider, variant)).map((provider) => ({
1456
+ label: provider.label,
1457
+ value: provider.id
1458
+ }));
1459
+ }
1460
+ function getProviderPickerOptions(variant) {
1461
+ return SMTP_PROVIDER_DEFINITIONS.map((provider) => ({
1462
+ label: isProviderAvailable(provider, variant) ? provider.label : `${provider.label} (trusted-only)`,
1463
+ value: provider.id
1464
+ }));
1465
+ }
1466
+ function collectAllowedHosts(variant) {
1467
+ return unique(SMTP_PROVIDER_DEFINITIONS.filter((provider) => isProviderAvailable(provider, variant)).flatMap((provider) => provider.allowedHosts));
1468
+ }
1469
+
1470
+ //#endregion
1471
+ //#region src/delivery.ts
1472
+ function trimmed(value) {
1473
+ if (!value) return void 0;
1474
+ const next = value.trim();
1475
+ return next === "" ? void 0 : next;
1476
+ }
1477
+ function stripHtml(html) {
1478
+ return html.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
1479
+ }
1480
+ function normalizeMessageInput(message, settings) {
1481
+ const to = (Array.isArray(message.to) ? message.to : String(message.to).split(",")).map((entry) => entry.trim()).filter(Boolean);
1482
+ if (to.length === 0) throw new Error("At least one recipient email address is required.");
1483
+ const fromEmail = trimmed(settings.fromEmail);
1484
+ if (!fromEmail) throw new Error("A default from email must be configured before sending.");
1485
+ const text = trimmed(message.text) ?? (message.html ? stripHtml(message.html) : "");
1486
+ if (!text && !trimmed(message.html)) throw new Error("A message body is required.");
1487
+ return {
1488
+ to,
1489
+ subject: message.subject,
1490
+ text,
1491
+ html: trimmed(message.html),
1492
+ fromEmail,
1493
+ fromName: trimmed(settings.fromName),
1494
+ replyTo: trimmed(settings.replyTo) ? [settings.replyTo.trim()] : void 0
1495
+ };
1496
+ }
1497
+ async function resolvePrimaryProvider(ctx, runtime, settings) {
1498
+ const preferred = settings.primaryProviderId ? getProviderById(settings.primaryProviderId) : void 0;
1499
+ if (preferred && isProviderAvailable(preferred, runtime.variant)) {
1500
+ const providerSettings = await getProviderSettings(ctx, preferred.id);
1501
+ if (isProviderConfigured(preferred, providerSettings)) return {
1502
+ provider: preferred,
1503
+ providerSettings
1504
+ };
1505
+ }
1506
+ for (const provider of SMTP_PROVIDER_DEFINITIONS) {
1507
+ if (!isProviderAvailable(provider, runtime.variant)) continue;
1508
+ const providerSettings = await getProviderSettings(ctx, provider.id);
1509
+ if (isProviderConfigured(provider, providerSettings)) return {
1510
+ provider,
1511
+ providerSettings
1512
+ };
1513
+ }
1514
+ const variantLabel = runtime.variant === "marketplace" ? "marketplace-compatible" : "trusted";
1515
+ throw new Error(`No configured ${variantLabel} SMTP provider is available.`);
1516
+ }
1517
+ async function resolveFallbackProvider(ctx, runtime, settings, primaryProviderId) {
1518
+ if (!settings.fallbackProviderId || settings.fallbackProviderId === primaryProviderId) return void 0;
1519
+ const provider = getProviderById(settings.fallbackProviderId);
1520
+ if (!provider || !isProviderAvailable(provider, runtime.variant)) return void 0;
1521
+ const providerSettings = await getProviderSettings(ctx, provider.id);
1522
+ if (!isProviderConfigured(provider, providerSettings)) return void 0;
1523
+ return {
1524
+ provider,
1525
+ providerSettings
1526
+ };
1527
+ }
1528
+ async function sendWithProvider(ctx, provider, providerSettings, message, runtime) {
1529
+ const start = Date.now();
1530
+ const result = await provider.send({
1531
+ ctx,
1532
+ providerId: provider.id,
1533
+ settings: providerSettings,
1534
+ message,
1535
+ runtime
1536
+ });
1537
+ return {
1538
+ providerId: provider.id,
1539
+ remoteMessageId: result.remoteMessageId,
1540
+ durationMs: Date.now() - start
1541
+ };
1542
+ }
1543
+ async function deliverWithConfiguredProvider(args) {
1544
+ const settings = await getGlobalSettings(args.ctx);
1545
+ const normalizedMessage = normalizeMessageInput(args.message, settings);
1546
+ const primary = await resolvePrimaryProvider(args.ctx, args.runtime, settings);
1547
+ const fallback = await resolveFallbackProvider(args.ctx, args.runtime, settings, primary.provider.id);
1548
+ try {
1549
+ return await sendWithProvider(args.ctx, primary.provider, primary.providerSettings, normalizedMessage, args.runtime);
1550
+ } catch (primaryError) {
1551
+ if (!fallback) throw primaryError;
1552
+ args.ctx.log?.warn("Primary SMTP provider failed, attempting fallback provider.", {
1553
+ primaryProviderId: primary.provider.id,
1554
+ fallbackProviderId: fallback.provider.id,
1555
+ error: primaryError instanceof Error ? primaryError.message : String(primaryError)
1556
+ });
1557
+ return sendWithProvider(args.ctx, fallback.provider, fallback.providerSettings, normalizedMessage, args.runtime);
1558
+ }
1559
+ }
1560
+ async function isDeliveryReady(args) {
1561
+ const settings = await getGlobalSettings(args.ctx);
1562
+ if (!trimmed(settings.fromEmail)) return false;
1563
+ try {
1564
+ await resolvePrimaryProvider(args.ctx, args.runtime, settings);
1565
+ return true;
1566
+ } catch {
1567
+ return false;
1568
+ }
1569
+ }
1570
+
1571
+ //#endregion
1572
+ //#region src/admin.ts
1573
+ const SMTP_PLUGIN_ID = "emdash-smtp";
1574
+ const SMTP_PLUGIN_VERSION = "0.2.0";
1575
+ const SMTP_ADMIN_PAGES = [{
1576
+ path: "/providers",
1577
+ label: "SMTP Providers",
1578
+ icon: "mail"
1579
+ }, {
1580
+ path: "/logs",
1581
+ label: "SMTP Logs",
1582
+ icon: "activity"
1583
+ }];
1584
+ const SMTP_ADMIN_WIDGETS = [{
1585
+ id: "smtp-overview",
1586
+ title: "SMTP",
1587
+ size: "third"
1588
+ }];
1589
+ function header(text) {
1590
+ return {
1591
+ type: "header",
1592
+ text
1593
+ };
1594
+ }
1595
+ function divider() {
1596
+ return { type: "divider" };
1597
+ }
1598
+ function context(text) {
1599
+ return {
1600
+ type: "context",
1601
+ text
1602
+ };
1603
+ }
1604
+ function banner(title, description, variant = "default") {
1605
+ return {
1606
+ type: "banner",
1607
+ title,
1608
+ description,
1609
+ variant
1610
+ };
1611
+ }
1612
+ function stats(summary) {
1613
+ return {
1614
+ type: "stats",
1615
+ items: [
1616
+ {
1617
+ label: "Active provider",
1618
+ value: summary.activeProviderLabel
1619
+ },
1620
+ {
1621
+ label: "Sent",
1622
+ value: summary.sentCount,
1623
+ trend: summary.sentCount > 0 ? "up" : "neutral"
1624
+ },
1625
+ {
1626
+ label: "Failed",
1627
+ value: summary.failedCount,
1628
+ trend: summary.failedCount > 0 ? "down" : "neutral"
1629
+ }
1630
+ ]
1631
+ };
1632
+ }
1633
+ function actions(elements) {
1634
+ return {
1635
+ type: "actions",
1636
+ elements
1637
+ };
1638
+ }
1639
+ function button(actionId, label, opts) {
1640
+ return {
1641
+ type: "button",
1642
+ action_id: actionId,
1643
+ label,
1644
+ ...opts?.style ? { style: opts.style } : {},
1645
+ ...opts?.value !== void 0 ? { value: opts.value } : {},
1646
+ ...opts?.confirm ? { confirm: opts.confirm } : {}
1647
+ };
1648
+ }
1649
+ function textField(field, value) {
1650
+ return {
1651
+ type: "text_input",
1652
+ action_id: field.key,
1653
+ label: field.label,
1654
+ ...field.placeholder ? { placeholder: field.placeholder } : {},
1655
+ ...value !== void 0 ? { initial_value: value } : {},
1656
+ ...field.type === "textarea" || field.multiline ? { multiline: true } : {}
1657
+ };
1658
+ }
1659
+ function secretField(field, hasValue) {
1660
+ return {
1661
+ type: "secret_input",
1662
+ action_id: field.key,
1663
+ label: field.label,
1664
+ ...field.placeholder ? { placeholder: field.placeholder } : {},
1665
+ has_value: hasValue
1666
+ };
1667
+ }
1668
+ function numberField(field, value) {
1669
+ return {
1670
+ type: "number_input",
1671
+ action_id: field.key,
1672
+ label: field.label,
1673
+ ...value !== void 0 ? { initial_value: value } : {}
1674
+ };
1675
+ }
1676
+ function selectField(field, value, overrideOptions) {
1677
+ return {
1678
+ type: "select",
1679
+ action_id: field.key,
1680
+ label: field.label,
1681
+ options: overrideOptions ?? field.options ?? [],
1682
+ ...value !== void 0 ? { initial_value: value } : {}
1683
+ };
1684
+ }
1685
+ function toggleField(field, value) {
1686
+ return {
1687
+ type: "toggle",
1688
+ action_id: field.key,
1689
+ label: field.label,
1690
+ ...field.description ? { description: field.description } : {},
1691
+ ...value !== void 0 ? { initial_value: value } : {}
1692
+ };
1693
+ }
1694
+ function stringValue(value) {
1695
+ if (typeof value !== "string") return void 0;
1696
+ const next = value.trim();
1697
+ return next === "" ? void 0 : next;
1698
+ }
1699
+ function numberValue(value) {
1700
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1701
+ if (typeof value === "string" && value.trim() !== "") {
1702
+ const parsed = Number(value);
1703
+ if (Number.isFinite(parsed)) return parsed;
1704
+ }
1705
+ }
1706
+ function booleanValue(value) {
1707
+ if (typeof value === "boolean") return value;
1708
+ if (typeof value === "string") return value === "true" || value === "1" || value === "on";
1709
+ return Boolean(value);
1710
+ }
1711
+ async function buildSummary(ctx) {
1712
+ const settings = await getGlobalSettings(ctx);
1713
+ const sentCount = await countDeliveryLogs(ctx, "sent");
1714
+ const failedCount = await countDeliveryLogs(ctx, "failed");
1715
+ return {
1716
+ activeProviderLabel: getProviderLabel(settings.primaryProviderId),
1717
+ sentCount,
1718
+ failedCount
1719
+ };
1720
+ }
1721
+ async function getCurrentProvider(ctx, variant) {
1722
+ const selected = await getSelectedProviderId(ctx);
1723
+ const preferred = selected ? getProviderById(selected) : void 0;
1724
+ if (preferred) return preferred;
1725
+ return SMTP_PROVIDER_DEFINITIONS.find((provider) => isProviderAvailable(provider, variant)) ?? SMTP_PROVIDER_DEFINITIONS[0];
1726
+ }
1727
+ function buildGlobalSettingsForm(settings, variant) {
1728
+ const availableOptions = getAvailableProviderSelectOptions(variant);
1729
+ const logLevelOptions = [
1730
+ {
1731
+ label: "All deliveries",
1732
+ value: "all"
1733
+ },
1734
+ {
1735
+ label: "Errors only",
1736
+ value: "errors"
1737
+ },
1738
+ {
1739
+ label: "Disabled",
1740
+ value: "off"
1741
+ }
1742
+ ];
1743
+ return {
1744
+ type: "form",
1745
+ block_id: "global-settings",
1746
+ fields: [
1747
+ selectField({
1748
+ key: "primaryProviderId",
1749
+ label: "Primary Provider",
1750
+ type: "select",
1751
+ options: availableOptions
1752
+ }, settings.primaryProviderId, availableOptions),
1753
+ selectField({
1754
+ key: "fallbackProviderId",
1755
+ label: "Fallback Provider",
1756
+ type: "select",
1757
+ options: [{
1758
+ label: "None",
1759
+ value: ""
1760
+ }, ...availableOptions]
1761
+ }, settings.fallbackProviderId, [{
1762
+ label: "None",
1763
+ value: ""
1764
+ }, ...availableOptions]),
1765
+ textField({
1766
+ key: "fromEmail",
1767
+ label: "Default From Email",
1768
+ type: "text",
1769
+ required: true,
1770
+ placeholder: "noreply@example.com"
1771
+ }, settings.fromEmail),
1772
+ textField({
1773
+ key: "fromName",
1774
+ label: "Default From Name",
1775
+ type: "text",
1776
+ placeholder: "Example Site"
1777
+ }, settings.fromName),
1778
+ textField({
1779
+ key: "replyTo",
1780
+ label: "Default Reply-To Email",
1781
+ type: "text",
1782
+ placeholder: "support@example.com"
1783
+ }, settings.replyTo),
1784
+ selectField({
1785
+ key: "logLevel",
1786
+ label: "Log Level",
1787
+ type: "select",
1788
+ options: logLevelOptions
1789
+ }, settings.logLevel ?? "all", logLevelOptions)
1790
+ ],
1791
+ submit: {
1792
+ label: "Save Global Settings",
1793
+ action_id: "save_global"
1794
+ }
1795
+ };
1796
+ }
1797
+ function buildProviderPickerForm(providerId, variant) {
1798
+ const options = getProviderPickerOptions(variant);
1799
+ return {
1800
+ type: "form",
1801
+ block_id: "provider-picker",
1802
+ fields: [selectField({
1803
+ key: "providerId",
1804
+ label: "Provider",
1805
+ type: "select",
1806
+ options
1807
+ }, providerId, options)],
1808
+ submit: {
1809
+ label: "Load Provider",
1810
+ action_id: "select_provider"
1811
+ }
1812
+ };
1813
+ }
1814
+ function buildProviderDetails(provider, variant, configured) {
1815
+ const available = isProviderAvailable(provider, variant);
1816
+ return [{
1817
+ type: "fields",
1818
+ fields: [
1819
+ {
1820
+ label: "Provider",
1821
+ value: provider.label
1822
+ },
1823
+ {
1824
+ label: "Availability",
1825
+ value: available ? "Available" : "Trusted-only"
1826
+ },
1827
+ {
1828
+ label: "Configured",
1829
+ value: configured ? "Yes" : "No"
1830
+ },
1831
+ {
1832
+ label: "Allowed Hosts",
1833
+ value: provider.allowedHosts.length ? provider.allowedHosts.join(", ") : "None"
1834
+ }
1835
+ ]
1836
+ }, context(provider.description)];
1837
+ }
1838
+ function buildProviderSettingsForm(provider, settings) {
1839
+ return {
1840
+ type: "form",
1841
+ block_id: "provider-settings",
1842
+ fields: provider.fields.map((field) => {
1843
+ if (field.type === "secret") return secretField(field, Boolean(stringValue(settings[field.key])));
1844
+ if (field.type === "number") return numberField(field, numberValue(settings[field.key]) ?? (typeof field.defaultValue === "number" ? field.defaultValue : void 0));
1845
+ if (field.type === "select") return selectField(field, stringValue(settings[field.key]) ?? (typeof field.defaultValue === "string" ? field.defaultValue : void 0));
1846
+ if (field.type === "toggle") return toggleField(field, typeof settings[field.key] === "boolean" ? booleanValue(settings[field.key]) : typeof field.defaultValue === "boolean" ? field.defaultValue : void 0);
1847
+ return textField(field, stringValue(settings[field.key]) ?? (typeof field.defaultValue === "string" ? field.defaultValue : void 0));
1848
+ }),
1849
+ submit: {
1850
+ label: "Save Provider Settings",
1851
+ action_id: "save_provider"
1852
+ }
1853
+ };
1854
+ }
1855
+ function buildProviderSecretActions(provider, settings) {
1856
+ const elements = provider.fields.filter((field) => field.type === "secret" && Boolean(stringValue(settings[field.key]))).map((field) => button(`clear_secret:${provider.id}:${field.key}`, `Clear ${field.label}`, {
1857
+ style: "danger",
1858
+ confirm: {
1859
+ title: `Clear ${field.label}?`,
1860
+ text: `This will remove the stored ${field.label.toLowerCase()} from ${provider.label}.`,
1861
+ confirm: "Clear",
1862
+ deny: "Cancel",
1863
+ style: "danger"
1864
+ }
1865
+ }));
1866
+ return elements.length ? actions(elements) : null;
1867
+ }
1868
+ function buildTestSendForm(lastResult) {
1869
+ const blocks = [{
1870
+ type: "form",
1871
+ block_id: "test-send",
1872
+ fields: [
1873
+ {
1874
+ type: "text_input",
1875
+ action_id: "to",
1876
+ label: "Recipient Email",
1877
+ placeholder: "you@example.com"
1878
+ },
1879
+ {
1880
+ type: "text_input",
1881
+ action_id: "subject",
1882
+ label: "Subject",
1883
+ initial_value: "EmDash SMTP test email"
1884
+ },
1885
+ {
1886
+ type: "text_input",
1887
+ action_id: "text",
1888
+ label: "Message",
1889
+ multiline: true,
1890
+ initial_value: "This is a test email sent from EmDash SMTP."
1891
+ }
1892
+ ],
1893
+ submit: {
1894
+ label: "Send Test Email",
1895
+ action_id: "send_test"
1896
+ }
1897
+ }];
1898
+ if (lastResult) blocks.push(banner(lastResult.status === "sent" ? "Last test succeeded" : "Last test failed", `${lastResult.createdAt}: ${lastResult.message}`, lastResult.status === "sent" ? "default" : "error"));
1899
+ return blocks;
1900
+ }
1901
+ async function buildLogsTable(ctx) {
1902
+ return {
1903
+ type: "table",
1904
+ page_action_id: "go_logs",
1905
+ empty_text: "No delivery logs yet.",
1906
+ columns: [
1907
+ {
1908
+ key: "createdAt",
1909
+ label: "Created",
1910
+ format: "relative_time",
1911
+ sortable: true
1912
+ },
1913
+ {
1914
+ key: "status",
1915
+ label: "Status",
1916
+ format: "badge"
1917
+ },
1918
+ {
1919
+ key: "provider",
1920
+ label: "Provider"
1921
+ },
1922
+ {
1923
+ key: "to",
1924
+ label: "To"
1925
+ },
1926
+ {
1927
+ key: "subject",
1928
+ label: "Subject"
1929
+ },
1930
+ {
1931
+ key: "source",
1932
+ label: "Source"
1933
+ },
1934
+ {
1935
+ key: "details",
1936
+ label: "Details",
1937
+ format: "code"
1938
+ }
1939
+ ],
1940
+ rows: (await queryRecentDeliveryLogs(ctx, 25)).map(({ data }) => ({
1941
+ createdAt: data.createdAt,
1942
+ status: data.status,
1943
+ provider: getProviderLabel(data.providerId),
1944
+ to: data.message.to,
1945
+ subject: data.message.subject,
1946
+ source: data.source,
1947
+ details: data.errorMessage ?? data.remoteMessageId ?? "—"
1948
+ }))
1949
+ };
1950
+ }
1951
+ async function buildProvidersPage(ctx, variant, runtime, toast) {
1952
+ const summary = await buildSummary(ctx);
1953
+ const settings = await getGlobalSettings(ctx);
1954
+ const currentProvider = await getCurrentProvider(ctx, variant);
1955
+ const currentProviderSettings = await getProviderSettings(ctx, currentProvider.id);
1956
+ const configured = isProviderConfigured(currentProvider, currentProviderSettings);
1957
+ const lastTestResult = await getLastTestResult(ctx);
1958
+ const secretActions = buildProviderSecretActions(currentProvider, currentProviderSettings);
1959
+ const providerRows = await Promise.all(SMTP_PROVIDER_DEFINITIONS.map(async (provider) => {
1960
+ const providerSettings = await getProviderSettings(ctx, provider.id);
1961
+ return {
1962
+ provider: provider.label,
1963
+ id: provider.id,
1964
+ availability: isProviderAvailable(provider, variant) ? "available" : "trusted-only",
1965
+ configured: isProviderConfigured(provider, providerSettings) ? "yes" : "no",
1966
+ selected: provider.id === currentProvider.id ? "current" : ""
1967
+ };
1968
+ }));
1969
+ const blocks = [
1970
+ header("SMTP Providers"),
1971
+ banner(variant === "marketplace" ? "Marketplace-safe variant" : "Trusted variant", variant === "marketplace" ? "This install can use HTTP API providers. Generic SMTP and local sendmail remain visible for parity but are not available here." : `This install can use all providers, including Generic SMTP and local sendmail. Allowed hosts: ${collectAllowedHosts("trusted").join(", ")}`, variant === "marketplace" ? "alert" : "default"),
1972
+ stats(summary),
1973
+ actions([button("go_providers", "Providers", { style: "secondary" }), button("go_logs", "View Logs", { style: "primary" })]),
1974
+ {
1975
+ type: "table",
1976
+ page_action_id: "go_providers",
1977
+ empty_text: "No providers available.",
1978
+ columns: [
1979
+ {
1980
+ key: "provider",
1981
+ label: "Provider"
1982
+ },
1983
+ {
1984
+ key: "id",
1985
+ label: "ID",
1986
+ format: "code"
1987
+ },
1988
+ {
1989
+ key: "availability",
1990
+ label: "Availability",
1991
+ format: "badge"
1992
+ },
1993
+ {
1994
+ key: "configured",
1995
+ label: "Configured",
1996
+ format: "badge"
1997
+ },
1998
+ {
1999
+ key: "selected",
2000
+ label: "Selected",
2001
+ format: "badge"
2002
+ }
2003
+ ],
2004
+ rows: providerRows
2005
+ },
2006
+ divider(),
2007
+ buildProviderPickerForm(currentProvider.id, variant),
2008
+ ...buildProviderDetails(currentProvider, variant, configured),
2009
+ buildGlobalSettingsForm(settings, variant)
2010
+ ];
2011
+ if (!isProviderAvailable(currentProvider, variant)) blocks.push(banner(`${currentProvider.label} is not available in the marketplace variant`, "Use the trusted emdash-smtp package in astro.config.mjs if you need this transport.", "alert"));
2012
+ else {
2013
+ blocks.push(buildProviderSettingsForm(currentProvider, currentProviderSettings));
2014
+ if (secretActions) blocks.push(secretActions);
2015
+ blocks.push(...buildTestSendForm(lastTestResult));
2016
+ }
2017
+ return {
2018
+ blocks,
2019
+ ...toast ? { toast } : {}
2020
+ };
2021
+ }
2022
+ async function buildLogsPage(ctx, toast) {
2023
+ const summary = await buildSummary(ctx);
2024
+ return {
2025
+ blocks: [
2026
+ header("SMTP Logs"),
2027
+ stats(summary),
2028
+ actions([button("go_providers", "Providers", { style: "primary" }), button("go_logs", "Refresh Logs", { style: "secondary" })]),
2029
+ await buildLogsTable(ctx)
2030
+ ],
2031
+ ...toast ? { toast } : {}
2032
+ };
2033
+ }
2034
+ async function buildWidgetPage(ctx) {
2035
+ return { blocks: [stats(await buildSummary(ctx)), context("EmDash SMTP monitors the active provider and recent delivery outcomes.")] };
2036
+ }
2037
+ async function handleAdminInteraction(args) {
2038
+ const { ctx, interaction, variant, runtime } = args;
2039
+ if (interaction.type === "page_load") {
2040
+ if (interaction.page === "/logs") return buildLogsPage(ctx);
2041
+ if (interaction.page === "widget:smtp-overview") return buildWidgetPage(ctx);
2042
+ return buildProvidersPage(ctx, variant, runtime);
2043
+ }
2044
+ if (interaction.type === "block_action" || interaction.type === "action") {
2045
+ if (interaction.action_id === "go_logs") return buildLogsPage(ctx);
2046
+ if (interaction.action_id === "go_providers") return buildProvidersPage(ctx, variant, runtime);
2047
+ if (interaction.action_id.startsWith("clear_secret:")) {
2048
+ const [, providerId, fieldKey] = interaction.action_id.split(":");
2049
+ if (providerId && fieldKey) {
2050
+ await clearProviderSecret(ctx, providerId, fieldKey);
2051
+ return buildProvidersPage(ctx, variant, runtime, {
2052
+ message: `Cleared stored secret for ${fieldKey}.`,
2053
+ type: "success"
2054
+ });
2055
+ }
2056
+ }
2057
+ return buildProvidersPage(ctx, variant, runtime);
2058
+ }
2059
+ if (interaction.type === "form_submit") {
2060
+ if (interaction.action_id === "save_global") {
2061
+ await saveGlobalSettingsFromValues(ctx, interaction.values);
2062
+ return buildProvidersPage(ctx, variant, runtime, {
2063
+ message: "Global SMTP settings saved.",
2064
+ type: "success"
2065
+ });
2066
+ }
2067
+ if (interaction.action_id === "select_provider") {
2068
+ const providerId = stringValue(interaction.values.providerId);
2069
+ if (providerId) await setSelectedProviderId(ctx, providerId);
2070
+ return buildProvidersPage(ctx, variant, runtime, {
2071
+ message: "Provider selection updated.",
2072
+ type: "info"
2073
+ });
2074
+ }
2075
+ if (interaction.action_id === "save_provider") {
2076
+ const provider = await getCurrentProvider(ctx, variant);
2077
+ await saveProviderSettingsFromValues(ctx, provider, interaction.values);
2078
+ return buildProvidersPage(ctx, variant, runtime, {
2079
+ message: `${provider.label} settings saved.`,
2080
+ type: "success"
2081
+ });
2082
+ }
2083
+ if (interaction.action_id === "send_test") {
2084
+ const to = stringValue(interaction.values.to);
2085
+ const subject = stringValue(interaction.values.subject) ?? "EmDash SMTP test email";
2086
+ const text = stringValue(interaction.values.text) ?? "This is a test email sent from EmDash SMTP.";
2087
+ if (!to) return buildProvidersPage(ctx, variant, runtime, {
2088
+ message: "A recipient email address is required for test sends.",
2089
+ type: "error"
2090
+ });
2091
+ try {
2092
+ const result = await deliverWithConfiguredProvider({
2093
+ ctx,
2094
+ runtime,
2095
+ message: {
2096
+ to,
2097
+ subject,
2098
+ text
2099
+ },
2100
+ source: `${ctx.plugin.id}:test`
2101
+ });
2102
+ await writeDeliveryLog(ctx, createDeliveryLogRecord({
2103
+ providerId: result.providerId,
2104
+ status: "sent",
2105
+ source: `${ctx.plugin.id}:test`,
2106
+ durationMs: result.durationMs,
2107
+ message: {
2108
+ to,
2109
+ subject
2110
+ },
2111
+ remoteMessageId: result.remoteMessageId
2112
+ }));
2113
+ await setLastTestResult(ctx, {
2114
+ status: "sent",
2115
+ providerId: result.providerId,
2116
+ message: `Sent with ${getProviderLabel(result.providerId)}${result.remoteMessageId ? ` (${result.remoteMessageId})` : ""}.`,
2117
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2118
+ });
2119
+ return buildProvidersPage(ctx, variant, runtime, {
2120
+ message: `Test email sent with ${getProviderLabel(result.providerId)}.`,
2121
+ type: "success"
2122
+ });
2123
+ } catch (error) {
2124
+ const message = error instanceof Error ? error.message : String(error);
2125
+ await writeDeliveryLog(ctx, createDeliveryLogRecord({
2126
+ providerId: "unknown",
2127
+ status: "failed",
2128
+ source: `${ctx.plugin.id}:test`,
2129
+ durationMs: 0,
2130
+ message: {
2131
+ to,
2132
+ subject
2133
+ },
2134
+ errorMessage: message
2135
+ }));
2136
+ await setLastTestResult(ctx, {
2137
+ status: "failed",
2138
+ message,
2139
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2140
+ });
2141
+ return buildProvidersPage(ctx, variant, runtime, {
2142
+ message,
2143
+ type: "error"
2144
+ });
2145
+ }
2146
+ }
2147
+ }
2148
+ return buildProvidersPage(ctx, variant, runtime);
2149
+ }
2150
+
2151
+ //#endregion
2152
+ export { GLOBAL_SETTINGS_KEY, LAST_TEST_RESULT_KEY, SELECTED_PROVIDER_KEY, SMTP_ADMIN_PAGES, SMTP_ADMIN_WIDGETS, SMTP_PLUGIN_ID, SMTP_PLUGIN_VERSION, SMTP_PROVIDER_DEFINITIONS, clearProviderSecret, collectAllowedHosts, countDeliveryLogs, createDeliveryLogRecord, deliverWithConfiguredProvider, getAvailableProviderSelectOptions, getGlobalSettings, getLastTestResult, getProviderById, getProviderLabel, getProviderPickerOptions, getProviderSettings, getSelectedProviderId, handleAdminInteraction, isDeliveryReady, isProviderAvailable, isProviderConfigured, patchProviderSettings, queryRecentDeliveryLogs, saveGlobalSettingsFromValues, saveProviderSettingsFromValues, setLastTestResult, setSelectedProviderId, writeDeliveryLog };
2153
+ //# sourceMappingURL=index.mjs.map