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