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,579 +1,579 @@
1
- import 'server-only';
2
-
3
- import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
4
-
5
- import { describeSmtpError, resolveFromDomain, sendEmail } from '../../app/actions/email';
6
- import { resolveEmailBranding } from '../email/branding';
7
- import { resolveSellerContactEmail } from '../commerce/seller-contact';
8
- import { usableEmail } from '../email/placeholder-address';
9
- import { hasExplicitSiteUrl, isPubliclyRoutableSiteUrl, resolveSiteUrl } from '../site-url';
10
- import { mintThreadToken } from './thread-token';
11
-
12
- /**
13
- * The private-conversation lane, shared by product enquiries and contact-form
14
- * submissions.
15
- *
16
- * The governing rule: THE ROW IS THE DELIVERABLE, NOT THE EMAIL. `sendEmail` throws
17
- * when SMTP is unconfigured, and a store that has not finished its payment setup very
18
- * often has not finished its mail setup either. So every path here writes first and
19
- * notifies afterwards, and a failed send is recorded on the message rather than
20
- * surfaced to the visitor as a failure — because from their side it was not one.
21
- */
22
-
23
- export type ThreadSource = 'product_inquiry' | 'contact_form';
24
-
25
- /** Everything visitor-supplied is escaped before it reaches an HTML mail body. */
26
- export function escapeHtml(value: string): string {
27
- return value
28
- .replace(/&/g, '&')
29
- .replace(/</g, '&lt;')
30
- .replace(/>/g, '&gt;')
31
- .replace(/"/g, '&quot;')
32
- .replace(/'/g, '&#39;');
33
- }
34
-
35
- /** Newlines in a header would let a caller inject extra headers (Bcc, …). */
36
- export function sanitizeSubject(value: string): string {
37
- return value.replace(/[\r\n]+/g, ' ').trim();
38
- }
39
-
40
- /**
41
- * Short, readable reference for one conversation, e.g. `NRH-K3M9QX`.
42
- *
43
- * Every thread from a given form otherwise produces a byte-identical subject, with three
44
- * consequences, all bad:
45
- *
46
- * - Exchange derives its ConversationTopic from the SUBJECT, not from References, so
47
- * every enquiry the site ever receives collapses into one conversation. Separating the
48
- * References headers was necessary but not sufficient.
49
- * - Anything applied to that conversation — Ignore Conversation, a rule, a filter —
50
- * silently applies to every future enquiry. A real install lost replies exactly this
51
- * way: SMTP accepted them, Outlook routed them to Deleted Items, nothing reported it.
52
- * - The subject told the owner nothing about who had written.
53
- *
54
- * Base36 of the first 32 bits of the thread id, folded into six characters — about 2.2
55
- * billion distinct references, which is not a collision risk for one site's inbox — and
56
- * uppercased so it reads as a ticket number people can quote back rather than a hex dump.
57
- */
58
- export function threadReference(threadId: string, siteName?: string): string {
59
- const hex = threadId.replace(/-/g, '').slice(0, 8);
60
- const parsed = Number.parseInt(hex, 16);
61
- const code = Number.isNaN(parsed)
62
- ? '000000'
63
- : (parsed % 2_176_782_336).toString(36).toUpperCase().padStart(6, '0');
64
-
65
- const prefix = sitePrefix(siteName);
66
- return prefix ? `${prefix}-${code}` : code;
67
- }
68
-
69
- /** "New Roots Herbal" -> "NRH"; a single-word name -> its first three letters. */
70
- function sitePrefix(siteName?: string): string {
71
- const words = (siteName ?? '')
72
- .split(/\s+/)
73
- .map((word) => word.replace(/[^a-z0-9]/gi, ''))
74
- .filter(Boolean);
75
-
76
- if (words.length === 0) return '';
77
- if (words.length === 1) return words[0].slice(0, 3).toUpperCase();
78
- return words
79
- .slice(0, 4)
80
- .map((word) => word[0])
81
- .join('')
82
- .toUpperCase();
83
- }
84
-
85
- export interface FormEndpoint {
86
- form_key: string;
87
- label: string;
88
- recipient_email: string | null;
89
- fields: Array<{ temp_id?: string; label?: string; field_type?: string }>;
90
- }
91
-
92
- /**
93
- * Load a contact form's server-side manifest.
94
- *
95
- * This is what replaced `recipient_email` living in the block's content. The browser
96
- * now posts only `form_key`, which grants nothing; the address and the field labels are
97
- * read here, so neither can be forged by editing the request.
98
- */
99
- export async function getFormEndpoint(formKey: string): Promise<FormEndpoint | null> {
100
- try {
101
- const supabase = getServiceRoleSupabaseClient();
102
- const { data } = await supabase
103
- .from('form_endpoints')
104
- .select('form_key, label, recipient_email, fields')
105
- .eq('form_key', formKey)
106
- .maybeSingle();
107
- if (!data) return null;
108
- return {
109
- form_key: data.form_key,
110
- label: data.label,
111
- recipient_email: data.recipient_email,
112
- fields: Array.isArray(data.fields) ? (data.fields as FormEndpoint['fields']) : [],
113
- };
114
- } catch {
115
- return null;
116
- }
117
- }
118
-
119
- /**
120
- * Who a contact-form submission should reach. Per-form address first, then the
121
- * site-wide forms address, then the same ladder product enquiries use — so an install
122
- * that configured any one of them is reachable.
123
- */
124
- export async function resolveFormRecipient(endpoint: FormEndpoint | null): Promise<string | null> {
125
- // The sandbox is wiped and re-seeded on a schedule, so anything stored there is
126
- // dummy data by construction.
127
- if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
128
- const sandbox = process.env['SANDBOX_CONTACT_EMAIL']?.trim();
129
- if (sandbox) return sandbox;
130
- }
131
-
132
- // A seeded contact@example.com is not a destination, it is a placeholder that survived
133
- // setup. Fall past it rather than handing the transport an address that can never
134
- // deliver — the CMS raises a reminder about it separately.
135
- const perForm = usableEmail(endpoint?.recipient_email);
136
- if (perForm) return perForm;
137
-
138
- try {
139
- const supabase = getServiceRoleSupabaseClient();
140
- const { data } = await supabase
141
- .from('site_settings')
142
- .select('value')
143
- .eq('key', 'forms_contact')
144
- .maybeSingle();
145
- const value = data?.value;
146
- if (value && typeof value === 'object' && !Array.isArray(value)) {
147
- const configured = usableEmail(
148
- (value as Record<string, unknown>)['contactEmail'] as string | undefined
149
- );
150
- if (configured) return configured;
151
- }
152
- } catch {
153
- /* fall through */
154
- }
155
-
156
- const { email } = await resolveSellerContactEmail();
157
- return email;
158
- }
159
-
160
- export interface CreateThreadInput {
161
- source: ThreadSource;
162
- /** product_inquiries.id for an enquiry. */
163
- subjectId?: string | null;
164
- formKey?: string | null;
165
- subjectLabel: string;
166
- senderName: string | null;
167
- senderEmail: string | null;
168
- message: string;
169
- locale?: string | null;
170
- /** Submitted field map for a contact form: {temp_id: value}. */
171
- fields?: Record<string, string>;
172
- ipMasked?: string | null;
173
- userAgent?: string | null;
174
- }
175
-
176
- export interface CreatedThread {
177
- threadId: string;
178
- messageId: string;
179
- }
180
-
181
- /**
182
- * Open a conversation and record its first inbound turn. Returns null only if the write
183
- * itself failed — the caller should then tell the visitor something went wrong, because
184
- * at that point nothing was kept anywhere.
185
- */
186
- export async function createThread(input: CreateThreadInput): Promise<CreatedThread | null> {
187
- try {
188
- const supabase = getServiceRoleSupabaseClient();
189
-
190
- const { data: thread, error: threadError } = await supabase
191
- .from('message_threads')
192
- .insert({
193
- source: input.source,
194
- subject_id: input.subjectId ?? null,
195
- form_key: input.formKey ?? null,
196
- subject_label: input.subjectLabel,
197
- sender_name: input.senderName,
198
- sender_email: input.senderEmail,
199
- locale: input.locale ?? null,
200
- fields: input.fields ?? {},
201
- ip_masked: input.ipMasked ?? null,
202
- user_agent: input.userAgent ?? null,
203
- last_message_at: new Date().toISOString(),
204
- })
205
- .select('id')
206
- .single();
207
-
208
- if (threadError || !thread) {
209
- console.error('[messages] Could not open thread:', threadError?.message);
210
- return null;
211
- }
212
-
213
- const { data: message, error: messageError } = await supabase
214
- .from('thread_messages')
215
- .insert({
216
- thread_id: thread.id,
217
- direction: 'inbound',
218
- body: input.message,
219
- author_name: input.senderName,
220
- ip_masked: input.ipMasked ?? null,
221
- })
222
- .select('id')
223
- .single();
224
-
225
- if (messageError || !message) {
226
- console.error('[messages] Thread opened but first message failed:', messageError?.message);
227
- return null;
228
- }
229
-
230
- return { threadId: thread.id, messageId: message.id };
231
- } catch (error) {
232
- console.error('[messages] createThread failed:', error);
233
- return null;
234
- }
235
- }
236
-
237
- /** Mark a message delivered, or record why it was not. Never throws. */
238
- async function recordDelivery(messageId: string, delivered: boolean, error?: string): Promise<void> {
239
- try {
240
- const supabase = getServiceRoleSupabaseClient();
241
- await supabase
242
- .from('thread_messages')
243
- .update({ email_delivered: delivered, email_error: error ?? null })
244
- .eq('id', messageId);
245
- } catch {
246
- /* bookkeeping only */
247
- }
248
- }
249
-
250
- export interface NotifyAdminInput {
251
- threadId: string;
252
- /** Shapes the subject: an enquiry names the product, a form names the site. */
253
- source?: ThreadSource;
254
- messageId: string;
255
- subjectLabel: string;
256
- senderName: string | null;
257
- senderEmail: string | null;
258
- message: string;
259
- recipient: string | null;
260
- /** Rendered as a labelled table above the message body (contact forms). */
261
- extraFields?: Array<{ label: string; value: string }>;
262
- }
263
-
264
- /**
265
- * Tell the store owner a message arrived. Best-effort by design: the thread is already
266
- * stored, and the CMS inbox is the source of truth.
267
- */
268
- export async function notifyAdminOfMessage(input: NotifyAdminInput): Promise<void> {
269
- if (!input.recipient) {
270
- console.warn(
271
- `[messages] No recipient resolved; thread ${input.threadId} is stored but nobody was emailed. Set an address at CMS → Messages.`
272
- );
273
- await recordDelivery(input.messageId, false, 'no recipient configured');
274
- return;
275
- }
276
-
277
- const siteUrl = resolveSiteUrl();
278
- const { siteName } = await resolveEmailBranding();
279
- const who = input.senderName || 'Someone';
280
- const reference = threadReference(input.threadId, siteName);
281
- const safeName = escapeHtml(who);
282
- const safeSubject = escapeHtml(input.subjectLabel);
283
- const safeBody = escapeHtml(input.message).replace(/\n/g, '<br />');
284
-
285
- const fieldRows = (input.extraFields ?? [])
286
- .map(
287
- (field) =>
288
- `<tr><td style="padding: 8px;"><strong>${escapeHtml(field.label)}</strong></td><td style="padding: 8px;">${escapeHtml(field.value)}</td></tr>`
289
- )
290
- .join('');
291
-
292
- const html = `
293
- {{brand_header}}
294
- <h2>New message: ${safeSubject}</h2>
295
- <p><strong>${safeName}</strong>${input.senderEmail ? ` (${escapeHtml(input.senderEmail)})` : ''} sent you a message through your website.</p>
296
- ${fieldRows ? `<table border="1" cellpadding="5" cellspacing="0" style="border-collapse: collapse;"><tbody>${fieldRows}</tbody></table>` : ''}
297
- <blockquote style="border-left: 3px solid #ddd; margin: 16px 0; padding-left: 12px;">${safeBody}</blockquote>
298
- <p><a href="${siteUrl}/cms/messages?thread=${input.threadId}" style="display:inline-block;padding:10px 16px;background:#111;color:#fff;text-decoration:none;border-radius:6px;">Read and reply in your CMS</a></p>
299
- <p style="font-size:12px;color:#666;">Reply from the CMS rather than this email — that is what reaches the sender.</p>
300
- `;
301
-
302
- const text = [
303
- `New message: ${input.subjectLabel}`,
304
- '',
305
- `From: ${input.senderName || 'Someone'}${input.senderEmail ? ` <${input.senderEmail}>` : ''}`,
306
- ...(input.extraFields ?? []).map((field) => `${field.label}: ${field.value}`),
307
- '',
308
- input.message,
309
- '',
310
- `Read and reply: ${siteUrl}/cms/messages?thread=${input.threadId}`,
311
- ].join('\n');
312
-
313
- try {
314
- // The OWNER's side of this conversation, kept deliberately distinct from the
315
- // visitor's root below. They are two different exchanges with two different people,
316
- // and a shared root makes a mail client fold them into one — which, when the owner
317
- // is also testing as the visitor, looks exactly like the reply never arrived.
318
- const adminRoot = `<nb-thread-${input.threadId}-admin@${await resolveFromDomain()}>`;
319
-
320
- await sendEmail({
321
- to: input.recipient,
322
- // Leads with WHO wrote — the single most useful thing in an inbox listing — and
323
- // says what about, so the owner can triage without opening it.
324
- subject: sanitizeSubject(
325
- input.source === 'product_inquiry'
326
- ? `${who} asked about ${input.subjectLabel} [${reference}]`
327
- : `${who} sent you a message [${reference}]`
328
- ),
329
- text,
330
- html,
331
- // Groups every notification about one conversation in the owner's mail client.
332
- inReplyTo: adminRoot,
333
- references: adminRoot,
334
- // The owner should answer in the CMS (that is what reaches the visitor), but a
335
- // reply-to that goes somewhere real beats one that bounces off the SMTP identity.
336
- ...(input.senderEmail ? { replyTo: input.senderEmail } : {}),
337
- });
338
- await recordDelivery(input.messageId, true);
339
- } catch (error) {
340
- const reason = describeSmtpError(error);
341
- // Log the ORIGINAL alongside the friendly text. Replacing it in the log too would
342
- // throw away the only detail that makes an obscure transport failure diagnosable.
343
- console.error(
344
- `[messages] Thread ${input.threadId} stored but notification failed: ${reason}`,
345
- error
346
- );
347
- await recordDelivery(input.messageId, false, reason.slice(0, 500));
348
- }
349
- }
350
-
351
- export interface ThreadNoticeInput {
352
- threadId: string;
353
- source?: ThreadSource;
354
- /** Used to greet the visitor by name when the form captured one. */
355
- senderName?: string | null;
356
- messageId: string;
357
- subjectLabel: string;
358
- senderEmail: string | null;
359
- body: string;
360
- /** Plaintext token. Present only when one was just minted for this send. */
361
- token: string | null;
362
- /** Existing hash when the thread already had a live token. */
363
- hasExistingToken: boolean;
364
- }
365
-
366
- /**
367
- * Tell the visitor the store replied.
368
- *
369
- * A NOTE ON WORDING. This message previously ended with "This link is personal to you —
370
- * please don't forward it." The intent was sound (the link is a credential) but the
371
- * phrasing is close to a literal template for credential phishing: a secret one-off link,
372
- * a prominent call-to-action button, and an instruction not to share it. Microsoft
373
- * Defender purged real messages from the mailbox AFTER delivering them — ZAP, which
374
- * leaves no bounce and no SMTP error, so every log upstream reports success.
375
- *
376
- * The security property is kept by the token itself, which rotates on every reply, rather
377
- * than by asking the recipient to keep a secret. The closing line is now the ordinary
378
- * transactional one that anti-abuse systems expect to see.
379
- *
380
- * A NOTE ON THE LINK. It is a styled button, with the destination URL also shown as
381
- * plain text beneath it.
382
- *
383
- * Controlled tests against one Microsoft 365 tenant briefly suggested that concealing the
384
- * destination was what got a message purged after delivery — a body showing its own URL
385
- * survived where the same body behind a "Read and reply" button did not. Further testing
386
- * with the real notice contradicted that: it was removed either way. The tenant appears
387
- * to act on the overall shape of this message, and no rendering tested reliably survived
388
- * it.
389
- *
390
- * So the button is kept, because the plainer version bought nothing, and the visible URL
391
- * is kept alongside it, because showing a destination is good practice regardless and
392
- * helps any client that strips styling. Where a recipient's filtering removes the mail
393
- * anyway, the answer is `createVisitorLink` — the admin copies the link and sends it by a
394
- * channel that works. Delivery is best-effort by design; the stored thread is the record.
395
- */
396
- export async function sendThreadNotice(input: ThreadNoticeInput): Promise<void> {
397
- if (!input.senderEmail) {
398
- await recordDelivery(input.messageId, false, 'sender left no email address');
399
- return;
400
- }
401
-
402
- if (!input.token && !input.hasExistingToken) {
403
- await recordDelivery(input.messageId, false, 'no thread link available');
404
- return;
405
- }
406
-
407
- const siteUrl = resolveSiteUrl();
408
- const { siteName } = await resolveEmailBranding();
409
- const reference = threadReference(input.threadId, siteName);
410
-
411
- // The entire message is a link to the conversation, so where that link points is
412
- // load-bearing. Two cases, and only one of them is a mistake:
413
- //
414
- // - Nothing configured. resolveSiteUrl() invents http://localhost:3000, and a link
415
- // to it goes out to a real customer: a dead button, and — a non-routable host
416
- // under plain http beside a long opaque token — something mail filters treat as
417
- // phishing and drop without a bounce. Refuse; nobody chose this.
418
- // - Deliberately set to a local address. That is how you test the round trip on your
419
- // own machine, where the link works fine. Send it, and note it in the log.
420
- if (!isPubliclyRoutableSiteUrl(siteUrl) && !hasExplicitSiteUrl()) {
421
- const reason = `Your site has no address configured, so the reply link would point at "${siteUrl}" and would not work for the recipient. Set NEXT_PUBLIC_URL — to your public site URL, or to your local address if you are testing on this machine — and send again.`;
422
- console.error(`[messages] Refusing to send a thread link pointing at the unconfigured default ${siteUrl}.`);
423
- await recordDelivery(input.messageId, false, reason);
424
- return;
425
- }
426
-
427
- if (!isPubliclyRoutableSiteUrl(siteUrl)) {
428
- console.warn(
429
- `[messages] Sending a thread link to ${siteUrl}. It will only open on a machine that can reach that address — fine for local testing, wrong for a real customer.`
430
- );
431
- }
432
-
433
- // The VISITOR's root. Distinct from the owner-notification root by design: see the
434
- // note in notifyAdminOfMessage. Every notice to this visitor about this conversation
435
- // shares it, which is what makes the "Re:" subject legitimate rather than a
436
- // forged-reply signal and groups the exchange in their client.
437
- const threadRoot = `<nb-thread-${input.threadId}-visitor@${await resolveFromDomain()}>`;
438
- const preview = input.body.length > 300 ? `${input.body.slice(0, 300)}…` : input.body;
439
- const safePreview = escapeHtml(preview).replace(/\n/g, '<br />');
440
- const safeSubject = escapeHtml(input.subjectLabel);
441
- // Address the person by name when the form captured one; a bare "Hello" beats
442
- // "Hi undefined," or guessing at a first name.
443
- const greeting = input.senderName ? `Hi ${input.senderName},` : 'Hello,';
444
- const safeGreeting = escapeHtml(greeting);
445
- const safeSiteName = escapeHtml(siteName);
446
-
447
- // Always a tokenised link. `ensureThreadToken` now rotates, so `token` is present on
448
- // every send; the bare `/thread` fallback remains only for the impossible case, and it
449
- // is honest about being useless to anyone without the cookie.
450
- const link = input.token ? `${siteUrl}/thread/${input.token}` : `${siteUrl}/thread`;
451
-
452
- const html = `
453
- {{brand_header}}
454
- <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1f2328;font-size:16px;line-height:1.55;">
455
- <p style="margin:0 0 4px;font-size:13px;color:#6b7280;">Reply from ${safeSiteName}</p>
456
- <h1 style="margin:0 0 16px;font-size:22px;line-height:1.3;font-weight:600;color:#111827;">${safeGreeting}</h1>
457
- <p style="margin:0 0 20px;color:#374151;">You got in touch about <strong style="color:#111827;">${safeSubject}</strong>. Here is our reply:</p>
458
-
459
- <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px;">
460
- <tr>
461
- <td style="background:#f6f8fa;border-left:4px solid #111827;border-radius:0 8px 8px 0;padding:16px 18px;color:#1f2328;">${safePreview}</td>
462
- </tr>
463
- </table>
464
-
465
- <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 12px;">
466
- <tr>
467
- <td style="border-radius:8px;background:#111827;">
468
- <a href="${link}" style="display:inline-block;padding:13px 26px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">Read &amp; reply</a>
469
- </td>
470
- </tr>
471
- </table>
472
-
473
- <p style="margin:0 0 24px;font-size:14px;color:#6b7280;">The whole conversation is on that page, and you can answer straight from it.</p>
474
-
475
- <hr style="border:0;border-top:1px solid #e5e7eb;margin:0 0 16px;" />
476
-
477
- <p style="margin:0 0 8px;font-size:12px;line-height:1.5;color:#9ca3af;">Button not working? Paste this into your browser:<br /><span style="word-break:break-all;color:#6b7280;">${link}</span></p>
478
- <p style="margin:0;font-size:12px;color:#9ca3af;">If you didn't contact ${safeSiteName}, you can safely ignore this email.</p>
479
- </div>
480
- `;
481
-
482
- const text = [
483
- greeting,
484
- '',
485
- `You got in touch with ${siteName} about "${input.subjectLabel}". Here is our reply:`,
486
- '',
487
- preview,
488
- '',
489
- 'Read the whole conversation and reply here:',
490
- link,
491
- '',
492
- `If you didn't contact ${siteName}, you can safely ignore this email.`,
493
- ].join('\n');
494
-
495
- try {
496
- // Recorded verbatim below. When a message is accepted by every hop and still never
497
- // arrives, the subject and recipient that actually went out are the first things
498
- // anyone needs — and reconstructing them after the fact is guesswork.
499
- //
500
- // Deliberately NOT "Re:". The visitor's message was never an email, so a bare reply
501
- // prefix is both a spam heuristic and a small lie; naming the site is what actually
502
- // tells them who is writing.
503
- const subject = sanitizeSubject(
504
- input.source === 'product_inquiry'
505
- ? `${siteName} replied about ${input.subjectLabel} [${reference}]`
506
- : `${siteName} replied to your message [${reference}]`
507
- );
508
-
509
- // Reply-To is the store, not the no-reply identity the message is sent as.
510
- //
511
- // A visitor who hits Reply in their mail client is doing the most natural thing
512
- // available to them; without this it goes to donotreply@ and is never seen. The
513
- // thread page is still the better channel — it keeps the conversation in one place —
514
- // but "click this link or nothing" is a poor contract to offer someone.
515
- const { email: replyTo } = await resolveSellerContactEmail();
516
-
517
- await sendEmail({
518
- to: input.senderEmail,
519
- subject,
520
- text,
521
- html,
522
- inReplyTo: threadRoot,
523
- references: threadRoot,
524
- ...(replyTo ? { replyTo } : {}),
525
- });
526
-
527
- console.info(
528
- `[messages] Reply on thread ${input.threadId} handed to SMTP — to="${input.senderEmail}" replyTo="${replyTo ?? 'none'}" subject="${subject}" link=${input.token ? 'tokenised' : 'generic'}`
529
- );
530
- await recordDelivery(input.messageId, true);
531
- } catch (error) {
532
- const reason = describeSmtpError(error);
533
- console.error(
534
- `[messages] Reply on thread ${input.threadId} stored but not delivered: ${reason}`,
535
- error
536
- );
537
- await recordDelivery(input.messageId, false, reason.slice(0, 500));
538
- }
539
- }
540
-
541
- /**
542
- * Ensure a thread has a live visitor link, minting one on first use.
543
- *
544
- * Returns the plaintext token ONLY when it was just created — that is the single moment
545
- * it can be put in an email, and afterwards only its hash exists.
546
- */
547
- export async function ensureThreadToken(
548
- threadId: string
549
- ): Promise<{ token: string | null; hasExistingToken: boolean }> {
550
- const supabase = getServiceRoleSupabaseClient();
551
-
552
- // ROTATES on every reply, deliberately.
553
- //
554
- // Only the hash is stored, so once a token exists its plaintext is gone — and an
555
- // earlier version returned `{ token: null }` in that case, which made every reply after
556
- // the first mail a bare `/thread` link. That link works only for someone who still
557
- // holds the cookie, i.e. never for the visitor reading their email on another device.
558
- //
559
- // Minting fresh each time is what guarantees a working link in every message. The cost
560
- // is that the previous link stops working, which is a reasonable property for a
561
- // credential mailed in the clear: one live link per conversation at a time.
562
- const now = new Date();
563
- const minted = mintThreadToken(now);
564
- const { error } = await supabase
565
- .from('message_threads')
566
- .update({
567
- token_hash: minted.tokenHash,
568
- token_expires_at: minted.expiresAt,
569
- token_revoked_at: null,
570
- })
571
- .eq('id', threadId);
572
-
573
- if (error) {
574
- console.error('[messages] Could not mint a thread token:', error.message);
575
- return { token: null, hasExistingToken: false };
576
- }
577
-
578
- return { token: minted.token, hasExistingToken: false };
579
- }
1
+ import 'server-only';
2
+
3
+ import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
4
+
5
+ import { describeSmtpError, resolveFromDomain, sendEmail } from '../../app/actions/email';
6
+ import { resolveEmailBranding } from '../email/branding';
7
+ import { resolveSellerContactEmail } from '../commerce/seller-contact';
8
+ import { usableEmail } from '../email/placeholder-address';
9
+ import { hasExplicitSiteUrl, isPubliclyRoutableSiteUrl, resolveSiteUrl } from '../site-url';
10
+ import { mintThreadToken } from './thread-token';
11
+
12
+ /**
13
+ * The private-conversation lane, shared by product enquiries and contact-form
14
+ * submissions.
15
+ *
16
+ * The governing rule: THE ROW IS THE DELIVERABLE, NOT THE EMAIL. `sendEmail` throws
17
+ * when SMTP is unconfigured, and a store that has not finished its payment setup very
18
+ * often has not finished its mail setup either. So every path here writes first and
19
+ * notifies afterwards, and a failed send is recorded on the message rather than
20
+ * surfaced to the visitor as a failure — because from their side it was not one.
21
+ */
22
+
23
+ export type ThreadSource = 'product_inquiry' | 'contact_form';
24
+
25
+ /** Everything visitor-supplied is escaped before it reaches an HTML mail body. */
26
+ export function escapeHtml(value: string): string {
27
+ return value
28
+ .replace(/&/g, '&amp;')
29
+ .replace(/</g, '&lt;')
30
+ .replace(/>/g, '&gt;')
31
+ .replace(/"/g, '&quot;')
32
+ .replace(/'/g, '&#39;');
33
+ }
34
+
35
+ /** Newlines in a header would let a caller inject extra headers (Bcc, …). */
36
+ export function sanitizeSubject(value: string): string {
37
+ return value.replace(/[\r\n]+/g, ' ').trim();
38
+ }
39
+
40
+ /**
41
+ * Short, readable reference for one conversation, e.g. `NRH-K3M9QX`.
42
+ *
43
+ * Every thread from a given form otherwise produces a byte-identical subject, with three
44
+ * consequences, all bad:
45
+ *
46
+ * - Exchange derives its ConversationTopic from the SUBJECT, not from References, so
47
+ * every enquiry the site ever receives collapses into one conversation. Separating the
48
+ * References headers was necessary but not sufficient.
49
+ * - Anything applied to that conversation — Ignore Conversation, a rule, a filter —
50
+ * silently applies to every future enquiry. A real install lost replies exactly this
51
+ * way: SMTP accepted them, Outlook routed them to Deleted Items, nothing reported it.
52
+ * - The subject told the owner nothing about who had written.
53
+ *
54
+ * Base36 of the first 32 bits of the thread id, folded into six characters — about 2.2
55
+ * billion distinct references, which is not a collision risk for one site's inbox — and
56
+ * uppercased so it reads as a ticket number people can quote back rather than a hex dump.
57
+ */
58
+ export function threadReference(threadId: string, siteName?: string): string {
59
+ const hex = threadId.replace(/-/g, '').slice(0, 8);
60
+ const parsed = Number.parseInt(hex, 16);
61
+ const code = Number.isNaN(parsed)
62
+ ? '000000'
63
+ : (parsed % 2_176_782_336).toString(36).toUpperCase().padStart(6, '0');
64
+
65
+ const prefix = sitePrefix(siteName);
66
+ return prefix ? `${prefix}-${code}` : code;
67
+ }
68
+
69
+ /** "New Roots Herbal" -> "NRH"; a single-word name -> its first three letters. */
70
+ function sitePrefix(siteName?: string): string {
71
+ const words = (siteName ?? '')
72
+ .split(/\s+/)
73
+ .map((word) => word.replace(/[^a-z0-9]/gi, ''))
74
+ .filter(Boolean);
75
+
76
+ if (words.length === 0) return '';
77
+ if (words.length === 1) return words[0].slice(0, 3).toUpperCase();
78
+ return words
79
+ .slice(0, 4)
80
+ .map((word) => word[0])
81
+ .join('')
82
+ .toUpperCase();
83
+ }
84
+
85
+ export interface FormEndpoint {
86
+ form_key: string;
87
+ label: string;
88
+ recipient_email: string | null;
89
+ fields: Array<{ temp_id?: string; label?: string; field_type?: string }>;
90
+ }
91
+
92
+ /**
93
+ * Load a contact form's server-side manifest.
94
+ *
95
+ * This is what replaced `recipient_email` living in the block's content. The browser
96
+ * now posts only `form_key`, which grants nothing; the address and the field labels are
97
+ * read here, so neither can be forged by editing the request.
98
+ */
99
+ export async function getFormEndpoint(formKey: string): Promise<FormEndpoint | null> {
100
+ try {
101
+ const supabase = getServiceRoleSupabaseClient();
102
+ const { data } = await supabase
103
+ .from('form_endpoints')
104
+ .select('form_key, label, recipient_email, fields')
105
+ .eq('form_key', formKey)
106
+ .maybeSingle();
107
+ if (!data) return null;
108
+ return {
109
+ form_key: data.form_key,
110
+ label: data.label,
111
+ recipient_email: data.recipient_email,
112
+ fields: Array.isArray(data.fields) ? (data.fields as FormEndpoint['fields']) : [],
113
+ };
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Who a contact-form submission should reach. Per-form address first, then the
121
+ * site-wide forms address, then the same ladder product enquiries use — so an install
122
+ * that configured any one of them is reachable.
123
+ */
124
+ export async function resolveFormRecipient(endpoint: FormEndpoint | null): Promise<string | null> {
125
+ // The sandbox is wiped and re-seeded on a schedule, so anything stored there is
126
+ // dummy data by construction.
127
+ if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
128
+ const sandbox = process.env['SANDBOX_CONTACT_EMAIL']?.trim();
129
+ if (sandbox) return sandbox;
130
+ }
131
+
132
+ // A seeded contact@example.com is not a destination, it is a placeholder that survived
133
+ // setup. Fall past it rather than handing the transport an address that can never
134
+ // deliver — the CMS raises a reminder about it separately.
135
+ const perForm = usableEmail(endpoint?.recipient_email);
136
+ if (perForm) return perForm;
137
+
138
+ try {
139
+ const supabase = getServiceRoleSupabaseClient();
140
+ const { data } = await supabase
141
+ .from('site_settings')
142
+ .select('value')
143
+ .eq('key', 'forms_contact')
144
+ .maybeSingle();
145
+ const value = data?.value;
146
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
147
+ const configured = usableEmail(
148
+ (value as Record<string, unknown>)['contactEmail'] as string | undefined
149
+ );
150
+ if (configured) return configured;
151
+ }
152
+ } catch {
153
+ /* fall through */
154
+ }
155
+
156
+ const { email } = await resolveSellerContactEmail();
157
+ return email;
158
+ }
159
+
160
+ export interface CreateThreadInput {
161
+ source: ThreadSource;
162
+ /** product_inquiries.id for an enquiry. */
163
+ subjectId?: string | null;
164
+ formKey?: string | null;
165
+ subjectLabel: string;
166
+ senderName: string | null;
167
+ senderEmail: string | null;
168
+ message: string;
169
+ locale?: string | null;
170
+ /** Submitted field map for a contact form: {temp_id: value}. */
171
+ fields?: Record<string, string>;
172
+ ipMasked?: string | null;
173
+ userAgent?: string | null;
174
+ }
175
+
176
+ export interface CreatedThread {
177
+ threadId: string;
178
+ messageId: string;
179
+ }
180
+
181
+ /**
182
+ * Open a conversation and record its first inbound turn. Returns null only if the write
183
+ * itself failed — the caller should then tell the visitor something went wrong, because
184
+ * at that point nothing was kept anywhere.
185
+ */
186
+ export async function createThread(input: CreateThreadInput): Promise<CreatedThread | null> {
187
+ try {
188
+ const supabase = getServiceRoleSupabaseClient();
189
+
190
+ const { data: thread, error: threadError } = await supabase
191
+ .from('message_threads')
192
+ .insert({
193
+ source: input.source,
194
+ subject_id: input.subjectId ?? null,
195
+ form_key: input.formKey ?? null,
196
+ subject_label: input.subjectLabel,
197
+ sender_name: input.senderName,
198
+ sender_email: input.senderEmail,
199
+ locale: input.locale ?? null,
200
+ fields: input.fields ?? {},
201
+ ip_masked: input.ipMasked ?? null,
202
+ user_agent: input.userAgent ?? null,
203
+ last_message_at: new Date().toISOString(),
204
+ })
205
+ .select('id')
206
+ .single();
207
+
208
+ if (threadError || !thread) {
209
+ console.error('[messages] Could not open thread:', threadError?.message);
210
+ return null;
211
+ }
212
+
213
+ const { data: message, error: messageError } = await supabase
214
+ .from('thread_messages')
215
+ .insert({
216
+ thread_id: thread.id,
217
+ direction: 'inbound',
218
+ body: input.message,
219
+ author_name: input.senderName,
220
+ ip_masked: input.ipMasked ?? null,
221
+ })
222
+ .select('id')
223
+ .single();
224
+
225
+ if (messageError || !message) {
226
+ console.error('[messages] Thread opened but first message failed:', messageError?.message);
227
+ return null;
228
+ }
229
+
230
+ return { threadId: thread.id, messageId: message.id };
231
+ } catch (error) {
232
+ console.error('[messages] createThread failed:', error);
233
+ return null;
234
+ }
235
+ }
236
+
237
+ /** Mark a message delivered, or record why it was not. Never throws. */
238
+ async function recordDelivery(messageId: string, delivered: boolean, error?: string): Promise<void> {
239
+ try {
240
+ const supabase = getServiceRoleSupabaseClient();
241
+ await supabase
242
+ .from('thread_messages')
243
+ .update({ email_delivered: delivered, email_error: error ?? null })
244
+ .eq('id', messageId);
245
+ } catch {
246
+ /* bookkeeping only */
247
+ }
248
+ }
249
+
250
+ export interface NotifyAdminInput {
251
+ threadId: string;
252
+ /** Shapes the subject: an enquiry names the product, a form names the site. */
253
+ source?: ThreadSource;
254
+ messageId: string;
255
+ subjectLabel: string;
256
+ senderName: string | null;
257
+ senderEmail: string | null;
258
+ message: string;
259
+ recipient: string | null;
260
+ /** Rendered as a labelled table above the message body (contact forms). */
261
+ extraFields?: Array<{ label: string; value: string }>;
262
+ }
263
+
264
+ /**
265
+ * Tell the store owner a message arrived. Best-effort by design: the thread is already
266
+ * stored, and the CMS inbox is the source of truth.
267
+ */
268
+ export async function notifyAdminOfMessage(input: NotifyAdminInput): Promise<void> {
269
+ if (!input.recipient) {
270
+ console.warn(
271
+ `[messages] No recipient resolved; thread ${input.threadId} is stored but nobody was emailed. Set an address at CMS → Messages.`
272
+ );
273
+ await recordDelivery(input.messageId, false, 'no recipient configured');
274
+ return;
275
+ }
276
+
277
+ const siteUrl = resolveSiteUrl();
278
+ const { siteName } = await resolveEmailBranding();
279
+ const who = input.senderName || 'Someone';
280
+ const reference = threadReference(input.threadId, siteName);
281
+ const safeName = escapeHtml(who);
282
+ const safeSubject = escapeHtml(input.subjectLabel);
283
+ const safeBody = escapeHtml(input.message).replace(/\n/g, '<br />');
284
+
285
+ const fieldRows = (input.extraFields ?? [])
286
+ .map(
287
+ (field) =>
288
+ `<tr><td style="padding: 8px;"><strong>${escapeHtml(field.label)}</strong></td><td style="padding: 8px;">${escapeHtml(field.value)}</td></tr>`
289
+ )
290
+ .join('');
291
+
292
+ const html = `
293
+ {{brand_header}}
294
+ <h2>New message: ${safeSubject}</h2>
295
+ <p><strong>${safeName}</strong>${input.senderEmail ? ` (${escapeHtml(input.senderEmail)})` : ''} sent you a message through your website.</p>
296
+ ${fieldRows ? `<table border="1" cellpadding="5" cellspacing="0" style="border-collapse: collapse;"><tbody>${fieldRows}</tbody></table>` : ''}
297
+ <blockquote style="border-left: 3px solid #ddd; margin: 16px 0; padding-left: 12px;">${safeBody}</blockquote>
298
+ <p><a href="${siteUrl}/cms/messages?thread=${input.threadId}" style="display:inline-block;padding:10px 16px;background:#111;color:#fff;text-decoration:none;border-radius:6px;">Read and reply in your CMS</a></p>
299
+ <p style="font-size:12px;color:#666;">Reply from the CMS rather than this email — that is what reaches the sender.</p>
300
+ `;
301
+
302
+ const text = [
303
+ `New message: ${input.subjectLabel}`,
304
+ '',
305
+ `From: ${input.senderName || 'Someone'}${input.senderEmail ? ` <${input.senderEmail}>` : ''}`,
306
+ ...(input.extraFields ?? []).map((field) => `${field.label}: ${field.value}`),
307
+ '',
308
+ input.message,
309
+ '',
310
+ `Read and reply: ${siteUrl}/cms/messages?thread=${input.threadId}`,
311
+ ].join('\n');
312
+
313
+ try {
314
+ // The OWNER's side of this conversation, kept deliberately distinct from the
315
+ // visitor's root below. They are two different exchanges with two different people,
316
+ // and a shared root makes a mail client fold them into one — which, when the owner
317
+ // is also testing as the visitor, looks exactly like the reply never arrived.
318
+ const adminRoot = `<nb-thread-${input.threadId}-admin@${await resolveFromDomain()}>`;
319
+
320
+ await sendEmail({
321
+ to: input.recipient,
322
+ // Leads with WHO wrote — the single most useful thing in an inbox listing — and
323
+ // says what about, so the owner can triage without opening it.
324
+ subject: sanitizeSubject(
325
+ input.source === 'product_inquiry'
326
+ ? `${who} asked about ${input.subjectLabel} [${reference}]`
327
+ : `${who} sent you a message [${reference}]`
328
+ ),
329
+ text,
330
+ html,
331
+ // Groups every notification about one conversation in the owner's mail client.
332
+ inReplyTo: adminRoot,
333
+ references: adminRoot,
334
+ // The owner should answer in the CMS (that is what reaches the visitor), but a
335
+ // reply-to that goes somewhere real beats one that bounces off the SMTP identity.
336
+ ...(input.senderEmail ? { replyTo: input.senderEmail } : {}),
337
+ });
338
+ await recordDelivery(input.messageId, true);
339
+ } catch (error) {
340
+ const reason = describeSmtpError(error);
341
+ // Log the ORIGINAL alongside the friendly text. Replacing it in the log too would
342
+ // throw away the only detail that makes an obscure transport failure diagnosable.
343
+ console.error(
344
+ `[messages] Thread ${input.threadId} stored but notification failed: ${reason}`,
345
+ error
346
+ );
347
+ await recordDelivery(input.messageId, false, reason.slice(0, 500));
348
+ }
349
+ }
350
+
351
+ export interface ThreadNoticeInput {
352
+ threadId: string;
353
+ source?: ThreadSource;
354
+ /** Used to greet the visitor by name when the form captured one. */
355
+ senderName?: string | null;
356
+ messageId: string;
357
+ subjectLabel: string;
358
+ senderEmail: string | null;
359
+ body: string;
360
+ /** Plaintext token. Present only when one was just minted for this send. */
361
+ token: string | null;
362
+ /** Existing hash when the thread already had a live token. */
363
+ hasExistingToken: boolean;
364
+ }
365
+
366
+ /**
367
+ * Tell the visitor the store replied.
368
+ *
369
+ * A NOTE ON WORDING. This message previously ended with "This link is personal to you —
370
+ * please don't forward it." The intent was sound (the link is a credential) but the
371
+ * phrasing is close to a literal template for credential phishing: a secret one-off link,
372
+ * a prominent call-to-action button, and an instruction not to share it. Microsoft
373
+ * Defender purged real messages from the mailbox AFTER delivering them — ZAP, which
374
+ * leaves no bounce and no SMTP error, so every log upstream reports success.
375
+ *
376
+ * The security property is kept by the token itself, which rotates on every reply, rather
377
+ * than by asking the recipient to keep a secret. The closing line is now the ordinary
378
+ * transactional one that anti-abuse systems expect to see.
379
+ *
380
+ * A NOTE ON THE LINK. It is a styled button, with the destination URL also shown as
381
+ * plain text beneath it.
382
+ *
383
+ * Controlled tests against one Microsoft 365 tenant briefly suggested that concealing the
384
+ * destination was what got a message purged after delivery — a body showing its own URL
385
+ * survived where the same body behind a "Read and reply" button did not. Further testing
386
+ * with the real notice contradicted that: it was removed either way. The tenant appears
387
+ * to act on the overall shape of this message, and no rendering tested reliably survived
388
+ * it.
389
+ *
390
+ * So the button is kept, because the plainer version bought nothing, and the visible URL
391
+ * is kept alongside it, because showing a destination is good practice regardless and
392
+ * helps any client that strips styling. Where a recipient's filtering removes the mail
393
+ * anyway, the answer is `createVisitorLink` — the admin copies the link and sends it by a
394
+ * channel that works. Delivery is best-effort by design; the stored thread is the record.
395
+ */
396
+ export async function sendThreadNotice(input: ThreadNoticeInput): Promise<void> {
397
+ if (!input.senderEmail) {
398
+ await recordDelivery(input.messageId, false, 'sender left no email address');
399
+ return;
400
+ }
401
+
402
+ if (!input.token && !input.hasExistingToken) {
403
+ await recordDelivery(input.messageId, false, 'no thread link available');
404
+ return;
405
+ }
406
+
407
+ const siteUrl = resolveSiteUrl();
408
+ const { siteName } = await resolveEmailBranding();
409
+ const reference = threadReference(input.threadId, siteName);
410
+
411
+ // The entire message is a link to the conversation, so where that link points is
412
+ // load-bearing. Two cases, and only one of them is a mistake:
413
+ //
414
+ // - Nothing configured. resolveSiteUrl() invents http://localhost:3000, and a link
415
+ // to it goes out to a real customer: a dead button, and — a non-routable host
416
+ // under plain http beside a long opaque token — something mail filters treat as
417
+ // phishing and drop without a bounce. Refuse; nobody chose this.
418
+ // - Deliberately set to a local address. That is how you test the round trip on your
419
+ // own machine, where the link works fine. Send it, and note it in the log.
420
+ if (!isPubliclyRoutableSiteUrl(siteUrl) && !hasExplicitSiteUrl()) {
421
+ const reason = `Your site has no address configured, so the reply link would point at "${siteUrl}" and would not work for the recipient. Set NEXT_PUBLIC_URL — to your public site URL, or to your local address if you are testing on this machine — and send again.`;
422
+ console.error(`[messages] Refusing to send a thread link pointing at the unconfigured default ${siteUrl}.`);
423
+ await recordDelivery(input.messageId, false, reason);
424
+ return;
425
+ }
426
+
427
+ if (!isPubliclyRoutableSiteUrl(siteUrl)) {
428
+ console.warn(
429
+ `[messages] Sending a thread link to ${siteUrl}. It will only open on a machine that can reach that address — fine for local testing, wrong for a real customer.`
430
+ );
431
+ }
432
+
433
+ // The VISITOR's root. Distinct from the owner-notification root by design: see the
434
+ // note in notifyAdminOfMessage. Every notice to this visitor about this conversation
435
+ // shares it, which is what makes the "Re:" subject legitimate rather than a
436
+ // forged-reply signal and groups the exchange in their client.
437
+ const threadRoot = `<nb-thread-${input.threadId}-visitor@${await resolveFromDomain()}>`;
438
+ const preview = input.body.length > 300 ? `${input.body.slice(0, 300)}…` : input.body;
439
+ const safePreview = escapeHtml(preview).replace(/\n/g, '<br />');
440
+ const safeSubject = escapeHtml(input.subjectLabel);
441
+ // Address the person by name when the form captured one; a bare "Hello" beats
442
+ // "Hi undefined," or guessing at a first name.
443
+ const greeting = input.senderName ? `Hi ${input.senderName},` : 'Hello,';
444
+ const safeGreeting = escapeHtml(greeting);
445
+ const safeSiteName = escapeHtml(siteName);
446
+
447
+ // Always a tokenised link. `ensureThreadToken` now rotates, so `token` is present on
448
+ // every send; the bare `/thread` fallback remains only for the impossible case, and it
449
+ // is honest about being useless to anyone without the cookie.
450
+ const link = input.token ? `${siteUrl}/thread/${input.token}` : `${siteUrl}/thread`;
451
+
452
+ const html = `
453
+ {{brand_header}}
454
+ <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1f2328;font-size:16px;line-height:1.55;">
455
+ <p style="margin:0 0 4px;font-size:13px;color:#6b7280;">Reply from ${safeSiteName}</p>
456
+ <h1 style="margin:0 0 16px;font-size:22px;line-height:1.3;font-weight:600;color:#111827;">${safeGreeting}</h1>
457
+ <p style="margin:0 0 20px;color:#374151;">You got in touch about <strong style="color:#111827;">${safeSubject}</strong>. Here is our reply:</p>
458
+
459
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px;">
460
+ <tr>
461
+ <td style="background:#f6f8fa;border-left:4px solid #111827;border-radius:0 8px 8px 0;padding:16px 18px;color:#1f2328;">${safePreview}</td>
462
+ </tr>
463
+ </table>
464
+
465
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 12px;">
466
+ <tr>
467
+ <td style="border-radius:8px;background:#111827;">
468
+ <a href="${link}" style="display:inline-block;padding:13px 26px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">Read &amp; reply</a>
469
+ </td>
470
+ </tr>
471
+ </table>
472
+
473
+ <p style="margin:0 0 24px;font-size:14px;color:#6b7280;">The whole conversation is on that page, and you can answer straight from it.</p>
474
+
475
+ <hr style="border:0;border-top:1px solid #e5e7eb;margin:0 0 16px;" />
476
+
477
+ <p style="margin:0 0 8px;font-size:12px;line-height:1.5;color:#9ca3af;">Button not working? Paste this into your browser:<br /><span style="word-break:break-all;color:#6b7280;">${link}</span></p>
478
+ <p style="margin:0;font-size:12px;color:#9ca3af;">If you didn't contact ${safeSiteName}, you can safely ignore this email.</p>
479
+ </div>
480
+ `;
481
+
482
+ const text = [
483
+ greeting,
484
+ '',
485
+ `You got in touch with ${siteName} about "${input.subjectLabel}". Here is our reply:`,
486
+ '',
487
+ preview,
488
+ '',
489
+ 'Read the whole conversation and reply here:',
490
+ link,
491
+ '',
492
+ `If you didn't contact ${siteName}, you can safely ignore this email.`,
493
+ ].join('\n');
494
+
495
+ try {
496
+ // Recorded verbatim below. When a message is accepted by every hop and still never
497
+ // arrives, the subject and recipient that actually went out are the first things
498
+ // anyone needs — and reconstructing them after the fact is guesswork.
499
+ //
500
+ // Deliberately NOT "Re:". The visitor's message was never an email, so a bare reply
501
+ // prefix is both a spam heuristic and a small lie; naming the site is what actually
502
+ // tells them who is writing.
503
+ const subject = sanitizeSubject(
504
+ input.source === 'product_inquiry'
505
+ ? `${siteName} replied about ${input.subjectLabel} [${reference}]`
506
+ : `${siteName} replied to your message [${reference}]`
507
+ );
508
+
509
+ // Reply-To is the store, not the no-reply identity the message is sent as.
510
+ //
511
+ // A visitor who hits Reply in their mail client is doing the most natural thing
512
+ // available to them; without this it goes to donotreply@ and is never seen. The
513
+ // thread page is still the better channel — it keeps the conversation in one place —
514
+ // but "click this link or nothing" is a poor contract to offer someone.
515
+ const { email: replyTo } = await resolveSellerContactEmail();
516
+
517
+ await sendEmail({
518
+ to: input.senderEmail,
519
+ subject,
520
+ text,
521
+ html,
522
+ inReplyTo: threadRoot,
523
+ references: threadRoot,
524
+ ...(replyTo ? { replyTo } : {}),
525
+ });
526
+
527
+ console.info(
528
+ `[messages] Reply on thread ${input.threadId} handed to SMTP — to="${input.senderEmail}" replyTo="${replyTo ?? 'none'}" subject="${subject}" link=${input.token ? 'tokenised' : 'generic'}`
529
+ );
530
+ await recordDelivery(input.messageId, true);
531
+ } catch (error) {
532
+ const reason = describeSmtpError(error);
533
+ console.error(
534
+ `[messages] Reply on thread ${input.threadId} stored but not delivered: ${reason}`,
535
+ error
536
+ );
537
+ await recordDelivery(input.messageId, false, reason.slice(0, 500));
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Ensure a thread has a live visitor link, minting one on first use.
543
+ *
544
+ * Returns the plaintext token ONLY when it was just created — that is the single moment
545
+ * it can be put in an email, and afterwards only its hash exists.
546
+ */
547
+ export async function ensureThreadToken(
548
+ threadId: string
549
+ ): Promise<{ token: string | null; hasExistingToken: boolean }> {
550
+ const supabase = getServiceRoleSupabaseClient();
551
+
552
+ // ROTATES on every reply, deliberately.
553
+ //
554
+ // Only the hash is stored, so once a token exists its plaintext is gone — and an
555
+ // earlier version returned `{ token: null }` in that case, which made every reply after
556
+ // the first mail a bare `/thread` link. That link works only for someone who still
557
+ // holds the cookie, i.e. never for the visitor reading their email on another device.
558
+ //
559
+ // Minting fresh each time is what guarantees a working link in every message. The cost
560
+ // is that the previous link stops working, which is a reasonable property for a
561
+ // credential mailed in the clear: one live link per conversation at a time.
562
+ const now = new Date();
563
+ const minted = mintThreadToken(now);
564
+ const { error } = await supabase
565
+ .from('message_threads')
566
+ .update({
567
+ token_hash: minted.tokenHash,
568
+ token_expires_at: minted.expiresAt,
569
+ token_revoked_at: null,
570
+ })
571
+ .eq('id', threadId);
572
+
573
+ if (error) {
574
+ console.error('[messages] Could not mint a thread token:', error.message);
575
+ return { token: null, hasExistingToken: false };
576
+ }
577
+
578
+ return { token: minted.token, hasExistingToken: false };
579
+ }