@brandlab-digital/site-next 3.13.0 → 3.14.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 (169) hide show
  1. package/README.md +79 -0
  2. package/dist/contact/delivery.d.ts +81 -0
  3. package/dist/contact/delivery.d.ts.map +1 -0
  4. package/dist/contact/delivery.js +138 -0
  5. package/dist/contact/delivery.js.map +1 -0
  6. package/dist/contact/resend-webhook.d.ts +72 -0
  7. package/dist/contact/resend-webhook.d.ts.map +1 -0
  8. package/dist/contact/resend-webhook.js +300 -0
  9. package/dist/contact/resend-webhook.js.map +1 -0
  10. package/dist/contact/route-handler.d.ts +16 -0
  11. package/dist/contact/route-handler.d.ts.map +1 -1
  12. package/dist/contact/route-handler.js +8 -0
  13. package/dist/contact/route-handler.js.map +1 -1
  14. package/dist/contact/server-entry.d.ts +2 -0
  15. package/dist/contact/server-entry.d.ts.map +1 -1
  16. package/dist/contact/server-entry.js +2 -0
  17. package/dist/contact/server-entry.js.map +1 -1
  18. package/dist/contact/server.d.ts +26 -0
  19. package/dist/contact/server.d.ts.map +1 -1
  20. package/dist/contact/server.js +48 -3
  21. package/dist/contact/server.js.map +1 -1
  22. package/dist/next-config.d.ts +17 -1
  23. package/dist/next-config.d.ts.map +1 -1
  24. package/dist/next-config.js +36 -2
  25. package/dist/next-config.js.map +1 -1
  26. package/dist/ui-kit/brandlab-skin.d.ts +24 -1
  27. package/dist/ui-kit/brandlab-skin.d.ts.map +1 -1
  28. package/dist/ui-kit/brandlab-skin.js.map +1 -1
  29. package/dist/ui-kit/form/photo-upload.d.ts +4 -1
  30. package/dist/ui-kit/form/photo-upload.d.ts.map +1 -1
  31. package/dist/ui-kit/form/photo-upload.js +4 -4
  32. package/dist/ui-kit/form/photo-upload.js.map +1 -1
  33. package/dist/ui-kit/form/quote-form.d.ts +4 -1
  34. package/dist/ui-kit/form/quote-form.d.ts.map +1 -1
  35. package/dist/ui-kit/form/quote-form.js.map +1 -1
  36. package/dist/ui-kit/form/thank-you.d.ts +4 -1
  37. package/dist/ui-kit/form/thank-you.d.ts.map +1 -1
  38. package/dist/ui-kit/form/thank-you.js +2 -2
  39. package/dist/ui-kit/form/thank-you.js.map +1 -1
  40. package/dist/ui-kit/icon-glyphs.d.ts +41 -0
  41. package/dist/ui-kit/icon-glyphs.d.ts.map +1 -0
  42. package/dist/ui-kit/icon-glyphs.js +138 -0
  43. package/dist/ui-kit/icon-glyphs.js.map +1 -0
  44. package/dist/ui-kit/icon-sprite.d.ts +19 -0
  45. package/dist/ui-kit/icon-sprite.d.ts.map +1 -0
  46. package/dist/ui-kit/icon-sprite.js +44 -0
  47. package/dist/ui-kit/icon-sprite.js.map +1 -0
  48. package/dist/ui-kit/icons.d.ts +38 -3
  49. package/dist/ui-kit/icons.d.ts.map +1 -1
  50. package/dist/ui-kit/icons.js +47 -64
  51. package/dist/ui-kit/icons.js.map +1 -1
  52. package/dist/ui-kit/primitives/badge.d.ts +25 -0
  53. package/dist/ui-kit/primitives/badge.d.ts.map +1 -0
  54. package/dist/ui-kit/primitives/badge.js +21 -0
  55. package/dist/ui-kit/primitives/badge.js.map +1 -0
  56. package/dist/ui-kit/primitives/breadcrumbs.d.ts +27 -0
  57. package/dist/ui-kit/primitives/breadcrumbs.d.ts.map +1 -0
  58. package/dist/ui-kit/primitives/breadcrumbs.js +40 -0
  59. package/dist/ui-kit/primitives/breadcrumbs.js.map +1 -0
  60. package/dist/ui-kit/primitives/button.d.ts +32 -0
  61. package/dist/ui-kit/primitives/button.d.ts.map +1 -0
  62. package/dist/ui-kit/primitives/button.js +8 -0
  63. package/dist/ui-kit/primitives/button.js.map +1 -0
  64. package/dist/ui-kit/primitives/card.d.ts +40 -0
  65. package/dist/ui-kit/primitives/card.d.ts.map +1 -0
  66. package/dist/ui-kit/primitives/card.js +8 -0
  67. package/dist/ui-kit/primitives/card.js.map +1 -0
  68. package/dist/ui-kit/primitives/index.d.ts +11 -2
  69. package/dist/ui-kit/primitives/index.d.ts.map +1 -1
  70. package/dist/ui-kit/primitives/index.js +11 -2
  71. package/dist/ui-kit/primitives/index.js.map +1 -1
  72. package/dist/ui-kit/primitives/layout.d.ts +12 -0
  73. package/dist/ui-kit/primitives/layout.d.ts.map +1 -1
  74. package/dist/ui-kit/primitives/layout.js +20 -2
  75. package/dist/ui-kit/primitives/layout.js.map +1 -1
  76. package/dist/ui-kit/primitives/links.d.ts +15 -0
  77. package/dist/ui-kit/primitives/links.d.ts.map +1 -1
  78. package/dist/ui-kit/primitives/links.js +5 -1
  79. package/dist/ui-kit/primitives/links.js.map +1 -1
  80. package/dist/ui-kit/primitives/lists.d.ts +43 -0
  81. package/dist/ui-kit/primitives/lists.d.ts.map +1 -0
  82. package/dist/ui-kit/primitives/lists.js +34 -0
  83. package/dist/ui-kit/primitives/lists.js.map +1 -0
  84. package/dist/ui-kit/primitives/media-frame.d.ts +28 -0
  85. package/dist/ui-kit/primitives/media-frame.d.ts.map +1 -0
  86. package/dist/ui-kit/primitives/media-frame.js +58 -0
  87. package/dist/ui-kit/primitives/media-frame.js.map +1 -0
  88. package/dist/ui-kit/primitives/media.d.ts +38 -0
  89. package/dist/ui-kit/primitives/media.d.ts.map +1 -0
  90. package/dist/ui-kit/primitives/media.js +9 -0
  91. package/dist/ui-kit/primitives/media.js.map +1 -0
  92. package/dist/ui-kit/primitives/stat.d.ts +25 -0
  93. package/dist/ui-kit/primitives/stat.d.ts.map +1 -0
  94. package/dist/ui-kit/primitives/stat.js +7 -0
  95. package/dist/ui-kit/primitives/stat.js.map +1 -0
  96. package/dist/ui-kit/repeated-slots.d.ts +121 -0
  97. package/dist/ui-kit/repeated-slots.d.ts.map +1 -0
  98. package/dist/ui-kit/repeated-slots.js +81 -0
  99. package/dist/ui-kit/repeated-slots.js.map +1 -0
  100. package/dist/ui-kit/sections/accordion.d.ts +4 -1
  101. package/dist/ui-kit/sections/accordion.d.ts.map +1 -1
  102. package/dist/ui-kit/sections/accordion.js +2 -2
  103. package/dist/ui-kit/sections/accordion.js.map +1 -1
  104. package/dist/ui-kit/sections/contact-block.d.ts +4 -1
  105. package/dist/ui-kit/sections/contact-block.d.ts.map +1 -1
  106. package/dist/ui-kit/sections/contact-block.js +3 -3
  107. package/dist/ui-kit/sections/contact-block.js.map +1 -1
  108. package/dist/ui-kit/sections/faqs.d.ts +4 -1
  109. package/dist/ui-kit/sections/faqs.d.ts.map +1 -1
  110. package/dist/ui-kit/sections/faqs.js +4 -5
  111. package/dist/ui-kit/sections/faqs.js.map +1 -1
  112. package/dist/ui-kit/sections/hero.d.ts +113 -0
  113. package/dist/ui-kit/sections/hero.d.ts.map +1 -0
  114. package/dist/ui-kit/sections/hero.js +58 -0
  115. package/dist/ui-kit/sections/hero.js.map +1 -0
  116. package/dist/ui-kit/sections/index.d.ts +2 -0
  117. package/dist/ui-kit/sections/index.d.ts.map +1 -1
  118. package/dist/ui-kit/sections/index.js +2 -0
  119. package/dist/ui-kit/sections/index.js.map +1 -1
  120. package/dist/ui-kit/sections/price-guide.d.ts +4 -1
  121. package/dist/ui-kit/sections/price-guide.d.ts.map +1 -1
  122. package/dist/ui-kit/sections/price-guide.js +3 -3
  123. package/dist/ui-kit/sections/price-guide.js.map +1 -1
  124. package/dist/ui-kit/sections/price-table.d.ts +7 -9
  125. package/dist/ui-kit/sections/price-table.d.ts.map +1 -1
  126. package/dist/ui-kit/sections/price-table.js +8 -8
  127. package/dist/ui-kit/sections/price-table.js.map +1 -1
  128. package/dist/ui-kit/sections/proof-strip.d.ts +4 -1
  129. package/dist/ui-kit/sections/proof-strip.d.ts.map +1 -1
  130. package/dist/ui-kit/sections/proof-strip.js +2 -2
  131. package/dist/ui-kit/sections/proof-strip.js.map +1 -1
  132. package/dist/ui-kit/sections/reviews-band.d.ts +5 -2
  133. package/dist/ui-kit/sections/reviews-band.d.ts.map +1 -1
  134. package/dist/ui-kit/sections/reviews-band.js +2 -2
  135. package/dist/ui-kit/sections/reviews-band.js.map +1 -1
  136. package/dist/ui-kit/sections/services-grid.d.ts +82 -0
  137. package/dist/ui-kit/sections/services-grid.d.ts.map +1 -0
  138. package/dist/ui-kit/sections/services-grid.js +90 -0
  139. package/dist/ui-kit/sections/services-grid.js.map +1 -0
  140. package/dist/ui-kit/sections/site-footer.d.ts +4 -1
  141. package/dist/ui-kit/sections/site-footer.d.ts.map +1 -1
  142. package/dist/ui-kit/sections/site-footer.js +6 -6
  143. package/dist/ui-kit/sections/site-footer.js.map +1 -1
  144. package/dist/ui-kit/shell/mobile-drawer.d.ts +7 -3
  145. package/dist/ui-kit/shell/mobile-drawer.d.ts.map +1 -1
  146. package/dist/ui-kit/shell/mobile-drawer.js +20 -4
  147. package/dist/ui-kit/shell/mobile-drawer.js.map +1 -1
  148. package/dist/ui-kit/shell/site-header.d.ts +4 -1
  149. package/dist/ui-kit/shell/site-header.d.ts.map +1 -1
  150. package/dist/ui-kit/shell/site-header.js +8 -8
  151. package/dist/ui-kit/shell/site-header.js.map +1 -1
  152. package/dist/ui-kit/shell/social-links.d.ts +1 -2
  153. package/dist/ui-kit/shell/social-links.d.ts.map +1 -1
  154. package/dist/ui-kit/shell/social-links.js +6 -3
  155. package/dist/ui-kit/shell/social-links.js.map +1 -1
  156. package/dist/ui-kit/shell/utility-bar.d.ts +4 -1
  157. package/dist/ui-kit/shell/utility-bar.d.ts.map +1 -1
  158. package/dist/ui-kit/shell/utility-bar.js +2 -2
  159. package/dist/ui-kit/shell/utility-bar.js.map +1 -1
  160. package/dist/ui-kit/skin-index.d.ts +2 -1
  161. package/dist/ui-kit/skin-index.d.ts.map +1 -1
  162. package/dist/ui-kit/skin-index.js +2 -1
  163. package/dist/ui-kit/skin-index.js.map +1 -1
  164. package/dist/ui-kit/skin.d.ts +21 -4
  165. package/dist/ui-kit/skin.d.ts.map +1 -1
  166. package/dist/ui-kit/skin.js +14 -1
  167. package/dist/ui-kit/skin.js.map +1 -1
  168. package/dist/ui-kit/structure.css +383 -3
  169. package/package.json +6 -2
