@bygghammar/cms-platform 0.35.0 → 0.37.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/CHANGELOG.md +15 -0
- package/package.json +6 -2
- package/src/server/email-send-core.ts +266 -0
- package/src/server/forms-delivery-core.ts +504 -0
- package/src/server/provider-secrets.ts +170 -0
- package/src/server/smtp.ts +416 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.37.0
|
|
4
|
+
|
|
5
|
+
- Added `server/forms-delivery-core` — the full form submission delivery stack
|
|
6
|
+
(email provider resolution, smart-tag template rendering, admin notification,
|
|
7
|
+
autoreply, email logging, Google Sheets append and signed outbound webhook) is
|
|
8
|
+
now shared. A site keeps only a thin adapter that supplies its privileged
|
|
9
|
+
database client.
|
|
10
|
+
|
|
11
|
+
## 0.36.0
|
|
12
|
+
|
|
13
|
+
- New server cores: `server/provider-secrets`, `server/smtp` and
|
|
14
|
+
`server/email-send-core`. Provider credential resolution, the raw-socket
|
|
15
|
+
STARTTLS SMTP client and all provider transports (Mailgun, Amazon SES, HTTP
|
|
16
|
+
endpoint, SMTP) are now shared, so a site keeps only re-export adapters.
|
|
17
|
+
|
|
3
18
|
## 0.35.0
|
|
4
19
|
|
|
5
20
|
- Added `tracking/injections` — the pure "Code & tracking" helpers (profile
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -148,7 +148,11 @@
|
|
|
148
148
|
"./server/translate-db-core": "./src/server/translate-db-core.ts",
|
|
149
149
|
"./tracking/injections": "./src/tracking/injections.ts",
|
|
150
150
|
"./server/injections-core": "./src/server/injections-core.ts",
|
|
151
|
-
"./server/domains-core": "./src/server/domains-core.ts"
|
|
151
|
+
"./server/domains-core": "./src/server/domains-core.ts",
|
|
152
|
+
"./server/provider-secrets": "./src/server/provider-secrets.ts",
|
|
153
|
+
"./server/smtp": "./src/server/smtp.ts",
|
|
154
|
+
"./server/email-send-core": "./src/server/email-send-core.ts",
|
|
155
|
+
"./server/forms-delivery-core": "./src/server/forms-delivery-core.ts"
|
|
152
156
|
},
|
|
153
157
|
"dependencies": {
|
|
154
158
|
"fflate": "^0.8.3"
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic email dispatch for rows in `public.email_providers`.
|
|
3
|
+
*
|
|
4
|
+
* Each provider is dispatched by its `kind` (mailgun, ses, http, smtp). The
|
|
5
|
+
* credential is always resolved through the provider's preserved
|
|
6
|
+
* `secret_ref_name` environment variable — values are never read from the
|
|
7
|
+
* database and never appear in an error message.
|
|
8
|
+
*
|
|
9
|
+
* Server-only. Never import from client code.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { resolveProviderSecret, type Env } from "./provider-secrets";
|
|
13
|
+
import { sendSmtp } from "./smtp";
|
|
14
|
+
|
|
15
|
+
export type SendableProvider = {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
kind: string;
|
|
19
|
+
from_name: string | null;
|
|
20
|
+
from_email: string | null;
|
|
21
|
+
reply_to: string | null;
|
|
22
|
+
config: Record<string, unknown>;
|
|
23
|
+
secret_ref_name: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type SendResult = { ok: true } | { ok: false; error: string };
|
|
27
|
+
|
|
28
|
+
export type SendPayload = {
|
|
29
|
+
to: string;
|
|
30
|
+
subject: string;
|
|
31
|
+
html: string;
|
|
32
|
+
text: string;
|
|
33
|
+
fromName: string | null;
|
|
34
|
+
fromEmail: string;
|
|
35
|
+
replyTo: string | null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type SendOptions = {
|
|
39
|
+
/** Environment used for credential lookup. Defaults to `process.env`. */
|
|
40
|
+
env?: Env;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function envOf(options?: SendOptions): Env {
|
|
44
|
+
return options?.env ?? (process.env as Env);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function secretOf(provider: SendableProvider, env: Env): string | undefined {
|
|
48
|
+
const res = resolveProviderSecret(provider, env);
|
|
49
|
+
return res.ok ? res.value : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Value-free explanation of why a provider credential is unavailable. */
|
|
53
|
+
function secretProblem(provider: SendableProvider, env: Env): string {
|
|
54
|
+
const res = resolveProviderSecret(provider, env);
|
|
55
|
+
return res.ok ? "" : res.error;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function str(config: Record<string, unknown>, key: string): string | undefined {
|
|
59
|
+
const v = config?.[key];
|
|
60
|
+
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatFrom(payload: SendPayload) {
|
|
64
|
+
return payload.fromName ? `${payload.fromName} <${payload.fromEmail}>` : payload.fromEmail;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ---------------- Mailgun ---------------- */
|
|
68
|
+
async function sendMailgun(
|
|
69
|
+
p: SendableProvider,
|
|
70
|
+
payload: SendPayload,
|
|
71
|
+
env: Env,
|
|
72
|
+
): Promise<SendResult> {
|
|
73
|
+
const domain = str(p.config, "domain");
|
|
74
|
+
const key = secretOf(p, env);
|
|
75
|
+
if (!domain) return { ok: false, error: "Mailgun domain is missing in the provider config." };
|
|
76
|
+
if (!key) return { ok: false, error: secretProblem(p, env) };
|
|
77
|
+
const region = (str(p.config, "region") ?? "us").toLowerCase();
|
|
78
|
+
const base =
|
|
79
|
+
str(p.config, "base_url") ??
|
|
80
|
+
(region.startsWith("eu") ? "https://api.eu.mailgun.net/v3" : "https://api.mailgun.net/v3");
|
|
81
|
+
const body = new URLSearchParams({
|
|
82
|
+
from: formatFrom(payload),
|
|
83
|
+
to: payload.to,
|
|
84
|
+
subject: payload.subject,
|
|
85
|
+
text: payload.text,
|
|
86
|
+
html: payload.html,
|
|
87
|
+
});
|
|
88
|
+
if (payload.replyTo) body.set("h:Reply-To", payload.replyTo);
|
|
89
|
+
|
|
90
|
+
const res = await fetch(`${base.replace(/\/$/, "")}/${domain}/messages`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
Authorization: `Basic ${btoa(`api:${key}`)}`,
|
|
94
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
95
|
+
},
|
|
96
|
+
body,
|
|
97
|
+
});
|
|
98
|
+
if (!res.ok) return { ok: false, error: `Mailgun [${res.status}]: ${await res.text()}` };
|
|
99
|
+
return { ok: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ---------------- Amazon SES (v2 HTTPS API, SigV4) ---------------- */
|
|
103
|
+
const enc = new TextEncoder();
|
|
104
|
+
|
|
105
|
+
async function hmac(key: ArrayBuffer | Uint8Array, data: string): Promise<ArrayBuffer> {
|
|
106
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
107
|
+
"raw",
|
|
108
|
+
key as BufferSource,
|
|
109
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
110
|
+
false,
|
|
111
|
+
["sign"],
|
|
112
|
+
);
|
|
113
|
+
return crypto.subtle.sign("HMAC", cryptoKey, enc.encode(data));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hex(buf: ArrayBuffer): string {
|
|
117
|
+
return Array.from(new Uint8Array(buf))
|
|
118
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
119
|
+
.join("");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function sha256Hex(data: string): Promise<string> {
|
|
123
|
+
return hex(await crypto.subtle.digest("SHA-256", enc.encode(data)));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function sendSes(p: SendableProvider, payload: SendPayload, env: Env): Promise<SendResult> {
|
|
127
|
+
const region = str(p.config, "region");
|
|
128
|
+
const accessKeyId = str(p.config, "access_key_id");
|
|
129
|
+
const secretAccessKey = secretOf(p, env);
|
|
130
|
+
if (!region) return { ok: false, error: "SES region is missing in the provider config." };
|
|
131
|
+
if (!accessKeyId)
|
|
132
|
+
return { ok: false, error: "SES access key id is missing in the provider config." };
|
|
133
|
+
if (!secretAccessKey) return { ok: false, error: secretProblem(p, env) };
|
|
134
|
+
|
|
135
|
+
const host = `email.${region}.amazonaws.com`;
|
|
136
|
+
const path = "/v2/email/outbound-emails";
|
|
137
|
+
const body = JSON.stringify({
|
|
138
|
+
FromEmailAddress: formatFrom(payload),
|
|
139
|
+
Destination: { ToAddresses: [payload.to] },
|
|
140
|
+
...(payload.replyTo ? { ReplyToAddresses: [payload.replyTo] } : {}),
|
|
141
|
+
Content: {
|
|
142
|
+
Simple: {
|
|
143
|
+
Subject: { Data: payload.subject, Charset: "UTF-8" },
|
|
144
|
+
Body: {
|
|
145
|
+
Text: { Data: payload.text, Charset: "UTF-8" },
|
|
146
|
+
Html: { Data: payload.html, Charset: "UTF-8" },
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const now = new Date();
|
|
153
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
154
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
155
|
+
const payloadHash = await sha256Hex(body);
|
|
156
|
+
const canonicalHeaders = `content-type:application/json\nhost:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`;
|
|
157
|
+
const signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
|
|
158
|
+
const canonicalRequest = `POST\n${path}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`;
|
|
159
|
+
const scope = `${dateStamp}/${region}/ses/aws4_request`;
|
|
160
|
+
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${await sha256Hex(canonicalRequest)}`;
|
|
161
|
+
|
|
162
|
+
let signingKey: ArrayBuffer | Uint8Array = enc.encode(`AWS4${secretAccessKey}`);
|
|
163
|
+
for (const part of [dateStamp, region, "ses", "aws4_request"]) {
|
|
164
|
+
signingKey = await hmac(signingKey, part);
|
|
165
|
+
}
|
|
166
|
+
const signature = hex(await hmac(signingKey, stringToSign));
|
|
167
|
+
|
|
168
|
+
const res = await fetch(`https://${host}${path}`, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: {
|
|
171
|
+
"Content-Type": "application/json",
|
|
172
|
+
"X-Amz-Date": amzDate,
|
|
173
|
+
"x-amz-content-sha256": payloadHash,
|
|
174
|
+
Authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
175
|
+
},
|
|
176
|
+
body,
|
|
177
|
+
});
|
|
178
|
+
if (!res.ok) return { ok: false, error: `SES [${res.status}]: ${await res.text()}` };
|
|
179
|
+
return { ok: true };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ---------------- Generic HTTP / webhook ---------------- */
|
|
183
|
+
async function sendHttp(p: SendableProvider, payload: SendPayload, env: Env): Promise<SendResult> {
|
|
184
|
+
const url = str(p.config, "endpoint") ?? str(p.config, "url");
|
|
185
|
+
if (!url) return { ok: false, error: "Endpoint URL is missing in the provider config." };
|
|
186
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
187
|
+
const secret = secretOf(p, env);
|
|
188
|
+
if (secret) {
|
|
189
|
+
const headerName = str(p.config, "auth_header") ?? "Authorization";
|
|
190
|
+
const scheme = str(p.config, "auth_scheme");
|
|
191
|
+
headers[headerName] = scheme ? `${scheme} ${secret}` : secret;
|
|
192
|
+
}
|
|
193
|
+
const res = await fetch(url, {
|
|
194
|
+
method: (str(p.config, "method") ?? "POST").toUpperCase(),
|
|
195
|
+
headers,
|
|
196
|
+
body: JSON.stringify({
|
|
197
|
+
from: formatFrom(payload),
|
|
198
|
+
from_email: payload.fromEmail,
|
|
199
|
+
from_name: payload.fromName,
|
|
200
|
+
reply_to: payload.replyTo,
|
|
201
|
+
to: payload.to,
|
|
202
|
+
subject: payload.subject,
|
|
203
|
+
html: payload.html,
|
|
204
|
+
text: payload.text,
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok) return { ok: false, error: `Endpoint [${res.status}]: ${await res.text()}` };
|
|
208
|
+
return { ok: true };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ---------------- SMTP (raw socket, STARTTLS) ---------------- */
|
|
212
|
+
async function sendSmtpProvider(
|
|
213
|
+
p: SendableProvider,
|
|
214
|
+
payload: SendPayload,
|
|
215
|
+
env: Env,
|
|
216
|
+
): Promise<SendResult> {
|
|
217
|
+
const host = str(p.config, "host");
|
|
218
|
+
if (!host) return { ok: false, error: "SMTP host is missing in the provider config." };
|
|
219
|
+
const secret = resolveProviderSecret(p, env);
|
|
220
|
+
if (!secret.ok) return { ok: false, error: secret.error };
|
|
221
|
+
const rawPort = p.config?.port;
|
|
222
|
+
const port = Number(typeof rawPort === "string" ? rawPort.trim() : rawPort) || 587;
|
|
223
|
+
return sendSmtp(
|
|
224
|
+
{ host, port, username: str(p.config, "username"), password: secret.value },
|
|
225
|
+
{
|
|
226
|
+
to: payload.to,
|
|
227
|
+
fromEmail: payload.fromEmail,
|
|
228
|
+
fromName: payload.fromName,
|
|
229
|
+
replyTo: payload.replyTo,
|
|
230
|
+
subject: payload.subject,
|
|
231
|
+
text: payload.text,
|
|
232
|
+
html: payload.html,
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Dispatch one message through the provider's own transport. Never throws. */
|
|
238
|
+
export async function sendViaProvider(
|
|
239
|
+
provider: SendableProvider,
|
|
240
|
+
payload: SendPayload,
|
|
241
|
+
options?: SendOptions,
|
|
242
|
+
): Promise<SendResult> {
|
|
243
|
+
const env = envOf(options);
|
|
244
|
+
try {
|
|
245
|
+
switch (provider.kind) {
|
|
246
|
+
case "mailgun":
|
|
247
|
+
return await sendMailgun(provider, payload, env);
|
|
248
|
+
case "ses":
|
|
249
|
+
return await sendSes(provider, payload, env);
|
|
250
|
+
case "http":
|
|
251
|
+
return await sendHttp(provider, payload, env);
|
|
252
|
+
case "smtp":
|
|
253
|
+
return await sendSmtpProvider(provider, payload, env);
|
|
254
|
+
case "lovable":
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
error:
|
|
258
|
+
"Lovable Emails sending is not wired up yet — set up the email domain first, or test another provider.",
|
|
259
|
+
};
|
|
260
|
+
default:
|
|
261
|
+
return { ok: false, error: `Unknown provider kind "${provider.kind}".` };
|
|
262
|
+
}
|
|
263
|
+
} catch (e) {
|
|
264
|
+
return { ok: false, error: e instanceof Error ? e.message : "Unknown send error" };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic delivery side effects for form submissions: admin
|
|
3
|
+
* notification email, autoreply to the submitter, Google Sheets append and
|
|
4
|
+
* outbound webhook.
|
|
5
|
+
*
|
|
6
|
+
* Never throws — every function reports failure back so the submission itself
|
|
7
|
+
* is always kept (submissions are append-only, customer data first).
|
|
8
|
+
*
|
|
9
|
+
* The host injects a privileged database client; everything else (provider
|
|
10
|
+
* resolution, template rendering, email logging) is shared.
|
|
11
|
+
*
|
|
12
|
+
* Server-only. Never import from client code.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { FormFieldDef } from "../forms/field-sets";
|
|
16
|
+
import { sendViaProvider } from "./email-send-core";
|
|
17
|
+
|
|
18
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
19
|
+
/** Structural subset of a Supabase-shaped privileged client. */
|
|
20
|
+
export type DeliveryDb = { from: (table: string) => any };
|
|
21
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
22
|
+
|
|
23
|
+
export type DeliveryResult = { ok: boolean; error?: string };
|
|
24
|
+
|
|
25
|
+
type ResolvedProvider = {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
kind: string;
|
|
29
|
+
fromName: string | null;
|
|
30
|
+
fromEmail: string;
|
|
31
|
+
replyTo: string | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type ProviderRow = {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
kind: string;
|
|
38
|
+
from_name: string | null;
|
|
39
|
+
from_email: string | null;
|
|
40
|
+
reply_to: string | null;
|
|
41
|
+
config: Record<string, unknown> | null;
|
|
42
|
+
secret_ref_name: string | null;
|
|
43
|
+
is_active?: boolean | null;
|
|
44
|
+
sort_order?: number | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Resolve the default (or first active) email provider plus sender identity. */
|
|
48
|
+
export async function resolveDeliveryProvider(
|
|
49
|
+
db: DeliveryDb,
|
|
50
|
+
): Promise<
|
|
51
|
+
{ ok: true; provider: ResolvedProvider; raw: ProviderRow } | { ok: false; error: string }
|
|
52
|
+
> {
|
|
53
|
+
const { data: settings } = await db
|
|
54
|
+
.from("email_settings")
|
|
55
|
+
.select("*")
|
|
56
|
+
.eq("id", true)
|
|
57
|
+
.maybeSingle();
|
|
58
|
+
|
|
59
|
+
let raw: ProviderRow | null = null;
|
|
60
|
+
if (settings?.default_provider_id) {
|
|
61
|
+
const { data } = await db
|
|
62
|
+
.from("email_providers")
|
|
63
|
+
.select("*")
|
|
64
|
+
.eq("id", settings.default_provider_id)
|
|
65
|
+
.maybeSingle();
|
|
66
|
+
raw = (data ?? null) as ProviderRow | null;
|
|
67
|
+
}
|
|
68
|
+
if (!raw) {
|
|
69
|
+
const { data } = await db
|
|
70
|
+
.from("email_providers")
|
|
71
|
+
.select("*")
|
|
72
|
+
.eq("is_active", true)
|
|
73
|
+
.order("sort_order", { ascending: true })
|
|
74
|
+
.limit(1);
|
|
75
|
+
raw = ((data as ProviderRow[] | null)?.[0] ?? null) as ProviderRow | null;
|
|
76
|
+
}
|
|
77
|
+
if (!raw) return { ok: false, error: "No active email provider configured." };
|
|
78
|
+
|
|
79
|
+
const fromEmail = raw.from_email ?? settings?.primary_from_email ?? null;
|
|
80
|
+
if (!fromEmail) return { ok: false, error: "No from address configured." };
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
raw,
|
|
85
|
+
provider: {
|
|
86
|
+
id: raw.id,
|
|
87
|
+
name: raw.name,
|
|
88
|
+
kind: raw.kind,
|
|
89
|
+
fromName: raw.from_name ?? settings?.primary_from_name ?? null,
|
|
90
|
+
fromEmail,
|
|
91
|
+
replyTo: raw.reply_to ?? settings?.reply_to ?? null,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type SenderOverride = {
|
|
97
|
+
fromName?: string | null;
|
|
98
|
+
fromEmail?: string | null;
|
|
99
|
+
replyTo?: string | null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
async function dispatch(
|
|
103
|
+
db: DeliveryDb,
|
|
104
|
+
raw: ProviderRow,
|
|
105
|
+
provider: ResolvedProvider,
|
|
106
|
+
msg: { to: string; subject: string; html: string; text: string; replyTo: string | null },
|
|
107
|
+
meta: Record<string, unknown>,
|
|
108
|
+
from: SenderOverride = {},
|
|
109
|
+
): Promise<DeliveryResult> {
|
|
110
|
+
const fromName = (from.fromName ?? "").trim() || provider.fromName;
|
|
111
|
+
const fromEmail = (from.fromEmail ?? "").trim() || provider.fromEmail;
|
|
112
|
+
|
|
113
|
+
const result = await sendViaProvider(
|
|
114
|
+
{
|
|
115
|
+
id: provider.id,
|
|
116
|
+
name: provider.name,
|
|
117
|
+
kind: provider.kind,
|
|
118
|
+
from_name: fromName,
|
|
119
|
+
from_email: fromEmail,
|
|
120
|
+
reply_to: msg.replyTo,
|
|
121
|
+
config: (raw.config ?? {}) as Record<string, unknown>,
|
|
122
|
+
secret_ref_name: raw.secret_ref_name,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
to: msg.to,
|
|
126
|
+
subject: msg.subject,
|
|
127
|
+
html: msg.html,
|
|
128
|
+
text: msg.text,
|
|
129
|
+
fromName,
|
|
130
|
+
fromEmail,
|
|
131
|
+
replyTo: msg.replyTo,
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await db.from("email_logs").insert({
|
|
136
|
+
to_address: msg.to,
|
|
137
|
+
from_address: fromEmail,
|
|
138
|
+
reply_to: msg.replyTo,
|
|
139
|
+
subject: msg.subject,
|
|
140
|
+
body_preview: msg.text.slice(0, 200),
|
|
141
|
+
provider: provider.name,
|
|
142
|
+
status: result.ok ? "sent" : "failed",
|
|
143
|
+
error: result.ok ? null : result.error,
|
|
144
|
+
meta: { ...meta, kind: provider.kind },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return result.ok ? { ok: true } : { ok: false, error: result.error };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
151
|
+
|
|
152
|
+
const labelOf = (fld: FormFieldDef) => fld.label.sv ?? fld.label.en ?? fld.key;
|
|
153
|
+
|
|
154
|
+
/** Replace {{field_key}} placeholders with submitted values. */
|
|
155
|
+
export function renderTemplate(
|
|
156
|
+
input: string,
|
|
157
|
+
payload: Record<string, string>,
|
|
158
|
+
extra: Record<string, string> = {},
|
|
159
|
+
): string {
|
|
160
|
+
const all = { ...payload, ...extra };
|
|
161
|
+
return input.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_m, key: string) => all[key] ?? "");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Context tokens available to every notification/autoreply template. */
|
|
165
|
+
export type TokenContext = {
|
|
166
|
+
formName: string;
|
|
167
|
+
formKey: string;
|
|
168
|
+
locale?: string;
|
|
169
|
+
pagePath?: string | null;
|
|
170
|
+
submissionId?: string | null;
|
|
171
|
+
ip?: string | null;
|
|
172
|
+
userAgent?: string | null;
|
|
173
|
+
referrer?: string | null;
|
|
174
|
+
/** Absolute origin of the site, used to build page_url / admin_url. */
|
|
175
|
+
siteUrl?: string | null;
|
|
176
|
+
/** Backoffice base path, defaults to `/kontoret`. */
|
|
177
|
+
backofficePath?: string;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Tokens every notification/autoreply template can use besides field keys. */
|
|
181
|
+
export function templateTokens(opts: TokenContext): Record<string, string> {
|
|
182
|
+
const origin = (opts.siteUrl ?? "").replace(/\/+$/, "");
|
|
183
|
+
const path = opts.pagePath ?? "";
|
|
184
|
+
const base = (opts.backofficePath ?? "/kontoret").replace(/\/+$/, "");
|
|
185
|
+
const adminUrl = origin
|
|
186
|
+
? `${origin}${base}/forms/submissions?form=${encodeURIComponent(opts.formKey)}${
|
|
187
|
+
opts.submissionId ? `&q=${encodeURIComponent(opts.submissionId)}` : ""
|
|
188
|
+
}`
|
|
189
|
+
: "";
|
|
190
|
+
return {
|
|
191
|
+
form: opts.formName,
|
|
192
|
+
form_name: opts.formName,
|
|
193
|
+
form_key: opts.formKey,
|
|
194
|
+
locale: opts.locale ?? "",
|
|
195
|
+
page: path,
|
|
196
|
+
page_url: origin && path ? `${origin}${path.startsWith("/") ? "" : "/"}${path}` : "",
|
|
197
|
+
admin_url: adminUrl,
|
|
198
|
+
ip: opts.ip ?? "",
|
|
199
|
+
user_agent: opts.userAgent ?? "",
|
|
200
|
+
referrer: opts.referrer ?? "",
|
|
201
|
+
date: new Date().toLocaleString("sv-SE"),
|
|
202
|
+
submission_id: opts.submissionId ?? "",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const EMAIL_TOKEN_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
207
|
+
|
|
208
|
+
/** Render smart tags inside a single-line header value (sender name, reply-to). */
|
|
209
|
+
export function renderHeader(
|
|
210
|
+
input: string | undefined,
|
|
211
|
+
payload: Record<string, string>,
|
|
212
|
+
tokens: Record<string, string>,
|
|
213
|
+
): string {
|
|
214
|
+
return renderTemplate((input ?? "").trim(), payload, tokens)
|
|
215
|
+
.replace(/\s+/g, " ")
|
|
216
|
+
.trim();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const TABLE_TOKEN = "\u0000FIELDS_TABLE\u0000";
|
|
220
|
+
|
|
221
|
+
/** Every alias that expands to the full answer table. */
|
|
222
|
+
const tableTokens = (value: string): Record<string, string> => ({
|
|
223
|
+
fields_table: value,
|
|
224
|
+
all_fields: value,
|
|
225
|
+
all_answers: value,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
function answerRows(fields: FormFieldDef[], payload: Record<string, string>) {
|
|
229
|
+
return fields
|
|
230
|
+
.filter((fl) => fl.enabled)
|
|
231
|
+
.map((fl) => [labelOf(fl), payload[fl.key] ?? ""] as const)
|
|
232
|
+
.filter(([, v]) => v !== "");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function tableHtml(rows: readonly (readonly [string, string])[]) {
|
|
236
|
+
return `<table style="font-family:Arial,sans-serif;border-collapse:collapse">
|
|
237
|
+
${rows
|
|
238
|
+
.map(
|
|
239
|
+
([k, v]) =>
|
|
240
|
+
`<tr><td style="padding:4px 12px 4px 0;color:#666;vertical-align:top">${esc(k)}</td><td style="padding:4px 0">${esc(v).replace(/\n/g, "<br>")}</td></tr>`,
|
|
241
|
+
)
|
|
242
|
+
.join("")}
|
|
243
|
+
</table>`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export type NotifyOptions = TokenContext & {
|
|
247
|
+
to: string;
|
|
248
|
+
locale: string;
|
|
249
|
+
fields: FormFieldDef[];
|
|
250
|
+
payload: Record<string, string>;
|
|
251
|
+
/** Optional custom templates; empty falls back to the standard layout. */
|
|
252
|
+
subject?: string;
|
|
253
|
+
body?: string;
|
|
254
|
+
fromName?: string;
|
|
255
|
+
fromEmail?: string;
|
|
256
|
+
replyTo?: string;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** Admin notification email for a new submission (one mail per recipient). */
|
|
260
|
+
export async function notifySubmissionCore(
|
|
261
|
+
db: DeliveryDb,
|
|
262
|
+
opts: NotifyOptions,
|
|
263
|
+
): Promise<DeliveryResult> {
|
|
264
|
+
const recipients = Array.from(
|
|
265
|
+
new Set(
|
|
266
|
+
opts.to
|
|
267
|
+
.split(/[,;\s]+/)
|
|
268
|
+
.map((s) => s.trim().toLowerCase())
|
|
269
|
+
.filter((s) => s.includes("@")),
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
if (recipients.length === 0) return { ok: true };
|
|
273
|
+
|
|
274
|
+
const resolved = await resolveDeliveryProvider(db);
|
|
275
|
+
if (!resolved.ok) return { ok: false, error: resolved.error };
|
|
276
|
+
|
|
277
|
+
const rows = answerRows(opts.fields, opts.payload);
|
|
278
|
+
const tokens = templateTokens(opts);
|
|
279
|
+
const plainTable = rows.map(([k, v]) => `${k}: ${v}`).join("\n");
|
|
280
|
+
const escapedPayload = Object.fromEntries(
|
|
281
|
+
Object.entries(opts.payload).map(([k, v]) => [k, esc(v)]),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// Smart tags are allowed in the sender name and reply-to; an unusable
|
|
285
|
+
// reply-to falls back to the submitter's address, then the provider default.
|
|
286
|
+
const renderedReplyTo = renderHeader(opts.replyTo, opts.payload, tokens);
|
|
287
|
+
const replyTo = EMAIL_TOKEN_RE.test(renderedReplyTo)
|
|
288
|
+
? renderedReplyTo
|
|
289
|
+
: opts.payload.email?.includes("@")
|
|
290
|
+
? opts.payload.email
|
|
291
|
+
: resolved.provider.replyTo;
|
|
292
|
+
const fromName = renderHeader(opts.fromName, opts.payload, tokens) || undefined;
|
|
293
|
+
|
|
294
|
+
const subjectTpl = (opts.subject ?? "").trim();
|
|
295
|
+
const subject = subjectTpl
|
|
296
|
+
? renderTemplate(subjectTpl, opts.payload, { ...tokens, ...tableTokens(plainTable) }).trim() ||
|
|
297
|
+
`${opts.formName}: new submission`
|
|
298
|
+
: `${opts.formName}: new submission`;
|
|
299
|
+
|
|
300
|
+
const bodyTpl = (opts.body ?? "").trim();
|
|
301
|
+
let text: string;
|
|
302
|
+
let html: string;
|
|
303
|
+
|
|
304
|
+
if (bodyTpl) {
|
|
305
|
+
text = renderTemplate(bodyTpl, opts.payload, { ...tokens, ...tableTokens(plainTable) });
|
|
306
|
+
const htmlSource = renderTemplate(esc(bodyTpl), escapedPayload, {
|
|
307
|
+
...Object.fromEntries(Object.entries(tokens).map(([k, v]) => [k, esc(v)])),
|
|
308
|
+
...tableTokens(TABLE_TOKEN),
|
|
309
|
+
});
|
|
310
|
+
html = `<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#111">${htmlSource
|
|
311
|
+
.replace(/\n/g, "<br>")
|
|
312
|
+
.replace(TABLE_TOKEN, tableHtml(rows))}</div>`;
|
|
313
|
+
} else {
|
|
314
|
+
text = [
|
|
315
|
+
`Form: ${opts.formName} (${opts.formKey})`,
|
|
316
|
+
`Language: ${opts.locale}`,
|
|
317
|
+
opts.pagePath ? `Page: ${opts.pagePath}` : "",
|
|
318
|
+
"",
|
|
319
|
+
plainTable,
|
|
320
|
+
]
|
|
321
|
+
.filter(Boolean)
|
|
322
|
+
.join("\n");
|
|
323
|
+
html = `<h2 style="font-family:Arial,sans-serif">${esc(opts.formName)}</h2>
|
|
324
|
+
${tableHtml(rows)}
|
|
325
|
+
<p style="font-family:Arial,sans-serif;color:#888;font-size:12px">${esc(opts.formKey)} · ${esc(opts.locale)}${opts.pagePath ? ` · ${esc(opts.pagePath)}` : ""}</p>`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let lastError: string | undefined;
|
|
329
|
+
let anyOk = false;
|
|
330
|
+
for (const to of recipients) {
|
|
331
|
+
const res = await dispatch(
|
|
332
|
+
db,
|
|
333
|
+
resolved.raw,
|
|
334
|
+
resolved.provider,
|
|
335
|
+
{ to, subject, html, text, replyTo },
|
|
336
|
+
{ form_key: opts.formKey, purpose: "form_notification" },
|
|
337
|
+
{ fromName, fromEmail: opts.fromEmail },
|
|
338
|
+
);
|
|
339
|
+
if (res.ok) anyOk = true;
|
|
340
|
+
else lastError = res.error;
|
|
341
|
+
}
|
|
342
|
+
return anyOk ? { ok: true } : { ok: false, error: lastError };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export type AutoreplyOptions = TokenContext & {
|
|
346
|
+
to: string;
|
|
347
|
+
subject: string;
|
|
348
|
+
body: string;
|
|
349
|
+
payload: Record<string, string>;
|
|
350
|
+
/** Optional — enables the {{all_fields}} answer table. */
|
|
351
|
+
fields?: FormFieldDef[];
|
|
352
|
+
fromName?: string;
|
|
353
|
+
fromEmail?: string;
|
|
354
|
+
replyTo?: string;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/** Confirmation email back to the person who submitted the form. */
|
|
358
|
+
export async function sendAutoreplyCore(
|
|
359
|
+
db: DeliveryDb,
|
|
360
|
+
opts: AutoreplyOptions,
|
|
361
|
+
): Promise<DeliveryResult> {
|
|
362
|
+
const to = opts.to.trim();
|
|
363
|
+
if (!to || !EMAIL_TOKEN_RE.test(to)) {
|
|
364
|
+
return { ok: false, error: "No valid email address in the submission." };
|
|
365
|
+
}
|
|
366
|
+
const rows = answerRows(opts.fields ?? [], opts.payload);
|
|
367
|
+
const plainTable = rows.map(([k, v]) => `${k}: ${v}`).join("\n");
|
|
368
|
+
const tokens = { ...templateTokens(opts), ...tableTokens(plainTable) };
|
|
369
|
+
const subject = renderTemplate(opts.subject, opts.payload, tokens).trim();
|
|
370
|
+
const body = renderTemplate(opts.body, opts.payload, tokens).trim();
|
|
371
|
+
if (!subject || !body) return { ok: false, error: "Autoreply subject or body is empty." };
|
|
372
|
+
|
|
373
|
+
const resolved = await resolveDeliveryProvider(db);
|
|
374
|
+
if (!resolved.ok) return { ok: false, error: resolved.error };
|
|
375
|
+
|
|
376
|
+
const html = `<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#111">${esc(
|
|
377
|
+
body,
|
|
378
|
+
).replace(/\n/g, "<br>")}</div>`;
|
|
379
|
+
|
|
380
|
+
const renderedReplyTo = renderHeader(opts.replyTo, opts.payload, tokens);
|
|
381
|
+
const fromName = renderHeader(opts.fromName, opts.payload, tokens) || undefined;
|
|
382
|
+
|
|
383
|
+
return dispatch(
|
|
384
|
+
db,
|
|
385
|
+
resolved.raw,
|
|
386
|
+
resolved.provider,
|
|
387
|
+
{
|
|
388
|
+
to,
|
|
389
|
+
subject,
|
|
390
|
+
html,
|
|
391
|
+
text: body,
|
|
392
|
+
replyTo: EMAIL_TOKEN_RE.test(renderedReplyTo) ? renderedReplyTo : resolved.provider.replyTo,
|
|
393
|
+
},
|
|
394
|
+
{ form_key: opts.formKey, purpose: "form_autoreply" },
|
|
395
|
+
{ fromName, fromEmail: opts.fromEmail },
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const GATEWAY = "https://connector-gateway.lovable.dev/google_sheets/v4";
|
|
400
|
+
|
|
401
|
+
/** Append one submission row to a Google Sheet via the connector gateway. */
|
|
402
|
+
export async function appendToSheetCore(opts: {
|
|
403
|
+
sheetId: string;
|
|
404
|
+
tab: string;
|
|
405
|
+
values: string[];
|
|
406
|
+
}): Promise<DeliveryResult> {
|
|
407
|
+
const lovableKey = process.env['LOVABLE_API_KEY'];
|
|
408
|
+
const connKey = process.env['GOOGLE_SHEETS_API_KEY'];
|
|
409
|
+
if (!opts.sheetId) return { ok: false, error: "No Google Sheet configured." };
|
|
410
|
+
if (!lovableKey || !connKey)
|
|
411
|
+
return { ok: false, error: "Google Sheets is not connected for this project." };
|
|
412
|
+
|
|
413
|
+
const tab = opts.tab?.trim() || "Sheet1";
|
|
414
|
+
const range = `${tab}!A:Z`;
|
|
415
|
+
const res = await fetch(
|
|
416
|
+
`${GATEWAY}/spreadsheets/${opts.sheetId}/values/${range}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`,
|
|
417
|
+
{
|
|
418
|
+
method: "POST",
|
|
419
|
+
headers: {
|
|
420
|
+
Authorization: `Bearer ${lovableKey}`,
|
|
421
|
+
"X-Connection-Api-Key": connKey,
|
|
422
|
+
"Content-Type": "application/json",
|
|
423
|
+
},
|
|
424
|
+
body: JSON.stringify({ values: [opts.values] }),
|
|
425
|
+
},
|
|
426
|
+
);
|
|
427
|
+
if (!res.ok) {
|
|
428
|
+
const body = await res.text();
|
|
429
|
+
console.error(`[forms] sheets append failed [${res.status}]: ${body}`);
|
|
430
|
+
return { ok: false, error: `Google Sheets [${res.status}]: ${body.slice(0, 300)}` };
|
|
431
|
+
}
|
|
432
|
+
return { ok: true };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* POST one submission to an external endpoint (CRM, Zapier, Make…).
|
|
437
|
+
* Signed with an optional shared secret so the receiver can verify origin.
|
|
438
|
+
* Never throws: the submission is already stored and must never be lost.
|
|
439
|
+
*/
|
|
440
|
+
export async function postWebhookCore(opts: {
|
|
441
|
+
url: string;
|
|
442
|
+
secret?: string | null;
|
|
443
|
+
formKey: string;
|
|
444
|
+
formName: string;
|
|
445
|
+
submissionId: string;
|
|
446
|
+
locale: string;
|
|
447
|
+
pagePath?: string | null;
|
|
448
|
+
createdAt: string;
|
|
449
|
+
payload: Record<string, string>;
|
|
450
|
+
timeoutMs?: number;
|
|
451
|
+
}): Promise<DeliveryResult & { status?: number }> {
|
|
452
|
+
const url = (opts.url ?? "").trim();
|
|
453
|
+
if (!url) return { ok: false, error: "No webhook URL configured." };
|
|
454
|
+
if (!/^https:\/\//i.test(url))
|
|
455
|
+
return { ok: false, error: "Webhook URL must start with https://" };
|
|
456
|
+
|
|
457
|
+
const body = JSON.stringify({
|
|
458
|
+
event: "form.submission",
|
|
459
|
+
form: { key: opts.formKey, name: opts.formName },
|
|
460
|
+
submission: {
|
|
461
|
+
id: opts.submissionId,
|
|
462
|
+
locale: opts.locale,
|
|
463
|
+
page_path: opts.pagePath ?? null,
|
|
464
|
+
created_at: opts.createdAt,
|
|
465
|
+
payload: opts.payload,
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const headers: Record<string, string> = {
|
|
470
|
+
"Content-Type": "application/json",
|
|
471
|
+
"X-Form-Key": opts.formKey,
|
|
472
|
+
"X-Submission-Id": opts.submissionId,
|
|
473
|
+
};
|
|
474
|
+
const secret = (opts.secret ?? "").trim();
|
|
475
|
+
if (secret) {
|
|
476
|
+
try {
|
|
477
|
+
const { createHmac } = await import("crypto");
|
|
478
|
+
headers["X-Signature"] = createHmac("sha256", secret).update(body).digest("hex");
|
|
479
|
+
} catch {
|
|
480
|
+
/* signing unavailable — send unsigned rather than dropping the delivery */
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const controller = new AbortController();
|
|
485
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 10_000);
|
|
486
|
+
try {
|
|
487
|
+
const res = await fetch(url, {
|
|
488
|
+
method: "POST",
|
|
489
|
+
headers,
|
|
490
|
+
body,
|
|
491
|
+
signal: controller.signal,
|
|
492
|
+
});
|
|
493
|
+
if (!res.ok) {
|
|
494
|
+
const text = (await res.text().catch(() => "")).slice(0, 300);
|
|
495
|
+
return { ok: false, status: res.status, error: `Webhook [${res.status}]: ${text}` };
|
|
496
|
+
}
|
|
497
|
+
return { ok: true, status: res.status };
|
|
498
|
+
} catch (e) {
|
|
499
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
500
|
+
return { ok: false, error: msg === "The operation was aborted." ? "Webhook timed out." : msg };
|
|
501
|
+
} finally {
|
|
502
|
+
clearTimeout(timer);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-provider secret handling.
|
|
3
|
+
*
|
|
4
|
+
* Every row in `public.email_providers` names the environment variable that
|
|
5
|
+
* holds its credential (`secret_ref_name`). The value itself lives only in the
|
|
6
|
+
* server's environment — never in PostgreSQL, never in a migration package,
|
|
7
|
+
* never in a log line.
|
|
8
|
+
*
|
|
9
|
+
* This is deliberately separate from the system-mail path: `SMTP_PASSWORD`
|
|
10
|
+
* powers self-hosted account/password emails, while provider credentials are
|
|
11
|
+
* resolved per provider through the preserved `secret_ref_name`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Only plain, safe environment-variable names are accepted. */
|
|
15
|
+
export const SECRET_REF_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
|
|
17
|
+
/** Environment variable used by the self-hosted system-mail path. */
|
|
18
|
+
export const SYSTEM_SMTP_SECRET_NAME = "SMTP_PASSWORD";
|
|
19
|
+
|
|
20
|
+
export type ProviderSecretRow = {
|
|
21
|
+
id?: string | null;
|
|
22
|
+
name?: string | null;
|
|
23
|
+
kind?: string | null;
|
|
24
|
+
is_active?: boolean | null;
|
|
25
|
+
secret_ref_name?: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type Env = Record<string, string | undefined>;
|
|
29
|
+
|
|
30
|
+
export function isValidSecretRefName(value: unknown): value is string {
|
|
31
|
+
return typeof value === "string" && SECRET_REF_NAME_RE.test(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function label(provider: ProviderSecretRow): string {
|
|
35
|
+
return provider.name?.trim() || provider.kind?.trim() || provider.id || "provider";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type SecretResolution =
|
|
39
|
+
{ ok: true; name: string; value: string } | { ok: false; name: string | null; error: string };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve one provider's credential. Errors mention only the variable name,
|
|
43
|
+
* never a value.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveProviderSecret(
|
|
46
|
+
provider: ProviderSecretRow,
|
|
47
|
+
env: Env = process.env,
|
|
48
|
+
): SecretResolution {
|
|
49
|
+
const raw = provider.secret_ref_name;
|
|
50
|
+
const who = label(provider);
|
|
51
|
+
if (raw == null || String(raw).trim() === "") {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
name: null,
|
|
55
|
+
error: `Provider "${who}" has no secret reference name configured.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const name = String(raw).trim();
|
|
59
|
+
if (!isValidSecretRefName(name)) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
name: null,
|
|
63
|
+
error:
|
|
64
|
+
`Provider "${who}" has an invalid secret reference name — ` +
|
|
65
|
+
`it must match ${SECRET_REF_NAME_RE.source}.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const value = env[name];
|
|
69
|
+
if (value == null || value === "") {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
name,
|
|
73
|
+
error: `Environment variable "${name}" is not set on the server (provider "${who}").`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, name, value };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ProviderSecretRequirement = {
|
|
80
|
+
provider: string;
|
|
81
|
+
kind: string | null;
|
|
82
|
+
name: string | null;
|
|
83
|
+
valid: boolean;
|
|
84
|
+
present: boolean;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Every active provider's required variable name — values are never read out. */
|
|
88
|
+
export function requiredProviderSecrets(
|
|
89
|
+
providers: ProviderSecretRow[],
|
|
90
|
+
env: Env = process.env,
|
|
91
|
+
): ProviderSecretRequirement[] {
|
|
92
|
+
return providers
|
|
93
|
+
.filter((p) => p.is_active !== false)
|
|
94
|
+
.map((p) => {
|
|
95
|
+
const res = resolveProviderSecret(p, env);
|
|
96
|
+
const name = res.ok ? res.name : res.name;
|
|
97
|
+
return {
|
|
98
|
+
provider: label(p),
|
|
99
|
+
kind: p.kind ?? null,
|
|
100
|
+
name,
|
|
101
|
+
valid: name !== null,
|
|
102
|
+
present: res.ok,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Distinct, valid variable names that must be configured. */
|
|
108
|
+
export function requiredSecretNames(providers: ProviderSecretRow[]): string[] {
|
|
109
|
+
const out = new Set<string>();
|
|
110
|
+
for (const p of providers) {
|
|
111
|
+
if (p.is_active === false) continue;
|
|
112
|
+
const raw = typeof p.secret_ref_name === "string" ? p.secret_ref_name.trim() : "";
|
|
113
|
+
if (isValidSecretRefName(raw)) out.add(raw);
|
|
114
|
+
}
|
|
115
|
+
return [...out].sort();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Providers whose secret reference name is present but unsafe. A hard error:
|
|
120
|
+
* such a name can never be resolved to an environment variable.
|
|
121
|
+
*/
|
|
122
|
+
export function invalidSecretProviders(providers: ProviderSecretRow[]): string[] {
|
|
123
|
+
return providers
|
|
124
|
+
.filter((p) => p.is_active !== false)
|
|
125
|
+
.filter((p) => {
|
|
126
|
+
const raw = typeof p.secret_ref_name === "string" ? p.secret_ref_name.trim() : "";
|
|
127
|
+
return raw !== "" && !isValidSecretRefName(raw);
|
|
128
|
+
})
|
|
129
|
+
.map((p) => `${label(p)}: invalid secret reference name`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Active providers that name no secret at all — a hard error. */
|
|
133
|
+
export function providersWithoutSecretRef(providers: ProviderSecretRow[]): string[] {
|
|
134
|
+
return providers
|
|
135
|
+
.filter((p) => p.is_active !== false)
|
|
136
|
+
.filter((p) => (typeof p.secret_ref_name === "string" ? p.secret_ref_name.trim() : "") === "")
|
|
137
|
+
.map((p) => `${label(p)}: no secret reference name configured`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Every hard problem with provider secret references: unsafe names and
|
|
142
|
+
* missing/blank names on active providers. Inactive providers are ignored.
|
|
143
|
+
* Values are never inspected or printed.
|
|
144
|
+
*/
|
|
145
|
+
export function secretRefProblems(providers: ProviderSecretRow[]): string[] {
|
|
146
|
+
return [...invalidSecretProviders(providers), ...providersWithoutSecretRef(providers)];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Operator-facing, value-free report of what must go into
|
|
151
|
+
* `deploy/provider-secrets.env`.
|
|
152
|
+
*/
|
|
153
|
+
export function formatProviderSecretReport(
|
|
154
|
+
providers: ProviderSecretRow[],
|
|
155
|
+
env: Env = process.env,
|
|
156
|
+
): string {
|
|
157
|
+
const reqs = requiredProviderSecrets(providers, env);
|
|
158
|
+
if (reqs.length === 0) return "No active email providers — no provider secrets are required.";
|
|
159
|
+
const lines = [
|
|
160
|
+
"Provider secrets to configure in deploy/provider-secrets.env (names only):",
|
|
161
|
+
...reqs.map((r) => {
|
|
162
|
+
const state = !r.valid ? "INVALID/MISSING NAME" : r.present ? "set" : "NOT SET";
|
|
163
|
+
return ` ${(r.name ?? "(none)").padEnd(28)} ${state.padEnd(20)} ${r.provider}${r.kind ? ` (${r.kind})` : ""}`;
|
|
164
|
+
}),
|
|
165
|
+
"",
|
|
166
|
+
`System mail (account/password emails) uses ${SYSTEM_SMTP_SECRET_NAME} and is configured separately in .env.`,
|
|
167
|
+
"Never paste secret values into chat, logs, PostgreSQL, Git or the migration package.",
|
|
168
|
+
];
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SMTP client for the serverless runtime.
|
|
3
|
+
*
|
|
4
|
+
* Node mail libraries (nodemailer & co.) cannot run here, but the runtime does
|
|
5
|
+
* expose raw TCP sockets, so we speak SMTP directly:
|
|
6
|
+
*
|
|
7
|
+
* greeting -> EHLO -> STARTTLS -> TLS upgrade -> EHLO -> AUTH -> MAIL FROM
|
|
8
|
+
* -> RCPT TO -> DATA -> message -> QUIT
|
|
9
|
+
*
|
|
10
|
+
* Only explicit-TLS-on-connect (port 465) is out of reach: the socket API can
|
|
11
|
+
* upgrade a plain connection to TLS but cannot open an encrypted one. Use the
|
|
12
|
+
* submission port (587) instead.
|
|
13
|
+
*
|
|
14
|
+
* Server-only. Never import from client code.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type SmtpConfig = {
|
|
18
|
+
host: string;
|
|
19
|
+
port: number;
|
|
20
|
+
username?: string;
|
|
21
|
+
password?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type SmtpMessage = {
|
|
25
|
+
to: string;
|
|
26
|
+
fromEmail: string;
|
|
27
|
+
fromName?: string | null;
|
|
28
|
+
replyTo?: string | null;
|
|
29
|
+
subject: string;
|
|
30
|
+
text: string;
|
|
31
|
+
html: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type SmtpResult = { ok: true } | { ok: false; error: string };
|
|
35
|
+
|
|
36
|
+
const CONNECT_TIMEOUT_MS = 15_000;
|
|
37
|
+
const HOST_RE =
|
|
38
|
+
/^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i;
|
|
39
|
+
const BLOCKED_HOSTS = new Set([
|
|
40
|
+
"localhost",
|
|
41
|
+
"localhost.localdomain",
|
|
42
|
+
"127.0.0.1",
|
|
43
|
+
"0.0.0.0",
|
|
44
|
+
"::1",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** Ports we allow outbound. 465 is deliberately excluded (implicit TLS). */
|
|
48
|
+
export const ALLOWED_SMTP_PORTS = [25, 587, 2525] as const;
|
|
49
|
+
|
|
50
|
+
export function validateSmtpTarget(host: string, port: number): string | null {
|
|
51
|
+
const h = host.trim().toLowerCase();
|
|
52
|
+
if (!h) return "SMTP host is missing in the provider config.";
|
|
53
|
+
if (BLOCKED_HOSTS.has(h)) return "Connections to localhost are not allowed.";
|
|
54
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) {
|
|
55
|
+
const [a, b] = h.split(".").map(Number);
|
|
56
|
+
if (
|
|
57
|
+
a === 10 ||
|
|
58
|
+
a === 127 ||
|
|
59
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
60
|
+
(a === 192 && b === 168) ||
|
|
61
|
+
(a === 169 && b === 254)
|
|
62
|
+
) {
|
|
63
|
+
return "Connections to private network addresses are not allowed.";
|
|
64
|
+
}
|
|
65
|
+
} else if (!HOST_RE.test(h)) {
|
|
66
|
+
return `"${host}" is not a valid SMTP host name.`;
|
|
67
|
+
}
|
|
68
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return "SMTP port is invalid.";
|
|
69
|
+
if (port === 465) {
|
|
70
|
+
return "Port 465 (implicit TLS) is not supported by this runtime. Use port 587 with STARTTLS instead.";
|
|
71
|
+
}
|
|
72
|
+
if (!(ALLOWED_SMTP_PORTS as readonly number[]).includes(port)) {
|
|
73
|
+
return `Port ${port} is not allowed. Use 587 (recommended), 2525 or 25.`;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ---------------- reply parsing ---------------- */
|
|
79
|
+
|
|
80
|
+
export type SmtpReply = { code: number; lines: string[]; text: string };
|
|
81
|
+
|
|
82
|
+
/** True when `buf` holds a complete SMTP reply (last line uses "NNN " not "NNN-"). */
|
|
83
|
+
export function isCompleteReply(buf: string): boolean {
|
|
84
|
+
const lines = buf.split(/\r?\n/).filter((l) => l.length > 0);
|
|
85
|
+
if (!lines.length) return false;
|
|
86
|
+
const last = lines[lines.length - 1];
|
|
87
|
+
return /^\d{3}(\s|$)/.test(last);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseReply(buf: string): SmtpReply {
|
|
91
|
+
const lines = buf.split(/\r?\n/).filter((l) => l.length > 0);
|
|
92
|
+
const code = Number(lines[lines.length - 1]?.slice(0, 3)) || 0;
|
|
93
|
+
const text = lines
|
|
94
|
+
.map((l) => l.slice(4))
|
|
95
|
+
.join(" ")
|
|
96
|
+
.trim();
|
|
97
|
+
return { code, lines, text };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ---------------- MIME message ---------------- */
|
|
101
|
+
|
|
102
|
+
function encodeHeaderWord(value: string): string {
|
|
103
|
+
// RFC 2047 for any non-ASCII header content.
|
|
104
|
+
if (/^[\x20-\x7e]*$/.test(value)) return value;
|
|
105
|
+
const bytes = new TextEncoder().encode(value);
|
|
106
|
+
let binary = "";
|
|
107
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
108
|
+
return `=?UTF-8?B?${btoa(binary)}?=`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function addressHeader(email: string, name?: string | null): string {
|
|
112
|
+
return name ? `${encodeHeaderWord(name)} <${email}>` : email;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Escapes leading dots so message content cannot terminate the DATA stage. */
|
|
116
|
+
function dotStuff(body: string): string {
|
|
117
|
+
return body.replace(/\r?\n/g, "\r\n").replace(/^\./gm, "..");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function buildMimeMessage(msg: SmtpMessage, domain: string): string {
|
|
121
|
+
const boundary = `sba-${crypto.randomUUID()}`;
|
|
122
|
+
const headers = [
|
|
123
|
+
`From: ${addressHeader(msg.fromEmail, msg.fromName)}`,
|
|
124
|
+
`To: ${msg.to}`,
|
|
125
|
+
...(msg.replyTo ? [`Reply-To: ${msg.replyTo}`] : []),
|
|
126
|
+
`Subject: ${encodeHeaderWord(msg.subject)}`,
|
|
127
|
+
`Date: ${new Date().toUTCString()}`,
|
|
128
|
+
`Message-ID: <${crypto.randomUUID()}@${domain}>`,
|
|
129
|
+
"MIME-Version: 1.0",
|
|
130
|
+
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
|
131
|
+
];
|
|
132
|
+
const body = [
|
|
133
|
+
`--${boundary}`,
|
|
134
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
135
|
+
"Content-Transfer-Encoding: 8bit",
|
|
136
|
+
"",
|
|
137
|
+
dotStuff(msg.text),
|
|
138
|
+
`--${boundary}`,
|
|
139
|
+
'Content-Type: text/html; charset="UTF-8"',
|
|
140
|
+
"Content-Transfer-Encoding: 8bit",
|
|
141
|
+
"",
|
|
142
|
+
dotStuff(msg.html),
|
|
143
|
+
`--${boundary}--`,
|
|
144
|
+
].join("\r\n");
|
|
145
|
+
return `${headers.join("\r\n")}\r\n\r\n${body}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ---------------- connection ---------------- */
|
|
149
|
+
|
|
150
|
+
type AnySocket = {
|
|
151
|
+
readable: ReadableStream<Uint8Array>;
|
|
152
|
+
writable: WritableStream<Uint8Array>;
|
|
153
|
+
close: () => Promise<void> | void;
|
|
154
|
+
startTls: () => AnySocket;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Node fallback (vite dev / any Node host): wrap a `node:net` socket in the
|
|
159
|
+
* same web-stream shape the Workers socket API exposes, with `startTls()`
|
|
160
|
+
* upgrading the live connection via `node:tls`.
|
|
161
|
+
*/
|
|
162
|
+
type NodeRawSocket = {
|
|
163
|
+
on(event: "data", cb: (chunk: Buffer) => void): void;
|
|
164
|
+
once(event: string, cb: (...args: never[]) => void): void;
|
|
165
|
+
off(event: "data", cb: (chunk: Buffer) => void): void;
|
|
166
|
+
removeAllListeners(event: string): void;
|
|
167
|
+
write(chunk: Buffer, cb: (err?: Error | null) => void): void;
|
|
168
|
+
destroy(): void;
|
|
169
|
+
__smtpHost?: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
type NodeNetModule = { connect: (opts: { host: string; port: number }) => NodeRawSocket };
|
|
173
|
+
type NodeTlsModule = {
|
|
174
|
+
connect: (opts: {
|
|
175
|
+
socket: NodeRawSocket;
|
|
176
|
+
servername?: string;
|
|
177
|
+
rejectUnauthorized: boolean;
|
|
178
|
+
}) => NodeRawSocket;
|
|
179
|
+
};
|
|
180
|
+
type WorkerSocketsModule = {
|
|
181
|
+
connect: (
|
|
182
|
+
address: { hostname: string; port: number },
|
|
183
|
+
options: { allowHalfOpen: boolean; secureTransport: string },
|
|
184
|
+
) => AnySocket;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
function wrapNodeSocket(raw: NodeRawSocket): AnySocket {
|
|
188
|
+
let onData: ((chunk: Buffer) => void) | null = null;
|
|
189
|
+
const readable = new ReadableStream<Uint8Array>({
|
|
190
|
+
start(controller) {
|
|
191
|
+
onData = (chunk) => controller.enqueue(new Uint8Array(chunk));
|
|
192
|
+
raw.on("data", onData);
|
|
193
|
+
raw.once("end", () => {
|
|
194
|
+
try {
|
|
195
|
+
controller.close();
|
|
196
|
+
} catch {
|
|
197
|
+
/* closed */
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
raw.once("error", (e: Error) => {
|
|
201
|
+
try {
|
|
202
|
+
controller.error(e);
|
|
203
|
+
} catch {
|
|
204
|
+
/* closed */
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
cancel() {
|
|
209
|
+
if (onData) raw.off("data", onData);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
const writable = new WritableStream<Uint8Array>({
|
|
213
|
+
write(chunk) {
|
|
214
|
+
return new Promise<void>((resolve, reject) =>
|
|
215
|
+
raw.write(Buffer.from(chunk), (err?: Error | null) => (err ? reject(err) : resolve())),
|
|
216
|
+
);
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
return {
|
|
220
|
+
readable,
|
|
221
|
+
writable,
|
|
222
|
+
close: () => {
|
|
223
|
+
raw.destroy();
|
|
224
|
+
},
|
|
225
|
+
startTls: () => {
|
|
226
|
+
if (onData) raw.off("data", onData);
|
|
227
|
+
raw.removeAllListeners("end");
|
|
228
|
+
raw.removeAllListeners("error");
|
|
229
|
+
|
|
230
|
+
const tls = (globalThis as unknown as { __smtpTls: NodeTlsModule }).__smtpTls;
|
|
231
|
+
const secure = tls.connect({
|
|
232
|
+
socket: raw,
|
|
233
|
+
servername: raw.__smtpHost,
|
|
234
|
+
rejectUnauthorized: true,
|
|
235
|
+
});
|
|
236
|
+
secure.__smtpHost = raw.__smtpHost;
|
|
237
|
+
return wrapNodeSocket(secure);
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function openNodeSocket(host: string, port: number): Promise<AnySocket | null> {
|
|
243
|
+
try {
|
|
244
|
+
const net = (await import(/* @vite-ignore */ "node:net" as string)) as unknown as NodeNetModule;
|
|
245
|
+
const tls = (await import(/* @vite-ignore */ "node:tls" as string)) as unknown as NodeTlsModule;
|
|
246
|
+
if (!net?.connect) return null;
|
|
247
|
+
(globalThis as unknown as { __smtpTls: NodeTlsModule }).__smtpTls = tls;
|
|
248
|
+
const raw = await new Promise<NodeRawSocket>((resolve, reject) => {
|
|
249
|
+
const sock = net.connect({ host, port });
|
|
250
|
+
sock.once("connect", () => resolve(sock));
|
|
251
|
+
sock.once("error", reject);
|
|
252
|
+
});
|
|
253
|
+
raw.__smtpHost = host;
|
|
254
|
+
return wrapNodeSocket(raw);
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function openSocket(host: string, port: number): Promise<AnySocket> {
|
|
261
|
+
const specifier = "cloudflare:sockets";
|
|
262
|
+
const mod = (await import(/* @vite-ignore */ specifier).catch(
|
|
263
|
+
() => null,
|
|
264
|
+
)) as WorkerSocketsModule | null;
|
|
265
|
+
if (mod?.connect) {
|
|
266
|
+
return mod.connect(
|
|
267
|
+
{ hostname: host, port },
|
|
268
|
+
{ allowHalfOpen: false, secureTransport: "starttls" },
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const node = await openNodeSocket(host, port);
|
|
272
|
+
if (node) return node;
|
|
273
|
+
throw new Error("Raw TCP sockets are unavailable in this runtime, so SMTP cannot be used here.");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
class SmtpSession {
|
|
277
|
+
private reader: ReadableStreamDefaultReader<Uint8Array>;
|
|
278
|
+
private writer: WritableStreamDefaultWriter<Uint8Array>;
|
|
279
|
+
private decoder = new TextDecoder();
|
|
280
|
+
private encoder = new TextEncoder();
|
|
281
|
+
private buffer = "";
|
|
282
|
+
|
|
283
|
+
constructor(private socket: AnySocket) {
|
|
284
|
+
this.reader = socket.readable.getReader();
|
|
285
|
+
this.writer = socket.writable.getWriter();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
static async open(host: string, port: number) {
|
|
289
|
+
return new SmtpSession(await openSocket(host, port));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async read(): Promise<SmtpReply> {
|
|
293
|
+
while (!isCompleteReply(this.buffer)) {
|
|
294
|
+
const { value, done } = await this.reader.read();
|
|
295
|
+
if (done) break;
|
|
296
|
+
if (value) this.buffer += this.decoder.decode(value, { stream: true });
|
|
297
|
+
}
|
|
298
|
+
const reply = parseReply(this.buffer);
|
|
299
|
+
this.buffer = "";
|
|
300
|
+
if (!reply.code) throw new Error("The mail server closed the connection unexpectedly.");
|
|
301
|
+
return reply;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async send(line: string): Promise<SmtpReply> {
|
|
305
|
+
await this.writer.write(this.encoder.encode(`${line}\r\n`));
|
|
306
|
+
return this.read();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async sendRaw(data: string): Promise<void> {
|
|
310
|
+
await this.writer.write(this.encoder.encode(data));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Upgrade to TLS after a 220 STARTTLS reply, replacing the reader/writer. */
|
|
314
|
+
async upgrade(): Promise<void> {
|
|
315
|
+
this.reader.releaseLock();
|
|
316
|
+
this.writer.releaseLock();
|
|
317
|
+
this.socket = this.socket.startTls();
|
|
318
|
+
this.reader = this.socket.readable.getReader();
|
|
319
|
+
this.writer = this.socket.writable.getWriter();
|
|
320
|
+
this.buffer = "";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async close(): Promise<void> {
|
|
324
|
+
try {
|
|
325
|
+
this.reader.releaseLock();
|
|
326
|
+
this.writer.releaseLock();
|
|
327
|
+
await this.socket.close();
|
|
328
|
+
} catch {
|
|
329
|
+
/* already closed */
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function expect(reply: SmtpReply, ok: number[], stage: string): void {
|
|
335
|
+
if (!ok.includes(reply.code)) {
|
|
336
|
+
throw new Error(`${stage} failed — server said ${reply.code} ${reply.text}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function b64(value: string): string {
|
|
341
|
+
const bytes = new TextEncoder().encode(value);
|
|
342
|
+
let binary = "";
|
|
343
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
344
|
+
return btoa(binary);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function deliver(config: SmtpConfig, msg: SmtpMessage): Promise<void> {
|
|
348
|
+
const clientDomain = msg.fromEmail.split("@")[1] || "localhost";
|
|
349
|
+
const session = await SmtpSession.open(config.host, config.port);
|
|
350
|
+
try {
|
|
351
|
+
expect(await session.read(), [220], "Connection");
|
|
352
|
+
|
|
353
|
+
let ehlo = await session.send(`EHLO ${clientDomain}`);
|
|
354
|
+
expect(ehlo, [250], "EHLO");
|
|
355
|
+
|
|
356
|
+
const supportsStartTls = ehlo.lines.some((l) => /STARTTLS/i.test(l));
|
|
357
|
+
if (supportsStartTls) {
|
|
358
|
+
expect(await session.send("STARTTLS"), [220], "STARTTLS");
|
|
359
|
+
await session.upgrade();
|
|
360
|
+
ehlo = await session.send(`EHLO ${clientDomain}`);
|
|
361
|
+
expect(ehlo, [250], "EHLO after STARTTLS");
|
|
362
|
+
} else if (config.port !== 25) {
|
|
363
|
+
throw new Error(
|
|
364
|
+
"The mail server does not advertise STARTTLS, so credentials cannot be sent securely.",
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (config.username && config.password) {
|
|
369
|
+
const authLine = ehlo.lines.find((l) => /AUTH\s/i.test(l)) ?? "";
|
|
370
|
+
if (/PLAIN/i.test(authLine) && !/LOGIN/i.test(authLine)) {
|
|
371
|
+
const token = b64(`\0${config.username}\0${config.password}`);
|
|
372
|
+
expect(await session.send(`AUTH PLAIN ${token}`), [235], "Authentication");
|
|
373
|
+
} else {
|
|
374
|
+
expect(await session.send("AUTH LOGIN"), [334], "Authentication");
|
|
375
|
+
expect(await session.send(b64(config.username)), [334], "Authentication (username)");
|
|
376
|
+
expect(await session.send(b64(config.password)), [235], "Authentication (password)");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
expect(await session.send(`MAIL FROM:<${msg.fromEmail}>`), [250], "MAIL FROM");
|
|
381
|
+
expect(await session.send(`RCPT TO:<${msg.to}>`), [250, 251], "RCPT TO");
|
|
382
|
+
expect(await session.send("DATA"), [354], "DATA");
|
|
383
|
+
await session.sendRaw(`${buildMimeMessage(msg, clientDomain)}\r\n.\r\n`);
|
|
384
|
+
expect(await session.read(), [250], "Message delivery");
|
|
385
|
+
|
|
386
|
+
await session.send("QUIT").catch(() => undefined);
|
|
387
|
+
} finally {
|
|
388
|
+
await session.close();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function sendSmtp(config: SmtpConfig, msg: SmtpMessage): Promise<SmtpResult> {
|
|
393
|
+
const invalid = validateSmtpTarget(config.host, config.port);
|
|
394
|
+
if (invalid) return { ok: false, error: invalid };
|
|
395
|
+
if (config.username && !config.password) {
|
|
396
|
+
return { ok: false, error: "SMTP password secret is not available on the server." };
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
await Promise.race([
|
|
400
|
+
deliver(config, msg),
|
|
401
|
+
new Promise<never>((_, reject) =>
|
|
402
|
+
setTimeout(
|
|
403
|
+
() => reject(new Error(`No response from ${config.host}:${config.port} within 15s.`)),
|
|
404
|
+
CONNECT_TIMEOUT_MS,
|
|
405
|
+
),
|
|
406
|
+
),
|
|
407
|
+
]);
|
|
408
|
+
return { ok: true };
|
|
409
|
+
} catch (err) {
|
|
410
|
+
const base = err instanceof Error ? err.message : String(err);
|
|
411
|
+
const hint = /unavailable in this runtime|refused|not allowed|within 15s/i.test(base)
|
|
412
|
+
? " If your host blocks outbound SMTP, use the Mailgun, Amazon SES or HTTP endpoint provider instead."
|
|
413
|
+
: "";
|
|
414
|
+
return { ok: false, error: `${base}${hint}` };
|
|
415
|
+
}
|
|
416
|
+
}
|