create-nextblock 0.15.8 → 0.15.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -17
  3. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -0
  4. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -0
  5. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -0
  6. package/templates/nextblock-template/app/actions/email.ts +241 -110
  7. package/templates/nextblock-template/app/actions/formActions.ts +245 -116
  8. package/templates/nextblock-template/app/actions/interactions.ts +489 -396
  9. package/templates/nextblock-template/app/actions/threadActions.ts +166 -0
  10. package/templates/nextblock-template/app/api/checkout/route.ts +162 -146
  11. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +664 -1
  12. package/templates/nextblock-template/app/checkout/page.tsx +57 -52
  13. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +552 -529
  14. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -181
  15. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -0
  16. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -0
  17. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -528
  18. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -0
  19. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -0
  20. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -51
  21. package/templates/nextblock-template/app/cms/layout.tsx +101 -73
  22. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -0
  23. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -0
  24. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -0
  25. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -0
  26. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -0
  27. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -362
  28. package/templates/nextblock-template/app/cms/revisions/service.ts +20 -0
  29. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -185
  30. package/templates/nextblock-template/app/layout.tsx +671 -671
  31. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -482
  32. package/templates/nextblock-template/app/providers.tsx +96 -96
  33. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -0
  34. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -0
  35. package/templates/nextblock-template/app/thread/layout.tsx +15 -0
  36. package/templates/nextblock-template/app/thread/page.tsx +98 -0
  37. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -296
  38. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -0
  39. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -369
  40. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -419
  41. package/templates/nextblock-template/components/StaffReplies.tsx +102 -0
  42. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -17
  43. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -19
  44. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -22
  45. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -381
  46. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -92
  47. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -31
  48. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -600
  49. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -0
  50. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -0
  51. package/templates/nextblock-template/docs/README.md +42 -41
  52. package/templates/nextblock-template/docs/assets/lighthouse-scores.png +0 -0
  53. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +22 -2
  54. package/templates/nextblock-template/lib/blocks/blockColors.ts +44 -1
  55. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -753
  56. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -0
  57. package/templates/nextblock-template/lib/cms/payments-reminder.ts +98 -0
  58. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -0
  59. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -0
  60. package/templates/nextblock-template/lib/config/email-settings.ts +323 -254
  61. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -0
  62. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -0
  63. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -0
  64. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -0
  65. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -0
  66. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -0
  67. package/templates/nextblock-template/lib/messages/threads.ts +579 -0
  68. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +20 -0
  69. package/templates/nextblock-template/lib/site-url.test.ts +89 -0
  70. package/templates/nextblock-template/lib/site-url.ts +102 -48
  71. package/templates/nextblock-template/package.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.15.8",
3
+ "version": "0.15.10",
4
4
  "description": "Scaffold a production-ready NextBlock CMS project — the open-source, full-stack AI-native CMS for Next.js 16, Supabase, and Tailwind CSS.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1,17 +1,26 @@
