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.
Files changed (258) hide show
  1. package/docker-template/.dockerignore +1 -1
  2. package/libs/db/tsconfig.lib.json +3 -3
  3. package/libs/editor/tsconfig.lib.json +3 -3
  4. package/libs/ui/tsconfig.lib.json +3 -3
  5. package/libs/utils/tsconfig.json +3 -3
  6. package/package.json +50 -50
  7. package/project.json +19 -19
  8. package/templates/nextblock-template/.dockerignore +1 -1
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/AGENTS.md +9 -9
  11. package/templates/nextblock-template/CLAUDE.md +1 -1
  12. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  13. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  14. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  15. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
  16. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  17. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
  18. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
  19. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
  20. package/templates/nextblock-template/app/actions/email.ts +241 -241
  21. package/templates/nextblock-template/app/actions/formActions.ts +245 -245
  22. package/templates/nextblock-template/app/actions/interactions.ts +489 -489
  23. package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
  24. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
  25. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -221
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -3092
  30. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  31. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  32. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  33. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  34. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  35. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  37. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  38. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  39. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  40. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
  41. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  42. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  43. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  44. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
  45. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  46. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  47. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  48. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  49. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  50. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  51. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  52. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  53. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  54. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  55. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  56. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  57. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  58. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  59. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  60. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  61. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  62. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  63. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  64. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  65. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  66. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  67. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  68. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  69. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  70. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
  71. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -618
  72. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  73. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  74. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  75. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  76. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  77. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  78. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  79. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  80. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  81. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  82. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  83. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  84. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  85. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  86. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  87. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  88. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  89. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  90. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  91. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  92. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  93. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  94. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  95. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  96. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  97. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  98. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  99. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  100. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  101. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  102. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  103. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  104. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  105. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  106. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  107. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  108. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  109. package/templates/nextblock-template/app/layout.tsx +671 -671
  110. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  111. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  112. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  113. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  114. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  115. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  116. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  117. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  118. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  119. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  120. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  121. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  122. package/templates/nextblock-template/app/providers.tsx +96 -96
  123. package/templates/nextblock-template/app/robots.ts +123 -123
  124. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  125. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  126. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  127. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  128. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  129. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  130. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  131. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  132. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  133. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  134. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  135. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  136. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  137. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  138. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  139. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  140. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  141. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  142. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  143. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  144. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  145. package/templates/nextblock-template/components/Header.tsx +38 -38
  146. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  147. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  148. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  149. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  150. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  151. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  152. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  153. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  154. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  155. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  156. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  157. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  158. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  159. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  160. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  161. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  162. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  163. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  164. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  165. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  166. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  167. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  168. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  169. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  170. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  171. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  172. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  173. package/templates/nextblock-template/components/form-message.tsx +32 -32
  174. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
  175. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -244
  176. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  177. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  178. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  179. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  180. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  181. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  182. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  183. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  184. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  185. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  186. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  187. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  188. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  189. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  190. package/templates/nextblock-template/docs/README.md +42 -42
  191. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  192. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  193. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  194. package/templates/nextblock-template/index.d.ts +7 -7
  195. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  196. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  197. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  198. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  199. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  200. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  201. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  202. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  203. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  204. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  205. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  206. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  207. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
  208. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  209. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  210. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  211. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  212. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  213. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  214. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  215. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  216. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  217. package/templates/nextblock-template/lib/search/types.ts +27 -27
  218. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
  219. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  220. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  221. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  222. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  223. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -140
  224. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -350
  225. package/templates/nextblock-template/lib/seo/page-document.ts +412 -412
  226. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  227. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  228. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  229. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  230. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  231. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  232. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -177
  233. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  234. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  235. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  236. package/templates/nextblock-template/lib/site-url.ts +102 -102
  237. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  238. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  239. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  240. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  241. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  242. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  243. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  244. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  245. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  246. package/templates/nextblock-template/next-env.d.ts +1 -2
  247. package/templates/nextblock-template/package.json +1 -1
  248. package/templates/nextblock-template/postcss.config.js +6 -6
  249. package/templates/nextblock-template/scripts/backup.js +115 -115
  250. package/templates/nextblock-template/scripts/restore.js +385 -385
  251. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  252. package/templates/nextblock-template/tailwind.config.js +25 -25
  253. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  254. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  255. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  256. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  257. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  258. 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.