@bygghammar/cms-platform 0.35.0 → 0.36.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.35.0",
3
+ "version": "0.36.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,10 @@
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"
152
155
  },
153
156
  "dependencies": {
154
157
  "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,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
+ }