@bygghammar/cms-platform 0.36.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 +3 -2
- package/src/server/forms-delivery-core.ts +504 -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,
|
|
@@ -151,7 +151,8 @@
|
|
|
151
151
|
"./server/domains-core": "./src/server/domains-core.ts",
|
|
152
152
|
"./server/provider-secrets": "./src/server/provider-secrets.ts",
|
|
153
153
|
"./server/smtp": "./src/server/smtp.ts",
|
|
154
|
-
"./server/email-send-core": "./src/server/email-send-core.ts"
|
|
154
|
+
"./server/email-send-core": "./src/server/email-send-core.ts",
|
|
155
|
+
"./server/forms-delivery-core": "./src/server/forms-delivery-core.ts"
|
|
155
156
|
},
|
|
156
157
|
"dependencies": {
|
|
157
158
|
"fflate": "^0.8.3"
|
|
@@ -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
|
+
}
|