create-nextblock 0.16.1 → 0.16.3

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