package/README.md CHANGED
@@ -55,6 +55,85 @@ check W3 (full tier, from 3.13.0) fails an estate family that refreshes more oft
55
55
  estate that shows reviews adopts `on-change` with the route, or records the family in `siteSystem.refreshExceptions`
56
56
  in brandlab.config.ts: `{ family, revalidateSeconds, reason, decisionId }`.
57
57
 
58
+ ## Contact delivery: accepted is not delivered (3.14.0)
59
+
60
+ When Resend accepts an enquiry it is queued, not delivered. Resend can still bounce it, drop it because the business
61
+ inbox is on the account's suppression list, or have it reported as spam, while the site has already said "sent". That
62
+ is how BL Roofing lost enquiries. `contact_form` keeps its analytics-v4 meaning (Resend accepted the enquiry) and fires
63
+ exactly as before; delivery is now tracked beside it.
64
+
65
+ **Before sending.** `createBrandlabContactRouteHandler` asks Resend's suppression list about each business inbox
66
+ (`GET /suppressions/{email}`, cached for 5 minutes per inbox, at most 2.5 seconds per lookup). The check only
67
+ records and alerts; it never blocks a send. When an inbox is suppressed the route logs and alerts
68
+ `recipient_suppressed`, and the enquiry still goes to the client's own inbox (never rerouted, never copied to Brandlab)
69
+ with the normal "sent" result and `contact_form` as usual. Keeping the inbox and its email billing in order is the
70
+ client's responsibility; the alert lets Brandlab tell them the same day. A site can opt in to refusing instead with
71
+ `refuseWhenAllSuppressed: true`: when every inbox is suppressed the visitor then gets the form's "could not send,
72
+ please call" answer and no conversion fires. A failed or refused lookup never stops an enquiry: it is
73
+ sent as before and logged once per 5 minutes as `brandlab_contact_suppression_check_unavailable`. The lookup needs a
74
+ full-access key: set `RESEND_LOOKUP_API_KEY` so `RESEND_API_KEY` can stay sending-only (without it the lookup uses
75
+ `RESEND_API_KEY`). A sending-only key is refused with `restricted_api_key`, and the warning line names the missing
76
+ permission (`"needs":"full-access key (RESEND_LOOKUP_API_KEY)"`). `suppressionCheck: false` turns the check off; a
77
+ route that replaces the provider with `createResendSend` (synthetic tests) has it off unless it passes one.
78
+
79
+ **After sending.** A signed webhook route records what happened to the business copy. Add
80
+ `src/app/api/brandlab/resend-webhook/route.ts`:
81
+
82
+ ```ts
83
+ import { createBrandlabResendWebhookRoute } from '@brandlab-digital/site-next/contact/server'
84
+ import { contactContract } from '@/config/contact'
85
+
86
+ export const { POST } = createBrandlabResendWebhookRoute({ projectId: contactContract.projectId })
87
+ ```
88
+
89
+ Then, in plain steps:
90
+
91
+ 1. Deploy the route.
92
+ 2. In Resend, open Webhooks and add an endpoint: `https://<the site's domain>/api/brandlab/resend-webhook`.
93
+ 3. Tick the email events: delivered, delivery delayed, bounced, complained, suppressed and failed.
94
+ 4. Open the new webhook and copy its signing secret (it starts with `whsec_`).
95
+ 5. In the site's Vercel project, add `RESEND_WEBHOOK_SECRET` with that value for Production (and Preview if previews
96
+ send real enquiries). Never put it in git or `.env.example` values.
97
+ 6. Redeploy, send a test enquiry (`?bl_test=1`), and check the logs show a `brandlab_contact_delivery_status` line
98
+ with `"state":"delivered"`.
99
+
100
+ Resend webhooks cover the whole account, so each site's route records only the emails its own contact route sent (the
101
+ `project_id` tag) and answers 200 to the rest. The signature is checked the Svix way Resend documents
102
+ (HMAC-SHA256 over `<svix-id>.<svix-timestamp>.<raw body>`, 5 minutes' clock tolerance); a bad signature gets 401, a
103
+ missing secret 503. Failed attempts are rate-limited per `x-real-ip` (10 in 10 minutes, then 429 with `Retry-After`;
104
+ `rateLimit` adjusts it or `false` turns it off); a correctly signed request never counts. A retry with an `svix-id` the
105
+ instance already recorded answers 200 `duplicate: true` without logging again; that list is small and per instance,
106
+ so it is best effort and the monitor still de-duplicates.
107
+
108
+ Both halves accept `onDeliveryFailure(failure)`, a same-day alert hook (email, Slack or a durable store). It gets the
109
+ code, source, enquiry reference and Resend email id, never an address or form value. If it throws on a webhook event
110
+ the route answers 500 so Resend retries the event.
111
+
112
+ | Environment variable | Where | What |
113
+ | --- | --- | --- |
114
+ | `RESEND_API_KEY` | Vercel, Production and Preview | Sends enquiries; can stay a sending-only key |
115
+ | `RESEND_LOOKUP_API_KEY` | Vercel, Production and Preview (optional) | A full-access key used only for the suppression lookup; without it the lookup uses `RESEND_API_KEY` |
116
+ | `RESEND_WEBHOOK_SECRET` | Vercel, Production | The webhook's `whsec_` signing secret |
117
+ | the contract's `delivery.recipientEnv` | Vercel, Production and Preview | The business inbox |
118
+
119
+ **For the daily delivery monitor (P26).** Read these JSON lines from the site's logs:
120
+
121
+ | Line | Meaning | Action |
122
+ | --- | --- | --- |
123
+ | `{"event":"brandlab_contact_delivery_failed",…,"code":"recipient_suppressed","source":"pre-send","recipients":"all"}` | The inbox is suppressed; the enquiry was still sent (Resend will drop it) and the visitor saw "sent" (unless the site opted in to `refuseWhenAllSuppressed: true`, when it was not sent and the visitor was asked to call) | Same day: tell the client to fix the inbox, remove it from Resend → Suppressions, tell Romie |
124
+ | same, `"recipients":"some"` | One of several inboxes is suppressed; the others got it | Same day: fix or remove that inbox |
125
+ | `…"code":"recipient_bounced"/"recipient_complained"/"recipient_suppressed"/"provider_failed","source":"webhook"` | Resend accepted it but the business copy did not arrive (`detail` gives Resend's bounce type, suppression type or failure reason) | Same day: the enquiry counted as a `contact_form` but the business never saw it; find it by `submission` (the first 16 characters of the confirmation id) or `emailId` in Resend and forward it |
126
+ | `{"event":"brandlab_contact_delivery_status","state":"delivered"}` / `"state":"delayed"` | Delivered, or Resend is retrying a soft bounce | None; a `delayed` with no later `delivered` or failure within a day needs a look |
127
+ | `brandlab_contact_suppression_check_unavailable` | The pre-send check could not ask Resend (`code` says why; `needs` names a missing permission) | Set a full-access `RESEND_LOOKUP_API_KEY`; enquiries still send |
128
+ | `brandlab_contact_delivery_alert_failed` | The alert hook failed | Check the hook |
129
+
130
+ De-duplicate webhook lines on `webhookId` (Resend retries an event with the same id). Compare each day's
131
+ `contact_form` count in GA4 with `delivered` lines: every accepted enquiry should end in exactly one `delivered` or one
132
+ failure. Ready-gate check H13 (enquiry tier, from 3.14.0) checks the same inbox before launch: a real sender, MX records,
133
+ the sender domain verified in Resend and the inbox not suppressed. Run `brandlab-ready --launch` with the inbox variable
134
+ and a full-access key (`RESEND_LOOKUP_API_KEY`) set in the shell for that run, never written to a file. Without them H13
135
+ reports "not run" and names what to set: in an ordinary run that does not block the tier, and under `--launch` it fails.
136
+
58
137
  ## Contact route: abuse limits and the test marker (3.10.0)
59
138
 
60
139
  `createBrandlabContactRouteHandler` limits distinct enquiries per client IP and per email address where enquiries are
@@ -0,0 +1,81 @@
1
+ import 'server-only';
2
+ /**
3
+ * Delivery truth for contact enquiries (3.14.0).
4
+ *
5
+ * An enquiry Resend accepts is queued, not delivered: Resend can still bounce it, drop it because the inbox is on the
6
+ * account's suppression list, or have it reported as spam. `contact_form` keeps its analytics-v4 meaning (the provider
7
+ * accepted the enquiry) and is unchanged. Delivery is tracked beside it:
8
+ * - before sending, the business inboxes are checked against Resend's suppression list (`createResendSuppressionCheck`);
9
+ * - after sending, Resend's signed webhook reports delivered, delayed, bounced, complained, suppressed and failed
10
+ * copies (`createBrandlabResendWebhookRoute`).
11
+ * Every failure writes one `brandlab_contact_delivery_failed` line for the daily delivery monitor and calls the
12
+ * optional alert hook the same moment. No line carries an address, a name, a form value or a key.
13
+ */
14
+ export declare const BRANDLAB_CONTACT_DELIVERY_FAILED_EVENT: "brandlab_contact_delivery_failed";
15
+ export declare const BRANDLAB_CONTACT_DELIVERY_STATUS_EVENT: "brandlab_contact_delivery_status";
16
+ export declare const BRANDLAB_CONTACT_SUPPRESSION_UNAVAILABLE_EVENT: "brandlab_contact_suppression_check_unavailable";
17
+ export declare const BRANDLAB_CONTACT_DELIVERY_ALERT_FAILED_EVENT: "brandlab_contact_delivery_alert_failed";
18
+ /** How long one suppression answer (suppressed, clear or unavailable) is reused for an inbox. */
19
+ export declare const BRANDLAB_CONTACT_SUPPRESSION_CACHE_MS: number;
20
+ /** The longest an enquiry waits for the suppression lookup before it is sent anyway. */
21
+ export declare const BRANDLAB_CONTACT_SUPPRESSION_TIMEOUT_MS = 2500;
22
+ /**
23
+ * An optional full-access Resend key used only for the suppression and domain lookups, so the sending key
24
+ * (`RESEND_API_KEY`) can stay sending-only. Without it the lookups use `RESEND_API_KEY`.
25
+ */
26
+ export declare const BRANDLAB_CONTACT_LOOKUP_API_KEY_ENV: "RESEND_LOOKUP_API_KEY";
27
+ /** `suppressed`: Resend will not deliver to the inbox. `unknown`: the lookup could not tell, so the enquiry is sent. */
28
+ export type BrandlabContactSuppressionStatus = 'suppressed' | 'clear' | 'unknown';
29
+ export interface BrandlabContactSuppressionCheck {
30
+ /** Never throws; `unknown` whenever Resend could not be asked or did not answer clearly (fail open). */
31
+ check(address: string, context: {
32
+ apiKey: string;
33
+ }): Promise<BrandlabContactSuppressionStatus>;
34
+ }
35
+ export type BrandlabContactDeliveryFailureCode = 'recipient_suppressed' | 'recipient_bounced' | 'recipient_complained' | 'provider_failed';
36
+ /** One enquiry copy that did not, or will not, reach a business inbox. Never carries an address or form value. */
37
+ export interface BrandlabContactDeliveryFailure {
38
+ event: typeof BRANDLAB_CONTACT_DELIVERY_FAILED_EVENT;
39
+ provider: 'resend';
40
+ code: BrandlabContactDeliveryFailureCode;
41
+ /** `pre-send`: the suppression check stopped it before sending. `webhook`: Resend reported it after sending. */
42
+ source: 'pre-send' | 'webhook';
43
+ /** Pre-send only: whether every business inbox was suppressed (nothing sent) or only some (sent to the rest). */
44
+ recipients?: 'all' | 'some';
45
+ /** The first 16 hex characters of the enquiry's confirmation id (the `submission_hash` tag), when known. */
46
+ submission?: string;
47
+ /** Resend's email id, from the webhook. */
48
+ emailId?: string;
49
+ /** The webhook's `svix-id`, the same on every retry of one event, for de-duplication. */
50
+ webhookId?: string;
51
+ /** Resend's bounce type and subtype, suppression type or failure reason, when it gave a plain one. */
52
+ detail?: string;
53
+ /** When the library saw it (ISO 8601). */
54
+ at: string;
55
+ }
56
+ /**
57
+ * A same-day alert hook for delivery failures (email, Slack, a durable store): called once per failure after the log
58
+ * line is written. A hook that throws is logged; the webhook route then answers 500 so Resend retries the event.
59
+ */
60
+ export type BrandlabContactDeliveryAlert = (failure: BrandlabContactDeliveryFailure) => void | Promise<void>;
61
+ /** Writes the failure line (the daily monitor's input), then calls the alert hook. False when the hook failed. */
62
+ export declare function reportContactDeliveryFailure(failure: BrandlabContactDeliveryFailure, alert?: BrandlabContactDeliveryAlert): Promise<boolean>;
63
+ export interface ResendSuppressionCheckOptions {
64
+ /** Defaults to the global fetch. */
65
+ fetch?: typeof globalThis.fetch;
66
+ /** Defaults to `Date.now`. */
67
+ now?: () => number;
68
+ /** Defaults to BRANDLAB_CONTACT_SUPPRESSION_CACHE_MS. */
69
+ cacheMs?: number;
70
+ /** Defaults to BRANDLAB_CONTACT_SUPPRESSION_TIMEOUT_MS. */
71
+ timeoutMs?: number;
72
+ }
73
+ /**
74
+ * Asks Resend's suppression list about a business inbox before an enquiry is sent (`GET /suppressions/{email}`).
75
+ * Answers are reused for the cache window per inbox, so a busy form asks at most once per inbox in that window. Any
76
+ * error, timeout or refusal answers `unknown` and the enquiry is sent: the check can stop a send it knows is lost,
77
+ * never one it is unsure about. A refusal is logged once per cache window (a sending-only API key answers 401
78
+ * `restricted_api_key` and the line names the missing permission: set a full-access key in RESEND_LOOKUP_API_KEY).
79
+ */
80
+ export declare function createResendSuppressionCheck(options?: ResendSuppressionCheckOptions): BrandlabContactSuppressionCheck;
81
+ //# sourceMappingURL=delivery.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery.d.ts","sourceRoot":"","sources":["../../src/contact/delivery.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAIpB;;;;;;;;;;;GAWG;AAEH,eAAO,MAAM,sCAAsC,EAAG,kCAA2C,CAAA;AACjG,eAAO,MAAM,sCAAsC,EAAG,kCAA2C,CAAA;AACjG,eAAO,MAAM,8CAA8C,EAAG,gDAAyD,CAAA;AACvH,eAAO,MAAM,4CAA4C,EAAG,wCAAiD,CAAA;AAE7G,iGAAiG;AACjG,eAAO,MAAM,qCAAqC,QAAiB,CAAA;AACnE,wFAAwF;AACxF,eAAO,MAAM,uCAAuC,OAAQ,CAAA;AAI5D;;;GAGG;AACH,eAAO,MAAM,mCAAmC,EAAG,uBAAgC,CAAA;AAGnF,wHAAwH;AACxH,MAAM,MAAM,gCAAgC,GAAG,YAAY,GAAG,OAAO,GAAG,SAAS,CAAA;AAEjF,MAAM,WAAW,+BAA+B;IAC9C,wGAAwG;IACxG,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,gCAAgC,CAAC,CAAA;CAC/F;AAED,MAAM,MAAM,kCAAkC,GAC1C,sBAAsB,GACtB,mBAAmB,GACnB,sBAAsB,GACtB,iBAAiB,CAAA;AAErB,kHAAkH;AAClH,MAAM,WAAW,8BAA8B;IAC7C,KAAK,EAAE,OAAO,sCAAsC,CAAA;IACpD,QAAQ,EAAE,QAAQ,CAAA;IAClB,IAAI,EAAE,kCAAkC,CAAA;IACxC,gHAAgH;IAChH,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;IAC9B,iHAAiH;IACjH,UAAU,CAAC,EAAE,KAAK,GAAG,MAAM,CAAA;IAC3B,4GAA4G;IAC5G,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,2CAA2C;IAC3C,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,yFAAyF;IACzF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sGAAsG;IACtG,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0CAA0C;IAC1C,EAAE,EAAE,MAAM,CAAA;CACX;AAED;;;GAGG;AACH,MAAM,MAAM,4BAA4B,GAAG,CAAC,OAAO,EAAE,8BAA8B,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;AAE5G,kHAAkH;AAClH,wBAAsB,4BAA4B,CAChD,OAAO,EAAE,8BAA8B,EACvC,KAAK,CAAC,EAAE,4BAA4B,GACnC,OAAO,CAAC,OAAO,CAAC,CA6BlB;AAED,MAAM,WAAW,6BAA6B;IAC5C,oCAAoC;IACpC,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAA;IAC/B,8BAA8B;IAC9B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AA6CD;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,GAAE,6BAAkC,GAC1C,+BAA+B,CAoCjC"}
@@ -0,0 +1,138 @@
1
+ import 'server-only';
2
+ import { RESEND_ERROR_CODES } from './resend.js';
3
+ /**
4
+ * Delivery truth for contact enquiries (3.14.0).
5
+ *
6
+ * An enquiry Resend accepts is queued, not delivered: Resend can still bounce it, drop it because the inbox is on the
7
+ * account's suppression list, or have it reported as spam. `contact_form` keeps its analytics-v4 meaning (the provider
8
+ * accepted the enquiry) and is unchanged. Delivery is tracked beside it:
9
+ * - before sending, the business inboxes are checked against Resend's suppression list (`createResendSuppressionCheck`);
10
+ * - after sending, Resend's signed webhook reports delivered, delayed, bounced, complained, suppressed and failed
11
+ * copies (`createBrandlabResendWebhookRoute`).
12
+ * Every failure writes one `brandlab_contact_delivery_failed` line for the daily delivery monitor and calls the
13
+ * optional alert hook the same moment. No line carries an address, a name, a form value or a key.
14
+ */
15
+ export const BRANDLAB_CONTACT_DELIVERY_FAILED_EVENT = 'brandlab_contact_delivery_failed';
16
+ export const BRANDLAB_CONTACT_DELIVERY_STATUS_EVENT = 'brandlab_contact_delivery_status';
17
+ export const BRANDLAB_CONTACT_SUPPRESSION_UNAVAILABLE_EVENT = 'brandlab_contact_suppression_check_unavailable';
18
+ export const BRANDLAB_CONTACT_DELIVERY_ALERT_FAILED_EVENT = 'brandlab_contact_delivery_alert_failed';
19
+ /** How long one suppression answer (suppressed, clear or unavailable) is reused for an inbox. */
20
+ export const BRANDLAB_CONTACT_SUPPRESSION_CACHE_MS = 5 * 60 * 1_000;
21
+ /** The longest an enquiry waits for the suppression lookup before it is sent anyway. */
22
+ export const BRANDLAB_CONTACT_SUPPRESSION_TIMEOUT_MS = 2_500;
23
+ const RESEND_SUPPRESSIONS_URL = 'https://api.resend.com/suppressions/';
24
+ /**
25
+ * An optional full-access Resend key used only for the suppression and domain lookups, so the sending key
26
+ * (`RESEND_API_KEY`) can stay sending-only. Without it the lookups use `RESEND_API_KEY`.
27
+ */
28
+ export const BRANDLAB_CONTACT_LOOKUP_API_KEY_ENV = 'RESEND_LOOKUP_API_KEY';
29
+ const MAILBOX_PATTERN = /^[^\s<>,@]+@[^\s<>,@]+\.[^\s<>,@]+$/;
30
+ /** Writes the failure line (the daily monitor's input), then calls the alert hook. False when the hook failed. */
31
+ export async function reportContactDeliveryFailure(failure, alert) {
32
+ console.error(JSON.stringify({
33
+ event: failure.event,
34
+ provider: failure.provider,
35
+ status: null,
36
+ code: failure.code,
37
+ source: failure.source,
38
+ ...(failure.recipients ? { recipients: failure.recipients } : {}),
39
+ ...(failure.submission ? { submission: failure.submission } : {}),
40
+ ...(failure.emailId ? { emailId: failure.emailId } : {}),
41
+ ...(failure.webhookId ? { webhookId: failure.webhookId } : {}),
42
+ ...(failure.detail ? { detail: failure.detail } : {}),
43
+ }));
44
+ if (!alert)
45
+ return true;
46
+ try {
47
+ await alert(failure);
48
+ return true;
49
+ }
50
+ catch (error) {
51
+ console.error(JSON.stringify({
52
+ event: BRANDLAB_CONTACT_DELIVERY_ALERT_FAILED_EVENT,
53
+ code: failure.code,
54
+ reason: error instanceof Error ? error.name : 'error',
55
+ }));
56
+ return false;
57
+ }
58
+ }
59
+ async function lookupSuppression(fetcher, address, apiKey, timeoutMs) {
60
+ let response;
61
+ try {
62
+ response = await fetcher(`${RESEND_SUPPRESSIONS_URL}${encodeURIComponent(address)}`, {
63
+ method: 'GET',
64
+ headers: { authorization: `Bearer ${apiKey}`, accept: 'application/json' },
65
+ signal: AbortSignal.timeout(timeoutMs),
66
+ });
67
+ }
68
+ catch {
69
+ return { status: 'unknown', code: 'lookup_failed' };
70
+ }
71
+ let body;
72
+ try {
73
+ body = await response.json();
74
+ }
75
+ catch {
76
+ body = undefined;
77
+ }
78
+ const record = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
79
+ const name = typeof record.name === 'string' ? record.name.trim().toLowerCase() : '';
80
+ if (response.ok) {
81
+ return typeof record.email === 'string' && record.email.trim().toLowerCase() === address
82
+ ? { status: 'suppressed' }
83
+ : { status: 'unknown', httpStatus: response.status, code: 'unexpected_answer' };
84
+ }
85
+ if (response.status === 404)
86
+ return { status: 'clear' };
87
+ return {
88
+ status: 'unknown',
89
+ httpStatus: response.status,
90
+ code: RESEND_ERROR_CODES.has(name) ? name : 'provider_rejected',
91
+ };
92
+ }
93
+ /**
94
+ * Asks Resend's suppression list about a business inbox before an enquiry is sent (`GET /suppressions/{email}`).
95
+ * Answers are reused for the cache window per inbox, so a busy form asks at most once per inbox in that window. Any
96
+ * error, timeout or refusal answers `unknown` and the enquiry is sent: the check can stop a send it knows is lost,
97
+ * never one it is unsure about. A refusal is logged once per cache window (a sending-only API key answers 401
98
+ * `restricted_api_key` and the line names the missing permission: set a full-access key in RESEND_LOOKUP_API_KEY).
99
+ */
100
+ export function createResendSuppressionCheck(options = {}) {
101
+ const fetcher = options.fetch ?? ((...args) => globalThis.fetch(...args));
102
+ const now = options.now ?? Date.now;
103
+ const cacheMs = options.cacheMs ?? BRANDLAB_CONTACT_SUPPRESSION_CACHE_MS;
104
+ const timeoutMs = options.timeoutMs ?? BRANDLAB_CONTACT_SUPPRESSION_TIMEOUT_MS;
105
+ const cache = new Map();
106
+ return Object.freeze({
107
+ async check(address, context) {
108
+ const normalized = typeof address === 'string' ? address.trim().toLowerCase() : '';
109
+ if (!MAILBOX_PATTERN.test(normalized) || normalized.length > 320 || !context?.apiKey)
110
+ return 'unknown';
111
+ const at = now();
112
+ const cached = cache.get(normalized);
113
+ if (cached && cached.until > at)
114
+ return cached.status;
115
+ let lookup;
116
+ try {
117
+ lookup = await lookupSuppression(fetcher, normalized, context.apiKey, timeoutMs);
118
+ }
119
+ catch {
120
+ lookup = { status: 'unknown', code: 'lookup_failed' };
121
+ }
122
+ if (lookup.status === 'unknown') {
123
+ console.warn(JSON.stringify({
124
+ event: BRANDLAB_CONTACT_SUPPRESSION_UNAVAILABLE_EVENT,
125
+ provider: 'resend',
126
+ status: lookup.httpStatus ?? null,
127
+ code: lookup.code ?? 'unknown',
128
+ ...(lookup.code === 'restricted_api_key' ? { needs: `full-access key (${BRANDLAB_CONTACT_LOOKUP_API_KEY_ENV})` } : {}),
129
+ }));
130
+ }
131
+ if (cache.size > 64)
132
+ cache.clear();
133
+ cache.set(normalized, { status: lookup.status, until: at + cacheMs });
134
+ return lookup.status;
135
+ },
136
+ });
137
+ }
138
+ //# sourceMappingURL=delivery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery.js","sourceRoot":"","sources":["../../src/contact/delivery.ts"],"names":[],"mappings":"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","sourcesContent":["import 'server-only'\n\nimport { RESEND_ERROR_CODES } from './resend.js'\n\n/**\n * Delivery truth for contact enquiries (3.14.0).\n *\n * An enquiry Resend accepts is queued, not delivered: Resend can still bounce it, drop it because the inbox is on the\n * account's suppression list, or have it reported as spam. `contact_form` keeps its analytics-v4 meaning (the provider\n * accepted the enquiry) and is unchanged. Delivery is tracked beside it:\n * - before sending, the business inboxes are checked against Resend's suppression list (`createResendSuppressionCheck`);\n * - after sending, Resend's signed webhook reports delivered, delayed, bounced, complained, suppressed and failed\n * copies (`createBrandlabResendWebhookRoute`).\n * Every failure writes one `brandlab_contact_delivery_failed` line for the daily delivery monitor and calls the\n * optional alert hook the same moment. No line carries an address, a name, a form value or a key.\n */\n\nexport const BRANDLAB_CONTACT_DELIVERY_FAILED_EVENT = 'brandlab_contact_delivery_failed' as const\nexport const BRANDLAB_CONTACT_DELIVERY_STATUS_EVENT = 'brandlab_contact_delivery_status' as const\nexport const BRANDLAB_CONTACT_SUPPRESSION_UNAVAILABLE_EVENT = 'brandlab_contact_suppression_check_unavailable' as const\nexport const BRANDLAB_CONTACT_DELIVERY_ALERT_FAILED_EVENT = 'brandlab_contact_delivery_alert_failed' as const\n\n/** How long one suppression answer (suppressed, clear or unavailable) is reused for an inbox. */\nexport const BRANDLAB_CONTACT_SUPPRESSION_CACHE_MS = 5 * 60 * 1_000\n/** The longest an enquiry waits for the suppression lookup before it is sent anyway. */\nexport const BRANDLAB_CONTACT_SUPPRESSION_TIMEOUT_MS = 2_500\n\nconst RESEND_SUPPRESSIONS_URL = 'https://api.resend.com/suppressions/'\n\n/**\n * An optional full-access Resend key used only for the suppression and domain lookups, so the sending key\n * (`RESEND_API_KEY`) can stay sending-only. Without it the lookups use `RESEND_API_KEY`.\n */\nexport const BRANDLAB_CONTACT_LOOKUP_API_KEY_ENV = 'RESEND_LOOKUP_API_KEY' as const\nconst MAILBOX_PATTERN = /^[^\\s<>,@]+@[^\\s<>,@]+\\.[^\\s<>,@]+$/\n\n/** `suppressed`: Resend will not deliver to the inbox. `unknown`: the lookup could not tell, so the enquiry is sent. */\nexport type BrandlabContactSuppressionStatus = 'suppressed' | 'clear' | 'unknown'\n\nexport interface BrandlabContactSuppressionCheck {\n /** Never throws; `unknown` whenever Resend could not be asked or did not answer clearly (fail open). */\n check(address: string, context: { apiKey: string }): Promise<BrandlabContactSuppressionStatus>\n}\n\nexport type BrandlabContactDeliveryFailureCode =\n | 'recipient_suppressed'\n | 'recipient_bounced'\n | 'recipient_complained'\n | 'provider_failed'\n\n/** One enquiry copy that did not, or will not, reach a business inbox. Never carries an address or form value. */\nexport interface BrandlabContactDeliveryFailure {\n event: typeof BRANDLAB_CONTACT_DELIVERY_FAILED_EVENT\n provider: 'resend'\n code: BrandlabContactDeliveryFailureCode\n /** `pre-send`: the suppression check stopped it before sending. `webhook`: Resend reported it after sending. */\n source: 'pre-send' | 'webhook'\n /** Pre-send only: whether every business inbox was suppressed (nothing sent) or only some (sent to the rest). */\n recipients?: 'all' | 'some'\n /** The first 16 hex characters of the enquiry's confirmation id (the `submission_hash` tag), when known. */\n submission?: string\n /** Resend's email id, from the webhook. */\n emailId?: string\n /** The webhook's `svix-id`, the same on every retry of one event, for de-duplication. */\n webhookId?: string\n /** Resend's bounce type and subtype, suppression type or failure reason, when it gave a plain one. */\n detail?: string\n /** When the library saw it (ISO 8601). */\n at: string\n}\n\n/**\n * A same-day alert hook for delivery failures (email, Slack, a durable store): called once per failure after the log\n * line is written. A hook that throws is logged; the webhook route then answers 500 so Resend retries the event.\n */\nexport type BrandlabContactDeliveryAlert = (failure: BrandlabContactDeliveryFailure) => void | Promise<void>\n\n/** Writes the failure line (the daily monitor's input), then calls the alert hook. False when the hook failed. */\nexport async function reportContactDeliveryFailure(\n failure: BrandlabContactDeliveryFailure,\n alert?: BrandlabContactDeliveryAlert,\n): Promise<boolean> {\n console.error(\n JSON.stringify({\n event: failure.event,\n provider: failure.provider,\n status: null,\n code: failure.code,\n source: failure.source,\n ...(failure.recipients ? { recipients: failure.recipients } : {}),\n ...(failure.submission ? { submission: failure.submission } : {}),\n ...(failure.emailId ? { emailId: failure.emailId } : {}),\n ...(failure.webhookId ? { webhookId: failure.webhookId } : {}),\n ...(failure.detail ? { detail: failure.detail } : {}),\n }),\n )\n if (!alert) return true\n try {\n await alert(failure)\n return true\n } catch (error) {\n console.error(\n JSON.stringify({\n event: BRANDLAB_CONTACT_DELIVERY_ALERT_FAILED_EVENT,\n code: failure.code,\n reason: error instanceof Error ? error.name : 'error',\n }),\n )\n return false\n }\n}\n\nexport interface ResendSuppressionCheckOptions {\n /** Defaults to the global fetch. */\n fetch?: typeof globalThis.fetch\n /** Defaults to `Date.now`. */\n now?: () => number\n /** Defaults to BRANDLAB_CONTACT_SUPPRESSION_CACHE_MS. */\n cacheMs?: number\n /** Defaults to BRANDLAB_CONTACT_SUPPRESSION_TIMEOUT_MS. */\n timeoutMs?: number\n}\n\ninterface SuppressionLookup {\n status: BrandlabContactSuppressionStatus\n httpStatus?: number\n code?: string\n}\n\nasync function lookupSuppression(\n fetcher: typeof globalThis.fetch,\n address: string,\n apiKey: string,\n timeoutMs: number,\n): Promise<SuppressionLookup> {\n let response: Response\n try {\n response = await fetcher(`${RESEND_SUPPRESSIONS_URL}${encodeURIComponent(address)}`, {\n method: 'GET',\n headers: { authorization: `Bearer ${apiKey}`, accept: 'application/json' },\n signal: AbortSignal.timeout(timeoutMs),\n })\n } catch {\n return { status: 'unknown', code: 'lookup_failed' }\n }\n let body: unknown\n try {\n body = await response.json()\n } catch {\n body = undefined\n }\n const record = body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : {}\n const name = typeof record.name === 'string' ? record.name.trim().toLowerCase() : ''\n if (response.ok) {\n return typeof record.email === 'string' && record.email.trim().toLowerCase() === address\n ? { status: 'suppressed' }\n : { status: 'unknown', httpStatus: response.status, code: 'unexpected_answer' }\n }\n if (response.status === 404) return { status: 'clear' }\n return {\n status: 'unknown',\n httpStatus: response.status,\n code: RESEND_ERROR_CODES.has(name) ? name : 'provider_rejected',\n }\n}\n\n/**\n * Asks Resend's suppression list about a business inbox before an enquiry is sent (`GET /suppressions/{email}`).\n * Answers are reused for the cache window per inbox, so a busy form asks at most once per inbox in that window. Any\n * error, timeout or refusal answers `unknown` and the enquiry is sent: the check can stop a send it knows is lost,\n * never one it is unsure about. A refusal is logged once per cache window (a sending-only API key answers 401\n * `restricted_api_key` and the line names the missing permission: set a full-access key in RESEND_LOOKUP_API_KEY).\n */\nexport function createResendSuppressionCheck(\n options: ResendSuppressionCheckOptions = {},\n): BrandlabContactSuppressionCheck {\n const fetcher = options.fetch ?? ((...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args))\n const now = options.now ?? Date.now\n const cacheMs = options.cacheMs ?? BRANDLAB_CONTACT_SUPPRESSION_CACHE_MS\n const timeoutMs = options.timeoutMs ?? BRANDLAB_CONTACT_SUPPRESSION_TIMEOUT_MS\n const cache = new Map<string, { status: BrandlabContactSuppressionStatus; until: number }>()\n\n return Object.freeze({\n async check(address: string, context: { apiKey: string }): Promise<BrandlabContactSuppressionStatus> {\n const normalized = typeof address === 'string' ? address.trim().toLowerCase() : ''\n if (!MAILBOX_PATTERN.test(normalized) || normalized.length > 320 || !context?.apiKey) return 'unknown'\n const at = now()\n const cached = cache.get(normalized)\n if (cached && cached.until > at) return cached.status\n let lookup: SuppressionLookup\n try {\n lookup = await lookupSuppression(fetcher, normalized, context.apiKey, timeoutMs)\n } catch {\n lookup = { status: 'unknown', code: 'lookup_failed' }\n }\n if (lookup.status === 'unknown') {\n console.warn(\n JSON.stringify({\n event: BRANDLAB_CONTACT_SUPPRESSION_UNAVAILABLE_EVENT,\n provider: 'resend',\n status: lookup.httpStatus ?? null,\n code: lookup.code ?? 'unknown',\n ...(lookup.code === 'restricted_api_key' ? { needs: `full-access key (${BRANDLAB_CONTACT_LOOKUP_API_KEY_ENV})` } : {}),\n }),\n )\n }\n if (cache.size > 64) cache.clear()\n cache.set(normalized, { status: lookup.status, until: at + cacheMs })\n return lookup.status\n },\n })\n}\n"]}
@@ -0,0 +1,72 @@
1
+ import 'server-only';
2
+ import { type BrandlabContactDeliveryAlert } from './delivery.js';
3
+ import { type BrandlabContactRateLimitRule } from './rate-limit.js';
4
+ /**
5
+ * The signed Resend webhook route (3.14.0): it learns, the same day, when the business's copy of an enquiry bounced,
6
+ * was suppressed, was reported as spam or failed, and records each as a delivery failure. Serve it at
7
+ * `src/app/api/brandlab/resend-webhook/route.ts`:
8
+ *
9
+ * ```ts
10
+ * import { createBrandlabResendWebhookRoute } from '@brandlab-digital/site-next/contact/server'
11
+ * import { contactContract } from '@/config/contact'
12
+ *
13
+ * export const { POST } = createBrandlabResendWebhookRoute({ projectId: contactContract.projectId })
14
+ * ```
15
+ *
16
+ * Resend signs every event with Svix (`svix-id`, `svix-timestamp`, `svix-signature`): HMAC-SHA256 over
17
+ * `<svix-id>.<svix-timestamp>.<raw body>` keyed with the base64 part of the endpoint's `whsec_` signing secret, held in
18
+ * `RESEND_WEBHOOK_SECRET` and never logged. Webhooks are account-wide, so the route records only the emails this site's
19
+ * contact route sent (their `project_id` tag) and answers 200 to everything else so Resend stops retrying it.
20
+ *
21
+ * Answers: 200 `{ ok: true, recorded }` (`duplicate: true` for a retry this instance already recorded); 400 for an
22
+ * unreadable body; 401 for a missing or wrong signature or a timestamp more than 5 minutes off; 413 for a body over
23
+ * 256 KB; 429 with `Retry-After` once one client IP (`x-real-ip`) has failed those checks past the rate limit; 503 when
24
+ * the secret is not set; 500 when the alert hook failed (Resend retries).
25
+ *
26
+ * Retries are de-duplicated by `svix-id` in a small in-memory list per server instance: best effort only, so the daily
27
+ * monitor still de-duplicates on `webhookId`. Only failed attempts count towards the rate limit, so Resend's correctly
28
+ * signed requests are never refused with 429, and without `x-real-ip` nothing is rate-limited.
29
+ */
30
+ export declare const BRANDLAB_RESEND_WEBHOOK_SECRET_ENV: "RESEND_WEBHOOK_SECRET";
31
+ /** Failed attempts (bad signature, stale timestamp, oversized body) per client IP before the route answers 429. */
32
+ export declare const BRANDLAB_RESEND_WEBHOOK_RATE_LIMIT: Readonly<{
33
+ limit: 10;
34
+ windowMs: number;
35
+ }>;
36
+ export interface BrandlabResendWebhookRouteOptions {
37
+ /** The contact contract's canonical project id; only emails tagged with it are recorded. */
38
+ projectId: string;
39
+ /** Defaults to `process.env`, read on each request: `RESEND_WEBHOOK_SECRET` (the endpoint's `whsec_` secret). */
40
+ environment?: Readonly<Record<string, string | undefined>>;
41
+ /** The same-day alert for a bounced, suppressed, complained or failed business copy. */
42
+ onDeliveryFailure?: BrandlabContactDeliveryAlert;
43
+ /**
44
+ * Failed attempts per client IP (`x-real-ip`) before the route answers 429, held in memory per server instance
45
+ * (BRANDLAB_RESEND_WEBHOOK_RATE_LIMIT by default); `false` turns it off. Correctly signed requests never count.
46
+ */
47
+ rateLimit?: BrandlabContactRateLimitRule | false;
48
+ /** Defaults to `Date.now`. */
49
+ now?: () => number;
50
+ }
51
+ export interface BrandlabResendWebhookRoute {
52
+ POST(request: Request): Promise<Response>;
53
+ }
54
+ /**
55
+ * Verifies a Resend (Svix) webhook signature: one `v1,<base64>` entry in the space-separated `svix-signature` list must
56
+ * equal HMAC-SHA256 of `<id>.<timestamp>.<body>`, and the timestamp must be within 5 minutes of `now` (milliseconds).
57
+ */
58
+ export declare function verifyResendWebhookSignature(input: {
59
+ secret: string;
60
+ id: string;
61
+ timestamp: string;
62
+ signature: string;
63
+ body: string;
64
+ now: number;
65
+ }): Promise<boolean>;
66
+ /**
67
+ * Creates the signed Resend webhook route for one site. Create it once at module level. In Resend, add a webhook to
68
+ * `https://<site>/api/brandlab/resend-webhook` with the email events (at least delivered, delivery delayed, bounced,
69
+ * complained, suppressed and failed) and copy its signing secret into `RESEND_WEBHOOK_SECRET`.
70
+ */
71
+ export declare function createBrandlabResendWebhookRoute(options: BrandlabResendWebhookRouteOptions): BrandlabResendWebhookRoute;
72
+ //# sourceMappingURL=resend-webhook.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resend-webhook.d.ts","sourceRoot":"","sources":["../../src/contact/resend-webhook.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAGpB,OAAO,EAIL,KAAK,4BAA4B,EAElC,MAAM,eAAe,CAAA;AACtB,OAAO,EAAoC,KAAK,4BAA4B,EAAE,MAAM,iBAAiB,CAAA;AAGrG;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,eAAO,MAAM,kCAAkC,EAAG,uBAAgC,CAAA;AAWlF,mHAAmH;AACnH,eAAO,MAAM,kCAAkC;;;EAA0D,CAAA;AAazG,MAAM,WAAW,iCAAiC;IAChD,4FAA4F;IAC5F,SAAS,EAAE,MAAM,CAAA;IACjB,iHAAiH;IACjH,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;IAC1D,wFAAwF;IACxF,iBAAiB,CAAC,EAAE,4BAA4B,CAAA;IAChD;;;OAGG;IACH,SAAS,CAAC,EAAE,4BAA4B,GAAG,KAAK,CAAA;IAChD,8BAA8B;IAC9B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,0BAA0B;IACzC,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAC1C;AAgDD;;;GAGG;AACH,wBAAsB,4BAA4B,CAAC,KAAK,EAAE;IACxD,MAAM,EAAE,MAAM,CAAA;IACd,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;CACZ,GAAG,OAAO,CAAC,OAAO,CAAC,CAmBnB;AA4DD;;;;GAIG;AACH,wBAAgB,gCAAgC,CAC9C,OAAO,EAAE,iCAAiC,GACzC,0BAA0B,CAqH5B"}