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,57 +1,57 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { reconcileTlsForPort } from './email-settings';
4
-
5
- /**
6
- * The TLS mode is a property of the SMTP port, not a preference — and getting it wrong
7
- * produces "ssl3_get_record:wrong version number", which mentions neither TLS nor ports.
8
- * A real install hit exactly that: SMTP2GO on 2525 with the toggle left at its default
9
- * of ON. These tests pin the reconciliation that makes the combination unreachable.
10
- */
11
-
12
- describe('reconcileTlsForPort', () => {
13
- it('forces implicit TLS on 465 even when the setting says otherwise', () => {
14
- const result = reconcileTlsForPort(465, false, 'smtp.example.com');
15
- expect(result.secure).toBe(true);
16
- expect(result.requireTLS).toBe(false);
17
- expect(result.correctedFrom).toBe(false);
18
- });
19
-
20
- it('leaves a correct 465 configuration untouched', () => {
21
- const result = reconcileTlsForPort(465, true, 'smtp.example.com');
22
- expect(result.secure).toBe(true);
23
- expect(result.correctedFrom).toBeUndefined();
24
- });
25
-
26
- it.each([25, 587, 2525])('forces STARTTLS on port %i', (port) => {
27
- const result = reconcileTlsForPort(port, true, 'mail.smtp2go.com');
28
- expect(result.secure).toBe(false);
29
- // Downgrading to plaintext would be worse than the bug being fixed.
30
- expect(result.requireTLS).toBe(true);
31
- expect(result.correctedFrom).toBe(true);
32
- });
33
-
34
- it('reproduces the reported failure: SMTP2GO on 2525 with the toggle left on', () => {
35
- const result = reconcileTlsForPort(2525, true, 'mail.smtp2go.com');
36
- expect(result.secure).toBe(false);
37
- expect(result.correctedFrom).toBe(true);
38
- });
39
-
40
- it('does not demand STARTTLS from a local test relay', () => {
41
- for (const host of ['localhost', '127.0.0.1', '::1', 'LocalHost']) {
42
- expect(reconcileTlsForPort(587, false, host).requireTLS).toBe(false);
43
- }
44
- });
45
-
46
- it('honours the operator on a non-standard port, either way', () => {
47
- expect(reconcileTlsForPort(1025, false, 'localhost')).toMatchObject({
48
- secure: false,
49
- requireTLS: false,
50
- });
51
- expect(reconcileTlsForPort(2526, true, 'smtp.example.com')).toMatchObject({
52
- secure: true,
53
- requireTLS: false,
54
- });
55
- expect(reconcileTlsForPort(2526, true, 'smtp.example.com').correctedFrom).toBeUndefined();
56
- });
57
- });
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { reconcileTlsForPort } from './email-settings';
4
+
5
+ /**
6
+ * The TLS mode is a property of the SMTP port, not a preference — and getting it wrong
7
+ * produces "ssl3_get_record:wrong version number", which mentions neither TLS nor ports.
8
+ * A real install hit exactly that: SMTP2GO on 2525 with the toggle left at its default
9
+ * of ON. These tests pin the reconciliation that makes the combination unreachable.
10
+ */
11
+
12
+ describe('reconcileTlsForPort', () => {
13
+ it('forces implicit TLS on 465 even when the setting says otherwise', () => {
14
+ const result = reconcileTlsForPort(465, false, 'smtp.example.com');
15
+ expect(result.secure).toBe(true);
16
+ expect(result.requireTLS).toBe(false);
17
+ expect(result.correctedFrom).toBe(false);
18
+ });
19
+
20
+ it('leaves a correct 465 configuration untouched', () => {
21
+ const result = reconcileTlsForPort(465, true, 'smtp.example.com');
22
+ expect(result.secure).toBe(true);
23
+ expect(result.correctedFrom).toBeUndefined();
24
+ });
25
+
26
+ it.each([25, 587, 2525])('forces STARTTLS on port %i', (port) => {
27
+ const result = reconcileTlsForPort(port, true, 'mail.smtp2go.com');
28
+ expect(result.secure).toBe(false);
29
+ // Downgrading to plaintext would be worse than the bug being fixed.
30
+ expect(result.requireTLS).toBe(true);
31
+ expect(result.correctedFrom).toBe(true);
32
+ });
33
+
34
+ it('reproduces the reported failure: SMTP2GO on 2525 with the toggle left on', () => {
35
+ const result = reconcileTlsForPort(2525, true, 'mail.smtp2go.com');
36
+ expect(result.secure).toBe(false);
37
+ expect(result.correctedFrom).toBe(true);
38
+ });
39
+
40
+ it('does not demand STARTTLS from a local test relay', () => {
41
+ for (const host of ['localhost', '127.0.0.1', '::1', 'LocalHost']) {
42
+ expect(reconcileTlsForPort(587, false, host).requireTLS).toBe(false);
43
+ }
44
+ });
45
+
46
+ it('honours the operator on a non-standard port, either way', () => {
47
+ expect(reconcileTlsForPort(1025, false, 'localhost')).toMatchObject({
48
+ secure: false,
49
+ requireTLS: false,
50
+ });
51
+ expect(reconcileTlsForPort(2526, true, 'smtp.example.com')).toMatchObject({
52
+ secure: true,
53
+ requireTLS: false,
54
+ });
55
+ expect(reconcileTlsForPort(2526, true, 'smtp.example.com').correctedFrom).toBeUndefined();
56
+ });
57
+ });
@@ -1,86 +1,86 @@
1
- /**
2
- * Helpers for assembling a valid `POST /api/ai/seo/alt-text` body from what the CMS has
3
- * on hand. Both call sites — the image block editor and the media library edit form —
4
- * face the same two problems, so they are solved once here.
5
- */
6
-
7
- /**
8
- * Turn whatever the CMS has for an image into a URL the AI vision call can actually fetch.
9
- *
10
- * Why this is not just "pass the src along": `/api/ai/seo/alt-text` does not receive the
11
- * image bytes, it receives a URL, and the AI SDK downloads that URL *server-side* before
12
- * handing the image to the model. A relative path is meaningless in that context — there
13
- * is no document base to resolve it against — so the route rejects anything that is not
14
- * absolute http(s).
15
- *
16
- * And relative is the common case, not the exotic one. `resolveMediaUrl()` returns
17
- * `/${objectKey}` whenever `NEXT_PUBLIC_R2_BASE_URL` is unset, which is exactly how the
18
- * native Supabase-storage backend runs. So on a default install every single image in
19
- * the library would otherwise fail the alt-text call. Resolving against
20
- * `window.location.origin` fixes that for any deployment the browser can reach, which is
21
- * the same origin the server is serving from.
22
- *
23
- * The remaining failure the caller must surface rather than swallow is a `blob:` or
24
- * `data:` src (an un-uploaded local preview): the server cannot fetch either, so we
25
- * return null and let the UI say so instead of firing a request that is certain to 400.
26
- */
27
- export function toAbsoluteImageUrl(url: string | null | undefined): string | null {
28
- const candidate = (url ?? '').trim();
29
- if (!candidate) {
30
- return null;
31
- }
32
-
33
- if (/^https?:\/\//i.test(candidate)) {
34
- return candidate;
35
- }
36
-
37
- // Only the browser knows the origin this CMS is being served from; on the server we
38
- // have no basis to invent one, so a relative path stays unusable and the caller warns.
39
- if (typeof window === 'undefined') {
40
- return null;
41
- }
42
-
43
- try {
44
- const absolute = new URL(candidate, window.location.origin);
45
- return absolute.protocol === 'http:' || absolute.protocol === 'https:'
46
- ? absolute.toString()
47
- : null;
48
- } catch {
49
- return null;
50
- }
51
- }
52
-
53
- /** Shared copy for the inline warning shown when {@link toAbsoluteImageUrl} returns null. */
54
- export const UNRESOLVABLE_IMAGE_URL_MESSAGE =
55
- 'This image has no public http(s) address yet, so Cortex AI cannot look at it. Save it to the media library first.';
56
-
57
- /**
58
- * The route validates `context` with `z.string().max(2000)` inside a `z.strictObject`, so
59
- * an over-long value does not get trimmed server-side — it fails the whole request with a
60
- * 400. Captions are free-text inputs with no length limit of their own, which makes that
61
- * reachable: a long caption would break the alt-text button for reasons the operator could
62
- * never guess from the error. Trimming client-side keeps the request valid, and losing the
63
- * tail of a 2,000-character caption costs nothing, since context is a hint and the model
64
- * has the image itself.
65
- */
66
- const ALT_TEXT_CONTEXT_MAX_LENGTH = 2000;
67
-
68
- /**
69
- * Join the context fragments a caller has into one string the route will accept, or return
70
- * `undefined` when there is nothing worth sending — the field is optional, and an empty
71
- * string is a different (and less honest) thing to send than no field at all.
72
- */
73
- export function buildAltTextContext(...fragments: Array<string | null | undefined>): string | undefined {
74
- const joined = fragments
75
- .map((fragment) => (fragment ?? '').trim())
76
- .filter(Boolean)
77
- .join(' ');
78
-
79
- if (!joined) {
80
- return undefined;
81
- }
82
-
83
- return joined.length > ALT_TEXT_CONTEXT_MAX_LENGTH
84
- ? joined.slice(0, ALT_TEXT_CONTEXT_MAX_LENGTH)
85
- : joined;
86
- }
1
+ /**
2
+ * Helpers for assembling a valid `POST /api/ai/seo/alt-text` body from what the CMS has
3
+ * on hand. Both call sites — the image block editor and the media library edit form —
4
+ * face the same two problems, so they are solved once here.
5
+ */
6
+
7
+ /**
8
+ * Turn whatever the CMS has for an image into a URL the AI vision call can actually fetch.
9
+ *
10
+ * Why this is not just "pass the src along": `/api/ai/seo/alt-text` does not receive the
11
+ * image bytes, it receives a URL, and the AI SDK downloads that URL *server-side* before
12
+ * handing the image to the model. A relative path is meaningless in that context — there
13
+ * is no document base to resolve it against — so the route rejects anything that is not
14
+ * absolute http(s).
15
+ *
16
+ * And relative is the common case, not the exotic one. `resolveMediaUrl()` returns
17
+ * `/${objectKey}` whenever `NEXT_PUBLIC_R2_BASE_URL` is unset, which is exactly how the
18
+ * native Supabase-storage backend runs. So on a default install every single image in
19
+ * the library would otherwise fail the alt-text call. Resolving against
20
+ * `window.location.origin` fixes that for any deployment the browser can reach, which is
21
+ * the same origin the server is serving from.
22
+ *
23
+ * The remaining failure the caller must surface rather than swallow is a `blob:` or
24
+ * `data:` src (an un-uploaded local preview): the server cannot fetch either, so we
25
+ * return null and let the UI say so instead of firing a request that is certain to 400.
26
+ */
27
+ export function toAbsoluteImageUrl(url: string | null | undefined): string | null {
28
+ const candidate = (url ?? '').trim();
29
+ if (!candidate) {
30
+ return null;
31
+ }
32
+
33
+ if (/^https?:\/\//i.test(candidate)) {
34
+ return candidate;
35
+ }
36
+
37
+ // Only the browser knows the origin this CMS is being served from; on the server we
38
+ // have no basis to invent one, so a relative path stays unusable and the caller warns.
39
+ if (typeof window === 'undefined') {
40
+ return null;
41
+ }
42
+
43
+ try {
44
+ const absolute = new URL(candidate, window.location.origin);
45
+ return absolute.protocol === 'http:' || absolute.protocol === 'https:'
46
+ ? absolute.toString()
47
+ : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** Shared copy for the inline warning shown when {@link toAbsoluteImageUrl} returns null. */
54
+ export const UNRESOLVABLE_IMAGE_URL_MESSAGE =
55
+ 'This image has no public http(s) address yet, so Cortex AI cannot look at it. Save it to the media library first.';
56
+
57
+ /**
58
+ * The route validates `context` with `z.string().max(2000)` inside a `z.strictObject`, so
59
+ * an over-long value does not get trimmed server-side — it fails the whole request with a
60
+ * 400. Captions are free-text inputs with no length limit of their own, which makes that
61
+ * reachable: a long caption would break the alt-text button for reasons the operator could
62
+ * never guess from the error. Trimming client-side keeps the request valid, and losing the
63
+ * tail of a 2,000-character caption costs nothing, since context is a hint and the model
64
+ * has the image itself.
65
+ */
66
+ const ALT_TEXT_CONTEXT_MAX_LENGTH = 2000;
67
+
68
+ /**
69
+ * Join the context fragments a caller has into one string the route will accept, or return
70
+ * `undefined` when there is nothing worth sending — the field is optional, and an empty
71
+ * string is a different (and less honest) thing to send than no field at all.
72
+ */
73
+ export function buildAltTextContext(...fragments: Array<string | null | undefined>): string | undefined {
74
+ const joined = fragments
75
+ .map((fragment) => (fragment ?? '').trim())
76
+ .filter(Boolean)
77
+ .join(' ');
78
+
79
+ if (!joined) {
80
+ return undefined;
81
+ }
82
+
83
+ return joined.length > ALT_TEXT_CONTEXT_MAX_LENGTH
84
+ ? joined.slice(0, ALT_TEXT_CONTEXT_MAX_LENGTH)
85
+ : joined;
86
+ }
@@ -1,60 +1,60 @@
1
- /**
2
- * Sandbox credential passthrough for browser-initiated Cortex AI requests.
3
- *
4
- * On the hosted sandbox there is no server-side OpenRouter key, because every visitor
5
- * shares one deployment and we will not spend the project's credits on strangers.
6
- * Instead each visitor pastes their own key into the Cortex AI settings screen, which
7
- * stores it in `localStorage`, and every AI call made from the browser forwards it on
8
- * the request as a header. The route only reads those headers when
9
- * `NEXT_PUBLIC_IS_SANDBOX === 'true'`, so on a real install the headers are ignored
10
- * even if something were to send them.
11
- *
12
- * This lived inline in `NotionEditor.handleAiGenerate` and was about to be copied into
13
- * three more call sites (image alt text, media alt text, page/post metadata). Copies
14
- * drift: the day a storage key or header name changes, the forgotten copy silently
15
- * stops authenticating and the sandbox user sees "no credentials" from one button and
16
- * not another. One exported builder keeps that impossible.
17
- */
18
-
19
- /** localStorage key holding the visitor's own OpenRouter API key on the sandbox. */
20
- export const CORTEX_AI_SANDBOX_API_KEY_STORAGE_KEY = 'cortex_ai_sandbox_openrouter_api_key';
21
-
22
- /** localStorage key holding the visitor's serialized model selection on the sandbox. */
23
- export const CORTEX_AI_SANDBOX_MODEL_STORAGE_KEY = 'cortex_ai_sandbox_openrouter_model_selection';
24
-
25
- /**
26
- * Builds the headers for a JSON POST to a Cortex AI route, adding the sandbox
27
- * credential headers when — and only when — this build is the sandbox and the visitor
28
- * has actually stored a key.
29
- *
30
- * `localStorage` access is wrapped in try/catch on purpose: a browser configured to
31
- * block site data throws on the *accessor itself*, not just on read, and an AI button
32
- * that explodes before it can send a request is a far worse failure than one that sends
33
- * an unauthenticated request and gets a clean 403 back.
34
- */
35
- export function buildCortexAiRequestHeaders(): Record<string, string> {
36
- const headers: Record<string, string> = {
37
- 'content-type': 'application/json',
38
- };
39
-
40
- if (process.env.NEXT_PUBLIC_IS_SANDBOX !== 'true' || typeof window === 'undefined') {
41
- return headers;
42
- }
43
-
44
- try {
45
- const sandboxKey = window.localStorage.getItem(CORTEX_AI_SANDBOX_API_KEY_STORAGE_KEY);
46
- const sandboxModel = window.localStorage.getItem(CORTEX_AI_SANDBOX_MODEL_STORAGE_KEY);
47
-
48
- if (sandboxKey) {
49
- headers['x-sandbox-openrouter-key'] = sandboxKey;
50
- }
51
- if (sandboxModel) {
52
- headers['x-sandbox-openrouter-model'] = sandboxModel;
53
- }
54
- } catch {
55
- // Storage is unavailable (private window, blocked site data). Fall through with the
56
- // plain content-type header; the route will answer 403 and the caller will toast it.
57
- }
58
-
59
- return headers;
60
- }
1
+ /**
2
+ * Sandbox credential passthrough for browser-initiated Cortex AI requests.
3
+ *
4
+ * On the hosted sandbox there is no server-side OpenRouter key, because every visitor
5
+ * shares one deployment and we will not spend the project's credits on strangers.
6
+ * Instead each visitor pastes their own key into the Cortex AI settings screen, which
7
+ * stores it in `localStorage`, and every AI call made from the browser forwards it on
8
+ * the request as a header. The route only reads those headers when
9
+ * `NEXT_PUBLIC_IS_SANDBOX === 'true'`, so on a real install the headers are ignored
10
+ * even if something were to send them.
11
+ *
12
+ * This lived inline in `NotionEditor.handleAiGenerate` and was about to be copied into
13
+ * three more call sites (image alt text, media alt text, page/post metadata). Copies
14
+ * drift: the day a storage key or header name changes, the forgotten copy silently
15
+ * stops authenticating and the sandbox user sees "no credentials" from one button and
16
+ * not another. One exported builder keeps that impossible.
17
+ */
18
+
19
+ /** localStorage key holding the visitor's own OpenRouter API key on the sandbox. */
20
+ export const CORTEX_AI_SANDBOX_API_KEY_STORAGE_KEY = 'cortex_ai_sandbox_openrouter_api_key';
21
+
22
+ /** localStorage key holding the visitor's serialized model selection on the sandbox. */
23
+ export const CORTEX_AI_SANDBOX_MODEL_STORAGE_KEY = 'cortex_ai_sandbox_openrouter_model_selection';
24
+
25
+ /**
26
+ * Builds the headers for a JSON POST to a Cortex AI route, adding the sandbox
27
+ * credential headers when — and only when — this build is the sandbox and the visitor
28
+ * has actually stored a key.
29
+ *
30
+ * `localStorage` access is wrapped in try/catch on purpose: a browser configured to
31
+ * block site data throws on the *accessor itself*, not just on read, and an AI button
32
+ * that explodes before it can send a request is a far worse failure than one that sends
33
+ * an unauthenticated request and gets a clean 403 back.
34
+ */
35
+ export function buildCortexAiRequestHeaders(): Record<string, string> {
36
+ const headers: Record<string, string> = {
37
+ 'content-type': 'application/json',
38
+ };
39
+
40
+ if (process.env.NEXT_PUBLIC_IS_SANDBOX !== 'true' || typeof window === 'undefined') {
41
+ return headers;
42
+ }
43
+
44
+ try {
45
+ const sandboxKey = window.localStorage.getItem(CORTEX_AI_SANDBOX_API_KEY_STORAGE_KEY);
46
+ const sandboxModel = window.localStorage.getItem(CORTEX_AI_SANDBOX_MODEL_STORAGE_KEY);
47
+
48
+ if (sandboxKey) {
49
+ headers['x-sandbox-openrouter-key'] = sandboxKey;
50
+ }
51
+ if (sandboxModel) {
52
+ headers['x-sandbox-openrouter-model'] = sandboxModel;
53
+ }
54
+ } catch {
55
+ // Storage is unavailable (private window, blocked site data). Fall through with the
56
+ // plain content-type header; the route will answer 403 and the caller will toast it.
57
+ }
58
+
59
+ return headers;
60
+ }
@@ -1,59 +1,59 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { isPlaceholderEmail, usableEmail } from './placeholder-address';
4
-
5
- /**
6
- * The starter content ships a contact page addressed to `contact@example.com`, and an
7
- * install ran that way without knowing: the visitor was thanked, the relay accepted the
8
- * message, and it went to a domain RFC 2606 reserves so it can never exist. No error
9
- * anywhere. Treating these as unset is what lets the recipient ladder reach something
10
- * real — and lets the CMS say so.
11
- */
12
-
13
- describe('isPlaceholderEmail', () => {
14
- it.each([
15
- 'contact@example.com',
16
- 'CONTACT@EXAMPLE.COM',
17
- ' hello@example.com ',
18
- 'privacy@example.org',
19
- 'support@example.net',
20
- 'someone@example.edu',
21
- 'a@host.example',
22
- 'a@something.invalid',
23
- 'a@my.test',
24
- 'a@dev.localhost',
25
- 'a@printer.local',
26
- ])('treats %s as a placeholder', (email) => {
27
- expect(isPlaceholderEmail(email)).toBe(true);
28
- expect(usableEmail(email)).toBeNull();
29
- });
30
-
31
- it.each([
32
- 'hosts@newrootsherbal.com',
33
- 'sales@nextblock.dev',
34
- // A real domain that merely contains the word, and a real TLD lookalike.
35
- 'hi@example-company.com',
36
- 'hi@myexample.com',
37
- 'hi@shop.exampleshop.co.uk',
38
- ])('leaves the real address %s alone', (email) => {
39
- expect(isPlaceholderEmail(email)).toBe(false);
40
- expect(usableEmail(email)).toBe(email);
41
- });
42
-
43
- it('does not treat an empty or malformed value as a placeholder', () => {
44
- // Empty means "unset", which callers already handle; malformed is not our call.
45
- for (const value of ['', ' ', null, undefined, 'not-an-email']) {
46
- expect(isPlaceholderEmail(value)).toBe(false);
47
- }
48
- });
49
-
50
- it('reports an empty value as unusable without calling it a placeholder', () => {
51
- expect(usableEmail('')).toBeNull();
52
- expect(usableEmail(null)).toBeNull();
53
- expect(usableEmail(' ')).toBeNull();
54
- });
55
-
56
- it('trims a usable address rather than returning it padded', () => {
57
- expect(usableEmail(' sales@nextblock.dev ')).toBe('sales@nextblock.dev');
58
- });
59
- });
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { isPlaceholderEmail, usableEmail } from './placeholder-address';
4
+
5
+ /**
6
+ * The starter content ships a contact page addressed to `contact@example.com`, and an
7
+ * install ran that way without knowing: the visitor was thanked, the relay accepted the
8
+ * message, and it went to a domain RFC 2606 reserves so it can never exist. No error
9
+ * anywhere. Treating these as unset is what lets the recipient ladder reach something
10
+ * real — and lets the CMS say so.
11
+ */
12
+
13
+ describe('isPlaceholderEmail', () => {
14
+ it.each([
15
+ 'contact@example.com',
16
+ 'CONTACT@EXAMPLE.COM',
17
+ ' hello@example.com ',
18
+ 'privacy@example.org',
19
+ 'support@example.net',
20
+ 'someone@example.edu',
21
+ 'a@host.example',
22
+ 'a@something.invalid',
23
+ 'a@my.test',
24
+ 'a@dev.localhost',
25
+ 'a@printer.local',
26
+ ])('treats %s as a placeholder', (email) => {
27
+ expect(isPlaceholderEmail(email)).toBe(true);
28
+ expect(usableEmail(email)).toBeNull();
29
+ });
30
+
31
+ it.each([
32
+ 'hosts@newrootsherbal.com',
33
+ 'sales@nextblock.dev',
34
+ // A real domain that merely contains the word, and a real TLD lookalike.
35
+ 'hi@example-company.com',
36
+ 'hi@myexample.com',
37
+ 'hi@shop.exampleshop.co.uk',
38
+ ])('leaves the real address %s alone', (email) => {
39
+ expect(isPlaceholderEmail(email)).toBe(false);
40
+ expect(usableEmail(email)).toBe(email);
41
+ });
42
+
43
+ it('does not treat an empty or malformed value as a placeholder', () => {
44
+ // Empty means "unset", which callers already handle; malformed is not our call.
45
+ for (const value of ['', ' ', null, undefined, 'not-an-email']) {
46
+ expect(isPlaceholderEmail(value)).toBe(false);
47
+ }
48
+ });
49
+
50
+ it('reports an empty value as unusable without calling it a placeholder', () => {
51
+ expect(usableEmail('')).toBeNull();
52
+ expect(usableEmail(null)).toBeNull();
53
+ expect(usableEmail(' ')).toBeNull();
54
+ });
55
+
56
+ it('trims a usable address rather than returning it padded', () => {
57
+ expect(usableEmail(' sales@nextblock.dev ')).toBe('sales@nextblock.dev');
58
+ });
59
+ });
@@ -1,39 +1,39 @@
1
- /**
2
- * Addresses that look configured but can never receive mail.
3
- *
4
- * Seed data has to put *something* in a contact field, and `contact@example.com` is the
5
- * conventional choice. The trouble is that it is indistinguishable from a real setting to
6
- * every code path downstream: the form reports success, the transport accepts the
7
- * message, and it is delivered nowhere. An operator can run that way for months without
8
- * a single error to notice.
9
- *
10
- * The domains here are reserved by RFC 2606 and RFC 6761 precisely so they can never be
11
- * registered, which is what makes this a safe judgement rather than a guess: nothing at
12
- * `example.com` is ever a real inbox. Treating them as unset lets the recipient ladder
13
- * fall through to something that works, and lets the CMS say so out loud.
14
- *
15
- * Deliberately dependency-free so both server modules and the CMS can use it.
16
- */
17
-
18
- const RESERVED_DOMAINS = new Set(['example.com', 'example.org', 'example.net', 'example.edu']);
19
- const RESERVED_TLDS = ['.example', '.invalid', '.test', '.localhost', '.local'];
20
-
21
- export function isPlaceholderEmail(value: string | null | undefined): boolean {
22
- const email = value?.trim().toLowerCase() ?? '';
23
- if (!email) return false;
24
-
25
- const at = email.lastIndexOf('@');
26
- if (at === -1) return false;
27
-
28
- const domain = email.slice(at + 1);
29
- if (!domain) return false;
30
-
31
- return RESERVED_DOMAINS.has(domain) || RESERVED_TLDS.some((tld) => domain.endsWith(tld));
32
- }
33
-
34
- /** The address if it could actually receive mail, otherwise null. */
35
- export function usableEmail(value: string | null | undefined): string | null {
36
- const email = value?.trim() ?? '';
37
- if (!email || isPlaceholderEmail(email)) return null;
38
- return email;
39
- }
1
+ /**
2
+ * Addresses that look configured but can never receive mail.
3
+ *
4
+ * Seed data has to put *something* in a contact field, and `contact@example.com` is the
5
+ * conventional choice. The trouble is that it is indistinguishable from a real setting to
6
+ * every code path downstream: the form reports success, the transport accepts the
7
+ * message, and it is delivered nowhere. An operator can run that way for months without
8
+ * a single error to notice.
9
+ *
10
+ * The domains here are reserved by RFC 2606 and RFC 6761 precisely so they can never be
11
+ * registered, which is what makes this a safe judgement rather than a guess: nothing at
12
+ * `example.com` is ever a real inbox. Treating them as unset lets the recipient ladder
13
+ * fall through to something that works, and lets the CMS say so out loud.
14
+ *
15
+ * Deliberately dependency-free so both server modules and the CMS can use it.
16
+ */
17
+
18
+ const RESERVED_DOMAINS = new Set(['example.com', 'example.org', 'example.net', 'example.edu']);
19
+ const RESERVED_TLDS = ['.example', '.invalid', '.test', '.localhost', '.local'];
20
+
21
+ export function isPlaceholderEmail(value: string | null | undefined): boolean {
22
+ const email = value?.trim().toLowerCase() ?? '';
23
+ if (!email) return false;
24
+
25
+ const at = email.lastIndexOf('@');
26
+ if (at === -1) return false;
27
+
28
+ const domain = email.slice(at + 1);
29
+ if (!domain) return false;
30
+
31
+ return RESERVED_DOMAINS.has(domain) || RESERVED_TLDS.some((tld) => domain.endsWith(tld));
32
+ }
33
+
34
+ /** The address if it could actually receive mail, otherwise null. */
35
+ export function usableEmail(value: string | null | undefined): string | null {
36
+ const email = value?.trim() ?? '';
37
+ if (!email || isPlaceholderEmail(email)) return null;
38
+ return email;
39
+ }