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,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
+ }