1
- "use client";
2
-
3
- import { Toaster } from "react-hot-toast";
4
-
5
- export function ToasterProvider() {
6
- return (
7
- <Toaster
8
- position="top-right"
9
- toastOptions={{
10
- style: { fontSize: 14 },
11
- success: { iconTheme: { primary: '#16a34a', secondary: 'white' } },
12
- error: { iconTheme: { primary: '#dc2626', secondary: 'white' } },
13
- }}
14
- />
15
- );
16
- }
17
-
1
+ "use client";
2
+
3
+ import { Toaster } from "react-hot-toast";
4
+ import { Toaster as SonnerToaster } from "sonner";
5
+
6
+ // Two toast libraries are in use across the app: react-hot-toast in most of the CMS,
7
+ // and sonner in ~19 files (the commerce components, the settings pages, the new
8
+ // inbox). Only react-hot-toast was ever mounted, so every sonner toast — including
9
+ // "added to cart" — was silently discarded. Mount both rather than rewrite the call
10
+ // sites, and keep the two visually aligned.
11
+ export function ToasterProvider() {
12
+ return (
13
+ <>
14
+ <SonnerToaster position="top-right" richColors closeButton />
15
+ <Toaster
16
+ position="top-right"
17
+ toastOptions={{
18
+ style: { fontSize: 14 },
19
+ success: { iconTheme: { primary: '#16a34a', secondary: 'white' } },
20
+ error: { iconTheme: { primary: '#dc2626', secondary: 'white' } },
21
+ }}
22
+ />
23
+ </>
24
+ );
25
+ }
26
+
@@ -0,0 +1,280 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+
3
+ /**
4
+ * The enquiry action is the only unauthenticated, mail-sending endpoint the storefront
5
+ * exposes, so the tests here are about its guarantees rather than its happy path:
6
+ *
7
+ * - the lead is persisted even when SMTP is dead (that is the entire reason the table
8
+ * exists — a store with no payment keys usually has no mail server either);
9
+ * - the recipient is never taken from the request;
10
+ * - a tripped honeypot looks exactly like a success to the caller.
11
+ */
12
+
13
+ const mocks = vi.hoisted(() => ({
14
+ verifyBotProtection: vi.fn(),
15
+ sendEmail: vi.fn(),
16
+ resolveSellerContactEmail: vi.fn(),
17
+ insert: vi.fn(),
18
+ productLookup: vi.fn(),
19
+ throttleCount: vi.fn(),
20
+ throttleKey: vi.fn(),
21
+ update: vi.fn(),
22
+ afterPromises: [] as Promise<unknown>[],
23
+ requestHeaders: {} as Record<string, string>,
24
+ }));
25
+
26
+ vi.mock('next/headers', () => ({
27
+ headers: async () => ({
28
+ get: (name: string) => mocks.requestHeaders[name.toLowerCase()] ?? null,
29
+ }),
30
+ }));
31
+
32
+ // `after` defers work until past the response, so the notification genuinely has not
33
+ // run when the action returns. Capture each deferred promise so a test can await it
34
+ // instead of racing it.
35
+ vi.mock('next/server', () => ({
36
+ after: (callback: () => unknown) => {
37
+ const promise = Promise.resolve().then(callback);
38
+ mocks.afterPromises.push(promise);
39
+ return promise;
40
+ },
41
+ }));
42
+
43
+ /** Wait for everything `after()` deferred, mirroring what the platform does post-response. */
44
+ async function flushAfter(): Promise<void> {
45
+ await Promise.all(mocks.afterPromises);
46
+ }
47
+
48
+ vi.mock('../../lib/botProtection/verify', () => ({
49
+ verifyBotProtection: mocks.verifyBotProtection,
50
+ }));
51
+
52
+ // Partial mock: the real describeSmtpError is what turns a transport failure into text
53
+ // an admin can act on, so exercise it rather than stubbing it out. resolveFromDomain is
54
+ // stubbed because it reaches for the live SMTP config, which this suite does not model.
55
+ vi.mock('./email', async (importOriginal) => ({
56
+ ...(await importOriginal<typeof import('./email')>()),
57
+ sendEmail: mocks.sendEmail,
58
+ resolveFromDomain: async () => 'example.com',
59
+ }));
60
+
61
+ vi.mock('../../lib/commerce/seller-contact', () => ({
62
+ resolveSellerContactEmail: mocks.resolveSellerContactEmail,
63
+ }));
64
+
65
+ vi.mock('@nextblock-cms/db/server', () => ({
66
+ getServiceRoleSupabaseClient: () => ({
67
+ from: (table: string) => {
68
+ if (table === 'products') {
69
+ return {
70
+ select: () => ({ eq: () => ({ maybeSingle: mocks.productLookup }) }),
71
+ };
72
+ }
73
+ return {
74
+ select: () => ({
75
+ eq: (_column: string, value: string) => {
76
+ mocks.throttleKey(value);
77
+ return { gte: mocks.throttleCount };
78
+ },
79
+ }),
80
+ insert: () => ({ select: () => ({ single: mocks.insert }) }),
81
+ update: (values: unknown) => {
82
+ mocks.update(values);
83
+ return { eq: async () => ({ error: null }) };
84
+ },
85
+ };
86
+ },
87
+ }),
88
+ }));
89
+
90
+ import { submitProductInquiry } from './contactSellerActions';
91
+
92
+ function buildFormData(overrides: Record<string, string> = {}): FormData {
93
+ const formData = new FormData();
94
+ formData.set('product_id', 'prod-1');
95
+ formData.set('name', 'Ada Lovelace');
96
+ formData.set('email', 'ada@example.com');
97
+ formData.set('message', 'Can I buy ten of these?');
98
+ for (const [key, value] of Object.entries(overrides)) formData.set(key, value);
99
+ return formData;
100
+ }
101
+
102
+ describe('submitProductInquiry', () => {
103
+ beforeEach(() => {
104
+ vi.clearAllMocks();
105
+ mocks.afterPromises.length = 0;
106
+ mocks.requestHeaders = { 'x-forwarded-for': '203.0.113.7' };
107
+ mocks.verifyBotProtection.mockResolvedValue({ ok: true });
108
+ mocks.throttleCount.mockResolvedValue({ count: 0 });
109
+ mocks.productLookup.mockResolvedValue({
110
+ data: { id: 'prod-1', title: 'Brass Kettle', slug: 'brass-kettle' },
111
+ });
112
+ mocks.insert.mockResolvedValue({ data: { id: 'inq-1' }, error: null });
113
+ mocks.resolveSellerContactEmail.mockResolvedValue({
114
+ email: 'owner@example.com',
115
+ source: 'store_contact',
116
+ });
117
+ mocks.sendEmail.mockResolvedValue(undefined);
118
+ });
119
+
120
+ it('stores the enquiry and notifies the resolved seller address', async () => {
121
+ const result = await submitProductInquiry(null, buildFormData());
122
+ await flushAfter();
123
+
124
+ expect(result).toEqual({ success: true, messageKey: 'ecommerce.contact_seller_sent' });
125
+ expect(mocks.sendEmail).toHaveBeenCalledOnce();
126
+
127
+ const email = mocks.sendEmail.mock.calls[0][0];
128
+ expect(email.to).toBe('owner@example.com');
129
+ // The visitor's address goes in Reply-To so the owner can just hit reply.
130
+ expect(email.replyTo).toBe('ada@example.com');
131
+ expect(mocks.update).toHaveBeenCalledWith({ email_delivered: true, email_error: null });
132
+ });
133
+
134
+ it('still reports success when the mail server is unconfigured', async () => {
135
+ mocks.sendEmail.mockRejectedValue(new Error('Email server is not configured.'));
136
+
137
+ const result = await submitProductInquiry(null, buildFormData());
138
+ await flushAfter();
139
+
140
+ // The row is the deliverable; the visitor's message really did get through.
141
+ expect(result.success).toBe(true);
142
+ expect(mocks.insert).toHaveBeenCalled();
143
+ // The failure is recorded on the message rather than lost, so the CMS can show
144
+ // "not emailed" instead of implying the owner was told.
145
+ expect(mocks.update).toHaveBeenCalledWith(
146
+ expect.objectContaining({ email_delivered: false })
147
+ );
148
+ // And it is recorded as guidance, not as the raw transport text.
149
+ const recorded = mocks.update.mock.calls.at(-1)?.[0]?.email_error ?? '';
150
+ expect(recorded).toMatch(/CMS Settings/i);
151
+ });
152
+
153
+ it('ignores any recipient supplied by the client', async () => {
154
+ await submitProductInquiry(null, buildFormData({ recipient: 'attacker@evil.test' }));
155
+ await flushAfter();
156
+
157
+ expect(mocks.sendEmail.mock.calls[0][0].to).toBe('owner@example.com');
158
+ });
159
+
160
+ it('fakes a success when the honeypot is tripped, writing nothing', async () => {
161
+ mocks.verifyBotProtection.mockResolvedValue({ ok: false, reason: 'honeypot' });
162
+
163
+ const result = await submitProductInquiry(null, buildFormData());
164
+ await flushAfter();
165
+
166
+ expect(result).toEqual({ success: true, messageKey: 'ecommerce.contact_seller_sent' });
167
+ expect(mocks.insert).not.toHaveBeenCalled();
168
+ expect(mocks.sendEmail).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it('rejects a malformed email address before touching the database', async () => {
172
+ const result = await submitProductInquiry(null, buildFormData({ email: 'not-an-email' }));
173
+
174
+ expect(result).toEqual({ success: false, messageKey: 'ecommerce.contact_seller_invalid' });
175
+ expect(mocks.insert).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it('refuses a product id that does not exist', async () => {
179
+ mocks.productLookup.mockResolvedValue({ data: null });
180
+
181
+ const result = await submitProductInquiry(null, buildFormData());
182
+
183
+ expect(result).toEqual({ success: false, messageKey: 'ecommerce.contact_seller_invalid' });
184
+ expect(mocks.insert).not.toHaveBeenCalled();
185
+ });
186
+
187
+ it('throttles a flood from one masked IP', async () => {
188
+ mocks.throttleCount.mockResolvedValue({ count: 5 });
189
+
190
+ const result = await submitProductInquiry(null, buildFormData());
191
+
192
+ expect(result).toEqual({ success: false, messageKey: 'ecommerce.contact_seller_throttled' });
193
+ expect(mocks.insert).not.toHaveBeenCalled();
194
+ });
195
+
196
+ it('uses the stored product title rather than anything the client sent', async () => {
197
+ await submitProductInquiry(null, buildFormData({ product_title: 'FREE MONEY CLICK HERE' }));
198
+ await flushAfter();
199
+
200
+ const email = mocks.sendEmail.mock.calls[0][0];
201
+ expect(email.subject).toContain('Brass Kettle');
202
+ expect(email.subject).not.toContain('FREE MONEY');
203
+ });
204
+
205
+ it('escapes visitor text landing in the HTML body', async () => {
206
+ await submitProductInquiry(
207
+ null,
208
+ buildFormData({ name: '<script>alert(1)</script>', message: 'a < b & c' })
209
+ );
210
+ await flushAfter();
211
+
212
+ const email = mocks.sendEmail.mock.calls[0][0];
213
+ expect(email.html).not.toContain('<script>');
214
+ expect(email.html).toContain('&lt;script&gt;');
215
+ expect(email.html).toContain('a &lt; b &amp; c');
216
+ });
217
+
218
+ it('still throttles when no address header is present at all', async () => {
219
+ // Regression: the throttle used to sit inside `if (ipMasked)`, so a request with no
220
+ // usable address skipped the check entirely — a fail-open an attacker could trigger
221
+ // just by sending an unparseable header.
222
+ mocks.requestHeaders = {};
223
+ mocks.throttleCount.mockResolvedValue({ count: 5 });
224
+
225
+ const result = await submitProductInquiry(null, buildFormData());
226
+
227
+ expect(result).toEqual({ success: false, messageKey: 'ecommerce.contact_seller_throttled' });
228
+ expect(mocks.throttleKey).toHaveBeenCalledWith('unknown');
229
+ });
230
+
231
+ it('buckets an unparseable forwarded-for under the shared unknown key', async () => {
232
+ mocks.requestHeaders = { 'x-forwarded-for': 'nothanks' };
233
+ mocks.throttleCount.mockResolvedValue({ count: 0 });
234
+
235
+ await submitProductInquiry(null, buildFormData());
236
+
237
+ expect(mocks.throttleKey).toHaveBeenCalledWith('unknown');
238
+ });
239
+
240
+ it('prefers the platform header over the client-writable forwarded-for', async () => {
241
+ // x-forwarded-for is client-prependable on an appending proxy, so rotating it must
242
+ // not mint a fresh throttle bucket when a trusted header is available.
243
+ mocks.requestHeaders = {
244
+ 'x-forwarded-for': '9.9.9.9',
245
+ 'x-real-ip': '198.51.100.4',
246
+ };
247
+ mocks.throttleCount.mockResolvedValue({ count: 0 });
248
+
249
+ await submitProductInquiry(null, buildFormData());
250
+
251
+ expect(mocks.throttleKey).toHaveBeenCalledWith('198.51.100.x');
252
+ });
253
+
254
+ it('masks the stored address rather than keeping the full IP', async () => {
255
+ mocks.throttleCount.mockResolvedValue({ count: 0 });
256
+
257
+ await submitProductInquiry(null, buildFormData());
258
+
259
+ expect(mocks.throttleKey).toHaveBeenCalledWith('203.0.113.x');
260
+ });
261
+
262
+ it('translates a TLS/port mismatch into something the admin can act on', async () => {
263
+ // The verbatim OpenSSL error a real install hit: SMTP2GO on port 2525 with implicit
264
+ // TLS left switched on. It names neither TLS mode nor port, so it is stored as
265
+ // guidance instead.
266
+ mocks.sendEmail.mockRejectedValue(
267
+ new Error(
268
+ 'B4730000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:ssl3_record.c:355:'
269
+ )
270
+ );
271
+
272
+ await submitProductInquiry(null, buildFormData());
273
+ await flushAfter();
274
+
275
+ const recorded = mocks.update.mock.calls.at(-1)?.[0]?.email_error ?? '';
276
+ expect(recorded).toContain('2525');
277
+ expect(recorded).toContain('465');
278
+ expect(recorded).not.toContain('ssl3_get_record');
279
+ });
280
+ });
@@ -0,0 +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
+ }
@@ -0,0 +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
+ });