@logeix/contact-form 1.0.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/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/client/index.d.ts +27 -0
- package/dist/client/index.js +128 -0
- package/dist/client/index.js.map +1 -0
- package/dist/server/submit-form.d.ts +55 -0
- package/dist/server/submit-form.js +430 -0
- package/dist/server/submit-form.js.map +1 -0
- package/migrations/form_submissions.sql +26 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LOGEIX
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# @logeix/contact-form
|
|
2
|
+
|
|
3
|
+
Lead form intake for LOGEIX client sites (Astro + Cloudflare Pages + D1 + Brevo).
|
|
4
|
+
|
|
5
|
+
Stores **every** submission in the site's own D1 `form_submissions` table (including blocked spam). Sends notification email via Brevo only when the spam engine allows it.
|
|
6
|
+
|
|
7
|
+
This is a sibling of [`@logeix/phone-intent`](https://github.com/logeix/phone-intent). Same install style. **Not** the same package — and **not** a shared database across clients.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
"@logeix/contact-form": "^1.0.0"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Same npm org as [`@logeix/phone-intent`](https://www.npmjs.com/package/@logeix/phone-intent). Public, unlisted-by-search unless you know the name.
|
|
20
|
+
|
|
21
|
+
GitHub tarball still works if a CI job cannot hit npm:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
"@logeix/contact-form": "https://github.com/logeix/contact-form/archive/refs/tags/v1.0.0.tar.gz"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Site setup
|
|
28
|
+
|
|
29
|
+
### 1. D1 migration
|
|
30
|
+
|
|
31
|
+
Each site keeps its own D1 (e.g. `asap-plumbing-pros-forms`). Same binding `DB` as phone-intent.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx wrangler d1 execute YOUR-FORMS-DB --remote --file=node_modules/@logeix/contact-form/migrations/form_submissions.sql
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Existing sites that already have `form_submissions` with spam columns do **not** need to re-run this.
|
|
38
|
+
|
|
39
|
+
### 2. Pages Function
|
|
40
|
+
|
|
41
|
+
`functions/api/submit-form.ts`:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { createSubmitFormHandler } from "@logeix/contact-form/server/submit-form";
|
|
45
|
+
|
|
46
|
+
export const onRequestPost = createSubmitFormHandler({
|
|
47
|
+
phoneLocale: "nanp", // or "uk"
|
|
48
|
+
fallbackEmails: ["leads@example.com"],
|
|
49
|
+
buildEmail(formName, data) {
|
|
50
|
+
return {
|
|
51
|
+
subject: `[Example Co] New contact — ${data.name || "Unknown"}`,
|
|
52
|
+
html: `<p>${data.message || "—"}</p>`, // site-branded HTML
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
// Optional per-site extras (merged with the packaged lists):
|
|
56
|
+
extraHardTerms: ["that one local scam phrase"],
|
|
57
|
+
extraScoreTerms: ["odd sales pitch"],
|
|
58
|
+
extraHoneypotFields: ["company_fax"],
|
|
59
|
+
// gateFormNames: ["instant-quote"], // timing + aux only, no phrase lists
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires D1 binding `DB`, vars `SITE_NAME`, `NOTIFICATION_EMAIL`, and secret `BREVO_API_KEY`. Sender is `LOGEIX Agency <noreply@logeix.com>` unless you pass `sender`.
|
|
64
|
+
|
|
65
|
+
### 3. Form HTML
|
|
66
|
+
|
|
67
|
+
Tag the form and any decoy fields. **Do not** name fields or attributes `honeypot`, `trap`, `spam`, or `bot` — crawlers skip those.
|
|
68
|
+
|
|
69
|
+
| Attribute | Where | What it does |
|
|
70
|
+
|-----------|--------|----------------|
|
|
71
|
+
| `data-lgx-lead` | `<form>` | Client binds timestamp, aux fields, fetch POST |
|
|
72
|
+
| `data-lgx-aux` | decoy `<input>` | Hidden; any value → block. Name should look real (`confirm_email`) |
|
|
73
|
+
| `data-lgx-aux-row` | optional wrapper | Whole row is clipped off-screen |
|
|
74
|
+
|
|
75
|
+
```html
|
|
76
|
+
<form name="contact" method="POST" action="/api/submit-form" data-lgx-lead>
|
|
77
|
+
<input type="hidden" name="form-name" value="contact" />
|
|
78
|
+
<input type="hidden" name="submitted_at_client" value="" />
|
|
79
|
+
<input type="hidden" name="source" value="" />
|
|
80
|
+
|
|
81
|
+
<div data-lgx-aux-row>
|
|
82
|
+
<label for="confirm_email">Please leave this field blank</label>
|
|
83
|
+
<input id="confirm_email" name="confirm_email" type="email" data-lgx-aux />
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<!-- real fields: name, phone, email, message, … -->
|
|
87
|
+
<button type="submit" class="submit-btn">Send</button>
|
|
88
|
+
<p class="status-message"></p>
|
|
89
|
+
</form>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Default aux names the **server** always checks (even without JS): `confirm_email`, `bot-field`. Tagged names are sent in a boring meta field `form_build` so extra decoys work without listing them in the handler.
|
|
93
|
+
|
|
94
|
+
### 4. Client init
|
|
95
|
+
|
|
96
|
+
In the page/component script (same pattern as phone-intent):
|
|
97
|
+
|
|
98
|
+
```astro
|
|
99
|
+
<script>
|
|
100
|
+
import { initContactForms } from "@logeix/contact-form/client";
|
|
101
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
102
|
+
initContactForms({ debug: true });
|
|
103
|
+
});
|
|
104
|
+
</script>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`debug` defaults to **true** (verbose `[lgx-contact-form]` logs). Set `{ debug: false }` in production if it is noisy.
|
|
108
|
+
|
|
109
|
+
Keep site-specific JS (service dropdown from `?service=`, scroll-to-book) in the Astro file. Do not also attach a second submit handler.
|
|
110
|
+
|
|
111
|
+
## Handler options
|
|
112
|
+
|
|
113
|
+
Hardcoded defaults, then per-site overrides:
|
|
114
|
+
|
|
115
|
+
| Option | Default | Purpose |
|
|
116
|
+
|--------|---------|---------|
|
|
117
|
+
| `buildEmail` | required | Subject + HTML for Brevo |
|
|
118
|
+
| `phoneLocale` | `"nanp"` | `"uk"` for UK numbers |
|
|
119
|
+
| `fallbackEmails` | `[]` | If `NOTIFICATION_EMAIL` is empty |
|
|
120
|
+
| `extraHardTerms` | `[]` | Immediate block phrases |
|
|
121
|
+
| `extraScoreTerms` | `[]` | +2 each; block at `blockScoreAt` |
|
|
122
|
+
| `extraHoneypotFields` | `[]` | Extra POST names treated as aux |
|
|
123
|
+
| `assessFormNames` | `["contact"]` | Full scoring |
|
|
124
|
+
| `gateFormNames` | `[]` | Timing + aux only |
|
|
125
|
+
| `minFillMs` | `3000` | Fill-time gate (negative elapsed also blocks) |
|
|
126
|
+
| `blockScoreAt` | `4` | Accumulated score threshold |
|
|
127
|
+
| `debug` | `true` | Server `console.log` |
|
|
128
|
+
| `sender` | LOGEIX / noreply@logeix.com | Brevo from |
|
|
129
|
+
|
|
130
|
+
Phrase lists live in `src/server/terms.ts`. Bump the package to change them for every site.
|
|
131
|
+
|
|
132
|
+
## Spam behaviour
|
|
133
|
+
|
|
134
|
+
Runs in order. Immediate block → `score: 100`. Blocked rows still insert to D1 and return `{ success: true }` so bots cannot probe.
|
|
135
|
+
|
|
136
|
+
1. Aux field has a value (`honeypot-field-filled`)
|
|
137
|
+
2. Missing / non-numeric / **negative** `submitted_at_client`
|
|
138
|
+
3. Elapsed < `minFillMs`
|
|
139
|
+
4. URL in `message`
|
|
140
|
+
5. Hard phrase
|
|
141
|
+
6. Scored phrases, long message, many paragraphs, non-local phone
|
|
142
|
+
7. ≥ 3 same IP in 10 min, ≥ 2 same email in 10 min
|
|
143
|
+
8. Score ≥ `blockScoreAt`
|
|
144
|
+
|
|
145
|
+
## Publish (maintainers)
|
|
146
|
+
|
|
147
|
+
1. Bump `version` in `package.json`
|
|
148
|
+
2. `npm test` && `npm run build`
|
|
149
|
+
3. Commit, tag (`git tag v1.0.1`), push tag
|
|
150
|
+
4. `npm publish --access public`
|
|
151
|
+
5. Update client sites to `"@logeix/contact-form": "^1.0.1"`
|
|
152
|
+
|
|
153
|
+
## Debug
|
|
154
|
+
|
|
155
|
+
- Client: `{ debug: true }` or default — `[lgx-contact-form]` in the browser console
|
|
156
|
+
- Server: Pages Function logs `[submit-form]`
|
|
157
|
+
- Query D1: `spam_decision`, `spam_reasons`, `spam_elapsed_ms` on `form_submissions`
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
interface BindContactFormOptions {
|
|
2
|
+
endpoint?: string;
|
|
3
|
+
thankYouPath?: string;
|
|
4
|
+
/** Verbose console logs. Default true. */
|
|
5
|
+
debug?: boolean;
|
|
6
|
+
errorMessage?: string;
|
|
7
|
+
}
|
|
8
|
+
declare function bindContactForm(form: HTMLFormElement, options?: BindContactFormOptions): void;
|
|
9
|
+
/** Bind every `form[data-lgx-lead]` on the page. */
|
|
10
|
+
declare function initContactForms(options?: BindContactFormOptions): void;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Names used in HTML and POST bodies.
|
|
14
|
+
*
|
|
15
|
+
* Keep these boring. Do not use "honeypot", "trap", "spam", or "bot" —
|
|
16
|
+
* crawlers that read the source skip those.
|
|
17
|
+
*
|
|
18
|
+
* `data-lgx-aux` = auxiliary field (LOGEIX prefix, looks like a form helper).
|
|
19
|
+
* `form_build` = which aux field names were on the page (client → server).
|
|
20
|
+
*/
|
|
21
|
+
declare const AUX_ATTR = "data-lgx-aux";
|
|
22
|
+
declare const AUX_ROW_ATTR = "data-lgx-aux-row";
|
|
23
|
+
declare const LEAD_FORM_ATTR = "data-lgx-lead";
|
|
24
|
+
declare const FORM_BUILD_FIELD = "form_build";
|
|
25
|
+
declare const TIMESTAMP_FIELD = "submitted_at_client";
|
|
26
|
+
|
|
27
|
+
export { AUX_ATTR, AUX_ROW_ATTR, type BindContactFormOptions, FORM_BUILD_FIELD, LEAD_FORM_ATTR, TIMESTAMP_FIELD, bindContactForm, initContactForms };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// src/shared.ts
|
|
2
|
+
var AUX_ATTR = "data-lgx-aux";
|
|
3
|
+
var AUX_ROW_ATTR = "data-lgx-aux-row";
|
|
4
|
+
var LEAD_FORM_ATTR = "data-lgx-lead";
|
|
5
|
+
var FORM_BUILD_FIELD = "form_build";
|
|
6
|
+
var TIMESTAMP_FIELD = "submitted_at_client";
|
|
7
|
+
var SOURCE_FIELD = "source";
|
|
8
|
+
var FORM_BUILD_VERSION = "1";
|
|
9
|
+
function encodeFormBuild(auxNames) {
|
|
10
|
+
const names = uniqueFieldNames(auxNames);
|
|
11
|
+
return names.length ? `${FORM_BUILD_VERSION}~${names.join(",")}` : FORM_BUILD_VERSION;
|
|
12
|
+
}
|
|
13
|
+
function uniqueFieldNames(names) {
|
|
14
|
+
const out = [];
|
|
15
|
+
const seen = /* @__PURE__ */ new Set();
|
|
16
|
+
for (const name of names) {
|
|
17
|
+
const n = name.trim();
|
|
18
|
+
if (!n || !/^[A-Za-z][\w:-]*$/.test(n)) continue;
|
|
19
|
+
const key = n.toLowerCase();
|
|
20
|
+
if (seen.has(key)) continue;
|
|
21
|
+
seen.add(key);
|
|
22
|
+
out.push(n);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/client/bind.ts
|
|
28
|
+
var HIDE_STYLE = "position:absolute!important;left:-10000px!important;width:1px!important;height:1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;";
|
|
29
|
+
function debugLog(enabled, ...args) {
|
|
30
|
+
if (enabled) console.log("[lgx-contact-form]", ...args);
|
|
31
|
+
}
|
|
32
|
+
function ensureHiddenInput(form, name) {
|
|
33
|
+
const existing = form.querySelector(`input[name="${CSS.escape(name)}"]`);
|
|
34
|
+
if (existing instanceof HTMLInputElement) return existing;
|
|
35
|
+
const input = document.createElement("input");
|
|
36
|
+
input.type = "hidden";
|
|
37
|
+
input.name = name;
|
|
38
|
+
form.appendChild(input);
|
|
39
|
+
return input;
|
|
40
|
+
}
|
|
41
|
+
function collectAuxNames(form) {
|
|
42
|
+
const names = [];
|
|
43
|
+
form.querySelectorAll(`[${AUX_ATTR}]`).forEach((el) => {
|
|
44
|
+
if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement)) return;
|
|
45
|
+
if (!el.name) return;
|
|
46
|
+
names.push(el.name);
|
|
47
|
+
el.setAttribute("tabindex", "-1");
|
|
48
|
+
el.setAttribute("autocomplete", "off");
|
|
49
|
+
el.setAttribute("aria-hidden", "true");
|
|
50
|
+
el.style.cssText += HIDE_STYLE;
|
|
51
|
+
const row = el.closest(`[${AUX_ROW_ATTR}]`);
|
|
52
|
+
if (row instanceof HTMLElement) {
|
|
53
|
+
row.setAttribute("aria-hidden", "true");
|
|
54
|
+
row.style.cssText += HIDE_STYLE;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return names;
|
|
58
|
+
}
|
|
59
|
+
function bindContactForm(form, options = {}) {
|
|
60
|
+
const debug = options.debug !== false;
|
|
61
|
+
const endpoint = options.endpoint || form.getAttribute("action") || "/api/submit-form";
|
|
62
|
+
const thankYouPath = options.thankYouPath || "/thank-you/";
|
|
63
|
+
const errorMessage = options.errorMessage || "Something went wrong. Please call us or try again.";
|
|
64
|
+
const auxNames = collectAuxNames(form);
|
|
65
|
+
const timestampInput = ensureHiddenInput(form, TIMESTAMP_FIELD);
|
|
66
|
+
timestampInput.value = Date.now().toString();
|
|
67
|
+
const sourceInput = form.querySelector(`input[name="${SOURCE_FIELD}"]`);
|
|
68
|
+
if (sourceInput instanceof HTMLInputElement) {
|
|
69
|
+
sourceInput.value = window.location.href;
|
|
70
|
+
}
|
|
71
|
+
const buildInput = ensureHiddenInput(form, FORM_BUILD_FIELD);
|
|
72
|
+
buildInput.value = encodeFormBuild(auxNames);
|
|
73
|
+
debugLog(debug, "bound", {
|
|
74
|
+
endpoint,
|
|
75
|
+
auxNames,
|
|
76
|
+
formBuild: buildInput.value,
|
|
77
|
+
timestamp: timestampInput.value
|
|
78
|
+
});
|
|
79
|
+
form.addEventListener("submit", async (event) => {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
const submitBtn = form.querySelector(".submit-btn");
|
|
82
|
+
const statusMessage = form.querySelector(".status-message");
|
|
83
|
+
if (!submitBtn) return;
|
|
84
|
+
const originalText = submitBtn.textContent;
|
|
85
|
+
submitBtn.disabled = true;
|
|
86
|
+
submitBtn.textContent = "Sending...";
|
|
87
|
+
try {
|
|
88
|
+
const formData = new FormData(form);
|
|
89
|
+
const body = new URLSearchParams();
|
|
90
|
+
formData.forEach((value, key) => {
|
|
91
|
+
if (typeof value === "string") body.append(key, value);
|
|
92
|
+
});
|
|
93
|
+
debugLog(debug, "POST", endpoint, Object.fromEntries(body.entries()));
|
|
94
|
+
const response = await fetch(endpoint, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
97
|
+
body: body.toString()
|
|
98
|
+
});
|
|
99
|
+
const data = await response.json();
|
|
100
|
+
if (response.ok && data.success) {
|
|
101
|
+
debugLog(debug, "ok \u2192", thankYouPath);
|
|
102
|
+
form.reset();
|
|
103
|
+
window.location.href = thankYouPath;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
throw new Error(data.error || "Submission failed");
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error("[lgx-contact-form]", err);
|
|
109
|
+
if (statusMessage) statusMessage.textContent = errorMessage;
|
|
110
|
+
} finally {
|
|
111
|
+
submitBtn.disabled = false;
|
|
112
|
+
submitBtn.textContent = originalText || "Send message";
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function initContactForms(options = {}) {
|
|
117
|
+
const debug = options.debug !== false;
|
|
118
|
+
const selector = `form[${LEAD_FORM_ATTR}]`;
|
|
119
|
+
const forms = document.querySelectorAll(selector);
|
|
120
|
+
debugLog(debug, `init ${forms.length} form(s) matching`, selector);
|
|
121
|
+
forms.forEach((form) => {
|
|
122
|
+
if (form instanceof HTMLFormElement) bindContactForm(form, options);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export { AUX_ATTR, AUX_ROW_ATTR, FORM_BUILD_FIELD, LEAD_FORM_ATTR, TIMESTAMP_FIELD, bindContactForm, initContactForms };
|
|
127
|
+
//# sourceMappingURL=index.js.map
|
|
128
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/shared.ts","../../src/client/bind.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Names used in HTML and POST bodies.\n *\n * Keep these boring. Do not use \"honeypot\", \"trap\", \"spam\", or \"bot\" —\n * crawlers that read the source skip those.\n *\n * `data-lgx-aux` = auxiliary field (LOGEIX prefix, looks like a form helper).\n * `form_build` = which aux field names were on the page (client → server).\n */\n\nexport const AUX_ATTR = \"data-lgx-aux\";\nexport const AUX_ROW_ATTR = \"data-lgx-aux-row\";\nexport const LEAD_FORM_ATTR = \"data-lgx-lead\";\nexport const FORM_BUILD_FIELD = \"form_build\";\nexport const TIMESTAMP_FIELD = \"submitted_at_client\";\nexport const FORM_NAME_FIELD = \"form-name\";\nexport const SOURCE_FIELD = \"source\";\n\n/** Always treated as aux fields, even if the client never tags them. */\nexport const DEFAULT_AUX_FIELDS = [\"confirm_email\", \"bot-field\"] as const;\n\nexport const FORM_BUILD_VERSION = \"1\";\n\nexport function encodeFormBuild(auxNames: string[]): string {\n const names = uniqueFieldNames(auxNames);\n return names.length ? `${FORM_BUILD_VERSION}~${names.join(\",\")}` : FORM_BUILD_VERSION;\n}\n\nexport function parseFormBuild(raw: string | undefined): string[] {\n if (!raw) return [];\n const tilde = raw.indexOf(\"~\");\n if (tilde < 0) return [];\n return uniqueFieldNames(raw.slice(tilde + 1).split(\",\"));\n}\n\nexport function uniqueFieldNames(names: Iterable<string>): string[] {\n const out: string[] = [];\n const seen = new Set<string>();\n for (const name of names) {\n const n = name.trim();\n if (!n || !/^[A-Za-z][\\w:-]*$/.test(n)) continue;\n const key = n.toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(n);\n }\n return out;\n}\n","import {\n AUX_ATTR,\n AUX_ROW_ATTR,\n FORM_BUILD_FIELD,\n LEAD_FORM_ATTR,\n SOURCE_FIELD,\n TIMESTAMP_FIELD,\n encodeFormBuild,\n} from \"../shared\";\n\nexport interface BindContactFormOptions {\n endpoint?: string;\n thankYouPath?: string;\n /** Verbose console logs. Default true. */\n debug?: boolean;\n errorMessage?: string;\n}\n\nconst HIDE_STYLE =\n \"position:absolute!important;left:-10000px!important;width:1px!important;height:1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;\";\n\nfunction debugLog(enabled: boolean, ...args: unknown[]) {\n if (enabled) console.log(\"[lgx-contact-form]\", ...args);\n}\n\nfunction ensureHiddenInput(form: HTMLFormElement, name: string): HTMLInputElement {\n const existing = form.querySelector(`input[name=\"${CSS.escape(name)}\"]`);\n if (existing instanceof HTMLInputElement) return existing;\n const input = document.createElement(\"input\");\n input.type = \"hidden\";\n input.name = name;\n form.appendChild(input);\n return input;\n}\n\nfunction collectAuxNames(form: HTMLFormElement): string[] {\n const names: string[] = [];\n form.querySelectorAll(`[${AUX_ATTR}]`).forEach((el) => {\n if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement)) return;\n if (!el.name) return;\n names.push(el.name);\n el.setAttribute(\"tabindex\", \"-1\");\n el.setAttribute(\"autocomplete\", \"off\");\n el.setAttribute(\"aria-hidden\", \"true\");\n el.style.cssText += HIDE_STYLE;\n const row = el.closest(`[${AUX_ROW_ATTR}]`);\n if (row instanceof HTMLElement) {\n row.setAttribute(\"aria-hidden\", \"true\");\n row.style.cssText += HIDE_STYLE;\n }\n });\n return names;\n}\n\nexport function bindContactForm(\n form: HTMLFormElement,\n options: BindContactFormOptions = {},\n): void {\n const debug = options.debug !== false;\n const endpoint = options.endpoint || form.getAttribute(\"action\") || \"/api/submit-form\";\n const thankYouPath = options.thankYouPath || \"/thank-you/\";\n const errorMessage =\n options.errorMessage || \"Something went wrong. Please call us or try again.\";\n\n const auxNames = collectAuxNames(form);\n const timestampInput = ensureHiddenInput(form, TIMESTAMP_FIELD);\n timestampInput.value = Date.now().toString();\n\n const sourceInput = form.querySelector(`input[name=\"${SOURCE_FIELD}\"]`);\n if (sourceInput instanceof HTMLInputElement) {\n sourceInput.value = window.location.href;\n }\n\n const buildInput = ensureHiddenInput(form, FORM_BUILD_FIELD);\n buildInput.value = encodeFormBuild(auxNames);\n\n debugLog(debug, \"bound\", {\n endpoint,\n auxNames,\n formBuild: buildInput.value,\n timestamp: timestampInput.value,\n });\n\n form.addEventListener(\"submit\", async (event) => {\n event.preventDefault();\n const submitBtn = form.querySelector(\".submit-btn\") as HTMLButtonElement | null;\n const statusMessage = form.querySelector(\".status-message\") as HTMLElement | null;\n if (!submitBtn) return;\n\n const originalText = submitBtn.textContent;\n submitBtn.disabled = true;\n submitBtn.textContent = \"Sending...\";\n\n try {\n const formData = new FormData(form);\n const body = new URLSearchParams();\n formData.forEach((value, key) => {\n if (typeof value === \"string\") body.append(key, value);\n });\n debugLog(debug, \"POST\", endpoint, Object.fromEntries(body.entries()));\n\n const response = await fetch(endpoint, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: body.toString(),\n });\n const data = (await response.json()) as { success?: boolean; error?: string };\n\n if (response.ok && data.success) {\n debugLog(debug, \"ok →\", thankYouPath);\n form.reset();\n window.location.href = thankYouPath;\n return;\n }\n throw new Error(data.error || \"Submission failed\");\n } catch (err) {\n console.error(\"[lgx-contact-form]\", err);\n if (statusMessage) statusMessage.textContent = errorMessage;\n } finally {\n submitBtn.disabled = false;\n submitBtn.textContent = originalText || \"Send message\";\n }\n });\n}\n\n/** Bind every `form[data-lgx-lead]` on the page. */\nexport function initContactForms(options: BindContactFormOptions = {}): void {\n const debug = options.debug !== false;\n const selector = `form[${LEAD_FORM_ATTR}]`;\n const forms = document.querySelectorAll(selector);\n debugLog(debug, `init ${forms.length} form(s) matching`, selector);\n forms.forEach((form) => {\n if (form instanceof HTMLFormElement) bindContactForm(form, options);\n });\n}\n"]}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
type PhoneLocale = "nanp" | "uk";
|
|
2
|
+
interface EmailContent {
|
|
3
|
+
subject: string;
|
|
4
|
+
html: string;
|
|
5
|
+
}
|
|
6
|
+
interface SubmitFormEnv {
|
|
7
|
+
DB: D1Database;
|
|
8
|
+
BREVO_API_KEY: string;
|
|
9
|
+
SITE_NAME: string;
|
|
10
|
+
NOTIFICATION_EMAIL?: string;
|
|
11
|
+
}
|
|
12
|
+
interface SubmitFormOptions {
|
|
13
|
+
/** Build the notification email. `formName` is `contact`, `instant-quote`, etc. */
|
|
14
|
+
buildEmail: (formName: string, data: Record<string, string>) => EmailContent;
|
|
15
|
+
/** Phone format scoring. Default `nanp` (US/CA). Use `uk` for Sam's. */
|
|
16
|
+
phoneLocale?: PhoneLocale;
|
|
17
|
+
/** Used when `NOTIFICATION_EMAIL` is unset. */
|
|
18
|
+
fallbackEmails?: string[];
|
|
19
|
+
/** Extra immediate-block phrases (lowercased match after normalisation). */
|
|
20
|
+
extraHardTerms?: string[];
|
|
21
|
+
/** Extra +2 phrases. */
|
|
22
|
+
extraScoreTerms?: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Extra POST field names treated as aux (filled = bot).
|
|
25
|
+
* Tagged `data-lgx-aux` fields are picked up automatically via `form_build`.
|
|
26
|
+
*/
|
|
27
|
+
extraHoneypotFields?: string[];
|
|
28
|
+
/** Forms that get the full phrase / length / phone / rate-limit scoring. Default `["contact"]`. */
|
|
29
|
+
assessFormNames?: string[];
|
|
30
|
+
/**
|
|
31
|
+
* Forms that get timing + aux only (no phrase lists).
|
|
32
|
+
* Useful for `instant-quote` without running the contact copy filter.
|
|
33
|
+
*/
|
|
34
|
+
gateFormNames?: string[];
|
|
35
|
+
/** Minimum ms between page load timestamp and submit. Default 3000. */
|
|
36
|
+
minFillMs?: number;
|
|
37
|
+
/** Accumulated score that blocks. Default 4. */
|
|
38
|
+
blockScoreAt?: number;
|
|
39
|
+
debug?: boolean;
|
|
40
|
+
sender?: {
|
|
41
|
+
name: string;
|
|
42
|
+
email: string;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Cloudflare Pages Function factory — contact (and other) forms → D1 + Brevo.
|
|
48
|
+
*
|
|
49
|
+
* Every submission (including spam) is written to D1.
|
|
50
|
+
* Blocked rows get email suppressed and the reason stored in email_error.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
declare function createSubmitFormHandler(options: SubmitFormOptions): PagesFunction<SubmitFormEnv>;
|
|
54
|
+
|
|
55
|
+
export { type EmailContent, type PhoneLocale, type SubmitFormOptions, createSubmitFormHandler };
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// src/shared.ts
|
|
2
|
+
var FORM_BUILD_FIELD = "form_build";
|
|
3
|
+
var TIMESTAMP_FIELD = "submitted_at_client";
|
|
4
|
+
var FORM_NAME_FIELD = "form-name";
|
|
5
|
+
var DEFAULT_AUX_FIELDS = ["confirm_email", "bot-field"];
|
|
6
|
+
function parseFormBuild(raw) {
|
|
7
|
+
if (!raw) return [];
|
|
8
|
+
const tilde = raw.indexOf("~");
|
|
9
|
+
if (tilde < 0) return [];
|
|
10
|
+
return uniqueFieldNames(raw.slice(tilde + 1).split(","));
|
|
11
|
+
}
|
|
12
|
+
function uniqueFieldNames(names) {
|
|
13
|
+
const out = [];
|
|
14
|
+
const seen = /* @__PURE__ */ new Set();
|
|
15
|
+
for (const name of names) {
|
|
16
|
+
const n = name.trim();
|
|
17
|
+
if (!n || !/^[A-Za-z][\w:-]*$/.test(n)) continue;
|
|
18
|
+
const key = n.toLowerCase();
|
|
19
|
+
if (seen.has(key)) continue;
|
|
20
|
+
seen.add(key);
|
|
21
|
+
out.push(n);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/server/brevo.ts
|
|
27
|
+
var DEFAULT_SENDER = {
|
|
28
|
+
name: "LOGEIX Agency",
|
|
29
|
+
email: "noreply@logeix.com"
|
|
30
|
+
};
|
|
31
|
+
async function sendBrevoEmail(apiKey, toEmails, subject, htmlContent, sender = DEFAULT_SENDER) {
|
|
32
|
+
const payload = {
|
|
33
|
+
sender,
|
|
34
|
+
to: toEmails.map((email) => ({ email })),
|
|
35
|
+
subject,
|
|
36
|
+
htmlContent
|
|
37
|
+
};
|
|
38
|
+
const res = await fetch("https://api.brevo.com/v3/smtp/email", {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
Accept: "application/json",
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
"api-key": apiKey
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify(payload)
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
throw new Error(`Brevo API error: ${res.status} \u2013 ${await res.text()}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function notificationEmails(envEmail, fallback) {
|
|
52
|
+
const list = (envEmail || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
53
|
+
if (list.length) return list;
|
|
54
|
+
return fallback?.filter(Boolean) ?? [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/server/parse.ts
|
|
58
|
+
async function parseFormBody(request) {
|
|
59
|
+
const contentType = request.headers.get("content-type") || "";
|
|
60
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
61
|
+
const text = await request.text();
|
|
62
|
+
const formData = {};
|
|
63
|
+
new URLSearchParams(text).forEach((value, key) => {
|
|
64
|
+
formData[key] = value;
|
|
65
|
+
});
|
|
66
|
+
return formData;
|
|
67
|
+
}
|
|
68
|
+
if (contentType.includes("application/json")) {
|
|
69
|
+
const raw = await request.json();
|
|
70
|
+
const formData = {};
|
|
71
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
72
|
+
if (value == null) continue;
|
|
73
|
+
formData[key] = String(value);
|
|
74
|
+
}
|
|
75
|
+
return formData;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
function jsonResponse(body, status = 200) {
|
|
80
|
+
return new Response(JSON.stringify(body), {
|
|
81
|
+
status,
|
|
82
|
+
headers: { "Content-Type": "application/json" }
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function clientIp(request) {
|
|
86
|
+
return request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For") || "unknown";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/server/terms.ts
|
|
90
|
+
var HARD_SPAM_TERMS = [
|
|
91
|
+
"seo report",
|
|
92
|
+
"seo strategy",
|
|
93
|
+
"seo services",
|
|
94
|
+
"seo specialist",
|
|
95
|
+
"seo expert",
|
|
96
|
+
"seo audit",
|
|
97
|
+
"search engine optimisation",
|
|
98
|
+
"search engine optimization",
|
|
99
|
+
"digital marketing",
|
|
100
|
+
"digital marketing manager",
|
|
101
|
+
"guest post",
|
|
102
|
+
"domain authority",
|
|
103
|
+
"dr 50",
|
|
104
|
+
"backlink",
|
|
105
|
+
"cold outreach",
|
|
106
|
+
"reddit demand",
|
|
107
|
+
"real reddit conversations",
|
|
108
|
+
"monitoring relevant subreddits",
|
|
109
|
+
"warm inbound interest",
|
|
110
|
+
"proposal package",
|
|
111
|
+
"if this is relevant for you",
|
|
112
|
+
"if my previous email didn t go through",
|
|
113
|
+
"if your previous email didn t go through",
|
|
114
|
+
"if you're not interested",
|
|
115
|
+
"if you re not interested",
|
|
116
|
+
'send us "no"',
|
|
117
|
+
"send us no",
|
|
118
|
+
"motivated clients",
|
|
119
|
+
"ethical strategies to draw",
|
|
120
|
+
"social media content",
|
|
121
|
+
"ready to post social media content",
|
|
122
|
+
"7 days of posting content for free",
|
|
123
|
+
"internet marketing warlock",
|
|
124
|
+
"creating money out of thin air",
|
|
125
|
+
"hidden money",
|
|
126
|
+
"trigger points",
|
|
127
|
+
"sell to the affluent",
|
|
128
|
+
"escort application",
|
|
129
|
+
"spellpros com",
|
|
130
|
+
"unsubscribe",
|
|
131
|
+
"trustpilot",
|
|
132
|
+
"fake reviews",
|
|
133
|
+
"purchase reviews",
|
|
134
|
+
"buy reviews",
|
|
135
|
+
"buy google reviews",
|
|
136
|
+
"verified reviews package",
|
|
137
|
+
"reputation repair service",
|
|
138
|
+
"reputation management",
|
|
139
|
+
"manage your online reputation",
|
|
140
|
+
"suppress negative reviews",
|
|
141
|
+
"negative review removal",
|
|
142
|
+
"remove negative reviews",
|
|
143
|
+
"remove bad reviews",
|
|
144
|
+
"selling your business",
|
|
145
|
+
"sell your business",
|
|
146
|
+
"business broker",
|
|
147
|
+
"interested in selling",
|
|
148
|
+
"buying plumbing businesses",
|
|
149
|
+
"buying businesses in your industry",
|
|
150
|
+
"quantity takeoff",
|
|
151
|
+
"stop to opt out"
|
|
152
|
+
];
|
|
153
|
+
var SCORE_SPAM_TERMS = [
|
|
154
|
+
"search results",
|
|
155
|
+
"rankings",
|
|
156
|
+
"online presence",
|
|
157
|
+
"visibility on google",
|
|
158
|
+
"search visibility",
|
|
159
|
+
"organic traffic",
|
|
160
|
+
"relevant traffic",
|
|
161
|
+
"convenient time to connect",
|
|
162
|
+
"let me know a convenient time",
|
|
163
|
+
"i recently came across your website",
|
|
164
|
+
"came across your website",
|
|
165
|
+
"came across your business",
|
|
166
|
+
"i noticed your website",
|
|
167
|
+
"pricing and packages",
|
|
168
|
+
"my services and pricing",
|
|
169
|
+
"leads",
|
|
170
|
+
"learn more",
|
|
171
|
+
"book a call",
|
|
172
|
+
"operational systems",
|
|
173
|
+
"day to day workflows",
|
|
174
|
+
"specific examples",
|
|
175
|
+
"high quality email list",
|
|
176
|
+
"free posting content",
|
|
177
|
+
"local business owners",
|
|
178
|
+
"show up online",
|
|
179
|
+
"quote/package/proposal",
|
|
180
|
+
"brand-safe",
|
|
181
|
+
"aged-account",
|
|
182
|
+
"b2b",
|
|
183
|
+
"saas",
|
|
184
|
+
"high-intent threads",
|
|
185
|
+
"google reviews",
|
|
186
|
+
"yelp reviews",
|
|
187
|
+
"tripadvisor reviews",
|
|
188
|
+
"review building",
|
|
189
|
+
"boost your reviews",
|
|
190
|
+
"improve your reviews",
|
|
191
|
+
"more reviews for",
|
|
192
|
+
"positive reviews package",
|
|
193
|
+
"ratings and reviews",
|
|
194
|
+
"reviews for your business",
|
|
195
|
+
"reply yes",
|
|
196
|
+
"vas 4 hire",
|
|
197
|
+
"vas4hire",
|
|
198
|
+
"takeoff services",
|
|
199
|
+
"senior estimator"
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
// src/server/spam.ts
|
|
203
|
+
var DEFAULT_MIN_FILL_MS = 3e3;
|
|
204
|
+
var DEFAULT_BLOCK_SCORE = 4;
|
|
205
|
+
function auxFieldNames(formData, extra) {
|
|
206
|
+
return uniqueFieldNames([
|
|
207
|
+
...DEFAULT_AUX_FIELDS,
|
|
208
|
+
...extra ?? [],
|
|
209
|
+
...parseFormBuild(formData[FORM_BUILD_FIELD])
|
|
210
|
+
]);
|
|
211
|
+
}
|
|
212
|
+
function auxFieldFilled(formData, names) {
|
|
213
|
+
return names.some((name) => Boolean((formData[name] || "").trim()));
|
|
214
|
+
}
|
|
215
|
+
function stripMetaFields(formData, auxNames) {
|
|
216
|
+
const clean = { ...formData };
|
|
217
|
+
delete clean[FORM_NAME_FIELD];
|
|
218
|
+
delete clean[TIMESTAMP_FIELD];
|
|
219
|
+
delete clean[FORM_BUILD_FIELD];
|
|
220
|
+
for (const name of auxNames) delete clean[name];
|
|
221
|
+
return clean;
|
|
222
|
+
}
|
|
223
|
+
function normaliseText(input) {
|
|
224
|
+
return input.toLowerCase().replace(/[^\w\s]/g, " ").replace(/\s+/g, " ").trim();
|
|
225
|
+
}
|
|
226
|
+
function phoneScoreReason(rawPhone, locale) {
|
|
227
|
+
if (!rawPhone) return null;
|
|
228
|
+
if (locale === "uk") {
|
|
229
|
+
const compact = rawPhone.replace(/[\s\-().]/g, "");
|
|
230
|
+
if (!/^(\+44|0)[0-9]{9,10}$/.test(compact)) return "non-uk-phone";
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const digits = rawPhone.replace(/\D/g, "");
|
|
234
|
+
if (digits.length > 0 && !/^1?[2-9]\d{9}$/.test(digits)) return "non-us-phone";
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
async function assessFormSpam(params) {
|
|
238
|
+
const { db, formData, ipAddress, options, mode } = params;
|
|
239
|
+
const reasons = [];
|
|
240
|
+
let score = 0;
|
|
241
|
+
const minFillMs = options.minFillMs ?? DEFAULT_MIN_FILL_MS;
|
|
242
|
+
const blockScoreAt = options.blockScoreAt ?? DEFAULT_BLOCK_SCORE;
|
|
243
|
+
const locale = options.phoneLocale ?? "nanp";
|
|
244
|
+
const clientTimestamp = Number(formData[TIMESTAMP_FIELD] || 0);
|
|
245
|
+
let elapsedMs;
|
|
246
|
+
if (Number.isFinite(clientTimestamp) && clientTimestamp > 0) {
|
|
247
|
+
elapsedMs = Date.now() - clientTimestamp;
|
|
248
|
+
if (elapsedMs < 0 || elapsedMs < minFillMs) {
|
|
249
|
+
reasons.push(elapsedMs < 0 ? "invalid-client-timestamp" : "submitted-too-fast");
|
|
250
|
+
return { decision: "blocked", score: 100, reasons, elapsedMs };
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
reasons.push("missing-client-timestamp");
|
|
254
|
+
return { decision: "blocked", score: 100, reasons, elapsedMs };
|
|
255
|
+
}
|
|
256
|
+
if (mode === "gates") {
|
|
257
|
+
return { decision: "allow", score: 0, reasons, elapsedMs };
|
|
258
|
+
}
|
|
259
|
+
if (/(https?:\/\/|www\.)/i.test(formData.message || "")) {
|
|
260
|
+
reasons.push("contains-link");
|
|
261
|
+
return { decision: "blocked", score: 100, reasons, elapsedMs };
|
|
262
|
+
}
|
|
263
|
+
const msg = normaliseText(formData.message || "");
|
|
264
|
+
const hardTerms = [...HARD_SPAM_TERMS, ...options.extraHardTerms ?? []];
|
|
265
|
+
for (const term of hardTerms) {
|
|
266
|
+
if (msg.includes(normaliseText(term))) {
|
|
267
|
+
reasons.push(`hard-term:${term}`);
|
|
268
|
+
return { decision: "blocked", score: 100, reasons, elapsedMs };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const scoreTerms = [...SCORE_SPAM_TERMS, ...options.extraScoreTerms ?? []];
|
|
272
|
+
for (const term of scoreTerms) {
|
|
273
|
+
if (msg.includes(normaliseText(term))) {
|
|
274
|
+
score += 2;
|
|
275
|
+
reasons.push(`score-term:${term}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if ((formData.message || "").length > 550) {
|
|
279
|
+
score += 2;
|
|
280
|
+
reasons.push("message-too-long");
|
|
281
|
+
}
|
|
282
|
+
const paragraphBreaks = (formData.message || "").split(/\r?\n/).filter(Boolean).length;
|
|
283
|
+
if (paragraphBreaks >= 8) {
|
|
284
|
+
score += 1;
|
|
285
|
+
reasons.push("many-paragraphs");
|
|
286
|
+
}
|
|
287
|
+
const phoneReason = phoneScoreReason(formData.phone || "", locale);
|
|
288
|
+
if (phoneReason) {
|
|
289
|
+
score += 2;
|
|
290
|
+
reasons.push(phoneReason);
|
|
291
|
+
}
|
|
292
|
+
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1e3).toISOString();
|
|
293
|
+
const formName = formData[FORM_NAME_FIELD] || "contact";
|
|
294
|
+
const ipRow = await db.prepare(
|
|
295
|
+
`SELECT COUNT(*) AS c FROM form_submissions
|
|
296
|
+
WHERE form_name = ? AND submitted_at >= ? AND ip_address = ?`
|
|
297
|
+
).bind(formName, tenMinutesAgo, ipAddress).first();
|
|
298
|
+
if (Number(ipRow?.c || 0) >= 3) {
|
|
299
|
+
reasons.push("ip-rate-limited");
|
|
300
|
+
return { decision: "blocked", score: 100, reasons, elapsedMs };
|
|
301
|
+
}
|
|
302
|
+
const email = (formData.email || "").trim().toLowerCase();
|
|
303
|
+
if (email) {
|
|
304
|
+
const emailRow = await db.prepare(
|
|
305
|
+
`SELECT COUNT(*) AS c FROM form_submissions
|
|
306
|
+
WHERE form_name = ? AND submitted_at >= ?
|
|
307
|
+
AND json_extract(form_data, '$.email') = ?`
|
|
308
|
+
).bind(formName, tenMinutesAgo, email).first();
|
|
309
|
+
if (Number(emailRow?.c || 0) >= 2) {
|
|
310
|
+
reasons.push("email-rate-limited");
|
|
311
|
+
return { decision: "blocked", score: 100, reasons, elapsedMs };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (score >= blockScoreAt) {
|
|
315
|
+
return { decision: "blocked", score, reasons, elapsedMs };
|
|
316
|
+
}
|
|
317
|
+
return { decision: "allow", score, reasons, elapsedMs };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/server/submit-form.ts
|
|
321
|
+
function createSubmitFormHandler(options) {
|
|
322
|
+
const debug = options.debug !== false;
|
|
323
|
+
const assessFormNames = options.assessFormNames ?? ["contact"];
|
|
324
|
+
const gateFormNames = options.gateFormNames ?? [];
|
|
325
|
+
function debugLog(...args) {
|
|
326
|
+
if (debug) console.log("[submit-form]", ...args);
|
|
327
|
+
}
|
|
328
|
+
return async (context) => {
|
|
329
|
+
const { request, env } = context;
|
|
330
|
+
try {
|
|
331
|
+
const formData = await parseFormBody(request);
|
|
332
|
+
if (!formData) {
|
|
333
|
+
return jsonResponse({ error: "Unsupported content type" }, 400);
|
|
334
|
+
}
|
|
335
|
+
const formName = formData[FORM_NAME_FIELD] || "unknown";
|
|
336
|
+
const siteName = env.SITE_NAME || "unknown";
|
|
337
|
+
const auxNames = auxFieldNames(formData, options.extraHoneypotFields);
|
|
338
|
+
const honeypotTriggered = auxFieldFilled(formData, auxNames);
|
|
339
|
+
const ipAddress = clientIp(request);
|
|
340
|
+
const userAgent = request.headers.get("User-Agent") || "unknown";
|
|
341
|
+
const submittedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
342
|
+
let spamAssessment = { decision: "allow", score: 0, reasons: [] };
|
|
343
|
+
if (honeypotTriggered) {
|
|
344
|
+
spamAssessment = {
|
|
345
|
+
decision: "blocked",
|
|
346
|
+
score: 100,
|
|
347
|
+
reasons: ["honeypot-field-filled"]
|
|
348
|
+
};
|
|
349
|
+
debugLog("aux field filled \u2014 logging row without email");
|
|
350
|
+
} else if (assessFormNames.includes(formName)) {
|
|
351
|
+
spamAssessment = await assessFormSpam({
|
|
352
|
+
db: env.DB,
|
|
353
|
+
formData,
|
|
354
|
+
ipAddress,
|
|
355
|
+
options,
|
|
356
|
+
mode: "full"
|
|
357
|
+
});
|
|
358
|
+
if (spamAssessment.decision === "blocked") {
|
|
359
|
+
debugLog("submission blocked (logged):", spamAssessment.reasons.join("; "));
|
|
360
|
+
}
|
|
361
|
+
} else if (gateFormNames.includes(formName)) {
|
|
362
|
+
spamAssessment = await assessFormSpam({
|
|
363
|
+
db: env.DB,
|
|
364
|
+
formData,
|
|
365
|
+
ipAddress,
|
|
366
|
+
options,
|
|
367
|
+
mode: "gates"
|
|
368
|
+
});
|
|
369
|
+
if (spamAssessment.decision === "blocked") {
|
|
370
|
+
debugLog("gated form blocked (logged):", spamAssessment.reasons.join("; "));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const cleanFormData = stripMetaFields(formData, auxNames);
|
|
374
|
+
const recordSpam = honeypotTriggered || assessFormNames.includes(formName) || gateFormNames.includes(formName);
|
|
375
|
+
const insertResult = await env.DB.prepare(
|
|
376
|
+
`INSERT INTO form_submissions
|
|
377
|
+
(site_name, form_name, submitted_at, ip_address, user_agent, form_data, email_sent,
|
|
378
|
+
spam_decision, spam_score, spam_reasons, spam_elapsed_ms)
|
|
379
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`
|
|
380
|
+
).bind(
|
|
381
|
+
siteName,
|
|
382
|
+
formName,
|
|
383
|
+
submittedAt,
|
|
384
|
+
ipAddress,
|
|
385
|
+
userAgent,
|
|
386
|
+
JSON.stringify(cleanFormData),
|
|
387
|
+
recordSpam ? spamAssessment.decision : null,
|
|
388
|
+
recordSpam ? spamAssessment.score : null,
|
|
389
|
+
recordSpam ? JSON.stringify(spamAssessment.reasons) : null,
|
|
390
|
+
recordSpam ? spamAssessment.elapsedMs ?? null : null
|
|
391
|
+
).run();
|
|
392
|
+
if (!insertResult.success) throw new Error("Database insert failed");
|
|
393
|
+
const submissionId = insertResult.meta.last_row_id;
|
|
394
|
+
const emailSuppressedReason = spamAssessment.decision === "blocked" ? `Suppressed (score ${spamAssessment.score}): ${spamAssessment.reasons.join(", ")}` : null;
|
|
395
|
+
if (emailSuppressedReason) {
|
|
396
|
+
await env.DB.prepare(`UPDATE form_submissions SET email_error = ? WHERE id = ?`).bind(emailSuppressedReason, submissionId).run();
|
|
397
|
+
return jsonResponse({ success: true });
|
|
398
|
+
}
|
|
399
|
+
const to = notificationEmails(env.NOTIFICATION_EMAIL, options.fallbackEmails);
|
|
400
|
+
if (!to.length) {
|
|
401
|
+
throw new Error("No notification emails configured");
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const { subject, html } = options.buildEmail(formName, cleanFormData);
|
|
405
|
+
await sendBrevoEmail(
|
|
406
|
+
env.BREVO_API_KEY,
|
|
407
|
+
to,
|
|
408
|
+
subject,
|
|
409
|
+
html,
|
|
410
|
+
options.sender ?? DEFAULT_SENDER
|
|
411
|
+
);
|
|
412
|
+
await env.DB.prepare(
|
|
413
|
+
`UPDATE form_submissions SET email_sent = 1, email_sent_at = ? WHERE id = ?`
|
|
414
|
+
).bind((/* @__PURE__ */ new Date()).toISOString(), submissionId).run();
|
|
415
|
+
debugLog("email sent", { to, submissionId });
|
|
416
|
+
} catch (emailError) {
|
|
417
|
+
console.error("Email send failed:", emailError);
|
|
418
|
+
await env.DB.prepare(`UPDATE form_submissions SET email_error = ? WHERE id = ?`).bind(String(emailError), submissionId).run();
|
|
419
|
+
}
|
|
420
|
+
return jsonResponse({ success: true, submissionId });
|
|
421
|
+
} catch (error) {
|
|
422
|
+
console.error("Form submission error:", error);
|
|
423
|
+
return jsonResponse({ error: "Submission failed", message: String(error) }, 500);
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export { createSubmitFormHandler };
|
|
429
|
+
//# sourceMappingURL=submit-form.js.map
|
|
430
|
+
//# sourceMappingURL=submit-form.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/shared.ts","../../src/server/brevo.ts","../../src/server/parse.ts","../../src/server/terms.ts","../../src/server/spam.ts","../../src/server/submit-form.ts"],"names":[],"mappings":"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","file":"submit-form.js","sourcesContent":["/**\n * Names used in HTML and POST bodies.\n *\n * Keep these boring. Do not use \"honeypot\", \"trap\", \"spam\", or \"bot\" —\n * crawlers that read the source skip those.\n *\n * `data-lgx-aux` = auxiliary field (LOGEIX prefix, looks like a form helper).\n * `form_build` = which aux field names were on the page (client → server).\n */\n\nexport const AUX_ATTR = \"data-lgx-aux\";\nexport const AUX_ROW_ATTR = \"data-lgx-aux-row\";\nexport const LEAD_FORM_ATTR = \"data-lgx-lead\";\nexport const FORM_BUILD_FIELD = \"form_build\";\nexport const TIMESTAMP_FIELD = \"submitted_at_client\";\nexport const FORM_NAME_FIELD = \"form-name\";\nexport const SOURCE_FIELD = \"source\";\n\n/** Always treated as aux fields, even if the client never tags them. */\nexport const DEFAULT_AUX_FIELDS = [\"confirm_email\", \"bot-field\"] as const;\n\nexport const FORM_BUILD_VERSION = \"1\";\n\nexport function encodeFormBuild(auxNames: string[]): string {\n const names = uniqueFieldNames(auxNames);\n return names.length ? `${FORM_BUILD_VERSION}~${names.join(\",\")}` : FORM_BUILD_VERSION;\n}\n\nexport function parseFormBuild(raw: string | undefined): string[] {\n if (!raw) return [];\n const tilde = raw.indexOf(\"~\");\n if (tilde < 0) return [];\n return uniqueFieldNames(raw.slice(tilde + 1).split(\",\"));\n}\n\nexport function uniqueFieldNames(names: Iterable<string>): string[] {\n const out: string[] = [];\n const seen = new Set<string>();\n for (const name of names) {\n const n = name.trim();\n if (!n || !/^[A-Za-z][\\w:-]*$/.test(n)) continue;\n const key = n.toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(n);\n }\n return out;\n}\n","export interface BrevoEmailRequest {\n sender: { name: string; email: string };\n to: Array<{ email: string; name?: string }>;\n subject: string;\n htmlContent: string;\n}\n\nexport const DEFAULT_SENDER = {\n name: \"LOGEIX Agency\",\n email: \"noreply@logeix.com\",\n} as const;\n\nexport async function sendBrevoEmail(\n apiKey: string,\n toEmails: string[],\n subject: string,\n htmlContent: string,\n sender: { name: string; email: string } = DEFAULT_SENDER,\n): Promise<void> {\n const payload: BrevoEmailRequest = {\n sender,\n to: toEmails.map((email) => ({ email })),\n subject,\n htmlContent,\n };\n\n const res = await fetch(\"https://api.brevo.com/v3/smtp/email\", {\n method: \"POST\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n \"api-key\": apiKey,\n },\n body: JSON.stringify(payload),\n });\n\n if (!res.ok) {\n throw new Error(`Brevo API error: ${res.status} – ${await res.text()}`);\n }\n}\n\nexport function notificationEmails(\n envEmail: string | undefined,\n fallback: string[] | undefined,\n): string[] {\n const list = (envEmail || \"\")\n .split(\",\")\n .map((s) => s.trim())\n .filter(Boolean);\n if (list.length) return list;\n return fallback?.filter(Boolean) ?? [];\n}\n","export async function parseFormBody(request: Request): Promise<Record<string, string> | null> {\n const contentType = request.headers.get(\"content-type\") || \"\";\n\n if (contentType.includes(\"application/x-www-form-urlencoded\")) {\n const text = await request.text();\n const formData: Record<string, string> = {};\n new URLSearchParams(text).forEach((value, key) => {\n formData[key] = value;\n });\n return formData;\n }\n\n if (contentType.includes(\"application/json\")) {\n const raw = (await request.json()) as Record<string, unknown>;\n const formData: Record<string, string> = {};\n for (const [key, value] of Object.entries(raw)) {\n if (value == null) continue;\n formData[key] = String(value);\n }\n return formData;\n }\n\n return null;\n}\n\nexport function jsonResponse(body: unknown, status = 200): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { \"Content-Type\": \"application/json\" },\n });\n}\n\nexport function clientIp(request: Request): string {\n return (\n request.headers.get(\"CF-Connecting-IP\") ||\n request.headers.get(\"X-Forwarded-For\") ||\n \"unknown\"\n );\n}\n","/**\n * Shared phrase lists. Sites add extras via createSubmitFormHandler({ extraHardTerms, extraScoreTerms }).\n * Matching is case-insensitive after punctuation is stripped to spaces.\n */\n\nexport const HARD_SPAM_TERMS: readonly string[] = [\n \"seo report\",\n \"seo strategy\",\n \"seo services\",\n \"seo specialist\",\n \"seo expert\",\n \"seo audit\",\n \"search engine optimisation\",\n \"search engine optimization\",\n \"digital marketing\",\n \"digital marketing manager\",\n \"guest post\",\n \"domain authority\",\n \"dr 50\",\n \"backlink\",\n \"cold outreach\",\n \"reddit demand\",\n \"real reddit conversations\",\n \"monitoring relevant subreddits\",\n \"warm inbound interest\",\n \"proposal package\",\n \"if this is relevant for you\",\n \"if my previous email didn t go through\",\n \"if your previous email didn t go through\",\n \"if you're not interested\",\n \"if you re not interested\",\n 'send us \"no\"',\n \"send us no\",\n \"motivated clients\",\n \"ethical strategies to draw\",\n \"social media content\",\n \"ready to post social media content\",\n \"7 days of posting content for free\",\n \"internet marketing warlock\",\n \"creating money out of thin air\",\n \"hidden money\",\n \"trigger points\",\n \"sell to the affluent\",\n \"escort application\",\n \"spellpros com\",\n \"unsubscribe\",\n \"trustpilot\",\n \"fake reviews\",\n \"purchase reviews\",\n \"buy reviews\",\n \"buy google reviews\",\n \"verified reviews package\",\n \"reputation repair service\",\n \"reputation management\",\n \"manage your online reputation\",\n \"suppress negative reviews\",\n \"negative review removal\",\n \"remove negative reviews\",\n \"remove bad reviews\",\n \"selling your business\",\n \"sell your business\",\n \"business broker\",\n \"interested in selling\",\n \"buying plumbing businesses\",\n \"buying businesses in your industry\",\n \"quantity takeoff\",\n \"stop to opt out\",\n];\n\nexport const SCORE_SPAM_TERMS: readonly string[] = [\n \"search results\",\n \"rankings\",\n \"online presence\",\n \"visibility on google\",\n \"search visibility\",\n \"organic traffic\",\n \"relevant traffic\",\n \"convenient time to connect\",\n \"let me know a convenient time\",\n \"i recently came across your website\",\n \"came across your website\",\n \"came across your business\",\n \"i noticed your website\",\n \"pricing and packages\",\n \"my services and pricing\",\n \"leads\",\n \"learn more\",\n \"book a call\",\n \"operational systems\",\n \"day to day workflows\",\n \"specific examples\",\n \"high quality email list\",\n \"free posting content\",\n \"local business owners\",\n \"show up online\",\n \"quote/package/proposal\",\n \"brand-safe\",\n \"aged-account\",\n \"b2b\",\n \"saas\",\n \"high-intent threads\",\n \"google reviews\",\n \"yelp reviews\",\n \"tripadvisor reviews\",\n \"review building\",\n \"boost your reviews\",\n \"improve your reviews\",\n \"more reviews for\",\n \"positive reviews package\",\n \"ratings and reviews\",\n \"reviews for your business\",\n \"reply yes\",\n \"vas 4 hire\",\n \"vas4hire\",\n \"takeoff services\",\n \"senior estimator\",\n];\n","/// <reference types=\"@cloudflare/workers-types\" />\n\nimport {\n DEFAULT_AUX_FIELDS,\n FORM_BUILD_FIELD,\n FORM_NAME_FIELD,\n TIMESTAMP_FIELD,\n parseFormBuild,\n uniqueFieldNames,\n} from \"../shared\";\nimport { HARD_SPAM_TERMS, SCORE_SPAM_TERMS } from \"./terms\";\nimport type { PhoneLocale, SpamAssessment, SubmitFormOptions } from \"./types\";\n\nconst DEFAULT_MIN_FILL_MS = 3000;\nconst DEFAULT_BLOCK_SCORE = 4;\n\nexport function auxFieldNames(\n formData: Record<string, string>,\n extra: string[] | undefined,\n): string[] {\n return uniqueFieldNames([\n ...DEFAULT_AUX_FIELDS,\n ...(extra ?? []),\n ...parseFormBuild(formData[FORM_BUILD_FIELD]),\n ]);\n}\n\nexport function auxFieldFilled(\n formData: Record<string, string>,\n names: string[],\n): boolean {\n return names.some((name) => Boolean((formData[name] || \"\").trim()));\n}\n\nexport function stripMetaFields(\n formData: Record<string, string>,\n auxNames: string[],\n): Record<string, string> {\n const clean = { ...formData };\n delete clean[FORM_NAME_FIELD];\n delete clean[TIMESTAMP_FIELD];\n delete clean[FORM_BUILD_FIELD];\n for (const name of auxNames) delete clean[name];\n return clean;\n}\n\nfunction normaliseText(input: string): string {\n return input.toLowerCase().replace(/[^\\w\\s]/g, \" \").replace(/\\s+/g, \" \").trim();\n}\n\nfunction phoneScoreReason(rawPhone: string, locale: PhoneLocale): string | null {\n if (!rawPhone) return null;\n if (locale === \"uk\") {\n const compact = rawPhone.replace(/[\\s\\-().]/g, \"\");\n if (!/^(\\+44|0)[0-9]{9,10}$/.test(compact)) return \"non-uk-phone\";\n return null;\n }\n const digits = rawPhone.replace(/\\D/g, \"\");\n if (digits.length > 0 && !/^1?[2-9]\\d{9}$/.test(digits)) return \"non-us-phone\";\n return null;\n}\n\nexport async function assessFormSpam(params: {\n db: D1Database;\n formData: Record<string, string>;\n ipAddress: string;\n options: SubmitFormOptions;\n mode: \"full\" | \"gates\";\n}): Promise<SpamAssessment> {\n const { db, formData, ipAddress, options, mode } = params;\n const reasons: string[] = [];\n let score = 0;\n const minFillMs = options.minFillMs ?? DEFAULT_MIN_FILL_MS;\n const blockScoreAt = options.blockScoreAt ?? DEFAULT_BLOCK_SCORE;\n const locale = options.phoneLocale ?? \"nanp\";\n\n const clientTimestamp = Number(formData[TIMESTAMP_FIELD] || 0);\n let elapsedMs: number | undefined;\n if (Number.isFinite(clientTimestamp) && clientTimestamp > 0) {\n elapsedMs = Date.now() - clientTimestamp;\n // Negative elapsed = client clock ahead / forged future timestamp\n if (elapsedMs < 0 || elapsedMs < minFillMs) {\n reasons.push(elapsedMs < 0 ? \"invalid-client-timestamp\" : \"submitted-too-fast\");\n return { decision: \"blocked\", score: 100, reasons, elapsedMs };\n }\n } else {\n reasons.push(\"missing-client-timestamp\");\n return { decision: \"blocked\", score: 100, reasons, elapsedMs };\n }\n\n if (mode === \"gates\") {\n return { decision: \"allow\", score: 0, reasons, elapsedMs };\n }\n\n if (/(https?:\\/\\/|www\\.)/i.test(formData.message || \"\")) {\n reasons.push(\"contains-link\");\n return { decision: \"blocked\", score: 100, reasons, elapsedMs };\n }\n\n const msg = normaliseText(formData.message || \"\");\n const hardTerms = [...HARD_SPAM_TERMS, ...(options.extraHardTerms ?? [])];\n for (const term of hardTerms) {\n if (msg.includes(normaliseText(term))) {\n reasons.push(`hard-term:${term}`);\n return { decision: \"blocked\", score: 100, reasons, elapsedMs };\n }\n }\n\n const scoreTerms = [...SCORE_SPAM_TERMS, ...(options.extraScoreTerms ?? [])];\n for (const term of scoreTerms) {\n if (msg.includes(normaliseText(term))) {\n score += 2;\n reasons.push(`score-term:${term}`);\n }\n }\n\n if ((formData.message || \"\").length > 550) {\n score += 2;\n reasons.push(\"message-too-long\");\n }\n\n const paragraphBreaks = (formData.message || \"\").split(/\\r?\\n/).filter(Boolean).length;\n if (paragraphBreaks >= 8) {\n score += 1;\n reasons.push(\"many-paragraphs\");\n }\n\n const phoneReason = phoneScoreReason(formData.phone || \"\", locale);\n if (phoneReason) {\n score += 2;\n reasons.push(phoneReason);\n }\n\n const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();\n const formName = formData[FORM_NAME_FIELD] || \"contact\";\n\n const ipRow = await db\n .prepare(\n `SELECT COUNT(*) AS c FROM form_submissions\n WHERE form_name = ? AND submitted_at >= ? AND ip_address = ?`,\n )\n .bind(formName, tenMinutesAgo, ipAddress)\n .first<{ c: number }>();\n if (Number(ipRow?.c || 0) >= 3) {\n reasons.push(\"ip-rate-limited\");\n return { decision: \"blocked\", score: 100, reasons, elapsedMs };\n }\n\n const email = (formData.email || \"\").trim().toLowerCase();\n if (email) {\n const emailRow = await db\n .prepare(\n `SELECT COUNT(*) AS c FROM form_submissions\n WHERE form_name = ? AND submitted_at >= ?\n AND json_extract(form_data, '$.email') = ?`,\n )\n .bind(formName, tenMinutesAgo, email)\n .first<{ c: number }>();\n if (Number(emailRow?.c || 0) >= 2) {\n reasons.push(\"email-rate-limited\");\n return { decision: \"blocked\", score: 100, reasons, elapsedMs };\n }\n }\n\n if (score >= blockScoreAt) {\n return { decision: \"blocked\", score, reasons, elapsedMs };\n }\n\n return { decision: \"allow\", score, reasons, elapsedMs };\n}\n","/// <reference types=\"@cloudflare/workers-types\" />\n/**\n * Cloudflare Pages Function factory — contact (and other) forms → D1 + Brevo.\n *\n * Every submission (including spam) is written to D1.\n * Blocked rows get email suppressed and the reason stored in email_error.\n */\n\nimport { FORM_NAME_FIELD } from \"../shared\";\nimport { DEFAULT_SENDER, notificationEmails, sendBrevoEmail } from \"./brevo\";\nimport { clientIp, jsonResponse, parseFormBody } from \"./parse\";\nimport { assessFormSpam, auxFieldFilled, auxFieldNames, stripMetaFields } from \"./spam\";\nimport type { SpamAssessment, SubmitFormEnv, SubmitFormOptions } from \"./types\";\n\nexport type { EmailContent, PhoneLocale, SubmitFormOptions } from \"./types\";\n\nexport function createSubmitFormHandler(\n options: SubmitFormOptions,\n): PagesFunction<SubmitFormEnv> {\n const debug = options.debug !== false;\n const assessFormNames = options.assessFormNames ?? [\"contact\"];\n const gateFormNames = options.gateFormNames ?? [];\n\n function debugLog(...args: unknown[]) {\n if (debug) console.log(\"[submit-form]\", ...args);\n }\n\n return async (context) => {\n const { request, env } = context;\n\n try {\n const formData = await parseFormBody(request);\n if (!formData) {\n return jsonResponse({ error: \"Unsupported content type\" }, 400);\n }\n\n const formName = formData[FORM_NAME_FIELD] || \"unknown\";\n const siteName = env.SITE_NAME || \"unknown\";\n const auxNames = auxFieldNames(formData, options.extraHoneypotFields);\n const honeypotTriggered = auxFieldFilled(formData, auxNames);\n\n const ipAddress = clientIp(request);\n const userAgent = request.headers.get(\"User-Agent\") || \"unknown\";\n const submittedAt = new Date().toISOString();\n\n let spamAssessment: SpamAssessment = { decision: \"allow\", score: 0, reasons: [] };\n\n if (honeypotTriggered) {\n spamAssessment = {\n decision: \"blocked\",\n score: 100,\n reasons: [\"honeypot-field-filled\"],\n };\n debugLog(\"aux field filled — logging row without email\");\n } else if (assessFormNames.includes(formName)) {\n spamAssessment = await assessFormSpam({\n db: env.DB,\n formData,\n ipAddress,\n options,\n mode: \"full\",\n });\n if (spamAssessment.decision === \"blocked\") {\n debugLog(\"submission blocked (logged):\", spamAssessment.reasons.join(\"; \"));\n }\n } else if (gateFormNames.includes(formName)) {\n spamAssessment = await assessFormSpam({\n db: env.DB,\n formData,\n ipAddress,\n options,\n mode: \"gates\",\n });\n if (spamAssessment.decision === \"blocked\") {\n debugLog(\"gated form blocked (logged):\", spamAssessment.reasons.join(\"; \"));\n }\n }\n\n const cleanFormData = stripMetaFields(formData, auxNames);\n const recordSpam =\n honeypotTriggered ||\n assessFormNames.includes(formName) ||\n gateFormNames.includes(formName);\n\n const insertResult = await env.DB.prepare(\n `INSERT INTO form_submissions\n (site_name, form_name, submitted_at, ip_address, user_agent, form_data, email_sent,\n spam_decision, spam_score, spam_reasons, spam_elapsed_ms)\n VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`,\n )\n .bind(\n siteName,\n formName,\n submittedAt,\n ipAddress,\n userAgent,\n JSON.stringify(cleanFormData),\n recordSpam ? spamAssessment.decision : null,\n recordSpam ? spamAssessment.score : null,\n recordSpam ? JSON.stringify(spamAssessment.reasons) : null,\n recordSpam ? (spamAssessment.elapsedMs ?? null) : null,\n )\n .run();\n\n if (!insertResult.success) throw new Error(\"Database insert failed\");\n\n const submissionId = insertResult.meta.last_row_id as number;\n\n const emailSuppressedReason =\n spamAssessment.decision === \"blocked\"\n ? `Suppressed (score ${spamAssessment.score}): ${spamAssessment.reasons.join(\", \")}`\n : null;\n\n if (emailSuppressedReason) {\n await env.DB.prepare(`UPDATE form_submissions SET email_error = ? WHERE id = ?`)\n .bind(emailSuppressedReason, submissionId)\n .run();\n return jsonResponse({ success: true });\n }\n\n const to = notificationEmails(env.NOTIFICATION_EMAIL, options.fallbackEmails);\n if (!to.length) {\n throw new Error(\"No notification emails configured\");\n }\n\n try {\n const { subject, html } = options.buildEmail(formName, cleanFormData);\n await sendBrevoEmail(\n env.BREVO_API_KEY,\n to,\n subject,\n html,\n options.sender ?? DEFAULT_SENDER,\n );\n await env.DB.prepare(\n `UPDATE form_submissions SET email_sent = 1, email_sent_at = ? WHERE id = ?`,\n )\n .bind(new Date().toISOString(), submissionId)\n .run();\n debugLog(\"email sent\", { to, submissionId });\n } catch (emailError) {\n console.error(\"Email send failed:\", emailError);\n await env.DB.prepare(`UPDATE form_submissions SET email_error = ? WHERE id = ?`)\n .bind(String(emailError), submissionId)\n .run();\n }\n\n return jsonResponse({ success: true, submissionId });\n } catch (error) {\n console.error(\"Form submission error:\", error);\n return jsonResponse({ error: \"Submission failed\", message: String(error) }, 500);\n }\n };\n}\n"]}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- D1 baseline schema: form_submissions (contact + other lead forms)
|
|
2
|
+
-- Per-site database. Do not share one D1 across clients.
|
|
3
|
+
--
|
|
4
|
+
-- npx wrangler d1 execute YOUR-FORMS-DB --remote --file=node_modules/@logeix/contact-form/migrations/form_submissions.sql
|
|
5
|
+
-- npx wrangler d1 execute YOUR-FORMS-DB --local --file=node_modules/@logeix/contact-form/migrations/form_submissions.sql
|
|
6
|
+
|
|
7
|
+
CREATE TABLE IF NOT EXISTS form_submissions (
|
|
8
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
9
|
+
site_name TEXT NOT NULL,
|
|
10
|
+
form_name TEXT NOT NULL,
|
|
11
|
+
submitted_at TEXT NOT NULL,
|
|
12
|
+
ip_address TEXT NOT NULL DEFAULT 'unknown',
|
|
13
|
+
user_agent TEXT NOT NULL DEFAULT 'unknown',
|
|
14
|
+
form_data TEXT NOT NULL,
|
|
15
|
+
email_sent INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
email_sent_at TEXT,
|
|
17
|
+
email_error TEXT,
|
|
18
|
+
spam_decision TEXT,
|
|
19
|
+
spam_score INTEGER,
|
|
20
|
+
spam_reasons TEXT,
|
|
21
|
+
spam_elapsed_ms INTEGER
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_form_submissions_form_name ON form_submissions (form_name);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_form_submissions_submitted_at ON form_submissions (submitted_at);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_form_submissions_ip ON form_submissions (ip_address, form_name, submitted_at);
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@logeix/contact-form",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Contact form intake for LOGEIX Astro + Cloudflare Pages sites (D1 + Brevo + spam gates)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/logeix/contact-form.git"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"migrations",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/client/index.d.ts",
|
|
23
|
+
"import": "./dist/client/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./client": {
|
|
26
|
+
"types": "./dist/client/index.d.ts",
|
|
27
|
+
"import": "./dist/client/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./server/submit-form": {
|
|
30
|
+
"types": "./dist/server/submit-form.d.ts",
|
|
31
|
+
"import": "./dist/server/submit-form.js"
|
|
32
|
+
},
|
|
33
|
+
"./migrations/*": "./migrations/*"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup",
|
|
37
|
+
"test": "tsx --test test/spam.test.ts",
|
|
38
|
+
"prepublishOnly": "npm run build && npm test"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@cloudflare/workers-types": "^4.20260410.1",
|
|
42
|
+
"tsup": "^8.5.0",
|
|
43
|
+
"tsx": "^4.20.5",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@cloudflare/workers-types": "^4.0.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"@cloudflare/workers-types": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|