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.
- package/docker-template/.dockerignore +1 -1
- package/libs/db/tsconfig.lib.json +3 -3
- package/libs/editor/tsconfig.lib.json +3 -3
- package/libs/ui/tsconfig.lib.json +3 -3
- package/libs/utils/tsconfig.json +3 -3
- package/package.json +50 -50
- package/project.json +19 -19
- package/templates/nextblock-template/.dockerignore +1 -1
- package/templates/nextblock-template/.swcrc +30 -30
- package/templates/nextblock-template/AGENTS.md +9 -9
- package/templates/nextblock-template/CLAUDE.md +1 -1
- package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
- package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
- package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
- package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
- package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
- package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
- package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
- package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
- package/templates/nextblock-template/app/actions/email.ts +241 -241
- package/templates/nextblock-template/app/actions/formActions.ts +245 -245
- package/templates/nextblock-template/app/actions/interactions.ts +489 -489
- package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
- package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
- package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -221
- package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
- package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
- package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -3092
- package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
- package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
- package/templates/nextblock-template/app/api/search/route.ts +43 -43
- package/templates/nextblock-template/app/api/view/route.ts +114 -114
- package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
- package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
- package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
- package/templates/nextblock-template/app/cart/page.tsx +7 -7
- package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
- package/templates/nextblock-template/app/checkout/page.tsx +57 -57
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
- package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
- package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
- package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
- package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
- package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
- package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
- package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
- package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
- package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
- package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
- package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
- package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/layout.tsx +101 -101
- package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
- package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
- package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
- package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
- package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
- package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
- package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
- package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
- package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -618
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
- package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
- package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
- package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
- package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
- package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
- package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
- package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
- package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
- package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
- package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
- package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
- package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
- package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
- package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
- package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
- package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
- package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
- package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
- package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
- package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
- package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
- package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
- package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
- package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
- package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
- package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
- package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
- package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
- package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
- package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
- package/templates/nextblock-template/app/layout.tsx +671 -671
- package/templates/nextblock-template/app/lib/seo.ts +319 -319
- package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
- package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
- package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
- package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
- package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
- package/templates/nextblock-template/app/profile/account-links.ts +22 -22
- package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
- package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
- package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
- package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
- package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
- package/templates/nextblock-template/app/providers.tsx +96 -96
- package/templates/nextblock-template/app/robots.ts +123 -123
- package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
- package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
- package/templates/nextblock-template/app/thread/layout.tsx +15 -15
- package/templates/nextblock-template/app/thread/page.tsx +98 -98
- package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
- package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
- package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
- package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
- package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
- package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
- package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
- package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
- package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
- package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
- package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
- package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
- package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
- package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
- package/templates/nextblock-template/components/Header.tsx +38 -38
- package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
- package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
- package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
- package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
- package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
- package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
- package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
- package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
- package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
- package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
- package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
- package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
- package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
- package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
- package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
- package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
- package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
- package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
- package/templates/nextblock-template/components/blocks/types.ts +7 -7
- package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
- package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
- package/templates/nextblock-template/components/form-message.tsx +32 -32
- package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
- package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -244
- package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
- package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
- package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
- package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
- package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
- package/templates/nextblock-template/components/submit-button.tsx +23 -23
- package/templates/nextblock-template/components/theme-icon.tsx +78 -78
- package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
- package/templates/nextblock-template/context/AuthContext.tsx +23 -23
- package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
- package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
- package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
- package/templates/nextblock-template/docs/README.md +42 -42
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
- package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
- package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
- package/templates/nextblock-template/index.d.ts +7 -7
- package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
- package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
- package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
- package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
- package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
- package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
- package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
- package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
- package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
- package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
- package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
- package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
- package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
- package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
- package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
- package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
- package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
- package/templates/nextblock-template/lib/messages/threads.ts +579 -579
- package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
- package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
- package/templates/nextblock-template/lib/search/types.ts +27 -27
- package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
- package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
- package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
- package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
- package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
- package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -140
- package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -350
- package/templates/nextblock-template/lib/seo/page-document.ts +412 -412
- package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
- package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
- package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
- package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
- package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
- package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -177
- package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
- package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
- package/templates/nextblock-template/lib/site-url.test.ts +89 -89
- package/templates/nextblock-template/lib/site-url.ts +102 -102
- package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
- package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
- package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
- package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
- package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
- package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
- package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
- package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
- package/templates/nextblock-template/lib/zod-config.ts +5 -5
- package/templates/nextblock-template/next-env.d.ts +1 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/postcss.config.js +6 -6
- package/templates/nextblock-template/scripts/backup.js +115 -115
- package/templates/nextblock-template/scripts/restore.js +385 -385
- package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
- package/templates/nextblock-template/tailwind.config.js +25 -25
- package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
- package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
- package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
- package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
- package/templates/nextblock-template/tools/update.mjs +1303 -1303
- package/tsconfig.base.json +3 -3
|
@@ -1,222 +1,222 @@
|
|
|
1
|
-
'use server';
|
|
2
|
-
|
|
3
|
-
import { headers } from 'next/headers';
|
|
4
|
-
import { after } from 'next/server';
|
|
5
|
-
import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
6
|
-
|
|
7
|
-
import { resolveSellerContactEmail } from '../../lib/commerce/seller-contact';
|
|
8
|
-
import { createThread, notifyAdminOfMessage } from '../../lib/messages/threads';
|
|
9
|
-
import { verifyBotProtection } from '../../lib/botProtection/verify';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Public "contact the seller" enquiry, shown in place of Add-to-Cart when the store
|
|
13
|
-
* cannot take payment for a product.
|
|
14
|
-
*
|
|
15
|
-
* Two rules shape this module:
|
|
16
|
-
*
|
|
17
|
-
* 1. The recipient is resolved ENTIRELY server-side. The existing form-block action
|
|
18
|
-
* takes its recipient from a bound argument, which means the address travels in the
|
|
19
|
-
* client payload; doing that here would publish the shop owner's inbox on every
|
|
20
|
-
* product page and turn the store's SMTP credentials into an open relay.
|
|
21
|
-
*
|
|
22
|
-
* 2. The database row is the deliverable, not the email. A store with no payment keys
|
|
23
|
-
* very often has no SMTP either, so the enquiry is persisted FIRST and the
|
|
24
|
-
* notification attempted afterwards. A failed send downgrades to `email_delivered:
|
|
25
|
-
* false` — the visitor is still told their message got through, because it did.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
const MAX_NAME_LENGTH = 120;
|
|
29
|
-
const MAX_EMAIL_LENGTH = 254;
|
|
30
|
-
const MAX_MESSAGE_LENGTH = 2000;
|
|
31
|
-
const MAX_USER_AGENT_LENGTH = 500;
|
|
32
|
-
|
|
33
|
-
/** Per-IP submission cap. Deliberately generous — this is anti-flood, not anti-user. */
|
|
34
|
-
const THROTTLE_WINDOW_MINUTES = 10;
|
|
35
|
-
const THROTTLE_MAX_SUBMISSIONS = 5;
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Throttle bucket for a request whose origin cannot be determined. Deliberately a single
|
|
39
|
-
* shared key: unattributable requests are throttled TOGETHER rather than exempted.
|
|
40
|
-
*
|
|
41
|
-
* This is the difference between this helper and the visually similar one in
|
|
42
|
-
* app/actions/consent.ts. There, a null mask just means one audit row is less precise.
|
|
43
|
-
* Here the value is load-bearing for a rate limit, so "I could not parse that" must
|
|
44
|
-
* never be allowed to mean "no limit applies".
|
|
45
|
-
*/
|
|
46
|
-
const UNKNOWN_IP_BUCKET = 'unknown';
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Best available client address, preferring headers the platform sets over ones the
|
|
50
|
-
* client can write.
|
|
51
|
-
*
|
|
52
|
-
* `x-forwarded-for` is a list the client can prepend to: on an appending proxy (nginx's
|
|
53
|
-
* proxy_add_x_forwarded_for, the shape this repo's Dockerfile deploys) its LEFTMOST
|
|
54
|
-
* entry is attacker-controlled, so keying a throttle on it lets one caller mint a fresh
|
|
55
|
-
* bucket per request. `x-vercel-forwarded-for` and `x-real-ip` are overwritten by the
|
|
56
|
-
* proxy on every request, so they are checked first.
|
|
57
|
-
*/
|
|
58
|
-
function firstHop(headerValue: string | null): string | null {
|
|
59
|
-
const [first] = (headerValue ?? '').split(',');
|
|
60
|
-
const trimmed = first?.trim() ?? '';
|
|
61
|
-
return trimmed || null;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function resolveClientIp(requestHeaders: Headers): string | null {
|
|
65
|
-
return (
|
|
66
|
-
firstHop(requestHeaders.get('x-vercel-forwarded-for')) ??
|
|
67
|
-
firstHop(requestHeaders.get('x-real-ip')) ??
|
|
68
|
-
firstHop(requestHeaders.get('x-forwarded-for'))
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Reduce an address to a non-identifying prefix — never store a full IP. */
|
|
73
|
-
function maskIp(ip: string | null): string {
|
|
74
|
-
if (!ip) return UNKNOWN_IP_BUCKET;
|
|
75
|
-
if (ip.includes(':')) {
|
|
76
|
-
const hextets = ip.split(':').filter(Boolean);
|
|
77
|
-
return `${hextets.slice(0, 3).join(':')}::x`;
|
|
78
|
-
}
|
|
79
|
-
const octets = ip.split('.');
|
|
80
|
-
if (octets.length === 4) {
|
|
81
|
-
octets[3] = 'x';
|
|
82
|
-
return octets.join('.');
|
|
83
|
-
}
|
|
84
|
-
return UNKNOWN_IP_BUCKET;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function readField(formData: FormData, name: string, maxLength: number): string {
|
|
88
|
-
const raw = formData.get(name);
|
|
89
|
-
return typeof raw === 'string' ? raw.trim().slice(0, maxLength) : '';
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function isPlausibleEmail(value: string): boolean {
|
|
93
|
-
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export interface ContactSellerState {
|
|
97
|
-
success: boolean;
|
|
98
|
-
/** Translation key the client renders, with its own English fallback. */
|
|
99
|
-
messageKey:
|
|
100
|
-
| 'ecommerce.contact_seller_sent'
|
|
101
|
-
| 'ecommerce.contact_seller_error'
|
|
102
|
-
| 'ecommerce.contact_seller_throttled'
|
|
103
|
-
| 'ecommerce.contact_seller_invalid'
|
|
104
|
-
| '';
|
|
105
|
-
/** Set only for captcha failures, which carry their own provider-specific text. */
|
|
106
|
-
message?: string;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export async function submitProductInquiry(
|
|
110
|
-
_prevState: unknown,
|
|
111
|
-
formData: FormData
|
|
112
|
-
): Promise<ContactSellerState> {
|
|
113
|
-
const verification = await verifyBotProtection(formData);
|
|
114
|
-
if (!verification.ok) {
|
|
115
|
-
// Fake a success for the honeypot so the bot learns nothing about the check.
|
|
116
|
-
if (verification.reason === 'honeypot') {
|
|
117
|
-
return { success: true, messageKey: 'ecommerce.contact_seller_sent' };
|
|
118
|
-
}
|
|
119
|
-
return { success: false, messageKey: '', message: verification.message };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const productId = readField(formData, 'product_id', 64);
|
|
123
|
-
const senderName = readField(formData, 'name', MAX_NAME_LENGTH);
|
|
124
|
-
const senderEmail = readField(formData, 'email', MAX_EMAIL_LENGTH);
|
|
125
|
-
const message = readField(formData, 'message', MAX_MESSAGE_LENGTH);
|
|
126
|
-
const locale = readField(formData, 'locale', 12) || null;
|
|
127
|
-
|
|
128
|
-
if (!productId || !senderName || !senderEmail || !message || !isPlausibleEmail(senderEmail)) {
|
|
129
|
-
return { success: false, messageKey: 'ecommerce.contact_seller_invalid' };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
const supabase = getServiceRoleSupabaseClient();
|
|
134
|
-
|
|
135
|
-
const requestHeaders = await headers();
|
|
136
|
-
// Always a string, so the throttle below always runs. See UNKNOWN_IP_BUCKET.
|
|
137
|
-
const ipMasked = maskIp(resolveClientIp(requestHeaders));
|
|
138
|
-
const userAgent = requestHeaders.get('user-agent');
|
|
139
|
-
|
|
140
|
-
const since = new Date(Date.now() - THROTTLE_WINDOW_MINUTES * 60_000).toISOString();
|
|
141
|
-
const { count } = await supabase
|
|
142
|
-
.from('product_inquiries')
|
|
143
|
-
.select('id', { count: 'exact', head: true })
|
|
144
|
-
.eq('ip_masked', ipMasked)
|
|
145
|
-
.gte('created_at', since);
|
|
146
|
-
|
|
147
|
-
if ((count ?? 0) >= THROTTLE_MAX_SUBMISSIONS) {
|
|
148
|
-
return { success: false, messageKey: 'ecommerce.contact_seller_throttled' };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Look the product up server-side: a client-supplied title would let anyone send
|
|
152
|
-
// arbitrary text through the owner's own SMTP identity.
|
|
153
|
-
const { data: product } = await supabase
|
|
154
|
-
.from('products')
|
|
155
|
-
.select('id, title, slug')
|
|
156
|
-
.eq('id', productId)
|
|
157
|
-
.maybeSingle();
|
|
158
|
-
|
|
159
|
-
if (!product) {
|
|
160
|
-
return { success: false, messageKey: 'ecommerce.contact_seller_invalid' };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const { data: inserted, error: insertError } = await supabase
|
|
164
|
-
.from('product_inquiries')
|
|
165
|
-
.insert({
|
|
166
|
-
product_id: product.id,
|
|
167
|
-
product_slug: product.slug,
|
|
168
|
-
product_title: product.title,
|
|
169
|
-
sender_name: senderName,
|
|
170
|
-
sender_email: senderEmail,
|
|
171
|
-
message,
|
|
172
|
-
locale,
|
|
173
|
-
ip_masked: ipMasked,
|
|
174
|
-
user_agent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
|
|
175
|
-
})
|
|
176
|
-
.select('id')
|
|
177
|
-
.single();
|
|
178
|
-
|
|
179
|
-
if (insertError) {
|
|
180
|
-
console.error('Failed to record product inquiry:', insertError.message);
|
|
181
|
-
return { success: false, messageKey: 'ecommerce.contact_seller_error' };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Open the conversation this enquiry belongs to. The enquiry row above stays as
|
|
185
|
-
// the enquiry's own record (and owns the ip_masked the throttle counts); the thread
|
|
186
|
-
// is what the owner actually replies in.
|
|
187
|
-
const thread = await createThread({
|
|
188
|
-
source: 'product_inquiry',
|
|
189
|
-
subjectId: inserted.id,
|
|
190
|
-
subjectLabel: product.title,
|
|
191
|
-
senderName,
|
|
192
|
-
senderEmail,
|
|
193
|
-
message,
|
|
194
|
-
locale,
|
|
195
|
-
ipMasked,
|
|
196
|
-
userAgent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// Notification is best-effort and runs after the response. A bare floating promise
|
|
200
|
-
// would risk the serverless instance being frozen before the SMTP round trip ends.
|
|
201
|
-
if (thread) {
|
|
202
|
-
after(async () => {
|
|
203
|
-
const { email: recipient } = await resolveSellerContactEmail();
|
|
204
|
-
await notifyAdminOfMessage({
|
|
205
|
-
threadId: thread.threadId,
|
|
206
|
-
source: 'product_inquiry',
|
|
207
|
-
messageId: thread.messageId,
|
|
208
|
-
subjectLabel: product.title,
|
|
209
|
-
senderName,
|
|
210
|
-
senderEmail,
|
|
211
|
-
message,
|
|
212
|
-
recipient,
|
|
213
|
-
});
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return { success: true, messageKey: 'ecommerce.contact_seller_sent' };
|
|
218
|
-
} catch (error) {
|
|
219
|
-
console.error('Product inquiry submission failed:', error);
|
|
220
|
-
return { success: false, messageKey: 'ecommerce.contact_seller_error' };
|
|
221
|
-
}
|
|
222
|
-
}
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { headers } from 'next/headers';
|
|
4
|
+
import { after } from 'next/server';
|
|
5
|
+
import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
6
|
+
|
|
7
|
+
import { resolveSellerContactEmail } from '../../lib/commerce/seller-contact';
|
|
8
|
+
import { createThread, notifyAdminOfMessage } from '../../lib/messages/threads';
|
|
9
|
+
import { verifyBotProtection } from '../../lib/botProtection/verify';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Public "contact the seller" enquiry, shown in place of Add-to-Cart when the store
|
|
13
|
+
* cannot take payment for a product.
|
|
14
|
+
*
|
|
15
|
+
* Two rules shape this module:
|
|
16
|
+
*
|
|
17
|
+
* 1. The recipient is resolved ENTIRELY server-side. The existing form-block action
|
|
18
|
+
* takes its recipient from a bound argument, which means the address travels in the
|
|
19
|
+
* client payload; doing that here would publish the shop owner's inbox on every
|
|
20
|
+
* product page and turn the store's SMTP credentials into an open relay.
|
|
21
|
+
*
|
|
22
|
+
* 2. The database row is the deliverable, not the email. A store with no payment keys
|
|
23
|
+
* very often has no SMTP either, so the enquiry is persisted FIRST and the
|
|
24
|
+
* notification attempted afterwards. A failed send downgrades to `email_delivered:
|
|
25
|
+
* false` — the visitor is still told their message got through, because it did.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const MAX_NAME_LENGTH = 120;
|
|
29
|
+
const MAX_EMAIL_LENGTH = 254;
|
|
30
|
+
const MAX_MESSAGE_LENGTH = 2000;
|
|
31
|
+
const MAX_USER_AGENT_LENGTH = 500;
|
|
32
|
+
|
|
33
|
+
/** Per-IP submission cap. Deliberately generous — this is anti-flood, not anti-user. */
|
|
34
|
+
const THROTTLE_WINDOW_MINUTES = 10;
|
|
35
|
+
const THROTTLE_MAX_SUBMISSIONS = 5;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Throttle bucket for a request whose origin cannot be determined. Deliberately a single
|
|
39
|
+
* shared key: unattributable requests are throttled TOGETHER rather than exempted.
|
|
40
|
+
*
|
|
41
|
+
* This is the difference between this helper and the visually similar one in
|
|
42
|
+
* app/actions/consent.ts. There, a null mask just means one audit row is less precise.
|
|
43
|
+
* Here the value is load-bearing for a rate limit, so "I could not parse that" must
|
|
44
|
+
* never be allowed to mean "no limit applies".
|
|
45
|
+
*/
|
|
46
|
+
const UNKNOWN_IP_BUCKET = 'unknown';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Best available client address, preferring headers the platform sets over ones the
|
|
50
|
+
* client can write.
|
|
51
|
+
*
|
|
52
|
+
* `x-forwarded-for` is a list the client can prepend to: on an appending proxy (nginx's
|
|
53
|
+
* proxy_add_x_forwarded_for, the shape this repo's Dockerfile deploys) its LEFTMOST
|
|
54
|
+
* entry is attacker-controlled, so keying a throttle on it lets one caller mint a fresh
|
|
55
|
+
* bucket per request. `x-vercel-forwarded-for` and `x-real-ip` are overwritten by the
|
|
56
|
+
* proxy on every request, so they are checked first.
|
|
57
|
+
*/
|
|
58
|
+
function firstHop(headerValue: string | null): string | null {
|
|
59
|
+
const [first] = (headerValue ?? '').split(',');
|
|
60
|
+
const trimmed = first?.trim() ?? '';
|
|
61
|
+
return trimmed || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveClientIp(requestHeaders: Headers): string | null {
|
|
65
|
+
return (
|
|
66
|
+
firstHop(requestHeaders.get('x-vercel-forwarded-for')) ??
|
|
67
|
+
firstHop(requestHeaders.get('x-real-ip')) ??
|
|
68
|
+
firstHop(requestHeaders.get('x-forwarded-for'))
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Reduce an address to a non-identifying prefix — never store a full IP. */
|
|
73
|
+
function maskIp(ip: string | null): string {
|
|
74
|
+
if (!ip) return UNKNOWN_IP_BUCKET;
|
|
75
|
+
if (ip.includes(':')) {
|
|
76
|
+
const hextets = ip.split(':').filter(Boolean);
|
|
77
|
+
return `${hextets.slice(0, 3).join(':')}::x`;
|
|
78
|
+
}
|
|
79
|
+
const octets = ip.split('.');
|
|
80
|
+
if (octets.length === 4) {
|
|
81
|
+
octets[3] = 'x';
|
|
82
|
+
return octets.join('.');
|
|
83
|
+
}
|
|
84
|
+
return UNKNOWN_IP_BUCKET;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readField(formData: FormData, name: string, maxLength: number): string {
|
|
88
|
+
const raw = formData.get(name);
|
|
89
|
+
return typeof raw === 'string' ? raw.trim().slice(0, maxLength) : '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isPlausibleEmail(value: string): boolean {
|
|
93
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ContactSellerState {
|
|
97
|
+
success: boolean;
|
|
98
|
+
/** Translation key the client renders, with its own English fallback. */
|
|
99
|
+
messageKey:
|
|
100
|
+
| 'ecommerce.contact_seller_sent'
|
|
101
|
+
| 'ecommerce.contact_seller_error'
|
|
102
|
+
| 'ecommerce.contact_seller_throttled'
|
|
103
|
+
| 'ecommerce.contact_seller_invalid'
|
|
104
|
+
| '';
|
|
105
|
+
/** Set only for captcha failures, which carry their own provider-specific text. */
|
|
106
|
+
message?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function submitProductInquiry(
|
|
110
|
+
_prevState: unknown,
|
|
111
|
+
formData: FormData
|
|
112
|
+
): Promise<ContactSellerState> {
|
|
113
|
+
const verification = await verifyBotProtection(formData);
|
|
114
|
+
if (!verification.ok) {
|
|
115
|
+
// Fake a success for the honeypot so the bot learns nothing about the check.
|
|
116
|
+
if (verification.reason === 'honeypot') {
|
|
117
|
+
return { success: true, messageKey: 'ecommerce.contact_seller_sent' };
|
|
118
|
+
}
|
|
119
|
+
return { success: false, messageKey: '', message: verification.message };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const productId = readField(formData, 'product_id', 64);
|
|
123
|
+
const senderName = readField(formData, 'name', MAX_NAME_LENGTH);
|
|
124
|
+
const senderEmail = readField(formData, 'email', MAX_EMAIL_LENGTH);
|
|
125
|
+
const message = readField(formData, 'message', MAX_MESSAGE_LENGTH);
|
|
126
|
+
const locale = readField(formData, 'locale', 12) || null;
|
|
127
|
+
|
|
128
|
+
if (!productId || !senderName || !senderEmail || !message || !isPlausibleEmail(senderEmail)) {
|
|
129
|
+
return { success: false, messageKey: 'ecommerce.contact_seller_invalid' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const supabase = getServiceRoleSupabaseClient();
|
|
134
|
+
|
|
135
|
+
const requestHeaders = await headers();
|
|
136
|
+
// Always a string, so the throttle below always runs. See UNKNOWN_IP_BUCKET.
|
|
137
|
+
const ipMasked = maskIp(resolveClientIp(requestHeaders));
|
|
138
|
+
const userAgent = requestHeaders.get('user-agent');
|
|
139
|
+
|
|
140
|
+
const since = new Date(Date.now() - THROTTLE_WINDOW_MINUTES * 60_000).toISOString();
|
|
141
|
+
const { count } = await supabase
|
|
142
|
+
.from('product_inquiries')
|
|
143
|
+
.select('id', { count: 'exact', head: true })
|
|
144
|
+
.eq('ip_masked', ipMasked)
|
|
145
|
+
.gte('created_at', since);
|
|
146
|
+
|
|
147
|
+
if ((count ?? 0) >= THROTTLE_MAX_SUBMISSIONS) {
|
|
148
|
+
return { success: false, messageKey: 'ecommerce.contact_seller_throttled' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Look the product up server-side: a client-supplied title would let anyone send
|
|
152
|
+
// arbitrary text through the owner's own SMTP identity.
|
|
153
|
+
const { data: product } = await supabase
|
|
154
|
+
.from('products')
|
|
155
|
+
.select('id, title, slug')
|
|
156
|
+
.eq('id', productId)
|
|
157
|
+
.maybeSingle();
|
|
158
|
+
|
|
159
|
+
if (!product) {
|
|
160
|
+
return { success: false, messageKey: 'ecommerce.contact_seller_invalid' };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { data: inserted, error: insertError } = await supabase
|
|
164
|
+
.from('product_inquiries')
|
|
165
|
+
.insert({
|
|
166
|
+
product_id: product.id,
|
|
167
|
+
product_slug: product.slug,
|
|
168
|
+
product_title: product.title,
|
|
169
|
+
sender_name: senderName,
|
|
170
|
+
sender_email: senderEmail,
|
|
171
|
+
message,
|
|
172
|
+
locale,
|
|
173
|
+
ip_masked: ipMasked,
|
|
174
|
+
user_agent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
|
|
175
|
+
})
|
|
176
|
+
.select('id')
|
|
177
|
+
.single();
|
|
178
|
+
|
|
179
|
+
if (insertError) {
|
|
180
|
+
console.error('Failed to record product inquiry:', insertError.message);
|
|
181
|
+
return { success: false, messageKey: 'ecommerce.contact_seller_error' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Open the conversation this enquiry belongs to. The enquiry row above stays as
|
|
185
|
+
// the enquiry's own record (and owns the ip_masked the throttle counts); the thread
|
|
186
|
+
// is what the owner actually replies in.
|
|
187
|
+
const thread = await createThread({
|
|
188
|
+
source: 'product_inquiry',
|
|
189
|
+
subjectId: inserted.id,
|
|
190
|
+
subjectLabel: product.title,
|
|
191
|
+
senderName,
|
|
192
|
+
senderEmail,
|
|
193
|
+
message,
|
|
194
|
+
locale,
|
|
195
|
+
ipMasked,
|
|
196
|
+
userAgent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Notification is best-effort and runs after the response. A bare floating promise
|
|
200
|
+
// would risk the serverless instance being frozen before the SMTP round trip ends.
|
|
201
|
+
if (thread) {
|
|
202
|
+
after(async () => {
|
|
203
|
+
const { email: recipient } = await resolveSellerContactEmail();
|
|
204
|
+
await notifyAdminOfMessage({
|
|
205
|
+
threadId: thread.threadId,
|
|
206
|
+
source: 'product_inquiry',
|
|
207
|
+
messageId: thread.messageId,
|
|
208
|
+
subjectLabel: product.title,
|
|
209
|
+
senderName,
|
|
210
|
+
senderEmail,
|
|
211
|
+
message,
|
|
212
|
+
recipient,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { success: true, messageKey: 'ecommerce.contact_seller_sent' };
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.error('Product inquiry submission failed:', error);
|
|
220
|
+
return { success: false, messageKey: 'ecommerce.contact_seller_error' };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { describeSmtpError } from './email';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* A reply failed with "The mail server did not respond in time" on an install whose
|
|
7
|
-
* transport verifies in about a second and whose identical send succeeds moments later.
|
|
8
|
-
* That signature — everything healthy, one send hangs — is a pooled connection the relay
|
|
9
|
-
* reaped while it sat idle: nodemailer still believes it is open, so the send blocks
|
|
10
|
-
* until the socket timeout.
|
|
11
|
-
*
|
|
12
|
-
* sendEmail now drops the pool and retries once on a fresh connection for that class of
|
|
13
|
-
* fault only. These cover the classification, which is what decides retry vs report.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
describe('describeSmtpError', () => {
|
|
17
|
-
it('explains the TLS/port mismatch that produced an unreadable OpenSSL error', () => {
|
|
18
|
-
const message = describeSmtpError(
|
|
19
|
-
new Error('B4730000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:ssl3_record.c:355:')
|
|
20
|
-
);
|
|
21
|
-
expect(message).toContain('2525');
|
|
22
|
-
expect(message).toContain('465');
|
|
23
|
-
expect(message).not.toContain('ssl3_get_record');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('names credentials as the cause of an auth rejection', () => {
|
|
27
|
-
const error = Object.assign(new Error('Invalid login: 535 authentication failed'), {
|
|
28
|
-
code: 'EAUTH',
|
|
29
|
-
});
|
|
30
|
-
expect(describeSmtpError(error)).toMatch(/username or password/i);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('distinguishes a timeout from an unreachable host', () => {
|
|
34
|
-
const timeout = Object.assign(new Error('Connection timeout'), { code: 'ETIMEDOUT' });
|
|
35
|
-
expect(describeSmtpError(timeout)).toMatch(/did not respond in time/i);
|
|
36
|
-
|
|
37
|
-
const dns = Object.assign(new Error('getaddrinfo ENOTFOUND smtp.example.com'), {
|
|
38
|
-
code: 'ENOTFOUND',
|
|
39
|
-
});
|
|
40
|
-
expect(describeSmtpError(dns)).toMatch(/hostname could not be resolved/i);
|
|
41
|
-
|
|
42
|
-
const refused = Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' });
|
|
43
|
-
expect(describeSmtpError(refused)).toMatch(/refused the connection/i);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('tells an unconfigured install what to do rather than echoing the throw', () => {
|
|
47
|
-
expect(describeSmtpError(new Error('Email server is not configured.'))).toMatch(
|
|
48
|
-
/CMS Settings/i
|
|
49
|
-
);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('passes an unrecognised failure through rather than inventing an explanation', () => {
|
|
53
|
-
expect(describeSmtpError(new Error('552 5.3.4 Message too big'))).toBe(
|
|
54
|
-
'552 5.3.4 Message too big'
|
|
55
|
-
);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('handles a non-Error throw without crashing', () => {
|
|
59
|
-
expect(describeSmtpError('something odd')).toBe('something odd');
|
|
60
|
-
expect(describeSmtpError(null)).toBe('null');
|
|
61
|
-
});
|
|
62
|
-
});
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { describeSmtpError } from './email';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A reply failed with "The mail server did not respond in time" on an install whose
|
|
7
|
+
* transport verifies in about a second and whose identical send succeeds moments later.
|
|
8
|
+
* That signature — everything healthy, one send hangs — is a pooled connection the relay
|
|
9
|
+
* reaped while it sat idle: nodemailer still believes it is open, so the send blocks
|
|
10
|
+
* until the socket timeout.
|
|
11
|
+
*
|
|
12
|
+
* sendEmail now drops the pool and retries once on a fresh connection for that class of
|
|
13
|
+
* fault only. These cover the classification, which is what decides retry vs report.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
describe('describeSmtpError', () => {
|
|
17
|
+
it('explains the TLS/port mismatch that produced an unreadable OpenSSL error', () => {
|
|
18
|
+
const message = describeSmtpError(
|
|
19
|
+
new Error('B4730000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:ssl3_record.c:355:')
|
|
20
|
+
);
|
|
21
|
+
expect(message).toContain('2525');
|
|
22
|
+
expect(message).toContain('465');
|
|
23
|
+
expect(message).not.toContain('ssl3_get_record');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('names credentials as the cause of an auth rejection', () => {
|
|
27
|
+
const error = Object.assign(new Error('Invalid login: 535 authentication failed'), {
|
|
28
|
+
code: 'EAUTH',
|
|
29
|
+
});
|
|
30
|
+
expect(describeSmtpError(error)).toMatch(/username or password/i);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('distinguishes a timeout from an unreachable host', () => {
|
|
34
|
+
const timeout = Object.assign(new Error('Connection timeout'), { code: 'ETIMEDOUT' });
|
|
35
|
+
expect(describeSmtpError(timeout)).toMatch(/did not respond in time/i);
|
|
36
|
+
|
|
37
|
+
const dns = Object.assign(new Error('getaddrinfo ENOTFOUND smtp.example.com'), {
|
|
38
|
+
code: 'ENOTFOUND',
|
|
39
|
+
});
|
|
40
|
+
expect(describeSmtpError(dns)).toMatch(/hostname could not be resolved/i);
|
|
41
|
+
|
|
42
|
+
const refused = Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' });
|
|
43
|
+
expect(describeSmtpError(refused)).toMatch(/refused the connection/i);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('tells an unconfigured install what to do rather than echoing the throw', () => {
|
|
47
|
+
expect(describeSmtpError(new Error('Email server is not configured.'))).toMatch(
|
|
48
|
+
/CMS Settings/i
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('passes an unrecognised failure through rather than inventing an explanation', () => {
|
|
53
|
+
expect(describeSmtpError(new Error('552 5.3.4 Message too big'))).toBe(
|
|
54
|
+
'552 5.3.4 Message too big'
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('handles a non-Error throw without crashing', () => {
|
|
59
|
+
expect(describeSmtpError('something odd')).toBe('something odd');
|
|
60
|
+
expect(describeSmtpError(null)).toBe('null');
|
|
61
|
+
});
|
|
62
|
+
});
|