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,222 +1,222 @@
1
- 'use server';
2
-
3
- import { headers } from 'next/headers';
4
- import { after } from 'next/server';
5
- import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
6
-
7
- import { resolveSellerContactEmail } from '../../lib/commerce/seller-contact';
8
- import { createThread, notifyAdminOfMessage } from '../../lib/messages/threads';
9
- import { verifyBotProtection } from '../../lib/botProtection/verify';
10
-
11
- /**
12
- * Public "contact the seller" enquiry, shown in place of Add-to-Cart when the store
13
- * cannot take payment for a product.
14
- *
15
- * Two rules shape this module:
16
- *
17
- * 1. The recipient is resolved ENTIRELY server-side. The existing form-block action
18
- * takes its recipient from a bound argument, which means the address travels in the
19
- * client payload; doing that here would publish the shop owner's inbox on every
20
- * product page and turn the store's SMTP credentials into an open relay.
21
- *
22
- * 2. The database row is the deliverable, not the email. A store with no payment keys
23
- * very often has no SMTP either, so the enquiry is persisted FIRST and the
24
- * notification attempted afterwards. A failed send downgrades to `email_delivered:
25
- * false` — the visitor is still told their message got through, because it did.
26
- */
27
-
28
- const MAX_NAME_LENGTH = 120;
29
- const MAX_EMAIL_LENGTH = 254;
30
- const MAX_MESSAGE_LENGTH = 2000;
31
- const MAX_USER_AGENT_LENGTH = 500;
32
-
33
- /** Per-IP submission cap. Deliberately generous — this is anti-flood, not anti-user. */
34
- const THROTTLE_WINDOW_MINUTES = 10;
35
- const THROTTLE_MAX_SUBMISSIONS = 5;
36
-
37
- /**
38
- * Throttle bucket for a request whose origin cannot be determined. Deliberately a single
39
- * shared key: unattributable requests are throttled TOGETHER rather than exempted.
40
- *
41
- * This is the difference between this helper and the visually similar one in
42
- * app/actions/consent.ts. There, a null mask just means one audit row is less precise.
43
- * Here the value is load-bearing for a rate limit, so "I could not parse that" must
44
- * never be allowed to mean "no limit applies".
45
- */
46
- const UNKNOWN_IP_BUCKET = 'unknown';
47
-
48
- /**
49
- * Best available client address, preferring headers the platform sets over ones the
50
- * client can write.
51
- *
52
- * `x-forwarded-for` is a list the client can prepend to: on an appending proxy (nginx's
53
- * proxy_add_x_forwarded_for, the shape this repo's Dockerfile deploys) its LEFTMOST
54
- * entry is attacker-controlled, so keying a throttle on it lets one caller mint a fresh
55
- * bucket per request. `x-vercel-forwarded-for` and `x-real-ip` are overwritten by the
56
- * proxy on every request, so they are checked first.
57
- */
58
- function firstHop(headerValue: string | null): string | null {
59
- const [first] = (headerValue ?? '').split(',');
60
- const trimmed = first?.trim() ?? '';
61
- return trimmed || null;
62
- }
63
-
64
- function resolveClientIp(requestHeaders: Headers): string | null {
65
- return (
66
- firstHop(requestHeaders.get('x-vercel-forwarded-for')) ??
67
- firstHop(requestHeaders.get('x-real-ip')) ??
68
- firstHop(requestHeaders.get('x-forwarded-for'))
69
- );
70
- }
71
-
72
- /** Reduce an address to a non-identifying prefix — never store a full IP. */
73
- function maskIp(ip: string | null): string {
74
- if (!ip) return UNKNOWN_IP_BUCKET;
75
- if (ip.includes(':')) {
76
- const hextets = ip.split(':').filter(Boolean);
77
- return `${hextets.slice(0, 3).join(':')}::x`;
78
- }
79
- const octets = ip.split('.');
80
- if (octets.length === 4) {
81
- octets[3] = 'x';
82
- return octets.join('.');
83
- }
84
- return UNKNOWN_IP_BUCKET;
85
- }
86
-
87
- function readField(formData: FormData, name: string, maxLength: number): string {
88
- const raw = formData.get(name);
89
- return typeof raw === 'string' ? raw.trim().slice(0, maxLength) : '';
90
- }
91
-
92
- function isPlausibleEmail(value: string): boolean {
93
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
94
- }
95
-
96
- export interface ContactSellerState {
97
- success: boolean;
98
- /** Translation key the client renders, with its own English fallback. */
99
- messageKey:
100
- | 'ecommerce.contact_seller_sent'
101
- | 'ecommerce.contact_seller_error'
102
- | 'ecommerce.contact_seller_throttled'
103
- | 'ecommerce.contact_seller_invalid'
104
- | '';
105
- /** Set only for captcha failures, which carry their own provider-specific text. */
106
- message?: string;
107
- }
108
-
109
- export async function submitProductInquiry(
110
- _prevState: unknown,
111
- formData: FormData
112
- ): Promise<ContactSellerState> {
113
- const verification = await verifyBotProtection(formData);
114
- if (!verification.ok) {
115
- // Fake a success for the honeypot so the bot learns nothing about the check.
116
- if (verification.reason === 'honeypot') {
117
- return { success: true, messageKey: 'ecommerce.contact_seller_sent' };
118
- }
119
- return { success: false, messageKey: '', message: verification.message };
120
- }
121
-
122
- const productId = readField(formData, 'product_id', 64);
123
- const senderName = readField(formData, 'name', MAX_NAME_LENGTH);
124
- const senderEmail = readField(formData, 'email', MAX_EMAIL_LENGTH);
125
- const message = readField(formData, 'message', MAX_MESSAGE_LENGTH);
126
- const locale = readField(formData, 'locale', 12) || null;
127
-
128
- if (!productId || !senderName || !senderEmail || !message || !isPlausibleEmail(senderEmail)) {
129
- return { success: false, messageKey: 'ecommerce.contact_seller_invalid' };
130
- }
131
-
132
- try {
133
- const supabase = getServiceRoleSupabaseClient();
134
-
135
- const requestHeaders = await headers();
136
- // Always a string, so the throttle below always runs. See UNKNOWN_IP_BUCKET.
137
- const ipMasked = maskIp(resolveClientIp(requestHeaders));
138
- const userAgent = requestHeaders.get('user-agent');
139
-
140
- const since = new Date(Date.now() - THROTTLE_WINDOW_MINUTES * 60_000).toISOString();
141
- const { count } = await supabase
142
- .from('product_inquiries')
143
- .select('id', { count: 'exact', head: true })
144
- .eq('ip_masked', ipMasked)
145
- .gte('created_at', since);
146
-
147
- if ((count ?? 0) >= THROTTLE_MAX_SUBMISSIONS) {
148
- return { success: false, messageKey: 'ecommerce.contact_seller_throttled' };
149
- }
150
-
151
- // Look the product up server-side: a client-supplied title would let anyone send
152
- // arbitrary text through the owner's own SMTP identity.
153
- const { data: product } = await supabase
154
- .from('products')
155
- .select('id, title, slug')
156
- .eq('id', productId)
157
- .maybeSingle();
158
-
159
- if (!product) {
160
- return { success: false, messageKey: 'ecommerce.contact_seller_invalid' };
161
- }
162
-
163
- const { data: inserted, error: insertError } = await supabase
164
- .from('product_inquiries')
165
- .insert({
166
- product_id: product.id,
167
- product_slug: product.slug,
168
- product_title: product.title,
169
- sender_name: senderName,
170
- sender_email: senderEmail,
171
- message,
172
- locale,
173
- ip_masked: ipMasked,
174
- user_agent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
175
- })
176
- .select('id')
177
- .single();
178
-
179
- if (insertError) {
180
- console.error('Failed to record product inquiry:', insertError.message);
181
- return { success: false, messageKey: 'ecommerce.contact_seller_error' };
182
- }
183
-
184
- // Open the conversation this enquiry belongs to. The enquiry row above stays as
185
- // the enquiry's own record (and owns the ip_masked the throttle counts); the thread
186
- // is what the owner actually replies in.
187
- const thread = await createThread({
188
- source: 'product_inquiry',
189
- subjectId: inserted.id,
190
- subjectLabel: product.title,
191
- senderName,
192
- senderEmail,
193
- message,
194
- locale,
195
- ipMasked,
196
- userAgent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
197
- });
198
-
199
- // Notification is best-effort and runs after the response. A bare floating promise
200
- // would risk the serverless instance being frozen before the SMTP round trip ends.
201
- if (thread) {
202
- after(async () => {
203
- const { email: recipient } = await resolveSellerContactEmail();
204
- await notifyAdminOfMessage({
205
- threadId: thread.threadId,
206
- source: 'product_inquiry',
207
- messageId: thread.messageId,
208
- subjectLabel: product.title,
209
- senderName,
210
- senderEmail,
211
- message,
212
- recipient,
213
- });
214
- });
215
- }
216
-
217
- return { success: true, messageKey: 'ecommerce.contact_seller_sent' };
218
- } catch (error) {
219
- console.error('Product inquiry submission failed:', error);
220
- return { success: false, messageKey: 'ecommerce.contact_seller_error' };
221
- }
222
- }
1
+ 'use server';
2
+
3
+ import { headers } from 'next/headers';
4
+ import { after } from 'next/server';
5
+ import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
6
+
7
+ import { resolveSellerContactEmail } from '../../lib/commerce/seller-contact';
8
+ import { createThread, notifyAdminOfMessage } from '../../lib/messages/threads';
9
+ import { verifyBotProtection } from '../../lib/botProtection/verify';
10
+
11
+ /**
12
+ * Public "contact the seller" enquiry, shown in place of Add-to-Cart when the store
13
+ * cannot take payment for a product.
14
+ *
15
+ * Two rules shape this module:
16
+ *
17
+ * 1. The recipient is resolved ENTIRELY server-side. The existing form-block action
18
+ * takes its recipient from a bound argument, which means the address travels in the
19
+ * client payload; doing that here would publish the shop owner's inbox on every
20
+ * product page and turn the store's SMTP credentials into an open relay.
21
+ *
22
+ * 2. The database row is the deliverable, not the email. A store with no payment keys
23
+ * very often has no SMTP either, so the enquiry is persisted FIRST and the
24
+ * notification attempted afterwards. A failed send downgrades to `email_delivered:
25
+ * false` — the visitor is still told their message got through, because it did.
26
+ */
27
+
28
+ const MAX_NAME_LENGTH = 120;
29
+ const MAX_EMAIL_LENGTH = 254;
30
+ const MAX_MESSAGE_LENGTH = 2000;
31
+ const MAX_USER_AGENT_LENGTH = 500;
32
+
33
+ /** Per-IP submission cap. Deliberately generous — this is anti-flood, not anti-user. */
34
+ const THROTTLE_WINDOW_MINUTES = 10;
35
+ const THROTTLE_MAX_SUBMISSIONS = 5;
36
+
37
+ /**
38
+ * Throttle bucket for a request whose origin cannot be determined. Deliberately a single
39
+ * shared key: unattributable requests are throttled TOGETHER rather than exempted.
40
+ *
41
+ * This is the difference between this helper and the visually similar one in
42
+ * app/actions/consent.ts. There, a null mask just means one audit row is less precise.
43
+ * Here the value is load-bearing for a rate limit, so "I could not parse that" must
44
+ * never be allowed to mean "no limit applies".
45
+ */
46
+ const UNKNOWN_IP_BUCKET = 'unknown';
47
+
48
+ /**
49
+ * Best available client address, preferring headers the platform sets over ones the
50
+ * client can write.
51
+ *
52
+ * `x-forwarded-for` is a list the client can prepend to: on an appending proxy (nginx's
53
+ * proxy_add_x_forwarded_for, the shape this repo's Dockerfile deploys) its LEFTMOST
54
+ * entry is attacker-controlled, so keying a throttle on it lets one caller mint a fresh
55
+ * bucket per request. `x-vercel-forwarded-for` and `x-real-ip` are overwritten by the
56
+ * proxy on every request, so they are checked first.
57
+ */
58
+ function firstHop(headerValue: string | null): string | null {
59
+ const [first] = (headerValue ?? '').split(',');
60
+ const trimmed = first?.trim() ?? '';
61
+ return trimmed || null;
62
+ }
63
+
64
+ function resolveClientIp(requestHeaders: Headers): string | null {
65
+ return (
66
+ firstHop(requestHeaders.get('x-vercel-forwarded-for')) ??
67
+ firstHop(requestHeaders.get('x-real-ip')) ??
68
+ firstHop(requestHeaders.get('x-forwarded-for'))
69
+ );
70
+ }
71
+
72
+ /** Reduce an address to a non-identifying prefix — never store a full IP. */
73
+ function maskIp(ip: string | null): string {
74
+ if (!ip) return UNKNOWN_IP_BUCKET;
75
+ if (ip.includes(':')) {
76
+ const hextets = ip.split(':').filter(Boolean);
77
+ return `${hextets.slice(0, 3).join(':')}::x`;
78
+ }
79
+ const octets = ip.split('.');
80
+ if (octets.length === 4) {
81
+ octets[3] = 'x';
82
+ return octets.join('.');
83
+ }
84
+ return UNKNOWN_IP_BUCKET;
85
+ }
86
+
87
+ function readField(formData: FormData, name: string, maxLength: number): string {
88
+ const raw = formData.get(name);
89
+ return typeof raw === 'string' ? raw.trim().slice(0, maxLength) : '';
90
+ }
91
+
92
+ function isPlausibleEmail(value: string): boolean {
93
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
94
+ }
95
+
96
+ export interface ContactSellerState {
97
+ success: boolean;
98
+ /** Translation key the client renders, with its own English fallback. */
99
+ messageKey:
100
+ | 'ecommerce.contact_seller_sent'
101
+ | 'ecommerce.contact_seller_error'
102
+ | 'ecommerce.contact_seller_throttled'
103
+ | 'ecommerce.contact_seller_invalid'
104
+ | '';
105
+ /** Set only for captcha failures, which carry their own provider-specific text. */
106
+ message?: string;
107
+ }
108
+
109
+ export async function submitProductInquiry(
110
+ _prevState: unknown,
111
+ formData: FormData
112
+ ): Promise<ContactSellerState> {
113
+ const verification = await verifyBotProtection(formData);
114
+ if (!verification.ok) {
115
+ // Fake a success for the honeypot so the bot learns nothing about the check.
116
+ if (verification.reason === 'honeypot') {
117
+ return { success: true, messageKey: 'ecommerce.contact_seller_sent' };
118
+ }
119
+ return { success: false, messageKey: '', message: verification.message };
120
+ }
121
+
122
+ const productId = readField(formData, 'product_id', 64);
123
+ const senderName = readField(formData, 'name', MAX_NAME_LENGTH);
124
+ const senderEmail = readField(formData, 'email', MAX_EMAIL_LENGTH);
125
+ const message = readField(formData, 'message', MAX_MESSAGE_LENGTH);
126
+ const locale = readField(formData, 'locale', 12) || null;
127
+
128
+ if (!productId || !senderName || !senderEmail || !message || !isPlausibleEmail(senderEmail)) {
129
+ return { success: false, messageKey: 'ecommerce.contact_seller_invalid' };
130
+ }
131
+
132
+ try {
133
+ const supabase = getServiceRoleSupabaseClient();
134
+
135
+ const requestHeaders = await headers();
136
+ // Always a string, so the throttle below always runs. See UNKNOWN_IP_BUCKET.
137
+ const ipMasked = maskIp(resolveClientIp(requestHeaders));
138
+ const userAgent = requestHeaders.get('user-agent');
139
+
140
+ const since = new Date(Date.now() - THROTTLE_WINDOW_MINUTES * 60_000).toISOString();
141
+ const { count } = await supabase
142
+ .from('product_inquiries')
143
+ .select('id', { count: 'exact', head: true })
144
+ .eq('ip_masked', ipMasked)
145
+ .gte('created_at', since);
146
+
147
+ if ((count ?? 0) >= THROTTLE_MAX_SUBMISSIONS) {
148
+ return { success: false, messageKey: 'ecommerce.contact_seller_throttled' };
149
+ }
150
+
151
+ // Look the product up server-side: a client-supplied title would let anyone send
152
+ // arbitrary text through the owner's own SMTP identity.
153
+ const { data: product } = await supabase
154
+ .from('products')
155
+ .select('id, title, slug')
156
+ .eq('id', productId)
157
+ .maybeSingle();
158
+
159
+ if (!product) {
160
+ return { success: false, messageKey: 'ecommerce.contact_seller_invalid' };
161
+ }
162
+
163
+ const { data: inserted, error: insertError } = await supabase
164
+ .from('product_inquiries')
165
+ .insert({
166
+ product_id: product.id,
167
+ product_slug: product.slug,
168
+ product_title: product.title,
169
+ sender_name: senderName,
170
+ sender_email: senderEmail,
171
+ message,
172
+ locale,
173
+ ip_masked: ipMasked,
174
+ user_agent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
175
+ })
176
+ .select('id')
177
+ .single();
178
+
179
+ if (insertError) {
180
+ console.error('Failed to record product inquiry:', insertError.message);
181
+ return { success: false, messageKey: 'ecommerce.contact_seller_error' };
182
+ }
183
+
184
+ // Open the conversation this enquiry belongs to. The enquiry row above stays as
185
+ // the enquiry's own record (and owns the ip_masked the throttle counts); the thread
186
+ // is what the owner actually replies in.
187
+ const thread = await createThread({
188
+ source: 'product_inquiry',
189
+ subjectId: inserted.id,
190
+ subjectLabel: product.title,
191
+ senderName,
192
+ senderEmail,
193
+ message,
194
+ locale,
195
+ ipMasked,
196
+ userAgent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
197
+ });
198
+
199
+ // Notification is best-effort and runs after the response. A bare floating promise
200
+ // would risk the serverless instance being frozen before the SMTP round trip ends.
201
+ if (thread) {
202
+ after(async () => {
203
+ const { email: recipient } = await resolveSellerContactEmail();
204
+ await notifyAdminOfMessage({
205
+ threadId: thread.threadId,
206
+ source: 'product_inquiry',
207
+ messageId: thread.messageId,
208
+ subjectLabel: product.title,
209
+ senderName,
210
+ senderEmail,
211
+ message,
212
+ recipient,
213
+ });
214
+ });
215
+ }
216
+
217
+ return { success: true, messageKey: 'ecommerce.contact_seller_sent' };
218
+ } catch (error) {
219
+ console.error('Product inquiry submission failed:', error);
220
+ return { success: false, messageKey: 'ecommerce.contact_seller_error' };
221
+ }
222
+ }
@@ -1,62 +1,62 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { describeSmtpError } from './email';
4
-
5
- /**
6
- * A reply failed with "The mail server did not respond in time" on an install whose
7
- * transport verifies in about a second and whose identical send succeeds moments later.
8
- * That signature — everything healthy, one send hangs — is a pooled connection the relay
9
- * reaped while it sat idle: nodemailer still believes it is open, so the send blocks
10
- * until the socket timeout.
11
- *
12
- * sendEmail now drops the pool and retries once on a fresh connection for that class of
13
- * fault only. These cover the classification, which is what decides retry vs report.
14
- */
15
-
16
- describe('describeSmtpError', () => {
17
- it('explains the TLS/port mismatch that produced an unreadable OpenSSL error', () => {
18
- const message = describeSmtpError(
19
- new Error('B4730000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:ssl3_record.c:355:')
20
- );
21
- expect(message).toContain('2525');
22
- expect(message).toContain('465');
23
- expect(message).not.toContain('ssl3_get_record');
24
- });
25
-
26
- it('names credentials as the cause of an auth rejection', () => {
27
- const error = Object.assign(new Error('Invalid login: 535 authentication failed'), {
28
- code: 'EAUTH',
29
- });
30
- expect(describeSmtpError(error)).toMatch(/username or password/i);
31
- });
32
-
33
- it('distinguishes a timeout from an unreachable host', () => {
34
- const timeout = Object.assign(new Error('Connection timeout'), { code: 'ETIMEDOUT' });
35
- expect(describeSmtpError(timeout)).toMatch(/did not respond in time/i);
36
-
37
- const dns = Object.assign(new Error('getaddrinfo ENOTFOUND smtp.example.com'), {
38
- code: 'ENOTFOUND',
39
- });
40
- expect(describeSmtpError(dns)).toMatch(/hostname could not be resolved/i);
41
-
42
- const refused = Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' });
43
- expect(describeSmtpError(refused)).toMatch(/refused the connection/i);
44
- });
45
-
46
- it('tells an unconfigured install what to do rather than echoing the throw', () => {
47
- expect(describeSmtpError(new Error('Email server is not configured.'))).toMatch(
48
- /CMS Settings/i
49
- );
50
- });
51
-
52
- it('passes an unrecognised failure through rather than inventing an explanation', () => {
53
- expect(describeSmtpError(new Error('552 5.3.4 Message too big'))).toBe(
54
- '552 5.3.4 Message too big'
55
- );
56
- });
57
-
58
- it('handles a non-Error throw without crashing', () => {
59
- expect(describeSmtpError('something odd')).toBe('something odd');
60
- expect(describeSmtpError(null)).toBe('null');
61
- });
62
- });
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { describeSmtpError } from './email';
4
+
5
+ /**
6
+ * A reply failed with "The mail server did not respond in time" on an install whose
7
+ * transport verifies in about a second and whose identical send succeeds moments later.
8
+ * That signature — everything healthy, one send hangs — is a pooled connection the relay
9
+ * reaped while it sat idle: nodemailer still believes it is open, so the send blocks
10
+ * until the socket timeout.
11
+ *
12
+ * sendEmail now drops the pool and retries once on a fresh connection for that class of
13
+ * fault only. These cover the classification, which is what decides retry vs report.
14
+ */
15
+
16
+ describe('describeSmtpError', () => {
17
+ it('explains the TLS/port mismatch that produced an unreadable OpenSSL error', () => {
18
+ const message = describeSmtpError(
19
+ new Error('B4730000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:ssl3_record.c:355:')
20
+ );
21
+ expect(message).toContain('2525');
22
+ expect(message).toContain('465');
23
+ expect(message).not.toContain('ssl3_get_record');
24
+ });
25
+
26
+ it('names credentials as the cause of an auth rejection', () => {
27
+ const error = Object.assign(new Error('Invalid login: 535 authentication failed'), {
28
+ code: 'EAUTH',
29
+ });
30
+ expect(describeSmtpError(error)).toMatch(/username or password/i);
31
+ });
32
+
33
+ it('distinguishes a timeout from an unreachable host', () => {
34
+ const timeout = Object.assign(new Error('Connection timeout'), { code: 'ETIMEDOUT' });
35
+ expect(describeSmtpError(timeout)).toMatch(/did not respond in time/i);
36
+
37
+ const dns = Object.assign(new Error('getaddrinfo ENOTFOUND smtp.example.com'), {
38
+ code: 'ENOTFOUND',
39
+ });
40
+ expect(describeSmtpError(dns)).toMatch(/hostname could not be resolved/i);
41
+
42
+ const refused = Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' });
43
+ expect(describeSmtpError(refused)).toMatch(/refused the connection/i);
44
+ });
45
+
46
+ it('tells an unconfigured install what to do rather than echoing the throw', () => {
47
+ expect(describeSmtpError(new Error('Email server is not configured.'))).toMatch(
48
+ /CMS Settings/i
49
+ );
50
+ });
51
+
52
+ it('passes an unrecognised failure through rather than inventing an explanation', () => {
53
+ expect(describeSmtpError(new Error('552 5.3.4 Message too big'))).toBe(
54
+ '552 5.3.4 Message too big'
55
+ );
56
+ });
57
+
58
+ it('handles a non-Error throw without crashing', () => {
59
+ expect(describeSmtpError('something odd')).toBe('something odd');
60
+ expect(describeSmtpError(null)).toBe('null');
61
+ });
62
+ });