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