@brandlab-digital/site-next 3.12.1 → 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.
- package/README.md +132 -0
- package/dist/contact/delivery.d.ts +81 -0
- package/dist/contact/delivery.d.ts.map +1 -0
- package/dist/contact/delivery.js +138 -0
- package/dist/contact/delivery.js.map +1 -0
- package/dist/contact/resend-webhook.d.ts +72 -0
- package/dist/contact/resend-webhook.d.ts.map +1 -0
- package/dist/contact/resend-webhook.js +300 -0
- package/dist/contact/resend-webhook.js.map +1 -0
- package/dist/contact/route-handler.d.ts +16 -0
- package/dist/contact/route-handler.d.ts.map +1 -1
- package/dist/contact/route-handler.js +8 -0
- package/dist/contact/route-handler.js.map +1 -1
- package/dist/contact/server-entry.d.ts +2 -0
- package/dist/contact/server-entry.d.ts.map +1 -1
- package/dist/contact/server-entry.js +2 -0
- package/dist/contact/server-entry.js.map +1 -1
- package/dist/contact/server.d.ts +26 -0
- package/dist/contact/server.d.ts.map +1 -1
- package/dist/contact/server.js +48 -3
- package/dist/contact/server.js.map +1 -1
- package/dist/estate-segment.d.ts +48 -0
- package/dist/estate-segment.d.ts.map +1 -0
- package/dist/estate-segment.js +54 -0
- package/dist/estate-segment.js.map +1 -0
- package/dist/google-business-reviews-revalidate.d.ts +61 -0
- package/dist/google-business-reviews-revalidate.d.ts.map +1 -0
- package/dist/google-business-reviews-revalidate.js +151 -0
- package/dist/google-business-reviews-revalidate.js.map +1 -0
- package/dist/google-business-reviews-server.d.ts +21 -2
- package/dist/google-business-reviews-server.d.ts.map +1 -1
- package/dist/google-business-reviews-server.js +21 -4
- package/dist/google-business-reviews-server.js.map +1 -1
- package/dist/headers.d.ts.map +1 -1
- package/dist/headers.js +6 -0
- package/dist/headers.js.map +1 -1
- package/dist/next-config.d.ts +17 -1
- package/dist/next-config.d.ts.map +1 -1
- package/dist/next-config.js +36 -2
- package/dist/next-config.js.map +1 -1
- package/dist/robots.d.ts +26 -4
- package/dist/robots.d.ts.map +1 -1
- package/dist/robots.js +49 -2
- package/dist/robots.js.map +1 -1
- package/dist/ui-kit/brandlab-skin.d.ts +24 -1
- package/dist/ui-kit/brandlab-skin.d.ts.map +1 -1
- package/dist/ui-kit/brandlab-skin.js.map +1 -1
- package/dist/ui-kit/form/photo-upload.d.ts +4 -1
- package/dist/ui-kit/form/photo-upload.d.ts.map +1 -1
- package/dist/ui-kit/form/photo-upload.js +4 -4
- package/dist/ui-kit/form/photo-upload.js.map +1 -1
- package/dist/ui-kit/form/quote-form.d.ts +4 -1
- package/dist/ui-kit/form/quote-form.d.ts.map +1 -1
- package/dist/ui-kit/form/quote-form.js.map +1 -1
- package/dist/ui-kit/form/thank-you.d.ts +4 -1
- package/dist/ui-kit/form/thank-you.d.ts.map +1 -1
- package/dist/ui-kit/form/thank-you.js +2 -2
- package/dist/ui-kit/form/thank-you.js.map +1 -1
- package/dist/ui-kit/icon-glyphs.d.ts +41 -0
- package/dist/ui-kit/icon-glyphs.d.ts.map +1 -0
- package/dist/ui-kit/icon-glyphs.js +138 -0
- package/dist/ui-kit/icon-glyphs.js.map +1 -0
- package/dist/ui-kit/icon-sprite.d.ts +19 -0
- package/dist/ui-kit/icon-sprite.d.ts.map +1 -0
- package/dist/ui-kit/icon-sprite.js +44 -0
- package/dist/ui-kit/icon-sprite.js.map +1 -0
- package/dist/ui-kit/icons.d.ts +38 -3
- package/dist/ui-kit/icons.d.ts.map +1 -1
- package/dist/ui-kit/icons.js +47 -64
- package/dist/ui-kit/icons.js.map +1 -1
- package/dist/ui-kit/prefetch.d.ts +50 -0
- package/dist/ui-kit/prefetch.d.ts.map +1 -0
- package/dist/ui-kit/prefetch.js +50 -0
- package/dist/ui-kit/prefetch.js.map +1 -0
- package/dist/ui-kit/primitives/badge.d.ts +25 -0
- package/dist/ui-kit/primitives/badge.d.ts.map +1 -0
- package/dist/ui-kit/primitives/badge.js +21 -0
- package/dist/ui-kit/primitives/badge.js.map +1 -0
- package/dist/ui-kit/primitives/breadcrumbs.d.ts +27 -0
- package/dist/ui-kit/primitives/breadcrumbs.d.ts.map +1 -0
- package/dist/ui-kit/primitives/breadcrumbs.js +40 -0
- package/dist/ui-kit/primitives/breadcrumbs.js.map +1 -0
- package/dist/ui-kit/primitives/button.d.ts +32 -0
- package/dist/ui-kit/primitives/button.d.ts.map +1 -0
- package/dist/ui-kit/primitives/button.js +8 -0
- package/dist/ui-kit/primitives/button.js.map +1 -0
- package/dist/ui-kit/primitives/card.d.ts +40 -0
- package/dist/ui-kit/primitives/card.d.ts.map +1 -0
- package/dist/ui-kit/primitives/card.js +8 -0
- package/dist/ui-kit/primitives/card.js.map +1 -0
- package/dist/ui-kit/primitives/index.d.ts +12 -2
- package/dist/ui-kit/primitives/index.d.ts.map +1 -1
- package/dist/ui-kit/primitives/index.js +12 -2
- package/dist/ui-kit/primitives/index.js.map +1 -1
- package/dist/ui-kit/primitives/layout.d.ts +12 -0
- package/dist/ui-kit/primitives/layout.d.ts.map +1 -1
- package/dist/ui-kit/primitives/layout.js +20 -2
- package/dist/ui-kit/primitives/layout.js.map +1 -1
- package/dist/ui-kit/primitives/links.d.ts +28 -2
- package/dist/ui-kit/primitives/links.d.ts.map +1 -1
- package/dist/ui-kit/primitives/links.js +11 -6
- package/dist/ui-kit/primitives/links.js.map +1 -1
- package/dist/ui-kit/primitives/lists.d.ts +43 -0
- package/dist/ui-kit/primitives/lists.d.ts.map +1 -0
- package/dist/ui-kit/primitives/lists.js +34 -0
- package/dist/ui-kit/primitives/lists.js.map +1 -0
- package/dist/ui-kit/primitives/media-frame.d.ts +28 -0
- package/dist/ui-kit/primitives/media-frame.d.ts.map +1 -0
- package/dist/ui-kit/primitives/media-frame.js +58 -0
- package/dist/ui-kit/primitives/media-frame.js.map +1 -0
- package/dist/ui-kit/primitives/media.d.ts +38 -0
- package/dist/ui-kit/primitives/media.d.ts.map +1 -0
- package/dist/ui-kit/primitives/media.js +9 -0
- package/dist/ui-kit/primitives/media.js.map +1 -0
- package/dist/ui-kit/primitives/quote-button.d.ts +4 -1
- package/dist/ui-kit/primitives/quote-button.d.ts.map +1 -1
- package/dist/ui-kit/primitives/quote-button.js +3 -2
- package/dist/ui-kit/primitives/quote-button.js.map +1 -1
- package/dist/ui-kit/primitives/stat.d.ts +25 -0
- package/dist/ui-kit/primitives/stat.d.ts.map +1 -0
- package/dist/ui-kit/primitives/stat.js +7 -0
- package/dist/ui-kit/primitives/stat.js.map +1 -0
- package/dist/ui-kit/repeated-slots.d.ts +121 -0
- package/dist/ui-kit/repeated-slots.d.ts.map +1 -0
- package/dist/ui-kit/repeated-slots.js +81 -0
- package/dist/ui-kit/repeated-slots.js.map +1 -0
- package/dist/ui-kit/sections/accordion.d.ts +4 -1
- package/dist/ui-kit/sections/accordion.d.ts.map +1 -1
- package/dist/ui-kit/sections/accordion.js +2 -2
- package/dist/ui-kit/sections/accordion.js.map +1 -1
- package/dist/ui-kit/sections/contact-block.d.ts +4 -1
- package/dist/ui-kit/sections/contact-block.d.ts.map +1 -1
- package/dist/ui-kit/sections/contact-block.js +3 -3
- package/dist/ui-kit/sections/contact-block.js.map +1 -1
- package/dist/ui-kit/sections/faqs.d.ts +4 -1
- package/dist/ui-kit/sections/faqs.d.ts.map +1 -1
- package/dist/ui-kit/sections/faqs.js +4 -5
- package/dist/ui-kit/sections/faqs.js.map +1 -1
- package/dist/ui-kit/sections/hero.d.ts +113 -0
- package/dist/ui-kit/sections/hero.d.ts.map +1 -0
- package/dist/ui-kit/sections/hero.js +58 -0
- package/dist/ui-kit/sections/hero.js.map +1 -0
- package/dist/ui-kit/sections/index.d.ts +2 -0
- package/dist/ui-kit/sections/index.d.ts.map +1 -1
- package/dist/ui-kit/sections/index.js +2 -0
- package/dist/ui-kit/sections/index.js.map +1 -1
- package/dist/ui-kit/sections/price-guide.d.ts +4 -1
- package/dist/ui-kit/sections/price-guide.d.ts.map +1 -1
- package/dist/ui-kit/sections/price-guide.js +3 -3
- package/dist/ui-kit/sections/price-guide.js.map +1 -1
- package/dist/ui-kit/sections/price-table.d.ts +7 -9
- package/dist/ui-kit/sections/price-table.d.ts.map +1 -1
- package/dist/ui-kit/sections/price-table.js +8 -8
- package/dist/ui-kit/sections/price-table.js.map +1 -1
- package/dist/ui-kit/sections/proof-strip.d.ts +4 -1
- package/dist/ui-kit/sections/proof-strip.d.ts.map +1 -1
- package/dist/ui-kit/sections/proof-strip.js +2 -2
- package/dist/ui-kit/sections/proof-strip.js.map +1 -1
- package/dist/ui-kit/sections/reviews-band.d.ts +5 -2
- package/dist/ui-kit/sections/reviews-band.d.ts.map +1 -1
- package/dist/ui-kit/sections/reviews-band.js +2 -2
- package/dist/ui-kit/sections/reviews-band.js.map +1 -1
- package/dist/ui-kit/sections/services-grid.d.ts +82 -0
- package/dist/ui-kit/sections/services-grid.d.ts.map +1 -0
- package/dist/ui-kit/sections/services-grid.js +90 -0
- package/dist/ui-kit/sections/services-grid.js.map +1 -0
- package/dist/ui-kit/sections/site-footer.d.ts +4 -1
- package/dist/ui-kit/sections/site-footer.d.ts.map +1 -1
- package/dist/ui-kit/sections/site-footer.js +9 -6
- package/dist/ui-kit/sections/site-footer.js.map +1 -1
- package/dist/ui-kit/shell/current-link.d.ts +15 -1
- package/dist/ui-kit/shell/current-link.d.ts.map +1 -1
- package/dist/ui-kit/shell/current-link.js +10 -1
- package/dist/ui-kit/shell/current-link.js.map +1 -1
- package/dist/ui-kit/shell/index.d.ts +1 -0
- package/dist/ui-kit/shell/index.d.ts.map +1 -1
- package/dist/ui-kit/shell/index.js +1 -0
- package/dist/ui-kit/shell/index.js.map +1 -1
- package/dist/ui-kit/shell/intent-prefetch.d.ts +4 -0
- package/dist/ui-kit/shell/intent-prefetch.d.ts.map +1 -0
- package/dist/ui-kit/shell/intent-prefetch.js +61 -0
- package/dist/ui-kit/shell/intent-prefetch.js.map +1 -0
- package/dist/ui-kit/shell/mobile-drawer.d.ts +7 -3
- package/dist/ui-kit/shell/mobile-drawer.d.ts.map +1 -1
- package/dist/ui-kit/shell/mobile-drawer.js +20 -4
- package/dist/ui-kit/shell/mobile-drawer.js.map +1 -1
- package/dist/ui-kit/shell/site-header.d.ts +4 -1
- package/dist/ui-kit/shell/site-header.d.ts.map +1 -1
- package/dist/ui-kit/shell/site-header.js +9 -10
- package/dist/ui-kit/shell/site-header.js.map +1 -1
- package/dist/ui-kit/shell/social-links.d.ts +1 -2
- package/dist/ui-kit/shell/social-links.d.ts.map +1 -1
- package/dist/ui-kit/shell/social-links.js +6 -3
- package/dist/ui-kit/shell/social-links.js.map +1 -1
- package/dist/ui-kit/shell/utility-bar.d.ts +4 -1
- package/dist/ui-kit/shell/utility-bar.d.ts.map +1 -1
- package/dist/ui-kit/shell/utility-bar.js +2 -2
- package/dist/ui-kit/shell/utility-bar.js.map +1 -1
- package/dist/ui-kit/skin-index.d.ts +2 -1
- package/dist/ui-kit/skin-index.d.ts.map +1 -1
- package/dist/ui-kit/skin-index.js +2 -1
- package/dist/ui-kit/skin-index.js.map +1 -1
- package/dist/ui-kit/skin.d.ts +21 -4
- package/dist/ui-kit/skin.d.ts.map +1 -1
- package/dist/ui-kit/skin.js +14 -1
- package/dist/ui-kit/skin.js.map +1 -1
- package/dist/ui-kit/structure.css +383 -3
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -2,6 +2,138 @@
|
|
|
2
2
|
|
|
3
3
|
Proprietary software © Brandlab Digital Marketing. All rights reserved. Published for Brandlab client sites.
|
|
4
4
|
|
|
5
|
+
## Reviews: refresh on change, not every day (3.13.0)
|
|
6
|
+
|
|
7
|
+
The shared reviews fetch (`getBusinessProfileReviews`, `./google-business-reviews/server`) carries the tags
|
|
8
|
+
`brandlab-reviews` and `brandlab-reviews:<site>`. By default it still keeps a successful answer for one day
|
|
9
|
+
(`BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS`), so a site that installs 3.13.0 for another reason keeps daily reviews.
|
|
10
|
+
|
|
11
|
+
A site opts into refresh on change by passing `refresh: 'on-change'`, in the same call that already names the site,
|
|
12
|
+
once it has the route below deployed with its secret and is registered with the daily reviews checker:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// src/lib/reviews.ts
|
|
16
|
+
getBusinessProfileReviews({ site: 'hr-kitchens', refresh: 'on-change' })
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The answer is then kept for 7 days (`BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS`) as a safety net only.
|
|
20
|
+
Once a day the checker hashes each site's feed and, when the reviews have really changed, calls the site's signed
|
|
21
|
+
revalidate route, which refreshes the tag. A new review still shows within a day; pages are rewritten only after a
|
|
22
|
+
real change. If a call fails, pages refresh themselves within the week. The choice sits on the fetch, not in
|
|
23
|
+
brandlab.config.ts, because the fetch is where Next reads it at build time and a site's config file is not imported
|
|
24
|
+
at runtime.
|
|
25
|
+
|
|
26
|
+
The route, at `src/app/api/brandlab/reviews-revalidate/route.ts`:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { createBrandlabReviewsRevalidateRoute } from '@brandlab-digital/site-next/google-business-reviews/server'
|
|
30
|
+
|
|
31
|
+
export const { POST } = createBrandlabReviewsRevalidateRoute({ site: 'hr-kitchens' })
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Set `BRANDLAB_REVALIDATE_SECRET` (at least 32 characters) in the site's Vercel project and on the checker; never in
|
|
35
|
+
git. The call contract:
|
|
36
|
+
|
|
37
|
+
| | |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| Request | `POST /api/brandlab/reviews-revalidate`, body `{"site":"<slug>","hash":"<64 hex>"}` (1 KB at most) |
|
|
40
|
+
| Signature | `x-brandlab-timestamp: <Unix seconds>` and `x-brandlab-signature: v1=<hex HMAC-SHA256 of "<timestamp>.<body>">`; site-core `createBusinessProfileReviewsRevalidateRequest` builds both |
|
|
41
|
+
| Hash | site-core `hashBusinessProfileReviews(feed)`: the rating, the count and each review's id, rating, text, author, times and reply, sorted by id |
|
|
42
|
+
| Who decides | The checker keeps the last hash each site accepted with a 200 and calls only when today's differs. The route also skips a hash it already applied, but only per server instance |
|
|
43
|
+
| 200 | `{ ok: true, revalidated: true }` after `revalidateTag('brandlab-reviews:<site>', 'max')`, or `revalidated: false, reason: 'unchanged'` |
|
|
44
|
+
| Errors | 400 malformed body or another site's slug; 401 missing or wrong signature, or a timestamp more than 5 minutes off; 413 body over 1 KB; 429 with `Retry-After` past 10 requests per IP in 10 minutes; 503 secret not set; 500 refresh failed. The checker records the hash only after a 200 |
|
|
45
|
+
|
|
46
|
+
Refused requests log one line without the secret or signature, for example
|
|
47
|
+
`{"event":"brandlab_reviews_revalidate","site":"hr-kitchens","result":"refused","reason":"signature"}`.
|
|
48
|
+
|
|
49
|
+
### Estate segment preset (`./estate-segment`)
|
|
50
|
+
|
|
51
|
+
Service-in-suburb and suburb pages export `revalidate = false`, written literally because Next reads segment config
|
|
52
|
+
statically, and a site test calls `assertBrandlabEstateSegment(page)`. Deploys and the reviews tag rewrite them. A
|
|
53
|
+
page that shows reviews follows the reviews fetch: daily by default, weekly with `refresh: 'on-change'`. Ready-gate
|
|
54
|
+
check W3 (full tier, from 3.13.0) fails an estate family that refreshes more often than every 7 days, so a full-tier
|
|
55
|
+
estate that shows reviews adopts `on-change` with the route, or records the family in `siteSystem.refreshExceptions`
|
|
56
|
+
in brandlab.config.ts: `{ family, revalidateSeconds, reason, decisionId }`.
|
|
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
|
+
|
|
5
137
|
## Contact route: abuse limits and the test marker (3.10.0)
|
|
6
138
|
|
|
7
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":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAEhD;;;;;;;;;;;GAWG;AAEH,MAAM,CAAC,MAAM,sCAAsC,GAAG,kCAA2C,CAAA;AACjG,MAAM,CAAC,MAAM,sCAAsC,GAAG,kCAA2C,CAAA;AACjG,MAAM,CAAC,MAAM,8CAA8C,GAAG,gDAAyD,CAAA;AACvH,MAAM,CAAC,MAAM,4CAA4C,GAAG,wCAAiD,CAAA;AAE7G,iGAAiG;AACjG,MAAM,CAAC,MAAM,qCAAqC,GAAG,CAAC,GAAG,EAAE,GAAG,KAAK,CAAA;AACnE,wFAAwF;AACxF,MAAM,CAAC,MAAM,uCAAuC,GAAG,KAAK,CAAA;AAE5D,MAAM,uBAAuB,GAAG,sCAAsC,CAAA;AAEtE;;;GAGG;AACH,MAAM,CAAC,MAAM,mCAAmC,GAAG,uBAAgC,CAAA;AACnF,MAAM,eAAe,GAAG,qCAAqC,CAAA;AA2C7D,kHAAkH;AAClH,MAAM,CAAC,KAAK,UAAU,4BAA4B,CAChD,OAAuC,EACvC,KAAoC;IAEpC,OAAO,CAAC,KAAK,CACX,IAAI,CAAC,SAAS,CAAC;QACb,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,MAAM,EAAE,IAAI;QACZ,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,GAAG,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACjE,GAAG,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACjE,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACxD,GAAG,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9D,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACtD,CAAC,CACH,CAAA;IACD,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAA;IACvB,IAAI,CAAC;QACH,MAAM,KAAK,CAAC,OAAO,CAAC,CAAA;QACpB,OAAO,IAAI,CAAA;IACb,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CACX,IAAI,CAAC,SAAS,CAAC;YACb,KAAK,EAAE,4CAA4C;YACnD,IAAI,EAAE,OAAO,CAAC,IAAI;YAClB,MAAM,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO;SACtD,CAAC,CACH,CAAA;QACD,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAmBD,KAAK,UAAU,iBAAiB,CAC9B,OAAgC,EAChC,OAAe,EACf,MAAc,EACd,SAAiB;IAEjB,IAAI,QAAkB,CAAA;IACtB,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,OAAO,CAAC,GAAG,uBAAuB,GAAG,kBAAkB,CAAC,OAAO,CAAC,EAAE,EAAE;YACnF,MAAM,EAAE,KAAK;YACb,OAAO,EAAE,EAAE,aAAa,EAAE,UAAU,MAAM,EAAE,EAAE,MAAM,EAAE,kBAAkB,EAAE;YAC1E,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,SAAS,CAAC;SACvC,CAAC,CAAA;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,eAAe,EAAE,CAAA;IACrD,CAAC;IACD,IAAI,IAAa,CAAA;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAA;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,IAAI,GAAG,SAAS,CAAA;IAClB,CAAC;IACD,MAAM,MAAM,GAAG,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAE,IAAgC,CAAC,CAAC,CAAC,EAAE,CAAA;IAChH,MAAM,IAAI,GAAG,OAAO,MAAM,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAA;IACpF,IAAI,QAAQ,CAAC,EAAE,EAAE,CAAC;QAChB,OAAO,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,KAAK,OAAO;YACtF,CAAC,CAAC,EAAE,MAAM,EAAE,YAAY,EAAE;YAC1B,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,UAAU,EAAE,QAAQ,CAAC,MAAM,EAAE,IAAI,EAAE,mBAAmB,EAAE,CAAA;IACnF,CAAC;IACD,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG;QAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAA;IACvD,OAAO;QACL,MAAM,EAAE,SAAS;QACjB,UAAU,EAAE,QAAQ,CAAC,MAAM;QAC3B,IAAI,EAAE,kBAAkB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,mBAAmB;KAChE,CAAA;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,4BAA4B,CAC1C,UAAyC,EAAE;IAE3C,MAAM,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,CAAC,CAAC,GAAG,IAAyC,EAAE,EAAE,CAAC,UAAU,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,CAAC,CAAA;IAC9G,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAA;IACnC,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,qCAAqC,CAAA;IACxE,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,uCAAuC,CAAA;IAC9E,MAAM,KAAK,GAAG,IAAI,GAAG,EAAuE,CAAA;IAE5F,OAAO,MAAM,CAAC,MAAM,CAAC;QACnB,KAAK,CAAC,KAAK,CAAC,OAAe,EAAE,OAA2B;YACtD,MAAM,UAAU,GAAG,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAA;YAClF,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,EAAE,MAAM;gBAAE,OAAO,SAAS,CAAA;YACtG,MAAM,EAAE,GAAG,GAAG,EAAE,CAAA;YAChB,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,CAAA;YACpC,IAAI,MAAM,IAAI,MAAM,CAAC,KAAK,GAAG,EAAE;gBAAE,OAAO,MAAM,CAAC,MAAM,CAAA;YACrD,IAAI,MAAyB,CAAA;YAC7B,IAAI,CAAC;gBACH,MAAM,GAAG,MAAM,iBAAiB,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;YAClF,CAAC;YAAC,MAAM,CAAC;gBACP,MAAM,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,eAAe,EAAE,CAAA;YACvD,CAAC;YACD,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;gBAChC,OAAO,CAAC,IAAI,CACV,IAAI,CAAC,SAAS,CAAC;oBACb,KAAK,EAAE,8CAA8C;oBACrD,QAAQ,EAAE,QAAQ;oBAClB,MAAM,EAAE,MAAM,CAAC,UAAU,IAAI,IAAI;oBACjC,IAAI,EAAE,MAAM,CAAC,IAAI,IAAI,SAAS;oBAC9B,GAAG,CAAC,MAAM,CAAC,IAAI,KAAK,oBAAoB,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,oBAAoB,mCAAmC,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;iBACvH,CAAC,CACH,CAAA;YACH,CAAC;YACD,IAAI,KAAK,CAAC,IAAI,GAAG,EAAE;gBAAE,KAAK,CAAC,KAAK,EAAE,CAAA;YAClC,KAAK,CAAC,GAAG,CAAC,UAAU,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,GAAG,OAAO,EAAE,CAAC,CAAA;YACrE,OAAO,MAAM,CAAC,MAAM,CAAA;QACtB,CAAC;KACF,CAAC,CAAA;AACJ,CAAC","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"}
|