astroidjs 0.1.1 → 0.2.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.
Files changed (154) hide show
  1. package/README.md +240 -5
  2. package/bin/astroid.mjs +185 -9
  3. package/dist/analytics/index.d.ts +37 -0
  4. package/dist/analytics/index.js +108 -0
  5. package/dist/astro/csp.d.ts +64 -0
  6. package/dist/astro/csp.js +173 -0
  7. package/dist/astro/index.d.ts +1 -0
  8. package/dist/astro/index.js +7 -0
  9. package/dist/commerce/adapters.d.ts +60 -0
  10. package/dist/commerce/adapters.js +90 -0
  11. package/dist/commerce/checkout-scaffold.d.ts +42 -0
  12. package/dist/commerce/checkout-scaffold.js +306 -0
  13. package/dist/commerce/checkout.d.ts +72 -0
  14. package/dist/commerce/checkout.js +124 -0
  15. package/dist/commerce/index.d.ts +8 -0
  16. package/dist/commerce/index.js +9 -0
  17. package/dist/commerce/loader.d.ts +71 -0
  18. package/dist/commerce/loader.js +90 -0
  19. package/dist/commerce/mirror.d.ts +67 -0
  20. package/dist/commerce/mirror.js +203 -0
  21. package/dist/commerce/roles.d.ts +38 -0
  22. package/dist/commerce/roles.js +93 -0
  23. package/dist/commerce/secrets.d.ts +74 -0
  24. package/dist/commerce/secrets.js +129 -0
  25. package/dist/commerce/sync.d.ts +86 -0
  26. package/dist/commerce/sync.js +154 -0
  27. package/dist/components/sections.d.ts +577 -0
  28. package/dist/components/sections.js +425 -0
  29. package/dist/config.d.ts +174 -12
  30. package/dist/config.js +43 -1
  31. package/dist/email/index.d.ts +4 -0
  32. package/dist/email/index.js +5 -0
  33. package/dist/email/inquiry.d.ts +33 -0
  34. package/dist/email/inquiry.js +63 -0
  35. package/dist/email/send.d.ts +120 -0
  36. package/dist/email/send.js +196 -0
  37. package/dist/email/templates.d.ts +24 -0
  38. package/dist/email/templates.js +184 -0
  39. package/dist/email/theme.d.ts +24 -0
  40. package/dist/email/theme.js +150 -0
  41. package/dist/errors.d.ts +14 -0
  42. package/dist/errors.js +17 -0
  43. package/dist/index.d.ts +14 -0
  44. package/dist/index.js +14 -0
  45. package/dist/map/index.d.ts +3 -0
  46. package/dist/map/index.js +4 -0
  47. package/dist/map/pmtiles.d.ts +92 -0
  48. package/dist/map/pmtiles.js +130 -0
  49. package/dist/map/scaffold.d.ts +29 -0
  50. package/dist/map/scaffold.js +212 -0
  51. package/dist/map/style.d.ts +58 -0
  52. package/dist/map/style.js +154 -0
  53. package/dist/portal/config.d.ts +26 -0
  54. package/dist/portal/config.js +50 -0
  55. package/dist/portal/guard.d.ts +48 -0
  56. package/dist/portal/guard.js +64 -0
  57. package/dist/portal/index.d.ts +5 -0
  58. package/dist/portal/index.js +6 -0
  59. package/dist/portal/nav.d.ts +26 -0
  60. package/dist/portal/nav.js +35 -0
  61. package/dist/portal/scaffold.d.ts +28 -0
  62. package/dist/portal/scaffold.js +140 -0
  63. package/dist/portal/session.d.ts +36 -0
  64. package/dist/portal/session.js +86 -0
  65. package/dist/portfolio/index.d.ts +1 -0
  66. package/dist/portfolio/index.js +4 -0
  67. package/dist/portfolio/scaffold.d.ts +9 -0
  68. package/dist/portfolio/scaffold.js +93 -0
  69. package/dist/project/actions.d.ts +3 -0
  70. package/dist/project/actions.js +106 -0
  71. package/dist/project/generate.d.ts +15 -0
  72. package/dist/project/generate.js +144 -2
  73. package/dist/project/index.d.ts +2 -0
  74. package/dist/project/index.js +2 -0
  75. package/dist/project/scaffold.d.ts +29 -0
  76. package/dist/project/scaffold.js +140 -0
  77. package/dist/pwa/generate.d.ts +49 -0
  78. package/dist/pwa/generate.js +218 -0
  79. package/dist/pwa/index.d.ts +1 -0
  80. package/dist/pwa/index.js +2 -0
  81. package/dist/queues/consumer.d.ts +29 -0
  82. package/dist/queues/consumer.js +37 -0
  83. package/dist/queues/index.d.ts +4 -0
  84. package/dist/queues/index.js +5 -0
  85. package/dist/queues/messages.d.ts +60 -0
  86. package/dist/queues/messages.js +71 -0
  87. package/dist/queues/scaffold.d.ts +44 -0
  88. package/dist/queues/scaffold.js +204 -0
  89. package/dist/queues/webhook.d.ts +60 -0
  90. package/dist/queues/webhook.js +81 -0
  91. package/dist/realtime/index.d.ts +1 -0
  92. package/dist/realtime/index.js +4 -0
  93. package/dist/realtime/scaffold.d.ts +30 -0
  94. package/dist/realtime/scaffold.js +159 -0
  95. package/dist/schema/collections.d.ts +42 -8
  96. package/dist/schema/collections.js +102 -8
  97. package/dist/schema/generate.js +10 -1
  98. package/dist/secrets.d.ts +54 -0
  99. package/dist/secrets.js +80 -0
  100. package/dist/security/index.d.ts +1 -0
  101. package/dist/security/index.js +2 -0
  102. package/dist/security/rate-rules.d.ts +21 -0
  103. package/dist/security/rate-rules.js +107 -0
  104. package/dist/seo/index.d.ts +3 -0
  105. package/dist/seo/index.js +4 -0
  106. package/dist/seo/resolve.d.ts +68 -0
  107. package/dist/seo/resolve.js +73 -0
  108. package/dist/seo/routes.d.ts +44 -0
  109. package/dist/seo/routes.js +104 -0
  110. package/dist/seo/structured-data.d.ts +51 -0
  111. package/dist/seo/structured-data.js +105 -0
  112. package/dist/status.d.ts +51 -0
  113. package/dist/status.js +113 -0
  114. package/dist/worker/generate.d.ts +18 -10
  115. package/dist/worker/generate.js +325 -37
  116. package/dist/worker/routes.d.ts +1 -1
  117. package/dist/worker/routes.js +42 -0
  118. package/dist/workflow/advance.d.ts +102 -0
  119. package/dist/workflow/advance.js +145 -0
  120. package/dist/workflow/config.d.ts +60 -0
  121. package/dist/workflow/config.js +73 -0
  122. package/dist/workflow/generate.d.ts +22 -0
  123. package/dist/workflow/generate.js +138 -0
  124. package/dist/workflow/index.d.ts +3 -0
  125. package/dist/workflow/index.js +4 -0
  126. package/package.json +21 -5
  127. package/src/components/Editable.astro +33 -9
  128. package/src/components/JustifiedGallery.astro +254 -0
  129. package/src/components/MediaSlot.astro +178 -0
  130. package/src/components/PortalShell.astro +80 -0
  131. package/src/components/RegisterSW.astro +45 -0
  132. package/src/components/Section.astro +101 -35
  133. package/src/components/Sections.astro +64 -0
  134. package/src/components/Seo.astro +57 -0
  135. package/src/components/StageBar.astro +137 -0
  136. package/src/components/StructuredData.astro +33 -0
  137. package/src/components/justify.ts +170 -0
  138. package/src/components/media-meta.ts +174 -0
  139. package/src/components/sections/AboutIntro.astro +46 -0
  140. package/src/components/sections/Banner.astro +31 -0
  141. package/src/components/sections/Contact.astro +22 -9
  142. package/src/components/sections/Cta.astro +33 -10
  143. package/src/components/sections/Faq.astro +50 -0
  144. package/src/components/sections/FeatureGrid.astro +40 -11
  145. package/src/components/sections/Gallery.astro +46 -0
  146. package/src/components/sections/Hero.astro +40 -12
  147. package/src/components/sections/LocationHours.astro +59 -0
  148. package/src/components/sections/Media.astro +44 -0
  149. package/src/components/sections/PricingTiers.astro +79 -0
  150. package/src/components/sections/ProductGrid.astro +73 -0
  151. package/src/components/sections/SplitImage.astro +61 -0
  152. package/src/components/sections/Steps.astro +58 -0
  153. package/src/components/sections/Testimonial.astro +51 -0
  154. package/src/components/sections.ts +452 -67
