create-nextblock 0.16.1 → 0.16.2
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/docker-template/.dockerignore +1 -1
- package/libs/db/tsconfig.lib.json +3 -3
- package/libs/editor/tsconfig.lib.json +3 -3
- package/libs/ui/tsconfig.lib.json +3 -3
- package/libs/utils/tsconfig.json +3 -3
- package/package.json +50 -50
- package/project.json +19 -19
- package/templates/nextblock-template/.dockerignore +1 -1
- package/templates/nextblock-template/.swcrc +30 -30
- package/templates/nextblock-template/AGENTS.md +9 -9
- package/templates/nextblock-template/CLAUDE.md +1 -1
- package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
- package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
- package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
- package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
- package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
- package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
- package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
- package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
- package/templates/nextblock-template/app/actions/email.ts +241 -241
- package/templates/nextblock-template/app/actions/formActions.ts +245 -245
- package/templates/nextblock-template/app/actions/interactions.ts +489 -489
- package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
- package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
- package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -221
- package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
- package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
- package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -3092
- package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
- package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
- package/templates/nextblock-template/app/api/search/route.ts +43 -43
- package/templates/nextblock-template/app/api/view/route.ts +114 -114
- package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
- package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
- package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
- package/templates/nextblock-template/app/cart/page.tsx +7 -7
- package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
- package/templates/nextblock-template/app/checkout/page.tsx +57 -57
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
- package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
- package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
- package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
- package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
- package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
- package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
- package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
- package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
- package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
- package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
- package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
- package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/layout.tsx +101 -101
- package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
- package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
- package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
- package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
- package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
- package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
- package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
- package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
- package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -618
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
- package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
- package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
- package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
- package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
- package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
- package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
- package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
- package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
- package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
- package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
- package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
- package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
- package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
- package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
- package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
- package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
- package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
- package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
- package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
- package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
- package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
- package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
- package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
- package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
- package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
- package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
- package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
- package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
- package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
- package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
- package/templates/nextblock-template/app/layout.tsx +671 -671
- package/templates/nextblock-template/app/lib/seo.ts +319 -319
- package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
- package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
- package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
- package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
- package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
- package/templates/nextblock-template/app/profile/account-links.ts +22 -22
- package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
- package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
- package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
- package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
- package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
- package/templates/nextblock-template/app/providers.tsx +96 -96
- package/templates/nextblock-template/app/robots.ts +123 -123
- package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
- package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
- package/templates/nextblock-template/app/thread/layout.tsx +15 -15
- package/templates/nextblock-template/app/thread/page.tsx +98 -98
- package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
- package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
- package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
- package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
- package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
- package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
- package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
- package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
- package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
- package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
- package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
- package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
- package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
- package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
- package/templates/nextblock-template/components/Header.tsx +38 -38
- package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
- package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
- package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
- package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
- package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
- package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
- package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
- package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
- package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
- package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
- package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
- package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
- package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
- package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
- package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
- package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
- package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
- package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
- package/templates/nextblock-template/components/blocks/types.ts +7 -7
- package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
- package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
- package/templates/nextblock-template/components/form-message.tsx +32 -32
- package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
- package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -244
- package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
- package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
- package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
- package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
- package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
- package/templates/nextblock-template/components/submit-button.tsx +23 -23
- package/templates/nextblock-template/components/theme-icon.tsx +78 -78
- package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
- package/templates/nextblock-template/context/AuthContext.tsx +23 -23
- package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
- package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
- package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
- package/templates/nextblock-template/docs/README.md +42 -42
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
- package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
- package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
- package/templates/nextblock-template/index.d.ts +7 -7
- package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
- package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
- package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
- package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
- package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
- package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
- package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
- package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
- package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
- package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
- package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
- package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
- package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
- package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
- package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
- package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
- package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
- package/templates/nextblock-template/lib/messages/threads.ts +579 -579
- package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
- package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
- package/templates/nextblock-template/lib/search/types.ts +27 -27
- package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
- package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
- package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
- package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
- package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
- package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -140
- package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -350
- package/templates/nextblock-template/lib/seo/page-document.ts +412 -412
- package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
- package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
- package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
- package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
- package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
- package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -177
- package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
- package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
- package/templates/nextblock-template/lib/site-url.test.ts +89 -89
- package/templates/nextblock-template/lib/site-url.ts +102 -102
- package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
- package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
- package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
- package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
- package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
- package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
- package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
- package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
- package/templates/nextblock-template/lib/zod-config.ts +5 -5
- package/templates/nextblock-template/next-env.d.ts +1 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/postcss.config.js +6 -6
- package/templates/nextblock-template/scripts/backup.js +115 -115
- package/templates/nextblock-template/scripts/restore.js +385 -385
- package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
- package/templates/nextblock-template/tailwind.config.js +25 -25
- package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
- package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
- package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
- package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
- package/templates/nextblock-template/tools/update.mjs +1303 -1303
- package/tsconfig.base.json +3 -3
|
@@ -1,309 +1,309 @@
|
|
|
1
|
-
# 14 · Messages — the unified inbox
|
|
2
|
-
|
|
3
|
-
Everything a visitor sends the site arrives in one place: **CMS → Messages**
|
|
4
|
-
(`/cms/messages`). Four sources feed it, and the admin can reply to all of them from
|
|
5
|
-
there.
|
|
6
|
-
|
|
7
|
-
The inbox is one *view* over **two storage models**, because the sources are genuinely
|
|
8
|
-
different and flattening them would break something real.
|
|
9
|
-
|
|
10
|
-
| Source | Table | Visibility | What a reply is |
|
|
11
|
-
|---|---|---|---|
|
|
12
|
-
| Product enquiry | `message_threads` + `thread_messages` | private | a private message, delivered via a tokenised link |
|
|
13
|
-
| Contact form | `message_threads` + `thread_messages` | private | same |
|
|
14
|
-
| Product review | `cms_interactions` | already public | a **public** reply under the review |
|
|
15
|
-
| Post comment | `cms_interactions` | already public | a **public** reply under the comment |
|
|
16
|
-
|
|
17
|
-
`app/cms/messages/loadInbox.ts` performs the merge in the read path and normalises both
|
|
18
|
-
into an `InboxItem`. The detail pane branches on `item.kind`.
|
|
19
|
-
|
|
20
|
-
## Why not one table
|
|
21
|
-
|
|
22
|
-
`cms_interactions` cannot hold an enquiry, and the reasons are all enforced by the
|
|
23
|
-
schema rather than by convention:
|
|
24
|
-
|
|
25
|
-
- `user_id` is `NOT NULL` with a foreign key to `profiles` — every review and comment
|
|
26
|
-
belongs to a registered account. Enquiry senders are anonymous.
|
|
27
|
-
- `check_product_or_post` requires *exactly one* of `product_id` / `post_id`. A contact
|
|
28
|
-
form targets neither.
|
|
29
|
-
- Reviews and comments are readable by `anon` through an open `SELECT` policy once
|
|
30
|
-
approved. Enquiries carry visitor PII and have **no anon grant or policy at all**.
|
|
31
|
-
- `update_product_ratings()` fires on every `cms_interactions` write.
|
|
32
|
-
|
|
33
|
-
That last point is the sharp edge. The trigger aggregates
|
|
34
|
-
`WHERE product_id = ? AND type='review' AND status='approved'`, so anything stored as an
|
|
35
|
-
approved review moves the product's star average. **A staff reply is therefore stored as
|
|
36
|
-
`type='comment'` with a NULL rating**, carrying the parent's target. That is not a
|
|
37
|
-
workaround for convenience — `check_rating_only_for_review` is an exhaustive `OR` over
|
|
38
|
-
`('review','comment')`, so a `review` row with a NULL rating is rejected outright, and
|
|
39
|
-
adding a third enum value would violate the same constraint *and* hit PostgreSQL's rule
|
|
40
|
-
that an enum value cannot be used in the transaction that added it (i.e. in one
|
|
41
|
-
migration file). `cms_interactions_reply_check` pins the invariant in the schema.
|
|
42
|
-
|
|
43
|
-
## The private lane
|
|
44
|
-
|
|
45
|
-
`message_threads` is the spine; `thread_messages` holds the turns. Message *content* is
|
|
46
|
-
append-only, enforced by a trigger that binds the service role too — only the delivery
|
|
47
|
-
flags may change after insert. A consequence worth knowing: because the foreign key
|
|
48
|
-
cascades and the trigger blocks `DELETE`, **a thread cannot be deleted**. "Delete" in
|
|
49
|
-
the CMS means `status='closed'` plus a revoked token.
|
|
50
|
-
|
|
51
|
-
### The visitor's token
|
|
52
|
-
|
|
53
|
-
The only opaque credential this codebase issues to an anonymous person.
|
|
54
|
-
|
|
55
|
-
- 32 bytes of `crypto.randomBytes`, prefixed `nbt_`. Guessing is infeasible by
|
|
56
|
-
keyspace, which matters because the GET has no rate limit.
|
|
57
|
-
- Only `sha256` is stored, in `message_threads.token_hash` under a unique index. The
|
|
58
|
-
plaintext exists long enough to go into one email and is never written down.
|
|
59
|
-
- **Minted on the first admin reply, never at submission.** A store with a hundred
|
|
60
|
-
unanswered enquiries has zero live credentials.
|
|
61
|
-
- Rolling 90-day expiry, extended by each reply; revocable from the CMS.
|
|
62
|
-
|
|
63
|
-
`/thread/[token]` exchanges the token for an **HttpOnly cookie** and redirects to a
|
|
64
|
-
token-less `/thread`. This is the most important control in the feature: the app sends
|
|
65
|
-
`Referrer-Policy: strict-origin-when-cross-origin`, so a token left in the address bar
|
|
66
|
-
would travel in the `Referer` header of every same-origin navigation, and into history,
|
|
67
|
-
screenshots and proxy logs.
|
|
68
|
-
|
|
69
|
-
Every failure — expired, revoked, never existed — renders the *same* page. Distinguishing
|
|
70
|
-
them would make the route an oracle.
|
|
71
|
-
|
|
72
|
-
Reading the thread uses the service role **after** verifying the token in application
|
|
73
|
-
code. No RLS policy here authenticates an anonymous caller by token, and none was
|
|
74
|
-
invented; this matches how `/api/mcp` treats `mcp_access_tokens`.
|
|
75
|
-
|
|
76
|
-
## Contact forms and `form_key`
|
|
77
|
-
|
|
78
|
-
A form block used to store its destination in its own `content`. `FormBlockRenderer` is
|
|
79
|
-
a `"use client"` component that receives that content wholesale, so **the address was
|
|
80
|
-
serialized into the RSC payload of every page carrying a form** — published in the
|
|
81
|
-
markup.
|
|
82
|
-
|
|
83
|
-
Migration `00000000000027` moved every stored address into `form_endpoints` and left a
|
|
84
|
-
`form_key` behind: an opaque handle that grants nothing and is safe to serialize. The
|
|
85
|
-
migration walks three shapes, because a form nested in a section has no `blocks` row of
|
|
86
|
-
its own:
|
|
87
|
-
|
|
88
|
-
1. `blocks.content` where `block_type='form'`
|
|
89
|
-
2. `blocks.content` where `block_type='section'` (`column_blocks`, `slides`)
|
|
90
|
-
3. `content_drafts.blocks` / `product_drafts.blocks`
|
|
91
|
-
|
|
92
|
-
`form_endpoints.fields` is a **server-side snapshot** of the field manifest, so
|
|
93
|
-
notification emails and the inbox render labels the browser did not supply.
|
|
94
|
-
|
|
95
|
-
`BlockRenderer` and `SectionBlockRenderer` also strip `recipient_email` defensively
|
|
96
|
-
before handing content to the client — an import, a restored revision, or a fork that
|
|
97
|
-
has not migrated can still produce one.
|
|
98
|
-
|
|
99
|
-
## Delivery is best-effort; the row is the record
|
|
100
|
-
|
|
101
|
-
`sendEmail` **throws** when SMTP is unconfigured, and a store that has not finished one
|
|
102
|
-
piece of setup often has not finished the other. So every path writes first and notifies
|
|
103
|
-
afterwards, inside `after()`. A failed send sets `email_delivered = false` and records
|
|
104
|
-
`email_error`; the CMS shows "Not emailed" rather than pretending the owner was told.
|
|
105
|
-
|
|
106
|
-
The visitor is still shown success, because from their side it *was* one.
|
|
107
|
-
|
|
108
|
-
## Recipient resolution
|
|
109
|
-
|
|
110
|
-
Contact forms: per-form `form_endpoints.recipient_email` → `site_settings.forms_contact`
|
|
111
|
-
→ the seller-contact ladder (`lib/commerce/seller-contact.ts`: explicit store contact →
|
|
112
|
-
invoice email → privacy support email → oldest ADMIN's auth email). The sandbox
|
|
113
|
-
overrides everything.
|
|
114
|
-
|
|
115
|
-
**The address is never sent to the browser.** The form posts a `form_key`; the server
|
|
116
|
-
resolves the destination.
|
|
117
|
-
|
|
118
|
-
## The reply link and `NEXT_PUBLIC_URL`
|
|
119
|
-
|
|
120
|
-
`sendThreadNotice` refuses to send when the site URL was never configured, but happily
|
|
121
|
-
sends a local URL you chose deliberately.
|
|
122
|
-
|
|
123
|
-
`resolveSiteUrl()` falls back to `http://localhost:3000` when nothing is set, and a link
|
|
124
|
-
to that is dead for anyone not sitting at the machine. What must never happen is the code
|
|
125
|
-
*inventing* a localhost link because nothing was configured and mailing it to a customer.
|
|
126
|
-
A deliberate local URL is different: that is how you test the round trip, and it works.
|
|
127
|
-
|
|
128
|
-
So: honour an explicit choice, refuse an accidental default.
|
|
129
|
-
|
|
130
|
-
- Testing locally: set `NEXT_PUBLIC_URL=http://localhost:3000` (or your dev port). The
|
|
131
|
-
link is sent and works on that machine; a warning is logged saying who can open it.
|
|
132
|
-
- Production: set it to the public site URL. Vercel's project URL is picked up
|
|
133
|
-
automatically.
|
|
134
|
-
- Unset: the notice is refused and the reason is recorded on the message.
|
|
135
|
-
|
|
136
|
-
> An earlier version of this document blamed a `localhost` link for a quarantined
|
|
137
|
-
> message. That was wrong — a probe carrying exactly such a link was delivered normally.
|
|
138
|
-
> The real cause is below.
|
|
139
|
-
|
|
140
|
-
## Mail threading: two conversations, two roots
|
|
141
|
-
|
|
142
|
-
Outbound mail carries `In-Reply-To` and `References` pointing at a stable synthetic root,
|
|
143
|
-
because a `Re:` subject with no threading headers is itself a forged-reply heuristic, and
|
|
144
|
-
one consistent id per conversation groups the exchange in the recipient's client.
|
|
145
|
-
|
|
146
|
-
**The owner's notifications and the visitor's notices use DIFFERENT roots**, and that
|
|
147
|
-
separation is load-bearing:
|
|
148
|
-
|
|
149
|
-
- owner: `<nb-thread-{id}-admin@{from-domain}>`
|
|
150
|
-
- visitor: `<nb-thread-{id}-visitor@{from-domain}>`
|
|
151
|
-
|
|
152
|
-
They are two different exchanges with two different people. Sharing one root makes a mail
|
|
153
|
-
client fold them into a single conversation — and when an operator tests with their own
|
|
154
|
-
address as both the admin *and* the enquiring visitor (the obvious way to try the
|
|
155
|
-
feature), the reply is delivered, accepted, and then collapsed under the notification
|
|
156
|
-
they already read. It looks precisely like the email never arrived.
|
|
157
|
-
|
|
158
|
-
That symptom is a same-mailbox testing artifact, not a production fault: in real use the
|
|
159
|
-
customer and the shop owner are different mailboxes. But the shared root was a genuine
|
|
160
|
-
modelling error, and it is fixed.
|
|
161
|
-
|
|
162
|
-
## Where a form's mail goes
|
|
163
|
-
|
|
164
|
-
One rule, three rungs:
|
|
165
|
-
|
|
166
|
-
1. The form's own `form_endpoints.recipient_email`, if someone set one.
|
|
167
|
-
2. The site contact address from **CMS → Messages** (`site_settings.store_contact`).
|
|
168
|
-
3. The **first admin account's** own login address.
|
|
169
|
-
|
|
170
|
-
**A form has no address of its own by default**, and that is the intended state — since
|
|
171
|
-
the messaging system arrived, an operator does not need to think about per-form routing
|
|
172
|
-
at all. Submissions are stored as threads and answered in the CMS; the notification
|
|
173
|
-
address is one site-wide setting. A per-form address exists only to route one particular
|
|
174
|
-
form elsewhere — a careers form to HR, say.
|
|
175
|
-
|
|
176
|
-
Because rung 3 always resolves on a provisioned install, a fresh site reaches a real
|
|
177
|
-
human without configuring anything.
|
|
178
|
-
|
|
179
|
-
The sandbox overrides all of it: `resolveFormRecipient` returns `SANDBOX_CONTACT_EMAIL`
|
|
180
|
-
when `NEXT_PUBLIC_IS_SANDBOX` is set, so the hosted demo routes to the operator's inbox
|
|
181
|
-
without storing an address anywhere.
|
|
182
|
-
|
|
183
|
-
### Placeholder addresses count as unset
|
|
184
|
-
|
|
185
|
-
The starter content used to ship a contact form addressed to `contact@example.com`, and
|
|
186
|
-
migration 27 faithfully carried that into `form_endpoints`. Faithful was wrong:
|
|
187
|
-
`example.com` is reserved by RFC 2606 so it can never be registered, which makes the
|
|
188
|
-
address *guaranteed* undeliverable while looking like a real setting to every layer
|
|
189
|
-
downstream. The visitor is thanked, the relay accepts, nobody is notified, nothing errors.
|
|
190
|
-
An install ran that way without knowing.
|
|
191
|
-
|
|
192
|
-
Migration 29 clears those to NULL, so they fall through to the ladder above.
|
|
193
|
-
`lib/email/placeholder-address.ts` also treats the reserved domains (`example.com/.org/
|
|
194
|
-
.net/.edu` and the `.example`, `.invalid`, `.test`, `.localhost`, `.local` suffixes) as
|
|
195
|
-
unset at runtime, and `lib/cms/contact-reminder.ts` raises an ADMIN banner if one
|
|
196
|
-
reappears — via an import, a restored revision, or someone typing it.
|
|
197
|
-
|
|
198
|
-
The banner stays quiet when a form simply has no address, because that is now the correct
|
|
199
|
-
configuration rather than an omission.
|
|
200
|
-
|
|
201
|
-
## Subjects are per-conversation, and why that matters
|
|
202
|
-
|
|
203
|
-
| | contact form | product enquiry |
|
|
204
|
-
|---|---|---|
|
|
205
|
-
| **owner** | `Nicolas sent you a message [NRH-VWQOLB]` | `Nicolas asked about Brass Kettle [NRH-EMISCX]` |
|
|
206
|
-
| **visitor** | `New Roots Herbal replied to your message [NRH-VWQOLB]` | `New Roots Herbal replied about Brass Kettle [NRH-EMISCX]` |
|
|
207
|
-
|
|
208
|
-
`threadReference(threadId, siteName)` folds the first 32 bits of the thread id into six
|
|
209
|
-
base36 characters and prefixes the site's initials — about 2.2 billion references, read as
|
|
210
|
-
a ticket number rather than a hex dump.
|
|
211
|
-
|
|
212
|
-
**There is no `Re:` anywhere.** The visitor's message was never an email, so a reply
|
|
213
|
-
prefix is both a spam heuristic and a small lie; naming the site is what actually tells
|
|
214
|
-
them who is writing. The `In-Reply-To` / `References` headers still group each
|
|
215
|
-
conversation properly.
|
|
216
|
-
|
|
217
|
-
The reference is not decoration. **Exchange derives its ConversationTopic from the
|
|
218
|
-
subject, not from References**, so separating the References headers — necessary, and
|
|
219
|
-
done — was not sufficient. With a constant subject, every enquiry a site ever receives
|
|
220
|
-
collapses into one Outlook conversation, and anything applied to that conversation applies
|
|
221
|
-
to all of them: a rule, a filter, or Ignore Conversation.
|
|
222
|
-
|
|
223
|
-
A real install hit exactly this. Replies were accepted by the relay, accepted by Microsoft
|
|
224
|
-
with `250 Queued mail for delivery`, and routed straight to Deleted Items, because a
|
|
225
|
-
"Contact form" conversation had been ignored at some point. Nothing anywhere reported a
|
|
226
|
-
fault. It was isolated by sending two messages with byte-identical bodies and different
|
|
227
|
-
subjects: the unique subject arrived, `Re: Contact form` did not. When mail "doesn't
|
|
228
|
-
arrive", that A/B is the first thing to run — not a theory.
|
|
229
|
-
|
|
230
|
-
## Deliverability: accepted is not delivered
|
|
231
|
-
|
|
232
|
-
A reply can be accepted by the relay, accepted by the recipient's server with
|
|
233
|
-
`250 Queued mail for delivery`, appear in the inbox — and then be **removed from the
|
|
234
|
-
mailbox minutes later**. Microsoft calls this ZAP (Zero-hour Auto Purge): threat intel
|
|
235
|
-
updates after delivery and the message is retracted. There is no bounce and no SMTP
|
|
236
|
-
error, so `email_delivered = true` is accurate and every log upstream reports success.
|
|
237
|
-
|
|
238
|
-
**This is largely outside the application's control.** A strict corporate tenant can
|
|
239
|
-
purge a transactional message on its overall shape — a reply-styled body carrying a
|
|
240
|
-
quoted excerpt and a link to an opaque tokenised URL — and no rendering tested reliably
|
|
241
|
-
survived one such tenant. An intermediate result suggesting that showing the destination
|
|
242
|
-
URL was the deciding factor did not hold up against the real notice.
|
|
243
|
-
|
|
244
|
-
What the application does do:
|
|
245
|
-
|
|
246
|
-
**1. No secrecy language.** The message used to end "This link is personal to you —
|
|
247
|
-
please don't forward it." A secret one-off link, a prominent button, and an instruction
|
|
248
|
-
not to share it is a near-literal phishing template. The link's security comes from the
|
|
249
|
-
token rotating on every reply, not from asking the recipient to keep a secret.
|
|
250
|
-
|
|
251
|
-
**2. The destination is visible.** The call-to-action is a button, with the URL also
|
|
252
|
-
printed as plain text beneath it. Good practice regardless, and it survives clients that
|
|
253
|
-
strip styling.
|
|
254
|
-
|
|
255
|
-
**3. There is a manual channel.** `createVisitorLink` mints a link and hands it to the
|
|
256
|
-
admin without sending anything ("Copy visitor link" in the thread pane). When a
|
|
257
|
-
recipient's filtering removes the mail, the conversation is still intact and the link is
|
|
258
|
-
still obtainable — send it by whatever works.
|
|
259
|
-
|
|
260
|
-
### Diagnosing it
|
|
261
|
-
|
|
262
|
-
Send messages with **byte-identical bodies** differing in exactly one variable, and check
|
|
263
|
-
the recipient's Deleted Items as well as the inbox. Reasoning from correlation produced
|
|
264
|
-
five wrong answers here — a `localhost` link, spam filtering in general, a shared thread
|
|
265
|
-
root, the subject line, and the secrecy phrase — each of which fitted the evidence and
|
|
266
|
-
was killed by the next test.
|
|
267
|
-
|
|
268
|
-
The send path logs what actually went out:
|
|
269
|
-
|
|
270
|
-
```
|
|
271
|
-
[messages] Reply on thread <id> handed to SMTP — to="…" replyTo="…" subject="…" link=tokenised
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
Start there. **Accepted by SMTP is not arrived, and arrived is not still there.** When a
|
|
275
|
-
tenant is purging on shape, testing against a different provider (a personal Gmail, say)
|
|
276
|
-
separates "the app is broken" from "this mailbox rejects this class of mail".
|
|
277
|
-
|
|
278
|
-
## Rate limiting
|
|
279
|
-
|
|
280
|
-
There is no shared rate limiter in this repo. Both public write paths throttle by
|
|
281
|
-
counting recent rows for a masked IP, and both **fail closed**: an absent or unparseable
|
|
282
|
-
`X-Forwarded-For` buckets under the literal `'unknown'` rather than skipping the check.
|
|
283
|
-
Trusted platform headers (`x-vercel-forwarded-for`, `x-real-ip`) are preferred over
|
|
284
|
-
`x-forwarded-for`, whose leftmost entry the client can write.
|
|
285
|
-
|
|
286
|
-
## Files
|
|
287
|
-
|
|
288
|
-
- `libs/db/src/supabase/migrations/00000000000027_message_threads.sql` — private lane, `form_endpoints`, the form-block data migration
|
|
289
|
-
- `libs/db/src/supabase/migrations/00000000000028_interaction_replies.sql` — `parent_id`, the reply CHECK, and the indexes `cms_interactions` never had
|
|
290
|
-
- `apps/nextblock/lib/messages/thread-token.ts` (+ `.test.ts`) — mint, parse, verify
|
|
291
|
-
- `apps/nextblock/lib/messages/threads.ts` — thread creation, recipient resolution, both notification emails
|
|
292
|
-
- `apps/nextblock/app/thread/**` — the visitor's page and the token-exchange route
|
|
293
|
-
- `apps/nextblock/app/actions/threadActions.ts` — visitor replies
|
|
294
|
-
- `apps/nextblock/app/cms/messages/**` — the inbox, its loader and its admin actions
|
|
295
|
-
- `apps/nextblock/app/actions/interactions.ts` — `replyToInteraction`, the public lane
|
|
296
|
-
- `apps/nextblock/components/StaffReplies.tsx` — nested public replies on the storefront
|
|
297
|
-
|
|
298
|
-
`/cms/inquiries` and `/cms/interactions` are now redirects into the inbox; both were
|
|
299
|
-
linked from notification emails and bookmarks, so neither was deleted.
|
|
300
|
-
|
|
301
|
-
## Known gaps
|
|
302
|
-
|
|
303
|
-
- Interactions have **no per-user read marker** anywhere in the schema, so the nav badge
|
|
304
|
-
counts "pending moderation". Two admins working the queue see the same number.
|
|
305
|
-
- Public replies do not email the review/comment author. They have a reachable address,
|
|
306
|
-
but that would be the first time the platform emails a registered user about content
|
|
307
|
-
activity, with no preference to opt out of.
|
|
308
|
-
- Visitors get no acknowledgement email at submission time — that would require minting
|
|
309
|
-
a token immediately, which is exactly the live-credential surface this design avoids.
|
|
1
|
+
# 14 · Messages — the unified inbox
|
|
2
|
+
|
|
3
|
+
Everything a visitor sends the site arrives in one place: **CMS → Messages**
|
|
4
|
+
(`/cms/messages`). Four sources feed it, and the admin can reply to all of them from
|
|
5
|
+
there.
|
|
6
|
+
|
|
7
|
+
The inbox is one *view* over **two storage models**, because the sources are genuinely
|
|
8
|
+
different and flattening them would break something real.
|
|
9
|
+
|
|
10
|
+
| Source | Table | Visibility | What a reply is |
|
|
11
|
+
|---|---|---|---|
|
|
12
|
+
| Product enquiry | `message_threads` + `thread_messages` | private | a private message, delivered via a tokenised link |
|
|
13
|
+
| Contact form | `message_threads` + `thread_messages` | private | same |
|
|
14
|
+
| Product review | `cms_interactions` | already public | a **public** reply under the review |
|
|
15
|
+
| Post comment | `cms_interactions` | already public | a **public** reply under the comment |
|
|
16
|
+
|
|
17
|
+
`app/cms/messages/loadInbox.ts` performs the merge in the read path and normalises both
|
|
18
|
+
into an `InboxItem`. The detail pane branches on `item.kind`.
|
|
19
|
+
|
|
20
|
+
## Why not one table
|
|
21
|
+
|
|
22
|
+
`cms_interactions` cannot hold an enquiry, and the reasons are all enforced by the
|
|
23
|
+
schema rather than by convention:
|
|
24
|
+
|
|
25
|
+
- `user_id` is `NOT NULL` with a foreign key to `profiles` — every review and comment
|
|
26
|
+
belongs to a registered account. Enquiry senders are anonymous.
|
|
27
|
+
- `check_product_or_post` requires *exactly one* of `product_id` / `post_id`. A contact
|
|
28
|
+
form targets neither.
|
|
29
|
+
- Reviews and comments are readable by `anon` through an open `SELECT` policy once
|
|
30
|
+
approved. Enquiries carry visitor PII and have **no anon grant or policy at all**.
|
|
31
|
+
- `update_product_ratings()` fires on every `cms_interactions` write.
|
|
32
|
+
|
|
33
|
+
That last point is the sharp edge. The trigger aggregates
|
|
34
|
+
`WHERE product_id = ? AND type='review' AND status='approved'`, so anything stored as an
|
|
35
|
+
approved review moves the product's star average. **A staff reply is therefore stored as
|
|
36
|
+
`type='comment'` with a NULL rating**, carrying the parent's target. That is not a
|
|
37
|
+
workaround for convenience — `check_rating_only_for_review` is an exhaustive `OR` over
|
|
38
|
+
`('review','comment')`, so a `review` row with a NULL rating is rejected outright, and
|
|
39
|
+
adding a third enum value would violate the same constraint *and* hit PostgreSQL's rule
|
|
40
|
+
that an enum value cannot be used in the transaction that added it (i.e. in one
|
|
41
|
+
migration file). `cms_interactions_reply_check` pins the invariant in the schema.
|
|
42
|
+
|
|
43
|
+
## The private lane
|
|
44
|
+
|
|
45
|
+
`message_threads` is the spine; `thread_messages` holds the turns. Message *content* is
|
|
46
|
+
append-only, enforced by a trigger that binds the service role too — only the delivery
|
|
47
|
+
flags may change after insert. A consequence worth knowing: because the foreign key
|
|
48
|
+
cascades and the trigger blocks `DELETE`, **a thread cannot be deleted**. "Delete" in
|
|
49
|
+
the CMS means `status='closed'` plus a revoked token.
|
|
50
|
+
|
|
51
|
+
### The visitor's token
|
|
52
|
+
|
|
53
|
+
The only opaque credential this codebase issues to an anonymous person.
|
|
54
|
+
|
|
55
|
+
- 32 bytes of `crypto.randomBytes`, prefixed `nbt_`. Guessing is infeasible by
|
|
56
|
+
keyspace, which matters because the GET has no rate limit.
|
|
57
|
+
- Only `sha256` is stored, in `message_threads.token_hash` under a unique index. The
|
|
58
|
+
plaintext exists long enough to go into one email and is never written down.
|
|
59
|
+
- **Minted on the first admin reply, never at submission.** A store with a hundred
|
|
60
|
+
unanswered enquiries has zero live credentials.
|
|
61
|
+
- Rolling 90-day expiry, extended by each reply; revocable from the CMS.
|
|
62
|
+
|
|
63
|
+
`/thread/[token]` exchanges the token for an **HttpOnly cookie** and redirects to a
|
|
64
|
+
token-less `/thread`. This is the most important control in the feature: the app sends
|
|
65
|
+
`Referrer-Policy: strict-origin-when-cross-origin`, so a token left in the address bar
|
|
66
|
+
would travel in the `Referer` header of every same-origin navigation, and into history,
|
|
67
|
+
screenshots and proxy logs.
|
|
68
|
+
|
|
69
|
+
Every failure — expired, revoked, never existed — renders the *same* page. Distinguishing
|
|
70
|
+
them would make the route an oracle.
|
|
71
|
+
|
|
72
|
+
Reading the thread uses the service role **after** verifying the token in application
|
|
73
|
+
code. No RLS policy here authenticates an anonymous caller by token, and none was
|
|
74
|
+
invented; this matches how `/api/mcp` treats `mcp_access_tokens`.
|
|
75
|
+
|
|
76
|
+
## Contact forms and `form_key`
|
|
77
|
+
|
|
78
|
+
A form block used to store its destination in its own `content`. `FormBlockRenderer` is
|
|
79
|
+
a `"use client"` component that receives that content wholesale, so **the address was
|
|
80
|
+
serialized into the RSC payload of every page carrying a form** — published in the
|
|
81
|
+
markup.
|
|
82
|
+
|
|
83
|
+
Migration `00000000000027` moved every stored address into `form_endpoints` and left a
|
|
84
|
+
`form_key` behind: an opaque handle that grants nothing and is safe to serialize. The
|
|
85
|
+
migration walks three shapes, because a form nested in a section has no `blocks` row of
|
|
86
|
+
its own:
|
|
87
|
+
|
|
88
|
+
1. `blocks.content` where `block_type='form'`
|
|
89
|
+
2. `blocks.content` where `block_type='section'` (`column_blocks`, `slides`)
|
|
90
|
+
3. `content_drafts.blocks` / `product_drafts.blocks`
|
|
91
|
+
|
|
92
|
+
`form_endpoints.fields` is a **server-side snapshot** of the field manifest, so
|
|
93
|
+
notification emails and the inbox render labels the browser did not supply.
|
|
94
|
+
|
|
95
|
+
`BlockRenderer` and `SectionBlockRenderer` also strip `recipient_email` defensively
|
|
96
|
+
before handing content to the client — an import, a restored revision, or a fork that
|
|
97
|
+
has not migrated can still produce one.
|
|
98
|
+
|
|
99
|
+
## Delivery is best-effort; the row is the record
|
|
100
|
+
|
|
101
|
+
`sendEmail` **throws** when SMTP is unconfigured, and a store that has not finished one
|
|
102
|
+
piece of setup often has not finished the other. So every path writes first and notifies
|
|
103
|
+
afterwards, inside `after()`. A failed send sets `email_delivered = false` and records
|
|
104
|
+
`email_error`; the CMS shows "Not emailed" rather than pretending the owner was told.
|
|
105
|
+
|
|
106
|
+
The visitor is still shown success, because from their side it *was* one.
|
|
107
|
+
|
|
108
|
+
## Recipient resolution
|
|
109
|
+
|
|
110
|
+
Contact forms: per-form `form_endpoints.recipient_email` → `site_settings.forms_contact`
|
|
111
|
+
→ the seller-contact ladder (`lib/commerce/seller-contact.ts`: explicit store contact →
|
|
112
|
+
invoice email → privacy support email → oldest ADMIN's auth email). The sandbox
|
|
113
|
+
overrides everything.
|
|
114
|
+
|
|
115
|
+
**The address is never sent to the browser.** The form posts a `form_key`; the server
|
|
116
|
+
resolves the destination.
|
|
117
|
+
|
|
118
|
+
## The reply link and `NEXT_PUBLIC_URL`
|
|
119
|
+
|
|
120
|
+
`sendThreadNotice` refuses to send when the site URL was never configured, but happily
|
|
121
|
+
sends a local URL you chose deliberately.
|
|
122
|
+
|
|
123
|
+
`resolveSiteUrl()` falls back to `http://localhost:3000` when nothing is set, and a link
|
|
124
|
+
to that is dead for anyone not sitting at the machine. What must never happen is the code
|
|
125
|
+
*inventing* a localhost link because nothing was configured and mailing it to a customer.
|
|
126
|
+
A deliberate local URL is different: that is how you test the round trip, and it works.
|
|
127
|
+
|
|
128
|
+
So: honour an explicit choice, refuse an accidental default.
|
|
129
|
+
|
|
130
|
+
- Testing locally: set `NEXT_PUBLIC_URL=http://localhost:3000` (or your dev port). The
|
|
131
|
+
link is sent and works on that machine; a warning is logged saying who can open it.
|
|
132
|
+
- Production: set it to the public site URL. Vercel's project URL is picked up
|
|
133
|
+
automatically.
|
|
134
|
+
- Unset: the notice is refused and the reason is recorded on the message.
|
|
135
|
+
|
|
136
|
+
> An earlier version of this document blamed a `localhost` link for a quarantined
|
|
137
|
+
> message. That was wrong — a probe carrying exactly such a link was delivered normally.
|
|
138
|
+
> The real cause is below.
|
|
139
|
+
|
|
140
|
+
## Mail threading: two conversations, two roots
|
|
141
|
+
|
|
142
|
+
Outbound mail carries `In-Reply-To` and `References` pointing at a stable synthetic root,
|
|
143
|
+
because a `Re:` subject with no threading headers is itself a forged-reply heuristic, and
|
|
144
|
+
one consistent id per conversation groups the exchange in the recipient's client.
|
|
145
|
+
|
|
146
|
+
**The owner's notifications and the visitor's notices use DIFFERENT roots**, and that
|
|
147
|
+
separation is load-bearing:
|
|
148
|
+
|
|
149
|
+
- owner: `<nb-thread-{id}-admin@{from-domain}>`
|
|
150
|
+
- visitor: `<nb-thread-{id}-visitor@{from-domain}>`
|
|
151
|
+
|
|
152
|
+
They are two different exchanges with two different people. Sharing one root makes a mail
|
|
153
|
+
client fold them into a single conversation — and when an operator tests with their own
|
|
154
|
+
address as both the admin *and* the enquiring visitor (the obvious way to try the
|
|
155
|
+
feature), the reply is delivered, accepted, and then collapsed under the notification
|
|
156
|
+
they already read. It looks precisely like the email never arrived.
|
|
157
|
+
|
|
158
|
+
That symptom is a same-mailbox testing artifact, not a production fault: in real use the
|
|
159
|
+
customer and the shop owner are different mailboxes. But the shared root was a genuine
|
|
160
|
+
modelling error, and it is fixed.
|
|
161
|
+
|
|
162
|
+
## Where a form's mail goes
|
|
163
|
+
|
|
164
|
+
One rule, three rungs:
|
|
165
|
+
|
|
166
|
+
1. The form's own `form_endpoints.recipient_email`, if someone set one.
|
|
167
|
+
2. The site contact address from **CMS → Messages** (`site_settings.store_contact`).
|
|
168
|
+
3. The **first admin account's** own login address.
|
|
169
|
+
|
|
170
|
+
**A form has no address of its own by default**, and that is the intended state — since
|
|
171
|
+
the messaging system arrived, an operator does not need to think about per-form routing
|
|
172
|
+
at all. Submissions are stored as threads and answered in the CMS; the notification
|
|
173
|
+
address is one site-wide setting. A per-form address exists only to route one particular
|
|
174
|
+
form elsewhere — a careers form to HR, say.
|
|
175
|
+
|
|
176
|
+
Because rung 3 always resolves on a provisioned install, a fresh site reaches a real
|
|
177
|
+
human without configuring anything.
|
|
178
|
+
|
|
179
|
+
The sandbox overrides all of it: `resolveFormRecipient` returns `SANDBOX_CONTACT_EMAIL`
|
|
180
|
+
when `NEXT_PUBLIC_IS_SANDBOX` is set, so the hosted demo routes to the operator's inbox
|
|
181
|
+
without storing an address anywhere.
|
|
182
|
+
|
|
183
|
+
### Placeholder addresses count as unset
|
|
184
|
+
|
|
185
|
+
The starter content used to ship a contact form addressed to `contact@example.com`, and
|
|
186
|
+
migration 27 faithfully carried that into `form_endpoints`. Faithful was wrong:
|
|
187
|
+
`example.com` is reserved by RFC 2606 so it can never be registered, which makes the
|
|
188
|
+
address *guaranteed* undeliverable while looking like a real setting to every layer
|
|
189
|
+
downstream. The visitor is thanked, the relay accepts, nobody is notified, nothing errors.
|
|
190
|
+
An install ran that way without knowing.
|
|
191
|
+
|
|
192
|
+
Migration 29 clears those to NULL, so they fall through to the ladder above.
|
|
193
|
+
`lib/email/placeholder-address.ts` also treats the reserved domains (`example.com/.org/
|
|
194
|
+
.net/.edu` and the `.example`, `.invalid`, `.test`, `.localhost`, `.local` suffixes) as
|
|
195
|
+
unset at runtime, and `lib/cms/contact-reminder.ts` raises an ADMIN banner if one
|
|
196
|
+
reappears — via an import, a restored revision, or someone typing it.
|
|
197
|
+
|
|
198
|
+
The banner stays quiet when a form simply has no address, because that is now the correct
|
|
199
|
+
configuration rather than an omission.
|
|
200
|
+
|
|
201
|
+
## Subjects are per-conversation, and why that matters
|
|
202
|
+
|
|
203
|
+
| | contact form | product enquiry |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
| **owner** | `Nicolas sent you a message [NRH-VWQOLB]` | `Nicolas asked about Brass Kettle [NRH-EMISCX]` |
|
|
206
|
+
| **visitor** | `New Roots Herbal replied to your message [NRH-VWQOLB]` | `New Roots Herbal replied about Brass Kettle [NRH-EMISCX]` |
|
|
207
|
+
|
|
208
|
+
`threadReference(threadId, siteName)` folds the first 32 bits of the thread id into six
|
|
209
|
+
base36 characters and prefixes the site's initials — about 2.2 billion references, read as
|
|
210
|
+
a ticket number rather than a hex dump.
|
|
211
|
+
|
|
212
|
+
**There is no `Re:` anywhere.** The visitor's message was never an email, so a reply
|
|
213
|
+
prefix is both a spam heuristic and a small lie; naming the site is what actually tells
|
|
214
|
+
them who is writing. The `In-Reply-To` / `References` headers still group each
|
|
215
|
+
conversation properly.
|
|
216
|
+
|
|
217
|
+
The reference is not decoration. **Exchange derives its ConversationTopic from the
|
|
218
|
+
subject, not from References**, so separating the References headers — necessary, and
|
|
219
|
+
done — was not sufficient. With a constant subject, every enquiry a site ever receives
|
|
220
|
+
collapses into one Outlook conversation, and anything applied to that conversation applies
|
|
221
|
+
to all of them: a rule, a filter, or Ignore Conversation.
|
|
222
|
+
|
|
223
|
+
A real install hit exactly this. Replies were accepted by the relay, accepted by Microsoft
|
|
224
|
+
with `250 Queued mail for delivery`, and routed straight to Deleted Items, because a
|
|
225
|
+
"Contact form" conversation had been ignored at some point. Nothing anywhere reported a
|
|
226
|
+
fault. It was isolated by sending two messages with byte-identical bodies and different
|
|
227
|
+
subjects: the unique subject arrived, `Re: Contact form` did not. When mail "doesn't
|
|
228
|
+
arrive", that A/B is the first thing to run — not a theory.
|
|
229
|
+
|
|
230
|
+
## Deliverability: accepted is not delivered
|
|
231
|
+
|
|
232
|
+
A reply can be accepted by the relay, accepted by the recipient's server with
|
|
233
|
+
`250 Queued mail for delivery`, appear in the inbox — and then be **removed from the
|
|
234
|
+
mailbox minutes later**. Microsoft calls this ZAP (Zero-hour Auto Purge): threat intel
|
|
235
|
+
updates after delivery and the message is retracted. There is no bounce and no SMTP
|
|
236
|
+
error, so `email_delivered = true` is accurate and every log upstream reports success.
|
|
237
|
+
|
|
238
|
+
**This is largely outside the application's control.** A strict corporate tenant can
|
|
239
|
+
purge a transactional message on its overall shape — a reply-styled body carrying a
|
|
240
|
+
quoted excerpt and a link to an opaque tokenised URL — and no rendering tested reliably
|
|
241
|
+
survived one such tenant. An intermediate result suggesting that showing the destination
|
|
242
|
+
URL was the deciding factor did not hold up against the real notice.
|
|
243
|
+
|
|
244
|
+
What the application does do:
|
|
245
|
+
|
|
246
|
+
**1. No secrecy language.** The message used to end "This link is personal to you —
|
|
247
|
+
please don't forward it." A secret one-off link, a prominent button, and an instruction
|
|
248
|
+
not to share it is a near-literal phishing template. The link's security comes from the
|
|
249
|
+
token rotating on every reply, not from asking the recipient to keep a secret.
|
|
250
|
+
|
|
251
|
+
**2. The destination is visible.** The call-to-action is a button, with the URL also
|
|
252
|
+
printed as plain text beneath it. Good practice regardless, and it survives clients that
|
|
253
|
+
strip styling.
|
|
254
|
+
|
|
255
|
+
**3. There is a manual channel.** `createVisitorLink` mints a link and hands it to the
|
|
256
|
+
admin without sending anything ("Copy visitor link" in the thread pane). When a
|
|
257
|
+
recipient's filtering removes the mail, the conversation is still intact and the link is
|
|
258
|
+
still obtainable — send it by whatever works.
|
|
259
|
+
|
|
260
|
+
### Diagnosing it
|
|
261
|
+
|
|
262
|
+
Send messages with **byte-identical bodies** differing in exactly one variable, and check
|
|
263
|
+
the recipient's Deleted Items as well as the inbox. Reasoning from correlation produced
|
|
264
|
+
five wrong answers here — a `localhost` link, spam filtering in general, a shared thread
|
|
265
|
+
root, the subject line, and the secrecy phrase — each of which fitted the evidence and
|
|
266
|
+
was killed by the next test.
|
|
267
|
+
|
|
268
|
+
The send path logs what actually went out:
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
[messages] Reply on thread <id> handed to SMTP — to="…" replyTo="…" subject="…" link=tokenised
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Start there. **Accepted by SMTP is not arrived, and arrived is not still there.** When a
|
|
275
|
+
tenant is purging on shape, testing against a different provider (a personal Gmail, say)
|
|
276
|
+
separates "the app is broken" from "this mailbox rejects this class of mail".
|
|
277
|
+
|
|
278
|
+
## Rate limiting
|
|
279
|
+
|
|
280
|
+
There is no shared rate limiter in this repo. Both public write paths throttle by
|
|
281
|
+
counting recent rows for a masked IP, and both **fail closed**: an absent or unparseable
|
|
282
|
+
`X-Forwarded-For` buckets under the literal `'unknown'` rather than skipping the check.
|
|
283
|
+
Trusted platform headers (`x-vercel-forwarded-for`, `x-real-ip`) are preferred over
|
|
284
|
+
`x-forwarded-for`, whose leftmost entry the client can write.
|
|
285
|
+
|
|
286
|
+
## Files
|
|
287
|
+
|
|
288
|
+
- `libs/db/src/supabase/migrations/00000000000027_message_threads.sql` — private lane, `form_endpoints`, the form-block data migration
|
|
289
|
+
- `libs/db/src/supabase/migrations/00000000000028_interaction_replies.sql` — `parent_id`, the reply CHECK, and the indexes `cms_interactions` never had
|
|
290
|
+
- `apps/nextblock/lib/messages/thread-token.ts` (+ `.test.ts`) — mint, parse, verify
|
|
291
|
+
- `apps/nextblock/lib/messages/threads.ts` — thread creation, recipient resolution, both notification emails
|
|
292
|
+
- `apps/nextblock/app/thread/**` — the visitor's page and the token-exchange route
|
|
293
|
+
- `apps/nextblock/app/actions/threadActions.ts` — visitor replies
|
|
294
|
+
- `apps/nextblock/app/cms/messages/**` — the inbox, its loader and its admin actions
|
|
295
|
+
- `apps/nextblock/app/actions/interactions.ts` — `replyToInteraction`, the public lane
|
|
296
|
+
- `apps/nextblock/components/StaffReplies.tsx` — nested public replies on the storefront
|
|
297
|
+
|
|
298
|
+
`/cms/inquiries` and `/cms/interactions` are now redirects into the inbox; both were
|
|
299
|
+
linked from notification emails and bookmarks, so neither was deleted.
|
|
300
|
+
|
|
301
|
+
## Known gaps
|
|
302
|
+
|
|
303
|
+
- Interactions have **no per-user read marker** anywhere in the schema, so the nav badge
|
|
304
|
+
counts "pending moderation". Two admins working the queue see the same number.
|
|
305
|
+
- Public replies do not email the review/comment author. They have a reachable address,
|
|
306
|
+
but that would be the first time the platform emails a registered user about content
|
|
307
|
+
activity, with no preference to opt out of.
|
|
308
|
+
- Visitors get no acknowledgement email at submission time — that would require minting
|
|
309
|
+
a token immediately, which is exactly the live-credential surface this design avoids.
|