create-nextblock 0.16.1 → 0.16.2

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