@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,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
/*
|
|
3
|
+
* Google Tag Manager.
|
|
4
|
+
*
|
|
5
|
+
* Renders nothing unless an ID is passed, so it is safe to leave in the layout on every site
|
|
6
|
+
* whether or not the client uses GTM.
|
|
7
|
+
*
|
|
8
|
+
* USAGE — in src/layouts/base.astro:
|
|
9
|
+
*
|
|
10
|
+
* ---
|
|
11
|
+
* import TagManager from '../includes/google/tag-manager/TagManager.astro';
|
|
12
|
+
* ---
|
|
13
|
+
* <head>
|
|
14
|
+
* <TagManager id={Astro.locals.gtmId} /> <- or a value from wrangler vars
|
|
15
|
+
* </head>
|
|
16
|
+
* <body>
|
|
17
|
+
* <TagManager id={...} noscript /> <- immediately after <body>
|
|
18
|
+
*
|
|
19
|
+
* The container ID is PUBLIC — it belongs in `vars` in wrangler.jsonc, not in secrets.
|
|
20
|
+
*
|
|
21
|
+
* Placement matters: the script must be in <head>, and the <noscript> iframe must be the
|
|
22
|
+
* first thing inside <body>. GTM misbehaves otherwise.
|
|
23
|
+
*/
|
|
24
|
+
interface Props {
|
|
25
|
+
/** GTM container ID, `GTM-XXXXXXX`. Renders nothing when empty. */
|
|
26
|
+
id?: string;
|
|
27
|
+
/** Render the <noscript> iframe variant instead of the script. */
|
|
28
|
+
noscript?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { id, noscript = false } = Astro.props;
|
|
32
|
+
|
|
33
|
+
// A malformed ID silently breaks tracking, so fail visibly at build instead. This check also
|
|
34
|
+
// makes the interpolation below safe: only /^GTM-[A-Z0-9]+$/ can reach the script body.
|
|
35
|
+
if (id && !/^GTM-[A-Z0-9]+$/.test(id)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`[webm] Invalid GTM container ID "${id}". Expected the form GTM-XXXXXXX. ` +
|
|
38
|
+
`A Google Analytics measurement ID (G-XXXXXXX) is not the same thing.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
* Built as a string in frontmatter, then injected with `set:html`.
|
|
44
|
+
*
|
|
45
|
+
* Two things had to be avoided. Putting the raw JS inside a <script> in a JSX expression is
|
|
46
|
+
* what Astro actually wants, but Prettier's Astro parser cannot parse the braces and fails
|
|
47
|
+
* the file. Wrapping the JS in {`...`} instead makes Prettier happy and emits the braces and
|
|
48
|
+
* backticks LITERALLY — the browser reads a block statement containing a discarded string,
|
|
49
|
+
* so there is no error, no request, and no analytics at all.
|
|
50
|
+
*
|
|
51
|
+
* `set:html` on its own sidesteps both. Do NOT add `define:vars` alongside it — that
|
|
52
|
+
* combination makes Astro emit an empty script body.
|
|
53
|
+
*/
|
|
54
|
+
const snippet = id
|
|
55
|
+
? `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${id}');`
|
|
56
|
+
: null;
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
{snippet && !noscript && <script is:inline set:html={snippet} />}
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
id && noscript && (
|
|
63
|
+
<noscript>
|
|
64
|
+
<iframe
|
|
65
|
+
src={`https://www.googletagmanager.com/ns.html?id=${id}`}
|
|
66
|
+
height="0"
|
|
67
|
+
width="0"
|
|
68
|
+
style="display:none;visibility:hidden"
|
|
69
|
+
title="Google Tag Manager"
|
|
70
|
+
/>
|
|
71
|
+
</noscript>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { redirectRecipients, redirectedSubject } from './redirect.ts';
|
|
5
|
+
import { isStagingDeployment } from '../../webmonterey/config.ts';
|
|
6
|
+
|
|
7
|
+
const DEV = 'dev@webmonterey.com';
|
|
8
|
+
|
|
9
|
+
test('every recipient becomes the dev address', () => {
|
|
10
|
+
const out = redirectRecipients({ to: 'client@example.com' }, DEV);
|
|
11
|
+
assert.deepEqual(out.to, [DEV]);
|
|
12
|
+
assert.equal(out.replyTo, DEV);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('cc and bcc are dropped, not redirected', () => {
|
|
16
|
+
const out = redirectRecipients(
|
|
17
|
+
{ to: 'a@example.com', cc: 'b@example.com', bcc: 'c@example.com' },
|
|
18
|
+
DEV,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
/* A surviving bcc would be a real person receiving a test, invisibly. */
|
|
22
|
+
assert.equal(out.cc, undefined);
|
|
23
|
+
assert.equal(out.bcc, undefined);
|
|
24
|
+
assert.deepEqual(out.to, [DEV]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('the real recipients survive in headers', () => {
|
|
28
|
+
const out = redirectRecipients(
|
|
29
|
+
{ to: ['a@example.com', 'b@example.com'], bcc: 'c@example.com', replyTo: 'r@example.com' },
|
|
30
|
+
DEV,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
assert.equal(out.headers['X-Webm-Original-To'], 'a@example.com, b@example.com');
|
|
34
|
+
assert.equal(out.headers['X-Webm-Original-Bcc'], 'c@example.com');
|
|
35
|
+
assert.equal(out.headers['X-Webm-Original-Reply-To'], 'r@example.com');
|
|
36
|
+
assert.equal(out.headers['X-Webm-Environment'], 'staging');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('reply-to is rewritten, because this fleet inverts it on registration mail', () => {
|
|
40
|
+
const out = redirectRecipients({ to: 'a@example.com', replyTo: 'organizer@example.com' }, DEV);
|
|
41
|
+
assert.equal(out.replyTo, DEV);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('the subject names who it was really for', () => {
|
|
45
|
+
const out = redirectRecipients({ to: 'client@example.com' }, DEV);
|
|
46
|
+
assert.equal(
|
|
47
|
+
redirectedSubject('New enquiry', out.original),
|
|
48
|
+
'[staging → client@example.com] New enquiry',
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a long recipient list is capped so the real subject stays visible', () => {
|
|
53
|
+
const many = ['a@x.com', 'b@x.com', 'c@x.com', 'd@x.com', 'e@x.com'];
|
|
54
|
+
assert.equal(redirectedSubject('Hello', many), '[staging → a@x.com, b@x.com, c@x.com +2] Hello');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('an empty recipient list still marks the subject', () => {
|
|
58
|
+
assert.equal(redirectedSubject('Hello', []), '[staging] Hello');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/* --- the decision itself ------------------------------------------------ */
|
|
62
|
+
|
|
63
|
+
test('staging config redirects, with or without a hostname', () => {
|
|
64
|
+
assert.equal(isStagingDeployment('staging'), true);
|
|
65
|
+
assert.equal(isStagingDeployment('staging', 'example.com'), true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a cron on a staging site is covered, having no hostname at all', () => {
|
|
69
|
+
/* The case a request-based check cannot see: scheduled() has no Request. */
|
|
70
|
+
assert.equal(isStagingDeployment('staging', undefined), true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('workers.dev redirects even when the config says production', () => {
|
|
74
|
+
assert.equal(isStagingDeployment('production', 'webm-example.webmonterey.workers.dev'), true);
|
|
75
|
+
assert.equal(isStagingDeployment('production', 'workers.dev'), true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('a production site on its own domain sends for real', () => {
|
|
79
|
+
assert.equal(isStagingDeployment('production', 'example.com'), false);
|
|
80
|
+
assert.equal(isStagingDeployment(undefined, 'example.com'), false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a www variant still sends for real', () => {
|
|
84
|
+
/* The reason this tests workers.dev rather than "not the canonical domain": a bare `domain`
|
|
85
|
+
* plus a www hostname would otherwise silently redirect a live site's mail away. */
|
|
86
|
+
assert.equal(isStagingDeployment('production', 'www.example.com'), false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a domain merely ending in workers.dev is not a preview', () => {
|
|
90
|
+
assert.equal(isStagingDeployment('production', 'notworkers.dev'), false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('an unset environment and no hostname sends for real', () => {
|
|
94
|
+
/* Every site predating this field. Any other answer breaks their email on npm update. */
|
|
95
|
+
assert.equal(isStagingDeployment(undefined, undefined), false);
|
|
96
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* STAGING EMAIL REDIRECTION — the recipient rewrite, as a pure function.
|
|
3
|
+
*
|
|
4
|
+
* Kept in its own file, importing nothing, for the same reason config.ts is separate from
|
|
5
|
+
* site.ts: everything here is unit tested without a build, while send.ts reaches the network and
|
|
6
|
+
* reads `virtual:webm/site`, which only exists inside one.
|
|
7
|
+
*
|
|
8
|
+
* WHAT THIS SOLVES. A preview deployment sends real email to real people. The contact form on
|
|
9
|
+
* this fleet notifies the client's own inbox and the registration flow mails whoever organises a
|
|
10
|
+
* programme, so testing a preview means a client receives test enquiries and an organizer is
|
|
11
|
+
* told somebody signed up who did not. Nothing about that is visible to whoever is testing.
|
|
12
|
+
*
|
|
13
|
+
* WHY REWRITE RATHER THAN SUPPRESS. Mailgun's test mode accepts a message and never delivers it,
|
|
14
|
+
* which verifies the API call and nothing else — not the rendering, not the deliverability, not
|
|
15
|
+
* the attachment. The whole point of sending from staging is to see the real thing arrive, so
|
|
16
|
+
* the message is sent for real and only its recipients are changed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Every field that can put a message in front of a person. */
|
|
20
|
+
export interface Recipients {
|
|
21
|
+
to: string | string[];
|
|
22
|
+
cc?: string | string[];
|
|
23
|
+
bcc?: string | string[];
|
|
24
|
+
replyTo?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RedirectedRecipients {
|
|
28
|
+
to: string[];
|
|
29
|
+
/*
|
|
30
|
+
* Absent, never redirected. A cc or bcc that survived would be a second real person receiving
|
|
31
|
+
* a test — and bcc especially, because nothing in the delivered message would show it happened.
|
|
32
|
+
*/
|
|
33
|
+
cc: undefined;
|
|
34
|
+
bcc: undefined;
|
|
35
|
+
replyTo: string;
|
|
36
|
+
/**
|
|
37
|
+
* Diagnostic headers naming who the message was really for, so a redirected copy is still
|
|
38
|
+
* evidence of what production would have done.
|
|
39
|
+
*/
|
|
40
|
+
headers: Record<string, string>;
|
|
41
|
+
/** The addresses this message was addressed to, in order, for the subject annotation. */
|
|
42
|
+
original: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const list = (value: string | string[] | undefined): string[] =>
|
|
46
|
+
value === undefined ? [] : (Array.isArray(value) ? value : [value]).filter(Boolean);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Point every recipient at one address, preserving what was there.
|
|
50
|
+
*
|
|
51
|
+
* `replyTo` is rewritten as well as `to`. Leaving it alone is tempting — it does not cause
|
|
52
|
+
* delivery — but this fleet deliberately INVERTS reply-to on registration mail so a registrant
|
|
53
|
+
* reaching for reply gets the organizer. In a shared dev inbox that turns one careless reply
|
|
54
|
+
* into a real message to a real organizer, which is the failure this whole module exists to
|
|
55
|
+
* prevent. The original is kept in a header instead, where it can still be checked.
|
|
56
|
+
*/
|
|
57
|
+
export function redirectRecipients(
|
|
58
|
+
recipients: Recipients,
|
|
59
|
+
stagingEmail: string,
|
|
60
|
+
): RedirectedRecipients {
|
|
61
|
+
const to = list(recipients.to);
|
|
62
|
+
const cc = list(recipients.cc);
|
|
63
|
+
const bcc = list(recipients.bcc);
|
|
64
|
+
const original = [...to, ...cc, ...bcc];
|
|
65
|
+
|
|
66
|
+
const headers: Record<string, string> = { 'X-Webm-Environment': 'staging' };
|
|
67
|
+
if (to.length) headers['X-Webm-Original-To'] = to.join(', ');
|
|
68
|
+
if (cc.length) headers['X-Webm-Original-Cc'] = cc.join(', ');
|
|
69
|
+
if (bcc.length) headers['X-Webm-Original-Bcc'] = bcc.join(', ');
|
|
70
|
+
if (recipients.replyTo) headers['X-Webm-Original-Reply-To'] = recipients.replyTo;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
to: [stagingEmail],
|
|
74
|
+
cc: undefined,
|
|
75
|
+
bcc: undefined,
|
|
76
|
+
replyTo: stagingEmail,
|
|
77
|
+
headers,
|
|
78
|
+
original,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mark the subject so a redirected message is never mistaken for a real one at a glance.
|
|
84
|
+
*
|
|
85
|
+
* The real recipients go in the subject rather than only in a header because a shared dev inbox
|
|
86
|
+
* collects mail from every client site in the fleet, and "who was this actually for" is the
|
|
87
|
+
* question being asked of it. Capped so a long cc list cannot push the real subject out of view.
|
|
88
|
+
*/
|
|
89
|
+
export function redirectedSubject(subject: string, original: string[]): string {
|
|
90
|
+
if (!original.length) return `[staging] ${subject}`;
|
|
91
|
+
|
|
92
|
+
const shown = original.slice(0, 3).join(', ');
|
|
93
|
+
const more = original.length > 3 ? ` +${original.length - 3}` : '';
|
|
94
|
+
|
|
95
|
+
return `[staging → ${shown}${more}] ${subject}`;
|
|
96
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Mailgun (a Sinch product) — transactional email.
|
|
3
|
+
*
|
|
4
|
+
* Used for form notifications and autoresponders. There is no SDK: Mailgun's API is a
|
|
5
|
+
* form-encoded POST, and adding a Node-oriented SDK to a Worker is more trouble than the
|
|
6
|
+
* twenty lines below.
|
|
7
|
+
*
|
|
8
|
+
* Secrets: MAILGUN_API_KEY and MAILGUN_DOMAIN. Local values in .dev.vars; production via
|
|
9
|
+
* `npx wrangler secret put MAILGUN_API_KEY`.
|
|
10
|
+
*
|
|
11
|
+
* The route that calls this needs `export const prerender = false`.
|
|
12
|
+
*
|
|
13
|
+
* Sending domain convention is `webm.<client-domain>` — keeps client deliverability separate
|
|
14
|
+
* from their own mail, so a bounce problem on one never poisons the other. The mailbox is
|
|
15
|
+
* `website@`, giving `website@webm.<client-domain>` for every template the site sends.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { environment, stagingEmail } from '../../webmonterey/site.ts';
|
|
19
|
+
import { isStagingDeployment } from '../../webmonterey/config.ts';
|
|
20
|
+
import { redirectRecipients, redirectedSubject } from './redirect.ts';
|
|
21
|
+
|
|
22
|
+
export interface SendEmailOptions {
|
|
23
|
+
apiKey: string;
|
|
24
|
+
/** The Mailgun sending domain, e.g. `webm.example.com`. */
|
|
25
|
+
domain: string;
|
|
26
|
+
/** `Name <address@webm.example.com>` — must be on the sending domain. */
|
|
27
|
+
from: string;
|
|
28
|
+
to: string | string[];
|
|
29
|
+
subject: string;
|
|
30
|
+
/** Provide `text`, `html`, or both. Both is best for deliverability. */
|
|
31
|
+
text?: string;
|
|
32
|
+
html?: string;
|
|
33
|
+
/** Set this to the visitor's address so a reply reaches them, not the Worker. */
|
|
34
|
+
replyTo?: string;
|
|
35
|
+
cc?: string | string[];
|
|
36
|
+
bcc?: string | string[];
|
|
37
|
+
/** Mailgun tags, for filtering in their dashboard. */
|
|
38
|
+
tags?: string[];
|
|
39
|
+
/** EU-hosted accounts must pass 'eu'; sending to the wrong region fails auth. */
|
|
40
|
+
region?: 'us' | 'eu';
|
|
41
|
+
/*
|
|
42
|
+
* Files to attach.
|
|
43
|
+
*
|
|
44
|
+
* Added because friendsofthemarinalibrary.org attaches an .ics to its registration
|
|
45
|
+
* confirmation, and had put this field on a private copy of this file to do it. The rebuild
|
|
46
|
+
* pointed that site's action back here and the calendar invite silently stopped being sent -
|
|
47
|
+
* silently because an attachment is passed inside a conditional spread, where TypeScript's
|
|
48
|
+
* excess-property check does not apply, so nothing failed and nothing was said.
|
|
49
|
+
*
|
|
50
|
+
* That is the shape of a missing seam: a site needing one field kept a whole file to get it,
|
|
51
|
+
* and the copy is what a rebuild threw away. One optional field here costs nothing and ends it.
|
|
52
|
+
*
|
|
53
|
+
* `content` is a string rather than a Blob so callers stay easy to test - every generator in
|
|
54
|
+
* the fleet returns text - and the Blob is built at the last moment below.
|
|
55
|
+
*/
|
|
56
|
+
attachments?: Array<{ filename: string; content: string; contentType: string }>;
|
|
57
|
+
/**
|
|
58
|
+
* The hostname this send is happening on, when there is a request to take one from.
|
|
59
|
+
*
|
|
60
|
+
* Only ever used to REDIRECT recipients away from real people on a preview — see
|
|
61
|
+
* isStagingDeployment. Omit it and the decision falls to `environment` alone, which is
|
|
62
|
+
* correct for a scheduled handler: a cron has no request, and a production cron must still
|
|
63
|
+
* send for real.
|
|
64
|
+
*/
|
|
65
|
+
hostname?: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SendEmailResult {
|
|
69
|
+
id: string;
|
|
70
|
+
message: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ENDPOINTS = {
|
|
74
|
+
us: 'https://api.mailgun.net/v3',
|
|
75
|
+
eu: 'https://api.eu.mailgun.net/v3',
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Send one email. Throws on any non-2xx, with Mailgun's own message included — a silent
|
|
80
|
+
* failure here means a client never learns someone tried to contact them.
|
|
81
|
+
*/
|
|
82
|
+
export async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
|
|
83
|
+
const {
|
|
84
|
+
apiKey,
|
|
85
|
+
domain,
|
|
86
|
+
from,
|
|
87
|
+
to,
|
|
88
|
+
subject,
|
|
89
|
+
text,
|
|
90
|
+
html,
|
|
91
|
+
replyTo,
|
|
92
|
+
cc,
|
|
93
|
+
bcc,
|
|
94
|
+
tags,
|
|
95
|
+
region,
|
|
96
|
+
attachments,
|
|
97
|
+
hostname,
|
|
98
|
+
} = options;
|
|
99
|
+
|
|
100
|
+
if (!text && !html) {
|
|
101
|
+
throw new Error('[webm] sendEmail needs at least one of `text` or `html`.');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const body = new FormData();
|
|
105
|
+
body.set('from', from);
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
* STAGING NEVER MAILS A REAL PERSON.
|
|
109
|
+
*
|
|
110
|
+
* Applied here, at the one place every template in the fleet passes through, rather than at
|
|
111
|
+
* each call site: the form pipeline, this site's registration action and the nightly sweep
|
|
112
|
+
* all reach Mailgun through this function, and a guard that has to be remembered per call is
|
|
113
|
+
* one that will be missed. See redirect.ts for what is rewritten and why.
|
|
114
|
+
*/
|
|
115
|
+
const staging = isStagingDeployment(environment, hostname);
|
|
116
|
+
|
|
117
|
+
if (staging) {
|
|
118
|
+
/*
|
|
119
|
+
* REFUSE RATHER THAN GUESS. There is no default inbox in the package, so a staging site that
|
|
120
|
+
* has not named one has nowhere safe to send - and the alternative, sending to the real
|
|
121
|
+
* recipients, is the exact thing staging exists to prevent. `webm doctor` catches this
|
|
122
|
+
* before a form ever gets submitted.
|
|
123
|
+
*/
|
|
124
|
+
if (!stagingEmail) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
'[webm] This is a staging deployment and webmonterey.json has no "stagingEmail". ' +
|
|
127
|
+
'Refusing to send: without one the only alternative is the real recipients. Set ' +
|
|
128
|
+
'stagingEmail to the inbox that should receive test mail.',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const redirected = redirectRecipients({ to, cc, bcc, replyTo }, stagingEmail);
|
|
132
|
+
|
|
133
|
+
body.set('subject', redirectedSubject(subject, redirected.original));
|
|
134
|
+
for (const address of redirected.to) body.append('to', address);
|
|
135
|
+
for (const [name, value] of Object.entries(redirected.headers)) body.set(`h:${name}`, value);
|
|
136
|
+
body.set('h:Reply-To', redirected.replyTo);
|
|
137
|
+
|
|
138
|
+
/* Filterable in Mailgun, so a shared dev inbox never muddles the client's own traffic. */
|
|
139
|
+
body.append('o:tag', 'staging');
|
|
140
|
+
} else {
|
|
141
|
+
body.set('subject', subject);
|
|
142
|
+
|
|
143
|
+
for (const address of Array.isArray(to) ? to : [to]) body.append('to', address);
|
|
144
|
+
if (cc) for (const address of Array.isArray(cc) ? cc : [cc]) body.append('cc', address);
|
|
145
|
+
if (bcc) for (const address of Array.isArray(bcc) ? bcc : [bcc]) body.append('bcc', address);
|
|
146
|
+
if (replyTo) body.set('h:Reply-To', replyTo);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (text) body.set('text', text);
|
|
150
|
+
if (html) body.set('html', html);
|
|
151
|
+
if (tags) for (const tag of tags) body.append('o:tag', tag);
|
|
152
|
+
|
|
153
|
+
for (const file of attachments ?? []) {
|
|
154
|
+
body.append('attachment', new Blob([file.content], { type: file.contentType }), file.filename);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const endpoint = `${ENDPOINTS[region ?? 'us']}/${domain}/messages`;
|
|
158
|
+
|
|
159
|
+
const response = await fetch(endpoint, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: {
|
|
162
|
+
// Mailgun uses HTTP basic auth with the literal username "api".
|
|
163
|
+
Authorization: `Basic ${btoa(`api:${apiKey}`)}`,
|
|
164
|
+
},
|
|
165
|
+
body,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
/*
|
|
170
|
+
* Neither the domain nor the key is interpolated here. MAILGUN_API_KEY and
|
|
171
|
+
* MAILGUN_DOMAIN sit on adjacent lines in .dev.vars.example, and a swap would otherwise
|
|
172
|
+
* put the API key into this message — which `observability` then persists into Cloudflare
|
|
173
|
+
* Workers Logs, and which reaches the browser if a caller echoes `e.message`.
|
|
174
|
+
* Mailgun's own body is capped for the same reason: it is unbounded remote input.
|
|
175
|
+
*/
|
|
176
|
+
const detail = (await response.text()).slice(0, 200);
|
|
177
|
+
throw new Error(
|
|
178
|
+
`[webm] Mailgun refused the message (${response.status}): ${detail}\n` +
|
|
179
|
+
` - 401: wrong API key, or an EU account being sent to the US endpoint\n` +
|
|
180
|
+
` - 404: the sending domain does not exist on this account (check MAILGUN_DOMAIN)\n` +
|
|
181
|
+
` - 400: the "from" address is not on the sending domain`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (await response.json()) as SendEmailResult;
|
|
186
|
+
}
|