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,241 +1,241 @@
1
- import 'server-only';
2
- // The single outbound-mail choke point. Deliberately NOT a "use server" module: every
3
- // caller is server-side (2FA codes, form/interaction notifications, feedback, the SMTP
4
- // test), and marking it as an action would register `sendEmail` as a client-callable
5
- // endpoint — an open relay taking an arbitrary recipient, subject and HTML body.
6
-
7
- import nodemailer, { type Transporter } from 'nodemailer';
8
- import { resolveEmailServerConfig, type ResolvedEmailConfig } from '../../lib/config/email-settings';
9
- import { applyEmailBranding, resolveEmailBranding } from '../../lib/email/branding';
10
-
11
- interface EmailParams {
12
- to: string;
13
- subject: string;
14
- text: string;
15
- html: string;
16
- /**
17
- * Where a reply should go, when that is not the SMTP identity. Set only from a
18
- * server-validated address — it lands in a mail header, so an unvalidated value
19
- * would be a header-injection vector.
20
- */
21
- replyTo?: string;
22
- /** Stable id for this message, so later mail can reference it. */
23
- messageId?: string;
24
- /** Thread root this message answers. Makes a "Re:" subject legitimate. */
25
- inReplyTo?: string;
26
- /** Full reference chain; mail clients thread on this. */
27
- references?: string;
28
- }
29
-
30
- // Without explicit bounds nodemailer inherits the OS socket timeouts, so an unreachable
31
- // or silently-dropping relay hangs the request (and the user's spinner) for minutes.
32
- const CONNECTION_TIMEOUT_MS = 10_000;
33
- const GREETING_TIMEOUT_MS = 10_000;
34
- const SOCKET_TIMEOUT_MS = 20_000;
35
-
36
- // Opening a fresh SMTP connection per message costs a TCP handshake + TLS negotiation +
37
- // AUTH round trip before the first byte of the message is sent — typically the bulk of the
38
- // wait when a user clicks "send me a code". A pooled transport keeps the authenticated
39
- // connection warm so subsequent sends start at DATA. Set SMTP_POOL=false to opt out.
40
- const POOL_ENABLED = process.env['SMTP_POOL'] !== 'false';
41
-
42
- let cachedTransport: { key: string; transporter: Transporter } | null = null;
43
-
44
- /** Identity of a transport: anything that changes it must force a rebuild. */
45
- function transportKey(config: ResolvedEmailConfig): string {
46
- return [config.host, config.port, config.secure, config.requireTLS ?? false, config.auth.user].join('|');
47
- }
48
-
49
- function getTransporter(config: ResolvedEmailConfig): Transporter {
50
- const key = transportKey(config);
51
- if (cachedTransport && cachedTransport.key === key) {
52
- return cachedTransport.transporter;
53
- }
54
-
55
- // Settings changed — tear the old pool down rather than leaking its sockets.
56
- if (cachedTransport) {
57
- try {
58
- cachedTransport.transporter.close();
59
- } catch {
60
- /* already closed */
61
- }
62
- cachedTransport = null;
63
- }
64
-
65
- const base = {
66
- host: config.host,
67
- port: config.port,
68
- secure: config.secure,
69
- // Set on STARTTLS ports against a remote relay: without it nodemailer would fall
70
- // back to plaintext if the server stopped advertising STARTTLS, quietly shipping
71
- // credentials in the clear.
72
- ...(config.requireTLS ? { requireTLS: true } : {}),
73
- auth: config.auth,
74
- connectionTimeout: CONNECTION_TIMEOUT_MS,
75
- greetingTimeout: GREETING_TIMEOUT_MS,
76
- socketTimeout: SOCKET_TIMEOUT_MS,
77
- };
78
-
79
- // Branched rather than `pool: POOL_ENABLED` — nodemailer types `pool` as the literal
80
- // `true` to select the pooled transport overload, so a boolean matches neither.
81
- const transporter: Transporter = POOL_ENABLED
82
- ? nodemailer.createTransport({ ...base, pool: true, maxConnections: 3, maxMessages: 100 })
83
- : nodemailer.createTransport(base);
84
-
85
- // A pool that errors out (relay restart, credentials rotated, idle socket reaped) must
86
- // not be handed to the next caller — drop it so the following send reconnects cleanly.
87
- transporter.on('error', () => {
88
- if (cachedTransport?.transporter === transporter) {
89
- cachedTransport = null;
90
- }
91
- });
92
-
93
- cachedTransport = { key, transporter };
94
- return transporter;
95
- }
96
-
97
- /** Any value bound for a mail header: a newline here would inject extra headers. */
98
- function header(value: string): string {
99
- return value.replace(/[\r\n]+/g, ' ').trim();
100
- }
101
-
102
- /**
103
- * Domain of the configured From address, used to mint RFC-compliant Message-IDs.
104
- * Falls back to a neutral literal rather than inventing a domain we do not control.
105
- */
106
- export async function resolveFromDomain(): Promise<string> {
107
- const config = await resolveEmailServerConfig({ silent: true });
108
- const match = config?.from?.match(/@([^>\s]+)/);
109
- return match?.[1]?.trim() || 'localhost';
110
- }
111
-
112
- /**
113
- * Turn a transport failure into something the person reading it can act on.
114
- *
115
- * The raw text is written for whoever wrote OpenSSL, not for a shop owner: an admin who
116
- * sees "ssl3_get_record:wrong version number" beside their reply learns nothing, and the
117
- * actual cause — a TLS mode that does not match the port — is never mentioned. The
118
- * original is still logged server-side; this is what gets stored and shown.
119
- */
120
- export function describeSmtpError(error: unknown): string {
121
- const raw = error instanceof Error ? error.message : String(error);
122
- const code = (error as { code?: string } | null)?.code ?? '';
123
-
124
- if (/wrong version number/i.test(raw)) {
125
- return 'The mail server refused the TLS handshake. This almost always means the TLS setting does not match the port: ports 25, 587 and 2525 need TLS OFF (STARTTLS), port 465 needs it ON. Check CMS Settings → Email.';
126
- }
127
- if (/ssl|tls|certificate/i.test(raw) && /alert|handshake|self.signed|unable to verify/i.test(raw)) {
128
- return `The mail server's TLS certificate could not be verified (${raw.slice(0, 120)}). Check the host name matches the certificate.`;
129
- }
130
- if (code === 'EAUTH' || /invalid login|authentication failed|535/i.test(raw)) {
131
- return 'The mail server rejected the username or password. Re-enter the SMTP credentials in CMS Settings → Email.';
132
- }
133
- if (code === 'ENOTFOUND' || /getaddrinfo/i.test(raw)) {
134
- return 'The mail server hostname could not be resolved. Check the SMTP host in CMS Settings → Email.';
135
- }
136
- if (code === 'ECONNREFUSED') {
137
- return 'The mail server refused the connection. Check the SMTP host and port in CMS Settings → Email.';
138
- }
139
- if (code === 'ETIMEDOUT' || code === 'ESOCKET' || /timeout/i.test(raw)) {
140
- return 'The mail server did not respond in time. It may be unreachable from this host, or the port may be blocked.';
141
- }
142
- if (/not configured/i.test(raw)) {
143
- return 'Email sending is not configured yet. Add SMTP details in CMS Settings → Email.';
144
- }
145
- return raw;
146
- }
147
-
148
- export async function sendEmail({
149
- to,
150
- subject,
151
- text,
152
- html,
153
- replyTo,
154
- messageId,
155
- inReplyTo,
156
- references,
157
- }: EmailParams) {
158
- // DB-first (CMS Settings → Configuration → Email), falling back to SMTP_* env vars.
159
- // Resolved in parallel with branding — neither depends on the other, and both are
160
- // pure reads standing between the click and the first SMTP packet.
161
- const [emailConfig, branding] = await Promise.all([
162
- resolveEmailServerConfig(),
163
- resolveEmailBranding(),
164
- ]);
165
-
166
- if (!emailConfig) {
167
- throw new Error("Email server is not configured. Configure SMTP in CMS Settings → Configuration → Email.");
168
- }
169
-
170
- // Single interception point: white-label every outgoing email with the tenant's own
171
- // logo + site name (or a text banner when no logo is set). Every app-dispatched email
172
- // funnels through here, so branding is applied once, centrally.
173
- const brandedHtml = applyEmailBranding(html, branding);
174
-
175
- const transporter = getTransporter(emailConfig);
176
-
177
- const options = {
178
- from: emailConfig.from,
179
- to,
180
- subject,
181
- text,
182
- html: brandedHtml,
183
- // Stripped of CR/LF defensively: these values end up in mail headers.
184
- ...(replyTo ? { replyTo: header(replyTo) } : {}),
185
- ...(messageId ? { messageId: header(messageId) } : {}),
186
- ...(inReplyTo ? { inReplyTo: header(inReplyTo) } : {}),
187
- ...(references ? { references: header(references) } : {}),
188
- };
189
-
190
- try {
191
- return await transporter.sendMail(options);
192
- } catch (error) {
193
- if (!isTransientTransportError(error)) throw error;
194
-
195
- // A pooled connection that the relay reaped while it sat idle looks alive to
196
- // nodemailer until the send hangs and the socket times out. That is exactly what a
197
- // long-running dev server or a quiet production instance produces: everything
198
- // verifies, one send fails, the next works. Drop the pool and try once more on a
199
- // fresh single-use connection before reporting failure.
200
- console.warn('[email] Transient transport failure; retrying once on a fresh connection.', error);
201
- try {
202
- cachedTransport?.transporter.close();
203
- } catch {
204
- /* already closed */
205
- }
206
- cachedTransport = null;
207
-
208
- const retryTransport = nodemailer.createTransport({
209
- host: emailConfig.host,
210
- port: emailConfig.port,
211
- secure: emailConfig.secure,
212
- ...(emailConfig.requireTLS ? { requireTLS: true } : {}),
213
- auth: emailConfig.auth,
214
- connectionTimeout: CONNECTION_TIMEOUT_MS,
215
- greetingTimeout: GREETING_TIMEOUT_MS,
216
- socketTimeout: SOCKET_TIMEOUT_MS,
217
- });
218
-
219
- try {
220
- return await retryTransport.sendMail(options);
221
- } finally {
222
- retryTransport.close();
223
- }
224
- }
225
- }
226
-
227
- /**
228
- * Whether a failure is worth one more attempt.
229
- *
230
- * Only connection-level faults qualify. Retrying a rejected password or a TLS mismatch
231
- * just fails again a second time and delays the real answer.
232
- */
233
- function isTransientTransportError(error: unknown): boolean {
234
- const code = (error as { code?: string } | null)?.code ?? '';
235
- const message = error instanceof Error ? error.message : String(error);
236
-
237
- return (
238
- ['ETIMEDOUT', 'ESOCKET', 'ECONNRESET', 'EPIPE', 'ECONNECTION', 'EAI_AGAIN'].includes(code) ||
239
- /timeout|socket close|connection closed|read ECONNRESET/i.test(message)
240
- );
241
- }
1
+ import 'server-only';
2
+ // The single outbound-mail choke point. Deliberately NOT a "use server" module: every
3
+ // caller is server-side (2FA codes, form/interaction notifications, feedback, the SMTP
4
+ // test), and marking it as an action would register `sendEmail` as a client-callable
5
+ // endpoint — an open relay taking an arbitrary recipient, subject and HTML body.
6
+
7
+ import nodemailer, { type Transporter } from 'nodemailer';
8
+ import { resolveEmailServerConfig, type ResolvedEmailConfig } from '../../lib/config/email-settings';
9
+ import { applyEmailBranding, resolveEmailBranding } from '../../lib/email/branding';
10
+
11
+ interface EmailParams {
12
+ to: string;
13
+ subject: string;
14
+ text: string;
15
+ html: string;
16
+ /**
17
+ * Where a reply should go, when that is not the SMTP identity. Set only from a
18
+ * server-validated address — it lands in a mail header, so an unvalidated value
19
+ * would be a header-injection vector.
20
+ */
21
+ replyTo?: string;
22
+ /** Stable id for this message, so later mail can reference it. */
23
+ messageId?: string;
24
+ /** Thread root this message answers. Makes a "Re:" subject legitimate. */
25
+ inReplyTo?: string;
26
+ /** Full reference chain; mail clients thread on this. */
27
+ references?: string;
28
+ }
29
+
30
+ // Without explicit bounds nodemailer inherits the OS socket timeouts, so an unreachable
31
+ // or silently-dropping relay hangs the request (and the user's spinner) for minutes.
32
+ const CONNECTION_TIMEOUT_MS = 10_000;
33
+ const GREETING_TIMEOUT_MS = 10_000;
34
+ const SOCKET_TIMEOUT_MS = 20_000;
35
+
36
+ // Opening a fresh SMTP connection per message costs a TCP handshake + TLS negotiation +
37
+ // AUTH round trip before the first byte of the message is sent — typically the bulk of the
38
+ // wait when a user clicks "send me a code". A pooled transport keeps the authenticated
39
+ // connection warm so subsequent sends start at DATA. Set SMTP_POOL=false to opt out.
40
+ const POOL_ENABLED = process.env['SMTP_POOL'] !== 'false';
41
+
42
+ let cachedTransport: { key: string; transporter: Transporter } | null = null;
43
+
44
+ /** Identity of a transport: anything that changes it must force a rebuild. */
45
+ function transportKey(config: ResolvedEmailConfig): string {
46
+ return [config.host, config.port, config.secure, config.requireTLS ?? false, config.auth.user].join('|');
47
+ }
48
+
49
+ function getTransporter(config: ResolvedEmailConfig): Transporter {
50
+ const key = transportKey(config);
51
+ if (cachedTransport && cachedTransport.key === key) {
52
+ return cachedTransport.transporter;
53
+ }
54
+
55
+ // Settings changed — tear the old pool down rather than leaking its sockets.
56
+ if (cachedTransport) {
57
+ try {
58
+ cachedTransport.transporter.close();
59
+ } catch {
60
+ /* already closed */
61
+ }
62
+ cachedTransport = null;
63
+ }
64
+
65
+ const base = {
66
+ host: config.host,
67
+ port: config.port,
68
+ secure: config.secure,
69
+ // Set on STARTTLS ports against a remote relay: without it nodemailer would fall
70
+ // back to plaintext if the server stopped advertising STARTTLS, quietly shipping
71
+ // credentials in the clear.
72
+ ...(config.requireTLS ? { requireTLS: true } : {}),
73
+ auth: config.auth,
74
+ connectionTimeout: CONNECTION_TIMEOUT_MS,
75
+ greetingTimeout: GREETING_TIMEOUT_MS,
76
+ socketTimeout: SOCKET_TIMEOUT_MS,
77
+ };
78
+
79
+ // Branched rather than `pool: POOL_ENABLED` — nodemailer types `pool` as the literal
80
+ // `true` to select the pooled transport overload, so a boolean matches neither.
81
+ const transporter: Transporter = POOL_ENABLED
82
+ ? nodemailer.createTransport({ ...base, pool: true, maxConnections: 3, maxMessages: 100 })
83
+ : nodemailer.createTransport(base);
84
+
85
+ // A pool that errors out (relay restart, credentials rotated, idle socket reaped) must
86
+ // not be handed to the next caller — drop it so the following send reconnects cleanly.
87
+ transporter.on('error', () => {
88
+ if (cachedTransport?.transporter === transporter) {
89
+ cachedTransport = null;
90
+ }
91
+ });
92
+
93
+ cachedTransport = { key, transporter };
94
+ return transporter;
95
+ }
96
+
97
+ /** Any value bound for a mail header: a newline here would inject extra headers. */
98
+ function header(value: string): string {
99
+ return value.replace(/[\r\n]+/g, ' ').trim();
100
+ }
101
+
102
+ /**
103
+ * Domain of the configured From address, used to mint RFC-compliant Message-IDs.
104
+ * Falls back to a neutral literal rather than inventing a domain we do not control.
105
+ */
106
+ export async function resolveFromDomain(): Promise<string> {
107
+ const config = await resolveEmailServerConfig({ silent: true });
108
+ const match = config?.from?.match(/@([^>\s]+)/);
109
+ return match?.[1]?.trim() || 'localhost';
110
+ }
111
+
112
+ /**
113
+ * Turn a transport failure into something the person reading it can act on.
114
+ *
115
+ * The raw text is written for whoever wrote OpenSSL, not for a shop owner: an admin who
116
+ * sees "ssl3_get_record:wrong version number" beside their reply learns nothing, and the
117
+ * actual cause — a TLS mode that does not match the port — is never mentioned. The
118
+ * original is still logged server-side; this is what gets stored and shown.
119
+ */
120
+ export function describeSmtpError(error: unknown): string {
121
+ const raw = error instanceof Error ? error.message : String(error);
122
+ const code = (error as { code?: string } | null)?.code ?? '';
123
+
124
+ if (/wrong version number/i.test(raw)) {
125
+ return 'The mail server refused the TLS handshake. This almost always means the TLS setting does not match the port: ports 25, 587 and 2525 need TLS OFF (STARTTLS), port 465 needs it ON. Check CMS Settings → Email.';
126
+ }
127
+ if (/ssl|tls|certificate/i.test(raw) && /alert|handshake|self.signed|unable to verify/i.test(raw)) {
128
+ return `The mail server's TLS certificate could not be verified (${raw.slice(0, 120)}). Check the host name matches the certificate.`;
129
+ }
130
+ if (code === 'EAUTH' || /invalid login|authentication failed|535/i.test(raw)) {
131
+ return 'The mail server rejected the username or password. Re-enter the SMTP credentials in CMS Settings → Email.';
132
+ }
133
+ if (code === 'ENOTFOUND' || /getaddrinfo/i.test(raw)) {
134
+ return 'The mail server hostname could not be resolved. Check the SMTP host in CMS Settings → Email.';
135
+ }
136
+ if (code === 'ECONNREFUSED') {
137
+ return 'The mail server refused the connection. Check the SMTP host and port in CMS Settings → Email.';
138
+ }
139
+ if (code === 'ETIMEDOUT' || code === 'ESOCKET' || /timeout/i.test(raw)) {
140
+ return 'The mail server did not respond in time. It may be unreachable from this host, or the port may be blocked.';
141
+ }
142
+ if (/not configured/i.test(raw)) {
143
+ return 'Email sending is not configured yet. Add SMTP details in CMS Settings → Email.';
144
+ }
145
+ return raw;
146
+ }
147
+
148
+ export async function sendEmail({
149
+ to,
150
+ subject,
151
+ text,
152
+ html,
153
+ replyTo,
154
+ messageId,
155
+ inReplyTo,
156
+ references,
157
+ }: EmailParams) {
158
+ // DB-first (CMS Settings → Configuration → Email), falling back to SMTP_* env vars.
159
+ // Resolved in parallel with branding — neither depends on the other, and both are
160
+ // pure reads standing between the click and the first SMTP packet.
161
+ const [emailConfig, branding] = await Promise.all([
162
+ resolveEmailServerConfig(),
163
+ resolveEmailBranding(),
164
+ ]);
165
+
166
+ if (!emailConfig) {
167
+ throw new Error("Email server is not configured. Configure SMTP in CMS Settings → Configuration → Email.");
168
+ }
169
+
170
+ // Single interception point: white-label every outgoing email with the tenant's own
171
+ // logo + site name (or a text banner when no logo is set). Every app-dispatched email
172
+ // funnels through here, so branding is applied once, centrally.
173
+ const brandedHtml = applyEmailBranding(html, branding);
174
+
175
+ const transporter = getTransporter(emailConfig);
176
+
177
+ const options = {
178
+ from: emailConfig.from,
179
+ to,
180
+ subject,
181
+ text,
182
+ html: brandedHtml,
183
+ // Stripped of CR/LF defensively: these values end up in mail headers.
184
+ ...(replyTo ? { replyTo: header(replyTo) } : {}),
185
+ ...(messageId ? { messageId: header(messageId) } : {}),
186
+ ...(inReplyTo ? { inReplyTo: header(inReplyTo) } : {}),
187
+ ...(references ? { references: header(references) } : {}),
188
+ };
189
+
190
+ try {
191
+ return await transporter.sendMail(options);
192
+ } catch (error) {
193
+ if (!isTransientTransportError(error)) throw error;
194
+
195
+ // A pooled connection that the relay reaped while it sat idle looks alive to
196
+ // nodemailer until the send hangs and the socket times out. That is exactly what a
197
+ // long-running dev server or a quiet production instance produces: everything
198
+ // verifies, one send fails, the next works. Drop the pool and try once more on a
199
+ // fresh single-use connection before reporting failure.
200
+ console.warn('[email] Transient transport failure; retrying once on a fresh connection.', error);
201
+ try {
202
+ cachedTransport?.transporter.close();
203
+ } catch {
204
+ /* already closed */
205
+ }
206
+ cachedTransport = null;
207
+
208
+ const retryTransport = nodemailer.createTransport({
209
+ host: emailConfig.host,
210
+ port: emailConfig.port,
211
+ secure: emailConfig.secure,
212
+ ...(emailConfig.requireTLS ? { requireTLS: true } : {}),
213
+ auth: emailConfig.auth,
214
+ connectionTimeout: CONNECTION_TIMEOUT_MS,
215
+ greetingTimeout: GREETING_TIMEOUT_MS,
216
+ socketTimeout: SOCKET_TIMEOUT_MS,
217
+ });
218
+
219
+ try {
220
+ return await retryTransport.sendMail(options);
221
+ } finally {
222
+ retryTransport.close();
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Whether a failure is worth one more attempt.
229
+ *
230
+ * Only connection-level faults qualify. Retrying a rejected password or a TLS mismatch
231
+ * just fails again a second time and delays the real answer.
232
+ */
233
+ function isTransientTransportError(error: unknown): boolean {
234
+ const code = (error as { code?: string } | null)?.code ?? '';
235
+ const message = error instanceof Error ? error.message : String(error);
236
+
237
+ return (
238
+ ['ETIMEDOUT', 'ESOCKET', 'ECONNRESET', 'EPIPE', 'ECONNECTION', 'EAI_AGAIN'].includes(code) ||
239
+ /timeout|socket close|connection closed|read ECONNRESET/i.test(message)
240
+ );
241
+ }