@cparkerwebm/webmonterey 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/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/agents/.gitkeep +0 -0
- package/dist/webm.mjs +2381 -0
- package/hooks/.gitkeep +0 -0
- package/package.json +101 -0
- package/schema/design.json +118 -0
- package/skills/launch/SKILL.md +183 -0
- package/skills/new-component/SKILL.md +85 -0
- package/skills/start/SKILL.md +117 -0
- package/skills/traps/SKILL.md +333 -0
- package/skills/upgrade/SKILL.md +42 -0
- package/src/actions/index.ts +348 -0
- package/src/cli/checks.test.ts +711 -0
- package/src/cli/checks.ts +822 -0
- package/src/cli/codemods.test.ts +42 -0
- package/src/cli/codemods.ts +51 -0
- package/src/cli/compare.test.ts +144 -0
- package/src/cli/compare.ts +222 -0
- package/src/cli/design-extract.test.ts +96 -0
- package/src/cli/design-extract.ts +229 -0
- package/src/cli/doctor.ts +187 -0
- package/src/cli/mcp.test.ts +57 -0
- package/src/cli/mcp.ts +110 -0
- package/src/cli/new.ts +148 -0
- package/src/cli/package-root.ts +58 -0
- package/src/cli/scaffold.test.ts +230 -0
- package/src/cli/scaffold.ts +424 -0
- package/src/cli/seed.ts +133 -0
- package/src/cli/slug.test.ts +70 -0
- package/src/cli/slug.ts +109 -0
- package/src/cli/sync.test.ts +137 -0
- package/src/cli/sync.ts +266 -0
- package/src/cli/upgrade.ts +93 -0
- package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
- package/src/design/brand.ts +49 -0
- package/src/design/compile.test.ts +98 -0
- package/src/design/compile.ts +155 -0
- package/src/design/defaults.ts +315 -0
- package/src/design/index.ts +18 -0
- package/src/design/resolve.test.ts +80 -0
- package/src/design/resolve.ts +108 -0
- package/src/design/types.ts +96 -0
- package/src/emails/autoresponse.test.ts +82 -0
- package/src/emails/autoresponse.ts +83 -0
- package/src/emails/footer.test.ts +102 -0
- package/src/emails/footer.ts +91 -0
- package/src/emails/index.ts +24 -0
- package/src/emails/subject.test.ts +66 -0
- package/src/emails/subject.ts +80 -0
- package/src/emails/submission-notification.ts +76 -0
- package/src/env.d.ts +12 -0
- package/src/includes/cloudflare/d1/client.ts +86 -0
- package/src/includes/cloudflare/r2/README.md +68 -0
- package/src/includes/cloudflare/r2/media.ts +41 -0
- package/src/includes/cloudflare/r2/url.test.ts +44 -0
- package/src/includes/cloudflare/r2/url.ts +34 -0
- package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
- package/src/includes/cloudflare/turnstile/verify.ts +157 -0
- package/src/includes/cloudflare/workers/env.ts +56 -0
- package/src/includes/google/tag-manager/TagManager.astro +73 -0
- package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
- package/src/includes/sinch/mailgun/redirect.ts +96 -0
- package/src/includes/sinch/mailgun/send.ts +186 -0
- package/src/includes/webmonterey/animations/animations.css +347 -0
- package/src/includes/webmonterey/animations/observe.test.ts +88 -0
- package/src/includes/webmonterey/animations/observe.ts +209 -0
- package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
- package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
- package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
- package/src/includes/webmonterey/compliance/consent.ts +142 -0
- package/src/includes/webmonterey/config.test.ts +94 -0
- package/src/includes/webmonterey/config.ts +346 -0
- package/src/includes/webmonterey/copy-defaults.ts +148 -0
- package/src/includes/webmonterey/copy.ts +13 -0
- package/src/includes/webmonterey/credits/Credit.astro +80 -0
- package/src/includes/webmonterey/credits/credit.test.ts +111 -0
- package/src/includes/webmonterey/credits/credit.ts +59 -0
- package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
- package/src/includes/webmonterey/forms/honeypot.ts +66 -0
- package/src/includes/webmonterey/prose/inline.test.ts +94 -0
- package/src/includes/webmonterey/prose/inline.ts +71 -0
- package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
- package/src/includes/webmonterey/site.ts +136 -0
- package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
- package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
- package/src/integration/adapter.ts +53 -0
- package/src/integration/app-middleware.ts +43 -0
- package/src/integration/config.ts +96 -0
- package/src/integration/content.ts +66 -0
- package/src/integration/image-size.test.ts +83 -0
- package/src/integration/image-size.ts +100 -0
- package/src/integration/index.ts +386 -0
- package/src/integration/virtual.d.ts +101 -0
- package/src/layouts/base.astro +402 -0
- package/src/package.test.ts +121 -0
- package/src/pages/404.astro +33 -0
- package/src/pages/[...slug].astro +126 -0
- package/src/pages/robots.txt.ts +41 -0
- package/src/pages/webm.astro +23 -0
- package/src/scripts/.gitkeep +0 -0
- package/src/styles/base.css +185 -0
- package/src/styles/global.css +47 -0
- package/src/styles/layers.test.ts +32 -0
- package/src/styles/layers.ts +21 -0
- package/src/styles/layout.css +235 -0
- package/src/styles/reset.css +135 -0
- package/src/styles/utilities.css +90 -0
- package/src/worker.ts +76 -0
- package/template/assets/logo.svg +14 -0
- package/template/assets/open-graph.png +0 -0
- package/template/migrations/0001_create_submissions.sql +40 -0
- package/template/migrations/README.md +41 -0
- package/template/public/android-chrome-192x192.png +0 -0
- package/template/public/android-chrome-512x512.png +0 -0
- package/template/public/apple-touch-icon.png +0 -0
- package/template/public/favicon-16x16.png +0 -0
- package/template/public/favicon-32x32.png +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/favicon.svg +4 -0
- package/template/public/open-graph.png +0 -0
- package/template/scripts/check-node.mjs +72 -0
- package/template/scripts/test-hooks.mjs +96 -0
- package/template/site/.editorconfig +22 -0
- package/template/site/.prettierignore +15 -0
- package/template/site/.prettierrc.json +17 -0
- package/template/site/CLAUDE.md +245 -0
- package/template/site/CONTENT.md +135 -0
- package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
- package/template/site/public/_headers +70 -0
- package/template/site/src/forms/contact.json +45 -0
- package/template/workflows/ci.yml +52 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Astro Actions — typed server handlers callable from a form or from client JS.
|
|
3
|
+
*
|
|
4
|
+
* `src/actions/index.ts` is a FIXED path. Astro will not find actions anywhere else.
|
|
5
|
+
*
|
|
6
|
+
* The submit flow, in order, and the order matters:
|
|
7
|
+
*
|
|
8
|
+
* 1. Validate the fields — cheapest, and rejects most malformed input
|
|
9
|
+
* 2. Verify Turnstile — before anything is stored or sent
|
|
10
|
+
* 3. Write to D1 — the enquiry is now safe even if email fails
|
|
11
|
+
* 4. Send the notification email — failure here does NOT lose the enquiry
|
|
12
|
+
*
|
|
13
|
+
* Storing before sending is deliberate. Mailgun is the flakiest step, and a client would
|
|
14
|
+
* rather have an enquiry in the database with a failed notification than a lost one.
|
|
15
|
+
*/
|
|
16
|
+
import { defineAction, ActionError } from 'astro:actions';
|
|
17
|
+
import { z } from 'astro/zod';
|
|
18
|
+
import { copy, fill } from '../includes/webmonterey/copy.ts';
|
|
19
|
+
import { run } from '../includes/cloudflare/d1/client.ts';
|
|
20
|
+
import { getBinding, hasBinding } from '../includes/cloudflare/workers/env.ts';
|
|
21
|
+
import { TURNSTILE_FIELD, verifyTurnstile } from '../includes/cloudflare/turnstile/verify.ts';
|
|
22
|
+
import { HONEYPOT_FIELD, isHoneypotFilled } from '../includes/webmonterey/forms/honeypot.ts';
|
|
23
|
+
import { sendEmail } from '../includes/sinch/mailgun/send.ts';
|
|
24
|
+
import { renderHtml, renderText } from '../emails/submission-notification.ts';
|
|
25
|
+
import {
|
|
26
|
+
renderHtml as renderAutoresponseHtml,
|
|
27
|
+
renderText as renderAutoresponseText,
|
|
28
|
+
} from '../emails/autoresponse.ts';
|
|
29
|
+
import { renderSubject } from '../emails/subject.ts';
|
|
30
|
+
import { client, domain, displayName, features, hasClient } from '../includes/webmonterey/site.ts';
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* Form definitions live in the CLIENT repo, at src/forms/*.json - the filename is the form id.
|
|
34
|
+
* The integration collects them into this module, the same way it does the block registry,
|
|
35
|
+
* because a package cannot import a file from the site it is installed into.
|
|
36
|
+
*/
|
|
37
|
+
import { FORMS } from 'virtual:webm/forms';
|
|
38
|
+
|
|
39
|
+
type FormId = keyof typeof FORMS;
|
|
40
|
+
|
|
41
|
+
export const server = {
|
|
42
|
+
submitForm: defineAction({
|
|
43
|
+
accept: 'form',
|
|
44
|
+
input: z.object({
|
|
45
|
+
form: z.string(),
|
|
46
|
+
[TURNSTILE_FIELD]: z.string().optional(),
|
|
47
|
+
// Field values are validated below against the form's own definition, because the
|
|
48
|
+
// shape differs per form and per site.
|
|
49
|
+
data: z.record(z.string(), z.string()).optional(),
|
|
50
|
+
}),
|
|
51
|
+
|
|
52
|
+
handler: async (input, context) => {
|
|
53
|
+
const formId = input.form as FormId;
|
|
54
|
+
const definition = FORMS[formId];
|
|
55
|
+
|
|
56
|
+
if (!definition) {
|
|
57
|
+
throw new ActionError({ code: 'BAD_REQUEST', message: copy.form.unknownForm });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
* Astro gives us the parsed input, but per-form fields are dynamic, so read them
|
|
62
|
+
* straight off the raw FormData.
|
|
63
|
+
*/
|
|
64
|
+
const formData = await context.request.clone().formData();
|
|
65
|
+
/*
|
|
66
|
+
* getAll, NOT get. A checkbox GROUP posts one entry per ticked box, and `get` returns only
|
|
67
|
+
* the first - so a visitor selecting "Recording, Mixing, Mastering" had "Recording" stored
|
|
68
|
+
* in D1 and "Recording" emailed to the client. No error, no warning: the enquiry arrives
|
|
69
|
+
* looking complete and is quietly wrong. Found on two client sites independently.
|
|
70
|
+
*
|
|
71
|
+
* ONE READER FOR BOTH the field list and the required check. They used to be the same
|
|
72
|
+
* function and must stay so: read the value one way for display and another for validation
|
|
73
|
+
* and a required checkbox group starts rejecting valid submissions.
|
|
74
|
+
*
|
|
75
|
+
* Joined with ", " because the value is stored as one string per field and read by a human
|
|
76
|
+
* in an email. A caller wanting the parts back can split on it.
|
|
77
|
+
*/
|
|
78
|
+
const value = (name: string) =>
|
|
79
|
+
formData
|
|
80
|
+
.getAll(name)
|
|
81
|
+
.map((entry) => String(entry).trim())
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.join(', ');
|
|
84
|
+
|
|
85
|
+
// --- 1. validate -----------------------------------------------------
|
|
86
|
+
const fields = definition.fields.map((f) => ({
|
|
87
|
+
name: f.name,
|
|
88
|
+
label: f.label,
|
|
89
|
+
value: value(f.name),
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
const missing = definition.fields
|
|
93
|
+
.filter((f) => f.required && !value(f.name))
|
|
94
|
+
.map((f) => f.label);
|
|
95
|
+
|
|
96
|
+
if (missing.length) {
|
|
97
|
+
throw new ActionError({
|
|
98
|
+
code: 'BAD_REQUEST',
|
|
99
|
+
message: fill(copy.form.missing, { fields: missing.join(', ') }),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
* --- 1b. honeypot ----------------------------------------------------
|
|
105
|
+
*
|
|
106
|
+
* Before Turnstile, because it costs nothing: no network call, no third party. A form
|
|
107
|
+
* opts out with `"honeypot": false`, or renames the trap with a string, for the client
|
|
108
|
+
* whose form genuinely asks for a website.
|
|
109
|
+
*
|
|
110
|
+
* ANSWERS AS THOUGH IT SUCCEEDED. Telling a bot which check it failed is how the next
|
|
111
|
+
* attempt gets past it - and the visitor never sees this either way, so a false positive
|
|
112
|
+
* is silent. That is why the rendering contract in honeypot.ts is strict.
|
|
113
|
+
*/
|
|
114
|
+
const honeypot =
|
|
115
|
+
'honeypot' in definition
|
|
116
|
+
? (definition as { honeypot?: string | false }).honeypot
|
|
117
|
+
: HONEYPOT_FIELD;
|
|
118
|
+
|
|
119
|
+
if (honeypot !== false && isHoneypotFilled(formData, honeypot || HONEYPOT_FIELD)) {
|
|
120
|
+
console.warn(`[webm] Honeypot caught a submission to "${formId}". Discarded.`);
|
|
121
|
+
return { ok: true, submissionId: undefined, formId };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/*
|
|
125
|
+
* --- 2. Turnstile ----------------------------------------------------
|
|
126
|
+
*
|
|
127
|
+
* PER FORM, not site-wide. `features.turnstile` switches the capability on; a form opts
|
|
128
|
+
* OUT with `"turnstile": false` in its definition.
|
|
129
|
+
*
|
|
130
|
+
* THAT DISTINCTION IS LOAD-BEARING, and it was learned the hard way. The widget is
|
|
131
|
+
* rendered by the form COMPONENT, and a site does not necessarily render one on every
|
|
132
|
+
* form - a newsletter box is a single inline field with no room for a challenge.
|
|
133
|
+
* Verifying every form the moment the flag went on meant those boxes submitted no token,
|
|
134
|
+
* failed verification, and rejected EVERY subscriber, while the form itself looked
|
|
135
|
+
* completely normal. It cannot be reproduced with the flag off.
|
|
136
|
+
*
|
|
137
|
+
* Generation 2 carried this opt-out and the package lost it in the extraction. Restored
|
|
138
|
+
* after mikeformarina.com's rebuild surfaced the same failure a second time.
|
|
139
|
+
*
|
|
140
|
+
* Default is ON: a new form is protected unless someone deliberately says otherwise.
|
|
141
|
+
*/
|
|
142
|
+
const optsOutOfTurnstile = (definition as { turnstile?: boolean }).turnstile === false;
|
|
143
|
+
|
|
144
|
+
if (features?.turnstile && !optsOutOfTurnstile) {
|
|
145
|
+
let result;
|
|
146
|
+
try {
|
|
147
|
+
result = await verifyTurnstile({
|
|
148
|
+
secretKey: getBinding<string>('TURNSTILE_SECRET_KEY'),
|
|
149
|
+
token: value(TURNSTILE_FIELD),
|
|
150
|
+
expectedHostname: context.url.hostname,
|
|
151
|
+
expectedAction: formId,
|
|
152
|
+
remoteIp: context.request.headers.get('CF-Connecting-IP'),
|
|
153
|
+
});
|
|
154
|
+
} catch (error) {
|
|
155
|
+
/*
|
|
156
|
+
* LOG IT. Without this line "we are turning away bots" and "the secret key is wrong,
|
|
157
|
+
* so we are turning away every human" are the same silent 403. Observability is
|
|
158
|
+
* already on; this costs nothing and is the difference between a five-minute
|
|
159
|
+
* diagnosis and a fortnight of lost enquiries.
|
|
160
|
+
*/
|
|
161
|
+
console.error(`[webm] Turnstile verification failed for "${formId}":`, error);
|
|
162
|
+
// Verification is unavailable. REJECT — never admit on error, or an attacker
|
|
163
|
+
// simply breaks the siteverify call to bypass the check entirely.
|
|
164
|
+
throw new ActionError({
|
|
165
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
166
|
+
message: copy.form.verifyUnavailable,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!result.success) {
|
|
171
|
+
console.warn(
|
|
172
|
+
`[webm] Turnstile rejected a submission to "${formId}":`,
|
|
173
|
+
(result as { 'error-codes'?: string[] })['error-codes'] ?? '(no codes)',
|
|
174
|
+
);
|
|
175
|
+
throw new ActionError({
|
|
176
|
+
code: 'FORBIDDEN',
|
|
177
|
+
message: copy.form.verifyFailed,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- 3. store --------------------------------------------------------
|
|
183
|
+
let submissionId: number | undefined;
|
|
184
|
+
|
|
185
|
+
if (features?.d1) {
|
|
186
|
+
const cf = context.request.cf as { country?: string } | undefined;
|
|
187
|
+
|
|
188
|
+
const stored = await run(
|
|
189
|
+
getBinding<D1Database>('DB'),
|
|
190
|
+
`INSERT INTO submissions (form, data, ip, user_agent, country)
|
|
191
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
192
|
+
formId,
|
|
193
|
+
JSON.stringify(Object.fromEntries(fields.map((f) => [f.name, f.value]))),
|
|
194
|
+
context.request.headers.get('CF-Connecting-IP'),
|
|
195
|
+
context.request.headers.get('user-agent'),
|
|
196
|
+
cf?.country ?? null,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
submissionId = stored.meta.last_row_id;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --- 4. notify -------------------------------------------------------
|
|
203
|
+
const recipients = definition.notify.to;
|
|
204
|
+
|
|
205
|
+
/*
|
|
206
|
+
* A FORM WITH NOWHERE TO SEND MUST NOT SAY THANK YOU.
|
|
207
|
+
*
|
|
208
|
+
* With D1 off, no recipient configured and no Mailgun key, this handler validated the
|
|
209
|
+
* input, returned success, showed the cheerful confirmation page and dropped the enquiry
|
|
210
|
+
* on the floor. Nobody finds out until a customer asks why they were never called back.
|
|
211
|
+
*
|
|
212
|
+
* Checked AFTER validation and Turnstile so a misconfigured form still rejects rubbish,
|
|
213
|
+
* and gated on EXACTLY the conditions the store and send paths use below - a guard that
|
|
214
|
+
* disagrees with them turns a working form into an error page, which is a worse bug than
|
|
215
|
+
* the one it is preventing.
|
|
216
|
+
*/
|
|
217
|
+
const willStore = Boolean(features?.d1);
|
|
218
|
+
const willNotify = recipients.length > 0 && hasBinding('MAILGUN_API_KEY');
|
|
219
|
+
|
|
220
|
+
if (!willStore && !willNotify) {
|
|
221
|
+
console.error(
|
|
222
|
+
`[webm] Form "${formId}" has nowhere to deliver: features.d1 is off, and ` +
|
|
223
|
+
`${recipients.length ? 'MAILGUN_API_KEY is not set' : 'notify.to is empty'}. ` +
|
|
224
|
+
`The submission was NOT saved. Set one of them.`,
|
|
225
|
+
);
|
|
226
|
+
throw new ActionError({
|
|
227
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
228
|
+
message: copy.form.noDelivery,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (recipients.length && hasBinding('MAILGUN_API_KEY')) {
|
|
233
|
+
const emailInput = {
|
|
234
|
+
form: formId,
|
|
235
|
+
formName: definition.name,
|
|
236
|
+
fields,
|
|
237
|
+
client: displayName(),
|
|
238
|
+
domain,
|
|
239
|
+
submissionId,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const replyTo = fields.find((f) => f.name === 'email')?.value;
|
|
243
|
+
|
|
244
|
+
/*
|
|
245
|
+
* The sending domain is `webm.<client-domain>`, so `website@` lands as
|
|
246
|
+
* website@webm.example.com. Deliberately the same mailbox for every template — it is
|
|
247
|
+
* the address a client sees in their inbox and adds to their safe-senders list, and
|
|
248
|
+
* one per template would mean doing that repeatedly.
|
|
249
|
+
*/
|
|
250
|
+
const mailgunDomain = getBinding<string>('MAILGUN_DOMAIN');
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
await sendEmail({
|
|
254
|
+
apiKey: getBinding<string>('MAILGUN_API_KEY'),
|
|
255
|
+
/* So a preview of a PRODUCTION-configured site still redirects. See redirect.ts. */
|
|
256
|
+
hostname: context.url.hostname,
|
|
257
|
+
domain: mailgunDomain,
|
|
258
|
+
from: `${displayName()} Website <website@${mailgunDomain}>`,
|
|
259
|
+
to: recipients,
|
|
260
|
+
/*
|
|
261
|
+
* `[Client Name] Topic`. The prefix is dropped rather than sent as `[CHANGEME]`
|
|
262
|
+
* if webmonterey.json was never filled in — `go-live` checks for that, and
|
|
263
|
+
* failing a live enquiry over a cosmetic prefix would trade a real loss for a
|
|
264
|
+
* trivial one.
|
|
265
|
+
*/
|
|
266
|
+
subject: renderSubject(hasClient ? client : null, definition.notify.subject, fields),
|
|
267
|
+
text: renderText(emailInput),
|
|
268
|
+
html: renderHtml(emailInput),
|
|
269
|
+
// So hitting reply reaches the enquirer, not the Worker.
|
|
270
|
+
...(replyTo ? { replyTo } : {}),
|
|
271
|
+
tags: [`form:${formId}`],
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (submissionId !== undefined) {
|
|
275
|
+
await run(
|
|
276
|
+
getBinding<D1Database>('DB'),
|
|
277
|
+
`UPDATE submissions SET notified_at = datetime('now') WHERE id = ?`,
|
|
278
|
+
submissionId,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
} catch (error) {
|
|
282
|
+
/*
|
|
283
|
+
* The enquiry is already stored, so this is recoverable — do NOT fail the request
|
|
284
|
+
* and tell the visitor to resubmit. `notified_at` stays NULL, which is the query
|
|
285
|
+
* that finds anything needing a resend.
|
|
286
|
+
*/
|
|
287
|
+
console.error('[webm] Notification email failed for submission', submissionId, error);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/*
|
|
291
|
+
* AUTORESPONSE — the visitor's confirmation. Last, and in its own try, on purpose:
|
|
292
|
+
* the client's notification is the message that must not be lost, and a bounce from a
|
|
293
|
+
* visitor's mistyped address must not take it down with it. `notified_at` tracks the
|
|
294
|
+
* notification only; a failed autoresponse is logged and dropped, because resending a
|
|
295
|
+
* "we got your message" hours later is worse than never sending it.
|
|
296
|
+
*
|
|
297
|
+
* Skipped entirely unless the form opts in, the visitor gave an address, and there is
|
|
298
|
+
* a client recipient to point Reply-To at. See the `//autoresponse` note in the form
|
|
299
|
+
* definition for why the default is off.
|
|
300
|
+
*/
|
|
301
|
+
const autoresponse = 'autoresponse' in definition ? definition.autoresponse : undefined;
|
|
302
|
+
|
|
303
|
+
if (autoresponse && replyTo && recipients[0]) {
|
|
304
|
+
try {
|
|
305
|
+
await sendEmail({
|
|
306
|
+
apiKey: getBinding<string>('MAILGUN_API_KEY'),
|
|
307
|
+
hostname: context.url.hostname,
|
|
308
|
+
domain: mailgunDomain,
|
|
309
|
+
from: `${displayName()} <website@${mailgunDomain}>`,
|
|
310
|
+
// The visitor. The only mail this site sends to an address it does not control.
|
|
311
|
+
to: [replyTo],
|
|
312
|
+
subject: renderSubject(hasClient ? client : null, autoresponse.subject, fields),
|
|
313
|
+
text: renderAutoresponseText({
|
|
314
|
+
body: autoresponse.body,
|
|
315
|
+
fields,
|
|
316
|
+
client: displayName(),
|
|
317
|
+
domain,
|
|
318
|
+
}),
|
|
319
|
+
html: renderAutoresponseHtml({
|
|
320
|
+
body: autoresponse.body,
|
|
321
|
+
fields,
|
|
322
|
+
client: displayName(),
|
|
323
|
+
domain,
|
|
324
|
+
}),
|
|
325
|
+
/*
|
|
326
|
+
* INVERTED relative to the notification above: a visitor hitting reply must
|
|
327
|
+
* reach the client, not be sent their own address back.
|
|
328
|
+
*/
|
|
329
|
+
replyTo: recipients[0],
|
|
330
|
+
tags: [`form:${formId}`, 'autoresponse'],
|
|
331
|
+
});
|
|
332
|
+
} catch (error) {
|
|
333
|
+
console.error('[webm] Autoresponse failed for submission', submissionId, error);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/*
|
|
339
|
+
* `formId` TRAVELS WITH THE RESULT so a page hosting two forms can tell whose it is.
|
|
340
|
+
* Every form on a page posts to this one action, and a server-rendered form reads the
|
|
341
|
+
* outcome back through Astro.getActionResult - which answers "did submitForm run", not
|
|
342
|
+
* "did MY form run". Without this the second form on a page congratulates the visitor
|
|
343
|
+
* for a submission it never made.
|
|
344
|
+
*/
|
|
345
|
+
return { ok: true, submissionId, formId };
|
|
346
|
+
},
|
|
347
|
+
}),
|
|
348
|
+
};
|