package/dist/config.js CHANGED
@@ -19,7 +19,29 @@
19
19
  // `ModuleKind` are extracted from the real sites Astroid targets — a storefront
20
20
  // (coracle), a wholesale front (ghostfire), an artist portfolio (megbowen), and a
21
21
  // plain marketing baseline (louise-web).
22
+ import { assertCommerceRoles } from "./commerce/roles.js";
22
23
  import { AstroidConfigError } from "./errors.js";
24
+ /**
25
+ * Each archetype's default home-page sections.
26
+ *
27
+ * Lives here, in TypeScript, rather than in `create-astroid`'s plain JS — the
28
+ * other half of #277. As a JS object literal it could name a section that
29
+ * didn't exist and nothing would say so; typed against {@link SectionKind}
30
+ * (itself derived from the catalog) a stale name is a compile error, and CI
31
+ * type-checks this package.
32
+ *
33
+ * The four kinds this used to name — `marquee`, `featured`, `story`, `visit` —
34
+ * had no catalog entry or component and could never render. Each is replaced by
35
+ * the real section that does its job: a marquee is a `banner`, curated picks
36
+ * are a `productGrid`, a brand-origin block is `aboutIntro`, and "visit" is
37
+ * exactly `locationHours`.
38
+ */
39
+ export const ASTROID_ARCHETYPE_SECTIONS = {
40
+ marketing: ["hero", "featureGrid", "cta", "contact"],
41
+ storefront: ["hero", "banner", "productGrid", "locationHours", "contact"],
42
+ wholesale: ["hero", "featureGrid", "aboutIntro", "contact"],
43
+ portfolio: ["hero", "gallery", "aboutIntro", "contact"],
44
+ };
23
45
  /**
24
46
  * Define an Astroid project. An identity function in the shape of Astro's
25
47
  * `defineConfig`: it returns the config verbatim with full type-checking +
@@ -32,7 +54,7 @@ import { AstroidConfigError } from "./errors.js";
32
54
  * key: "coracle",
33
55
  * archetype: "storefront",
34
56
  * theme: { name: "Coracle Coffee", colors: { brand: "#1f6f78" } },
35
- * sections: ["hero", "marquee", "featured", "productGrid", "visit"],
57
+ * sections: ["hero", "banner", "productGrid", "locationHours", "contact"],
36
58
  * commerce: { provider: "square" },
37
59
  * deploy: { platform: "cloudflare" },
38
60
  * });
@@ -48,5 +70,25 @@ export function defineAstroid(config) {
48
70
  if (!config.theme.colors || !config.theme.colors.brand) {
49
71
  throw new AstroidConfigError("Astroid config requires `theme.colors.brand` (the primary brand color)");
50
72
  }
73
+ // A provider assigned to a role its client can't serve (invoicing over
74
+ // Fourthwall, a storefront over Stripe) fails here rather than at runtime on
75
+ // the first invoice, as a missing function.
76
+ assertCommerceRoles(config.commerce);
77
+ // `portal.gated` is declared and resolved but read by NOTHING — the guard
78
+ // table is built from `portal.routes` alone, and `portalGuard` allows any
79
+ // unmatched path. So a site that set it believed the whole site sat behind a
80
+ // login (a pre-launch client gallery) while every page outside /portal was
81
+ // public, and it type-checked.
82
+ //
83
+ // Refusing the flag is the only safe state until it's implemented. A security
84
+ // control that silently does nothing is strictly worse than one that isn't
85
+ // offered: the first gives false confidence, the second sends you looking for
86
+ // an answer. Fail loudly, at config load, naming the workaround.
87
+ if (config.portal?.gated) {
88
+ throw new AstroidConfigError("`portal.gated` is not implemented — it is accepted but wires no guard, so the site " +
89
+ "would be fully public while appearing gated. Remove it, and gate the whole site by " +
90
+ "listing the prefixes you mean in `portal.routes` (e.g. `[{ prefix: \"/\" }]` with your " +
91
+ "login and auth paths ahead of it).");
92
+ }
51
93
  return config;
52
94
  }
@@ -0,0 +1,4 @@
1
+ export { astroidMailTheme, type MailThemeOverrides } from "./theme.js";
2
+ export { type AstroidMailEnv, sendInquiryMail } from "./inquiry.js";
3
+ export { createMailer, type DeliveryResult, EMAIL_SECRET_NAMES, type EmailSender, type MailerEnv, type MailerOptions, type MailerStatus, type OutgoingMail, resolveMailer, resolveMailerStatus, sendTransactional, } from "./send.js";
4
+ export { type InquiryDetails, inquiryConfirmationEmail, inquiryNotificationEmail, magicLinkEmail, type MailContent, type MailTheme, passwordResetEmail, } from "./templates.js";
@@ -0,0 +1,5 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ export { astroidMailTheme } from "./theme.js";
3
+ export { sendInquiryMail } from "./inquiry.js";
4
+ export { createMailer, EMAIL_SECRET_NAMES, resolveMailer, resolveMailerStatus, sendTransactional, } from "./send.js";
5
+ export { inquiryConfirmationEmail, inquiryNotificationEmail, magicLinkEmail, passwordResetEmail, } from "./templates.js";
@@ -0,0 +1,33 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ import type { SecretSource } from "../secrets.js";
3
+ import type { EmailSender } from "./send.js";
4
+ import { type DeliveryResult } from "./send.js";
5
+ import { type MailThemeOverrides } from "./theme.js";
6
+ /** The bindings the inquiry hook reads. All optional — an unprovisioned mail
7
+ * setup logs instead of sending, per the dormant-until-provisioned convention. */
8
+ export interface AstroidMailEnv {
9
+ /** Cloudflare Email Sending binding. */
10
+ EMAIL?: EmailSender;
11
+ /**
12
+ * Envelope sender; its domain must be onboarded for Email Sending. A
13
+ * `SecretSource` rather than a plain string so a Secrets Store binding works
14
+ * here too — and so the placeholder sentinel reads as unconfigured.
15
+ */
16
+ MAIL_FROM?: SecretSource;
17
+ /** Where owner notifications go. Also the first editor's address. */
18
+ OWNER_EMAIL?: string;
19
+ }
20
+ /**
21
+ * Send the notify + confirm pair for one contact-form submission.
22
+ *
23
+ * Wire it into the generated worker's form route:
24
+ *
25
+ * ```ts
26
+ * formRoute({ form: contactForm, onSubmit: (values, env) => sendInquiryMail(config, env, values) })
27
+ * ```
28
+ *
29
+ * Each half is skipped when its recipient is unknown rather than failing the
30
+ * batch: no `OWNER_EMAIL` means no notification to send, and a submission whose
31
+ * email didn't validate still deserves to reach the owner.
32
+ */
33
+ export declare function sendInquiryMail(config: AstroidConfig, env: AstroidMailEnv, values: Record<string, unknown>, overrides?: MailThemeOverrides): Promise<DeliveryResult[]>;
@@ -0,0 +1,63 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The inquiry pair, wired to the contact form.
4
+ //
5
+ // `formRoute` fires `onSubmit` AFTER the row is inserted and off the response
6
+ // path (`waitUntil`), which is exactly the store-and-forward shape this wants:
7
+ // the submission is already durable, so mail is a notification of something that
8
+ // already happened and can fail without the visitor ever knowing.
9
+ //
10
+ // Two messages, not one — every site converged on the pair. The owner needs the
11
+ // message; the visitor needs to know it arrived, because a contact form with no
12
+ // acknowledgement is indistinguishable from one that's broken.
13
+ import { resolveMailer, sendTransactional } from "./send.js";
14
+ import { inquiryConfirmationEmail, inquiryNotificationEmail } from "./templates.js";
15
+ import { astroidMailTheme } from "./theme.js";
16
+ /** Trimmed string, or undefined for anything else. */
17
+ const str = (v) => {
18
+ const s = typeof v === "string" ? v.trim() : "";
19
+ return s || undefined;
20
+ };
21
+ /**
22
+ * Send the notify + confirm pair for one contact-form submission.
23
+ *
24
+ * Wire it into the generated worker's form route:
25
+ *
26
+ * ```ts
27
+ * formRoute({ form: contactForm, onSubmit: (values, env) => sendInquiryMail(config, env, values) })
28
+ * ```
29
+ *
30
+ * Each half is skipped when its recipient is unknown rather than failing the
31
+ * batch: no `OWNER_EMAIL` means no notification to send, and a submission whose
32
+ * email didn't validate still deserves to reach the owner.
33
+ */
34
+ export async function sendInquiryMail(config, env, values, overrides) {
35
+ const theme = astroidMailTheme(config, overrides);
36
+ const details = {
37
+ name: [str(values.firstName), str(values.lastName)].filter(Boolean).join(" "),
38
+ email: str(values.email) ?? "",
39
+ regarding: str(values.regarding),
40
+ message: str(values.message) ?? "",
41
+ };
42
+ const owner = str(env.OWNER_EMAIL);
43
+ const mails = [
44
+ ...(owner
45
+ ? [
46
+ {
47
+ to: owner,
48
+ content: inquiryNotificationEmail(theme, details),
49
+ // So the owner can answer by hitting reply, rather than copying an
50
+ // address out of the body.
51
+ ...(details.email ? { replyTo: details.email } : {}),
52
+ },
53
+ ]
54
+ : []),
55
+ ...(details.email
56
+ ? [{ to: details.email, content: inquiryConfirmationEmail(theme, details) }]
57
+ : []),
58
+ ];
59
+ // One resolver decides dormancy for the whole module: no binding, no sender
60
+ // address, or a sender still holding the placeholder sentinel all come back
61
+ // as `logOnly`, so nothing hands the Email API a dummy envelope.
62
+ return sendTransactional(await resolveMailer(env), mails);
63
+ }
@@ -0,0 +1,120 @@
1
+ import type { EmailSender, MailContent } from "louise-toolkit/email";
2
+ import { type ModuleSecrets, type SecretSource } from "../secrets.js";
3
+ export type { EmailSender };
4
+ /**
5
+ * The secrets the mailer needs. Just the envelope sender: the EMAIL binding is a
6
+ * binding, not a secret, so it's gated by presence rather than by value.
7
+ *
8
+ * Named here so `astroid doctor` and the scaffold's `.dev.vars` seed read the
9
+ * same list the runtime gate does.
10
+ */
11
+ export declare const EMAIL_SECRET_NAMES: readonly ["MAIL_FROM"];
12
+ /**
13
+ * The mailer's resolved gate. Deliberately NOT `ModuleSecrets<"MAIL_FROM">`:
14
+ * `missing` here can name `EMAIL`, which is a binding rather than a secret, so
15
+ * the key type is widened to plain strings.
16
+ */
17
+ export interface MailerStatus {
18
+ /** True only when a binding AND a real sender address are both present. */
19
+ configured: boolean;
20
+ values: ModuleSecrets<"MAIL_FROM">["values"];
21
+ /** What's unprovisioned — secret names and/or `"EMAIL"`. */
22
+ missing: string[];
23
+ /** Whether an Email Sending binding is present at all. */
24
+ hasBinding: boolean;
25
+ }
26
+ /** The env members the mailer reads. Structural, so a project's env fits. */
27
+ export interface MailerEnv {
28
+ EMAIL?: EmailSender | null;
29
+ MAIL_FROM?: SecretSource;
30
+ }
31
+ /**
32
+ * Resolve the email module's dormancy.
33
+ *
34
+ * Both halves are required, and for the same reason: a binding with no sender
35
+ * address can't build an envelope, and a sender address with no binding has
36
+ * nothing to send through. Either one missing means log-and-continue, which
37
+ * under `wrangler dev` (no EMAIL binding at all) is the normal case — and the
38
+ * reason the magic-link flow is still workable locally.
39
+ */
40
+ export declare function resolveMailerStatus(env: MailerEnv): Promise<MailerStatus>;
41
+ /**
42
+ * Build `MailerOptions` from an env, with dormancy already decided.
43
+ *
44
+ * The point of routing through {@link resolveMailerStatus} rather than checking
45
+ * `!env.EMAIL` inline is that the placeholder sentinel counts: a scaffold seeds
46
+ * `MAIL_FROM=DUMMY_REPLACE_ME`, and handing that to the Email API as an
47
+ * envelope sender is exactly the "called upstream with a dummy credential"
48
+ * failure the convention exists to prevent.
49
+ */
50
+ export declare function resolveMailer(env: MailerEnv, overrides?: Partial<Omit<MailerOptions, "binding" | "from">>): Promise<MailerOptions & {
51
+ status: MailerStatus;
52
+ }>;
53
+ /** One message queued for delivery. */
54
+ export interface OutgoingMail {
55
+ to: string;
56
+ content: MailContent;
57
+ /** Reply-To — for an inquiry notification, the visitor's own address, so the
58
+ * owner can just hit reply. */
59
+ replyTo?: string;
60
+ }
61
+ /** What happened to one message. Never an exception. */
62
+ export interface DeliveryResult {
63
+ to: string;
64
+ subject: string;
65
+ delivered: boolean;
66
+ messageId?: string;
67
+ /** Why it wasn't delivered — `"not-configured"`, `"log-only"`, or the error. */
68
+ reason?: string;
69
+ }
70
+ export interface MailerOptions {
71
+ /**
72
+ * The Cloudflare Email Sending binding. Absent or null → the mailer is
73
+ * dormant: messages are logged, and every result comes back
74
+ * `delivered: false, reason: "not-configured"`.
75
+ */
76
+ binding?: EmailSender | null;
77
+ /** Envelope sender. Its domain must be onboarded for Email Sending. */
78
+ from: string | {
79
+ email: string;
80
+ name?: string;
81
+ };
82
+ /** Log instead of sending even when a binding exists (a dry run). */
83
+ logOnly?: boolean;
84
+ /** Sink for the dev log. Defaults to `console.info`; pass one in a test. */
85
+ log?: (message: string) => void;
86
+ /**
87
+ * Print the message BODY when a send is skipped.
88
+ *
89
+ * The body carries single-use sign-in and password-reset links, so it is only
90
+ * printed where we can tell we're in development. Set this explicitly when the
91
+ * detection can't (a local `wrangler dev` against a real account, a test). Do
92
+ * not set it on a deployed Worker: the log is `wrangler tail` and Logpush.
93
+ */
94
+ devLog?: boolean;
95
+ }
96
+ /**
97
+ * Send a batch of transactional messages, best effort.
98
+ *
99
+ * Delivery runs concurrently and independently: an inquiry sends a notification
100
+ * to the owner and a confirmation to the visitor, and the owner's copy must
101
+ * still arrive when the visitor typo'd their address. Callers get a result per
102
+ * message and decide whether to care.
103
+ *
104
+ * ```ts
105
+ * const results = await sendTransactional(
106
+ * { binding: env.EMAIL, from: env.MAIL_FROM },
107
+ * [
108
+ * { to: owner, content: inquiryNotificationEmail(theme, i), replyTo: i.email },
109
+ * { to: i.email, content: inquiryConfirmationEmail(theme, i) },
110
+ * ],
111
+ * );
112
+ * ```
113
+ */
114
+ export declare function sendTransactional(options: MailerOptions, mails: OutgoingMail[]): Promise<DeliveryResult[]>;
115
+ /**
116
+ * Bind a mailer's options once so call sites read as `mailer([...])`. Useful
117
+ * where the binding and sender are resolved per request but the sends are
118
+ * scattered (an inquiry hook, an auth callback).
119
+ */
120
+ export declare function createMailer(options: MailerOptions): (mails: OutgoingMail[]) => Promise<DeliveryResult[]>;
@@ -0,0 +1,196 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Best-effort delivery.
4
+ //
5
+ // Transactional mail in this stack is always store-and-forward: the inquiry row
6
+ // is already in D1, the account already exists. Mail is the *notification* of
7
+ // something that happened, so a mail failure must never fail the request that
8
+ // caused it — and never throw into a `waitUntil` where it becomes an unhandled
9
+ // rejection. Every path here resolves.
10
+ //
11
+ // The second job is the dev story. There is no EMAIL binding under `wrangler
12
+ // dev`, and the single most common local task is "click the magic link" — so an
13
+ // unconfigured mailer LOGS the message instead of silently dropping it, and it
14
+ // logs the plaintext body, which is where the link is. That is the whole reason
15
+ // every template renders a text alternative.
16
+ //
17
+ // This is the email module's half of the dormant-until-provisioned convention:
18
+ // no binding means dormant and loudly simulated, not broken.
19
+ import { sendEmail } from "louise-toolkit/email";
20
+ import { resolveModuleSecrets } from "../secrets.js";
21
+ /**
22
+ * The secrets the mailer needs. Just the envelope sender: the EMAIL binding is a
23
+ * binding, not a secret, so it's gated by presence rather than by value.
24
+ *
25
+ * Named here so `astroid doctor` and the scaffold's `.dev.vars` seed read the
26
+ * same list the runtime gate does.
27
+ */
28
+ export const EMAIL_SECRET_NAMES = ["MAIL_FROM"];
29
+ /**
30
+ * Resolve the email module's dormancy.
31
+ *
32
+ * Both halves are required, and for the same reason: a binding with no sender
33
+ * address can't build an envelope, and a sender address with no binding has
34
+ * nothing to send through. Either one missing means log-and-continue, which
35
+ * under `wrangler dev` (no EMAIL binding at all) is the normal case — and the
36
+ * reason the magic-link flow is still workable locally.
37
+ */
38
+ export async function resolveMailerStatus(env) {
39
+ const secrets = await resolveModuleSecrets({ MAIL_FROM: env.MAIL_FROM });
40
+ const hasBinding = Boolean(env.EMAIL);
41
+ return {
42
+ configured: secrets.configured && hasBinding,
43
+ values: secrets.values,
44
+ missing: hasBinding ? [...secrets.missing] : [...secrets.missing, "EMAIL"],
45
+ hasBinding,
46
+ };
47
+ }
48
+ /**
49
+ * Build `MailerOptions` from an env, with dormancy already decided.
50
+ *
51
+ * The point of routing through {@link resolveMailerStatus} rather than checking
52
+ * `!env.EMAIL` inline is that the placeholder sentinel counts: a scaffold seeds
53
+ * `MAIL_FROM=DUMMY_REPLACE_ME`, and handing that to the Email API as an
54
+ * envelope sender is exactly the "called upstream with a dummy credential"
55
+ * failure the convention exists to prevent.
56
+ */
57
+ export async function resolveMailer(env, overrides = {}) {
58
+ const status = await resolveMailerStatus(env);
59
+ return {
60
+ ...overrides,
61
+ binding: env.EMAIL ?? null,
62
+ from: status.values.MAIL_FROM ?? "noreply@localhost",
63
+ logOnly: overrides.logOnly || !status.configured,
64
+ status,
65
+ };
66
+ }
67
+ /**
68
+ * The console rendering of an unsent message.
69
+ *
70
+ * The body is the whole point in dev — that's where a sign-in link actually is,
71
+ * and printing it is what lets you sign in with no mail provider configured.
72
+ *
73
+ * It is also a credential. `logOnly` turns on whenever `MAIL_FROM` is unset, and
74
+ * that can happen in PRODUCTION — a secret that didn't get set, or a Secrets
75
+ * Store read that failed. The body then went to `console.info`, which means
76
+ * `wrangler tail` and every Logpush sink, carrying live single-use magic links
77
+ * and password-reset URLs. Anyone with read access to observability could take
78
+ * over an editor or portal account.
79
+ *
80
+ * So the body is printed only when we can see we're in development. Everywhere
81
+ * else the log still records that a message went unsent, and why, but not the
82
+ * credential inside it.
83
+ */
84
+ function describe(mail, reason, includeBody) {
85
+ const head = [
86
+ `[astroid:email] ${reason} — not sent`,
87
+ ` to: ${mail.to}`,
88
+ ` subject: ${mail.content.subject}`,
89
+ ];
90
+ if (!includeBody) {
91
+ return [
92
+ ...head,
93
+ " (body withheld — it can contain a single-use sign-in or reset link, and this",
94
+ " does not look like a development environment. Set MAIL_FROM and the EMAIL",
95
+ " binding to deliver it, or pass `devLog: true` if this really is local.)",
96
+ ].join("\n");
97
+ }
98
+ return [...head, " ---", ...mail.content.text.split("\n").map((line) => ` ${line}`), " ---"].join("\n");
99
+ }
100
+ /**
101
+ * Best-effort "are we in development?".
102
+ *
103
+ * Deliberately conservative — it decides whether a credential is printed, so an
104
+ * unknown environment must read as production. Workers has no `NODE_ENV`, so we
105
+ * look at the signals that do exist and let a caller override explicitly.
106
+ */
107
+ function looksLikeDev() {
108
+ // Vite/Astro define this at build time; `import.meta.env` is absent in a plain
109
+ // Worker, hence the guarded read.
110
+ const viteDev = import.meta.env?.DEV;
111
+ if (typeof viteDev === "boolean")
112
+ return viteDev;
113
+ const nodeEnv = globalThis.process
114
+ ?.env?.NODE_ENV;
115
+ if (typeof nodeEnv === "string")
116
+ return nodeEnv !== "production";
117
+ return false;
118
+ }
119
+ /**
120
+ * Send a batch of transactional messages, best effort.
121
+ *
122
+ * Delivery runs concurrently and independently: an inquiry sends a notification
123
+ * to the owner and a confirmation to the visitor, and the owner's copy must
124
+ * still arrive when the visitor typo'd their address. Callers get a result per
125
+ * message and decide whether to care.
126
+ *
127
+ * ```ts
128
+ * const results = await sendTransactional(
129
+ * { binding: env.EMAIL, from: env.MAIL_FROM },
130
+ * [
131
+ * { to: owner, content: inquiryNotificationEmail(theme, i), replyTo: i.email },
132
+ * { to: i.email, content: inquiryConfirmationEmail(theme, i) },
133
+ * ],
134
+ * );
135
+ * ```
136
+ */
137
+ export async function sendTransactional(options, mails) {
138
+ const log = options.log ?? ((message) => console.info(message));
139
+ const dormant = !options.binding || options.logOnly;
140
+ if (dormant) {
141
+ const reason = options.binding ? "log-only" : "not-configured";
142
+ const includeBody = options.devLog ?? looksLikeDev();
143
+ for (const mail of mails)
144
+ log(describe(mail, reason, includeBody));
145
+ return mails.map((mail) => ({
146
+ to: mail.to,
147
+ subject: mail.content.subject,
148
+ delivered: false,
149
+ reason,
150
+ }));
151
+ }
152
+ const binding = options.binding;
153
+ const settled = await Promise.allSettled(mails.map((mail) => sendEmail(binding, {
154
+ from: options.from,
155
+ to: mail.to,
156
+ subject: mail.content.subject,
157
+ html: mail.content.html,
158
+ text: mail.content.text,
159
+ ...(mail.replyTo ? { replyTo: mail.replyTo } : {}),
160
+ })));
161
+ return settled.map((result, i) => {
162
+ const mail = mails[i];
163
+ if (result.status === "fulfilled") {
164
+ return {
165
+ to: mail.to,
166
+ subject: mail.content.subject,
167
+ delivered: true,
168
+ messageId: result.value.messageId,
169
+ };
170
+ }
171
+ // A genuine delivery failure is LOGGED, not just returned.
172
+ //
173
+ // The result array was the only record of it, and the one caller that
174
+ // matters — the generated inquiry handler — discards it by design (the row
175
+ // is already durable, and the visitor must not see a 500 because the owner's
176
+ // notification bounced). So a dead Email Sending domain or an exhausted
177
+ // quota produced silence everywhere: a success page for the visitor, nothing
178
+ // for the owner, and nothing in `wrangler tail`. Weeks later someone asks
179
+ // why the contact form stopped working.
180
+ //
181
+ // Logged here rather than left to callers because this is the only place
182
+ // that knows a send was attempted and failed; `log` is already injectable
183
+ // for tests and for routing somewhere other than console.
184
+ const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
185
+ log(`[astroid:email] delivery FAILED to ${mail.to} (${mail.content.subject}): ${reason}`);
186
+ return { to: mail.to, subject: mail.content.subject, delivered: false, reason };
187
+ });
188
+ }
189
+ /**
190
+ * Bind a mailer's options once so call sites read as `mailer([...])`. Useful
191
+ * where the binding and sender are resolved per request but the sends are
192
+ * scattered (an inquiry hook, an auth callback).
193
+ */
194
+ export function createMailer(options) {
195
+ return (mails) => sendTransactional(options, mails);
196
+ }
@@ -0,0 +1,24 @@
1
+ import { type MailContent, type MailTheme } from "louise-toolkit/email";
2
+ export type { MailContent, MailTheme };
3
+ /** Editor sign-in magic link. */
4
+ export declare function magicLinkEmail(theme: MailTheme, params: {
5
+ url: string;
6
+ toEmail: string;
7
+ }): MailContent;
8
+ /** Password-reset link (the portal's credential flow). */
9
+ export declare function passwordResetEmail(theme: MailTheme, params: {
10
+ url: string;
11
+ toEmail: string;
12
+ }): MailContent;
13
+ /** The fields an inquiry contributes to both halves of the pair. */
14
+ export interface InquiryDetails {
15
+ name: string;
16
+ email: string;
17
+ /** Subject line the visitor picked, if the form offers one. */
18
+ regarding?: string;
19
+ message: string;
20
+ }
21
+ /** Owner-facing notification for a new contact-form submission. */
22
+ export declare function inquiryNotificationEmail(theme: MailTheme, i: InquiryDetails): MailContent;
23
+ /** Confirmation back to whoever submitted the contact form. */
24
+ export declare function inquiryConfirmationEmail(theme: MailTheme, i: InquiryDetails): MailContent;