From 2a2132cbefa0967bffe8350a7aab7fb20057290f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 19:55:01 +0000 Subject: [PATCH 01/10] feat(newsletter): migrate to listmonk --- .../app/api/newsletter/confirm.test.ts | 121 +++------- .../app/api/newsletter/subscribe.test.ts | 100 ++++---- .../app/api/newsletter/unsubscribe.test.ts | 227 ++++++++---------- src/__tests__/lib/listmonk.test.ts | 159 ++++++++++++ src/app/api/newsletter/confirm/route.ts | 46 +--- src/app/api/newsletter/subscribe/route.ts | 202 +++------------- src/app/api/newsletter/unsubscribe/route.ts | 81 ++----- src/constant/env.ts | 6 + src/lib/listmonk.ts | 153 ++++++++++++ 9 files changed, 566 insertions(+), 529 deletions(-) create mode 100644 src/__tests__/lib/listmonk.test.ts create mode 100644 src/lib/listmonk.ts diff --git a/src/__tests__/app/api/newsletter/confirm.test.ts b/src/__tests__/app/api/newsletter/confirm.test.ts index 5cf798b1..e9401d25 100644 --- a/src/__tests__/app/api/newsletter/confirm.test.ts +++ b/src/__tests__/app/api/newsletter/confirm.test.ts @@ -4,12 +4,6 @@ // Mock @/constant/env with getters so values are read at call time (after beforeEach sets process.env) jest.mock('@/constant/env', () => ({ - get cmsApiUrl() { - return process.env.CMS_API_URL; - }, - get cmsApiToken() { - return process.env.CMS_API_TOKEN; - }, get altchaHmacSecret() { return process.env.ALTCHA_HMAC_SECRET; }, @@ -19,70 +13,50 @@ jest.mock('@/constant/env', () => ({ get cmsPublicUrl() { return process.env.NEXT_PUBLIC_CMS_URL; }, + get listmonkBaseUrl() { + return process.env.LISTMONK_BASE_URL; + }, + get listmonkApiUser() { + return process.env.LISTMONK_API_USER; + }, + get listmonkApiKey() { + return process.env.LISTMONK_API_KEY; + }, + get listmonkListId() { + return process.env.LISTMONK_LIST_ID; + }, })); // Import route module dynamically after polyfills (avoid hoisted import timing issues) let GET: (request: Request) => Promise; -let confirmSubscription: (token: string) => Promise; +let getListmonkOptInUrl: (token: string) => string | null; beforeAll(async () => { const mod = await import('@/app/api/newsletter/confirm/route'); GET = mod.GET; - confirmSubscription = mod.confirmSubscription; + getListmonkOptInUrl = mod.getListmonkOptInUrl; }); describe('Newsletter confirm route (unit)', () => { beforeEach(() => { jest.resetAllMocks(); - global.fetch = jest.fn() as jest.Mock; - process.env.CMS_API_URL = 'http://strapi.test'; - process.env.CMS_API_TOKEN = 'test-token-abc'; + process.env.LISTMONK_BASE_URL = 'https://newsletter.project-sentiment.org'; }); afterEach(() => { - delete process.env.CMS_API_URL; - delete process.env.CMS_API_TOKEN; + delete process.env.LISTMONK_BASE_URL; }); - describe('confirmSubscription helper', () => { - it('returns true when Strapi responds ok and calls fetch with correct headers', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: true }); - - const token = 'valid-token-123'; - const res = await confirmSubscription(token); - - expect(res).toBe(true); - expect(global.fetch).toHaveBeenCalledTimes(1); - expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe( - `${process.env.CMS_API_URL}/api/subscribers/confirm?token=${encodeURIComponent( - token, - )}`, - ); - - const opts = (global.fetch as jest.Mock).mock.calls[0][1]; - expect(opts.method).toBe('PUT'); - expect(opts.headers.Authorization).toBe( - `Bearer ${process.env.CMS_API_TOKEN}`, - ); - }); - - it('returns false when Strapi responds with non-ok', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: false, status: 400 }); - - const result = await confirmSubscription('bad-token'); - expect(result).toBe(false); + describe('getListmonkOptInUrl helper', () => { + it('returns null when LISTMONK_BASE_URL is missing', () => { + delete process.env.LISTMONK_BASE_URL; + expect(getListmonkOptInUrl('uuid')).toBeNull(); }); - it('propagates network errors (rejects) so caller can handle server-error', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockRejectedValueOnce(new Error('network')); - - await expect(confirmSubscription('any')).rejects.toThrow('network'); + it('builds opt-in URL with encoded token', () => { + expect(getListmonkOptInUrl('a/b+c')).toBe( + 'https://newsletter.project-sentiment.org/subscription/optin/a%2Fb%2Bc', + ); }); }); @@ -95,7 +69,6 @@ describe('Newsletter confirm route (unit)', () => { expect(res.headers.get('location')!).toContain( '/newsletter/error?reason=missing-token', ); - expect(global.fetch).not.toHaveBeenCalled(); }); it('redirects to invalid-token when token param is empty', async () => { @@ -106,60 +79,26 @@ describe('Newsletter confirm route (unit)', () => { expect(res.headers.get('location')!).toContain( '/newsletter/error?reason=invalid-token', ); - expect(global.fetch).not.toHaveBeenCalled(); }); - it('redirects to success when confirmation succeeds', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: true }); - + it('redirects to listmonk opt-in page when token is present', async () => { const req = new Request( 'http://localhost/api/newsletter/confirm?token=ok-token', ); const res = await GET(req); expect(res.status).toBe(307); - expect(res.headers.get('location')!).toContain('/newsletter/success'); - - // confirmSubscription should have called Strapi with token encoded - expect(global.fetch).toHaveBeenCalledWith( - `${process.env.CMS_API_URL}/api/subscribers/confirm?token=${encodeURIComponent('ok-token')}`, - expect.objectContaining({ - method: 'PUT', - headers: expect.objectContaining({ - Authorization: `Bearer ${process.env.CMS_API_TOKEN}`, - }), - }), - ); - }); - - it('redirects to invalid-token when Strapi responds non-ok', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: false, status: 400 }); - - const req = new Request( - 'http://localhost/api/newsletter/confirm?token=invalid-token', - ); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')!).toContain( - '/newsletter/error?reason=invalid-token', + expect(res.headers.get('location')!).toBe( + 'https://newsletter.project-sentiment.org/subscription/optin/ok-token', ); }); - it('redirects to server-error when Strapi network error occurs', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockRejectedValueOnce(new Error('network')); - + it('redirects to server-error when LISTMONK_BASE_URL is missing', async () => { + delete process.env.LISTMONK_BASE_URL; const req = new Request( - 'http://localhost/api/newsletter/confirm?token=network-error', + 'http://localhost/api/newsletter/confirm?token=ok-token', ); const res = await GET(req); - expect(res.status).toBe(307); expect(res.headers.get('location')!).toContain( '/newsletter/error?reason=server-error', diff --git a/src/__tests__/app/api/newsletter/subscribe.test.ts b/src/__tests__/app/api/newsletter/subscribe.test.ts index b74eb2d1..f645c602 100644 --- a/src/__tests__/app/api/newsletter/subscribe.test.ts +++ b/src/__tests__/app/api/newsletter/subscribe.test.ts @@ -14,34 +14,40 @@ import { newsletterSubscribeSchema } from '@/lib/newsletter-schema'; // --------------------------------------------------------------------------- jest.mock('@/constant/env', () => ({ - get cmsApiUrl() { - return process.env.CMS_API_URL; - }, - get cmsApiToken() { - return process.env.CMS_API_TOKEN; - }, get altchaHmacSecret() { return process.env.ALTCHA_HMAC_SECRET; }, - get siteUrl() { - return process.env.NEXT_PUBLIC_SITE_URL; + get listmonkBaseUrl() { + return process.env.LISTMONK_BASE_URL; + }, + get listmonkApiUser() { + return process.env.LISTMONK_API_USER; + }, + get listmonkApiKey() { + return process.env.LISTMONK_API_KEY; }, - get cmsPublicUrl() { - return process.env.NEXT_PUBLIC_CMS_URL; + get listmonkListId() { + return process.env.LISTMONK_LIST_ID; }, })); // altcha-lib: verifySolution is controlled per-test jest.mock('altcha-lib', () => ({ verifySolution: jest.fn() })); -// react-email: suppress actual rendering -jest.mock('@react-email/components', () => ({ - render: jest.fn().mockResolvedValue('
email
'), -})); - -jest.mock('@/emails/confirm-subscription', () => ({ - __esModule: true, - default: jest.fn().mockReturnValue(null), +// listmonk client wrapper +jest.mock('@/lib/listmonk', () => ({ + createSubscriber: jest.fn(), + sendOptInEmail: jest.fn(), + ListmonkError: class ListmonkError extends Error { + public readonly status: number; + public readonly responseBody: unknown; + constructor(message: string, status: number, responseBody: unknown) { + super(message); + this.name = 'ListmonkError'; + this.status = status; + this.responseBody = responseBody; + } + }, })); // POST handler — imported once after all mocks are set up @@ -327,18 +333,19 @@ describe('Newsletter Subscribe Validation', () => { describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { beforeEach(() => { jest.resetAllMocks(); - global.fetch = jest.fn() as jest.Mock; - process.env.CMS_API_URL = 'http://strapi.test'; - process.env.CMS_API_TOKEN = 'test-token-abc'; process.env.ALTCHA_HMAC_SECRET = 'test-secret-hex'; - process.env.NEXT_PUBLIC_SITE_URL = 'http://localhost:3000'; + process.env.LISTMONK_BASE_URL = 'http://listmonk.test'; + process.env.LISTMONK_API_USER = 'api'; + process.env.LISTMONK_API_KEY = 'key'; + process.env.LISTMONK_LIST_ID = '1'; }); afterEach(() => { - delete process.env.CMS_API_URL; - delete process.env.CMS_API_TOKEN; delete process.env.ALTCHA_HMAC_SECRET; - delete process.env.NEXT_PUBLIC_SITE_URL; + delete process.env.LISTMONK_BASE_URL; + delete process.env.LISTMONK_API_USER; + delete process.env.LISTMONK_API_KEY; + delete process.env.LISTMONK_LIST_ID; }); function makeRequest(body: object, ip = '1.2.3.4') { @@ -370,8 +377,9 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { expect(res.status).toBe(400); const json = await res.json(); expect(json.error).toMatch(/bot verification failed/i); - // Strapi must NOT be called when ALTCHA fails - expect(global.fetch).not.toHaveBeenCalled(); + const { createSubscriber, sendOptInEmail } = await import('@/lib/listmonk'); + expect(createSubscriber).not.toHaveBeenCalled(); + expect(sendOptInEmail).not.toHaveBeenCalled(); }); it('returns 400 "Bot verification failed" when ALTCHA_HMAC_SECRET is not configured', async () => { @@ -404,28 +412,24 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { // Schema rejects first (400 invalid input), which is fine — bot still blocked expect(res.status).toBe(400); - expect(global.fetch).not.toHaveBeenCalled(); + const { createSubscriber, sendOptInEmail } = await import('@/lib/listmonk'); + expect(createSubscriber).not.toHaveBeenCalled(); + expect(sendOptInEmail).not.toHaveBeenCalled(); }); - it('calls Strapi and returns 200 when verifySolution returns true', async () => { + it('calls listmonk and returns 200 when verifySolution returns true', async () => { const { verifySolution } = await import('altcha-lib'); (verifySolution as jest.Mock).mockResolvedValueOnce(true); - (global.fetch as jest.Mock) - // 1. createSubscriber - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - email: 'user@example.com', - token: 'tok-abc', - confirmed: false, - status: 'active', - }, - }), - }) - // 2. sendConfirmationEmail - .mockResolvedValueOnce({ ok: true }); + const { createSubscriber, sendOptInEmail } = await import('@/lib/listmonk'); + (createSubscriber as jest.Mock).mockResolvedValueOnce({ + id: 123, + uuid: 'sub-uuid', + email: 'user@example.com', + name: 'user@example.com', + status: 'enabled', + }); + (sendOptInEmail as jest.Mock).mockResolvedValueOnce(true); const res = await POST( makeRequest( @@ -441,8 +445,8 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { expect(res.status).toBe(200); const json = await res.json(); expect(json.message).toMatch(/confirm/i); - // Strapi called twice: create subscriber + send confirmation email - expect(global.fetch).toHaveBeenCalledTimes(2); + expect(createSubscriber).toHaveBeenCalledTimes(1); + expect(sendOptInEmail).toHaveBeenCalledTimes(1); }); it('returns 400 for missing privacy consent before ALTCHA check runs', async () => { @@ -462,6 +466,8 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { expect(res.status).toBe(400); // verifySolution must never be called if privacy is not accepted expect(verifySolution).not.toHaveBeenCalled(); - expect(global.fetch).not.toHaveBeenCalled(); + const { createSubscriber, sendOptInEmail } = await import('@/lib/listmonk'); + expect(createSubscriber).not.toHaveBeenCalled(); + expect(sendOptInEmail).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/app/api/newsletter/unsubscribe.test.ts b/src/__tests__/app/api/newsletter/unsubscribe.test.ts index 4cc28510..482d05fb 100644 --- a/src/__tests__/app/api/newsletter/unsubscribe.test.ts +++ b/src/__tests__/app/api/newsletter/unsubscribe.test.ts @@ -2,11 +2,6 @@ * @jest-environment node */ -// Mock render used by sendGoodbyeEmail so tests don't depend on react-email rendering -jest.mock('@react-email/components', () => ({ - render: jest.fn().mockResolvedValue('
email
'), -})); - // Mock @/constant/env with getters so values are read at call time (after beforeEach sets process.env) jest.mock('@/constant/env', () => ({ get cmsApiUrl() { @@ -24,6 +19,18 @@ jest.mock('@/constant/env', () => ({ get cmsPublicUrl() { return process.env.NEXT_PUBLIC_CMS_URL; }, + get listmonkBaseUrl() { + return process.env.LISTMONK_BASE_URL; + }, + get listmonkApiUser() { + return process.env.LISTMONK_API_USER; + }, + get listmonkApiKey() { + return process.env.LISTMONK_API_KEY; + }, + get listmonkListId() { + return process.env.LISTMONK_LIST_ID; + }, })); // Import route module dynamically after polyfills to avoid hoisting issues @@ -31,69 +38,73 @@ let GET: (request: Request) => Promise; let unsubscribeUser: ( token: string, ) => Promise<{ success: boolean; email?: string }>; -let sendGoodbyeEmail: (email: string) => Promise; beforeAll(async () => { const mod = await import('@/app/api/newsletter/unsubscribe/route'); GET = mod.GET; unsubscribeUser = mod.unsubscribeUser; - sendGoodbyeEmail = mod.sendGoodbyeEmail; }); describe('Newsletter unsubscribe (unit)', () => { beforeEach(() => { - // clearAllMocks preserves mock implementations (e.g. render's mockResolvedValue) - // while still resetting call counts — resetAllMocks would wipe the render stub. - jest.clearAllMocks(); + jest.resetAllMocks(); global.fetch = jest.fn() as jest.Mock; - process.env.CMS_API_URL = 'http://strapi.test'; - process.env.CMS_API_TOKEN = 'test-token-abc'; + process.env.LISTMONK_BASE_URL = 'http://listmonk.test'; + process.env.LISTMONK_API_USER = 'api_username'; + process.env.LISTMONK_API_KEY = 'api_key'; + process.env.LISTMONK_LIST_ID = '1'; }); afterEach(() => { - delete process.env.CMS_API_URL; - delete process.env.CMS_API_TOKEN; + delete process.env.LISTMONK_BASE_URL; + delete process.env.LISTMONK_API_USER; + delete process.env.LISTMONK_API_KEY; + delete process.env.LISTMONK_LIST_ID; }); describe('unsubscribeUser', () => { - it('returns success + email when Strapi responds ok with email', async () => { - (global.fetch as jest.Mock) = jest.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({ email: 'user@example.com' }), - }); + it('returns success + email when subscriber found and list update succeeds', async () => { + (global.fetch as jest.Mock) + // findSubscriberByUuid + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ + data: { + results: [ + { + id: 99, + uuid: 'uuid', + email: 'user@example.com', + name: 'n', + status: 'enabled', + }, + ], + total: 1, + }, + }), + }) + // unsubscribeSubscriberFromLists + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ data: true }), + }); const token = 't/oken+value'; const result = await unsubscribeUser(token); expect(result).toEqual({ success: true, email: 'user@example.com' }); - expect(global.fetch).toHaveBeenCalledWith( - `${process.env.CMS_API_URL}/api/subscribers/unsubscribe?token=${encodeURIComponent( - token, - )}`, - expect.objectContaining({ - method: 'PUT', - headers: expect.objectContaining({ - Authorization: `Bearer ${process.env.CMS_API_TOKEN}`, - }), - }), - ); + expect(global.fetch).toHaveBeenCalledTimes(2); }); - it('returns success true with undefined email when response has no email', async () => { + it('returns success:false when subscriber lookup fails', async () => { (global.fetch as jest.Mock) = jest.fn().mockResolvedValueOnce({ ok: true, - json: async () => ({}), + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ data: { results: [], total: 0 } }), }); - const res = await unsubscribeUser('token'); - expect(res).toEqual({ success: true, email: undefined }); - }); - - it('returns success:false when Strapi responds non-ok', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: false, status: 400 }); - const res = await unsubscribeUser('bad'); expect(res).toEqual({ success: false }); }); @@ -108,44 +119,6 @@ describe('Newsletter unsubscribe (unit)', () => { }); }); - describe('sendGoodbyeEmail', () => { - it('posts email to Strapi /api/email with correct body and headers', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: true }); - - await sendGoodbyeEmail('bye@example.com'); - - expect(global.fetch).toHaveBeenCalledWith( - `${process.env.CMS_API_URL}/api/email`, - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - Authorization: `Bearer ${process.env.CMS_API_TOKEN}`, - }), - body: expect.any(String), - }), - ); - - const body = JSON.parse( - (global.fetch as jest.Mock).mock.calls[0][1].body, - ); - expect(body.to).toBe('bye@example.com'); - expect(body.subject).toMatch(/unsubscription confirmed/i); - // html may be present or omitted depending on renderer during tests — accept both - expect(body.html === undefined || typeof body.html === 'string').toBe( - true, - ); - }); - - it('does not throw when email send fails', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockRejectedValueOnce(new Error('fail')); - await expect(sendGoodbyeEmail('a@b.c')).resolves.toBeUndefined(); - }); - }); - describe('GET handler', () => { it('redirects to missing-token when token is absent', async () => { const req = new Request('http://localhost/api/newsletter/unsubscribe'); @@ -158,60 +131,50 @@ describe('Newsletter unsubscribe (unit)', () => { expect(global.fetch).not.toHaveBeenCalled(); }); - it('redirects to unsubscribed and sends goodbye email when Strapi returns email', async () => { - // first call: unsubscribe -> returns email - // second call: sendGoodbyeEmail -> posts email + it('redirects to unsubscribed when unsubscribe succeeds', async () => { (global.fetch as jest.Mock) + // findSubscriberByUuid .mockResolvedValueOnce({ ok: true, - json: async () => ({ email: 'x@y.z' }), + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ + data: { + results: [ + { + id: 99, + uuid: 'uuid', + email: 'x@y.z', + name: 'n', + status: 'enabled', + }, + ], + total: 1, + }, + }), }) - .mockResolvedValueOnce({ ok: true }); + // unsubscribeSubscriberFromLists + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ data: true }), + }); const req = new Request( 'http://localhost/api/newsletter/unsubscribe?token=ok', ); const res = await GET(req); - // sendGoodbyeEmail is fire-and-forget in the route — flush microtasks so - // its internal awaits (render + fetch) complete before we assert on them. - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(res.status).toBe(307); expect(res.headers.get('location')).toContain('/newsletter/unsubscribed'); expect(global.fetch).toHaveBeenCalledTimes(2); - - // verify unsubscribe call - expect((global.fetch as jest.Mock).mock.calls[0][0]).toContain( - '/api/subscribers/unsubscribe', - ); - - // verify email call - const emailCall = (global.fetch as jest.Mock).mock.calls[1]; - expect(emailCall[0]).toBe(`${process.env.CMS_API_URL}/api/email`); - const body = JSON.parse(emailCall[1].body); - expect(body.to).toBe('x@y.z'); }); - it('redirects to unsubscribed and does not send email when Strapi returns no email', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: true, json: async () => ({}) }); - - const req = new Request( - 'http://localhost/api/newsletter/unsubscribe?token=no-email', - ); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')).toContain('/newsletter/unsubscribed'); - expect(global.fetch).toHaveBeenCalledTimes(1); - }); - - it('redirects to invalid-token when Strapi unsubscribe fails', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: false, status: 400 }); + it('redirects to invalid-token when unsubscribe fails', async () => { + (global.fetch as jest.Mock) = jest.fn().mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ data: { results: [], total: 0 } }), + }); const req = new Request( 'http://localhost/api/newsletter/unsubscribe?token=bad', @@ -239,10 +202,30 @@ describe('Newsletter unsubscribe (unit)', () => { try { // Make unsubscribe succeed so GET attempts a redirect inside try - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }); + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ + data: { + results: [ + { + id: 99, + uuid: 'uuid', + email: 'x@y.z', + name: 'n', + status: 'enabled', + }, + ], + total: 1, + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ data: true }), + }); const req = new Request( 'http://localhost/api/newsletter/unsubscribe?token=ok', diff --git a/src/__tests__/lib/listmonk.test.ts b/src/__tests__/lib/listmonk.test.ts new file mode 100644 index 00000000..a699fcb5 --- /dev/null +++ b/src/__tests__/lib/listmonk.test.ts @@ -0,0 +1,159 @@ +/** + * @jest-environment node + */ + +import { + createSubscriber, + findSubscriberByUuid, + ListmonkError, + listmonkRequest, + sendOptInEmail, + unsubscribeSubscriberFromLists, +} from '@/lib/listmonk'; + +describe('listmonk client', () => { + beforeEach(() => { + jest.resetAllMocks(); + global.fetch = jest.fn() as jest.Mock; + process.env.LISTMONK_BASE_URL = 'https://newsletter.project-sentiment.org'; + process.env.LISTMONK_API_USER = 'auth'; + process.env.LISTMONK_API_KEY = 'test-key'; + }); + + afterEach(() => { + delete process.env.LISTMONK_BASE_URL; + delete process.env.LISTMONK_API_USER; + delete process.env.LISTMONK_API_KEY; + }); + + it('adds Basic auth header and parses {data}', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ data: { ok: true } }), + }); + + const res = await listmonkRequest<{ ok: boolean }>('/api/health', { + method: 'GET', + }); + + expect(res).toEqual({ ok: true }); + const [, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(init.headers.Authorization).toMatch(/^Basic\s+/); + expect(init.headers.Accept).toBe('application/json'); + }); + + it('throws ListmonkError with body on non-ok', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ error: 'boom' }), + text: async () => 'boom', + }); + + try { + await listmonkRequest('/api/health'); + throw new Error('Expected listmonkRequest to throw'); + } catch (e) { + expect(e).toBeInstanceOf(ListmonkError); + const err = e as ListmonkError; + expect(err.status).toBe(500); + expect(err.responseBody).toEqual({ error: 'boom' }); + } + }); + + it('createSubscriber posts expected body', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ + data: { + id: 1, + uuid: 'u', + email: 'a@b.c', + name: 'a@b.c', + status: 'enabled', + }, + }), + }); + + await createSubscriber({ email: 'a@b.c', listIds: [1] }); + + const [url, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(url).toBe( + 'https://newsletter.project-sentiment.org/api/subscribers', + ); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body); + expect(body.email).toBe('a@b.c'); + expect(body.lists).toEqual([1]); + }); + + it('sendOptInEmail calls optin endpoint', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ data: true }), + }); + + await expect(sendOptInEmail(42)).resolves.toBe(true); + const [url] = (global.fetch as jest.Mock).mock.calls[0]; + expect(url).toContain('/api/subscribers/42/optin'); + }); + + it('findSubscriberByUuid returns first match', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ + data: { + results: [ + { + id: 7, + uuid: 'uuid', + email: 'x@y.z', + name: 'x', + status: 'enabled', + }, + ], + total: 1, + }, + }), + }); + + const sub = await findSubscriberByUuid('uuid'); + expect(sub?.id).toBe(7); + }); + + it('unsubscribeSubscriberFromLists calls /api/subscribers/lists', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ data: true }), + }); + + await unsubscribeSubscriberFromLists({ + subscriberIds: [1], + targetListIds: [2], + }); + + const [url, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(url).toContain('/api/subscribers/lists'); + const body = JSON.parse(init.body); + expect(body.action).toBe('unsubscribe'); + expect(body.ids).toEqual([1]); + expect(body.target_list_ids).toEqual([2]); + }); +}); diff --git a/src/app/api/newsletter/confirm/route.ts b/src/app/api/newsletter/confirm/route.ts index 92a6da40..2ae32239 100644 --- a/src/app/api/newsletter/confirm/route.ts +++ b/src/app/api/newsletter/confirm/route.ts @@ -1,34 +1,15 @@ import { NextResponse } from 'next/server'; -import { cmsApiToken, cmsApiUrl } from '@/constant/env'; +import { listmonkBaseUrl } from '@/constant/env'; /** * Confirm subscription endpoint helper - * Performs the PUT against Strapi to confirm a subscriber token. + * listmonk handles double opt-in confirmation on its public opt-in page. */ -export async function confirmSubscription(token: string): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - try { - // Strapi API call to confirm subscriber (token is URL-encoded) - const response = await fetch( - `${cmsApiUrl}/api/subscribers/confirm?token=${encodeURIComponent(token)}`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${cmsApiToken}`, - }, - signal: controller.signal, - }, - ); - - // propagate network errors and return boolean for API result - return response.ok; - } finally { - clearTimeout(timeoutId); - } +export function getListmonkOptInUrl(token: string): string | null { + const base = listmonkBaseUrl?.replace(/\/+$/, ''); + if (!base) return null; + return `${base}/subscription/optin/${encodeURIComponent(token)}`; } export async function GET(request: Request) { @@ -50,18 +31,15 @@ export async function GET(request: Request) { ); } - // Confirm the subscription - const success = await confirmSubscription(token); - - if (success) { - // Redirect to success page - return NextResponse.redirect(new URL('/newsletter/success', request.url)); - } else { - // Redirect to error page (token invalid, already used, or expired) + const optInUrl = getListmonkOptInUrl(token); + if (!optInUrl) { return NextResponse.redirect( - new URL('/newsletter/error?reason=invalid-token', request.url), + new URL('/newsletter/error?reason=server-error', request.url), ); } + + // Redirect user to listmonk's opt-in confirmation page. + return NextResponse.redirect(optInUrl); } catch { return NextResponse.redirect( new URL('/newsletter/error?reason=server-error', request.url), diff --git a/src/app/api/newsletter/subscribe/route.ts b/src/app/api/newsletter/subscribe/route.ts index 73865669..b87089d0 100644 --- a/src/app/api/newsletter/subscribe/route.ts +++ b/src/app/api/newsletter/subscribe/route.ts @@ -1,15 +1,13 @@ -import { render } from '@react-email/components'; import { NextResponse } from 'next/server'; +import { + createSubscriber, + ListmonkError, + sendOptInEmail, +} from '@/lib/listmonk'; import { newsletterSubscribeSchema } from '@/lib/newsletter-schema'; -import { - altchaHmacSecret, - cmsApiToken, - cmsApiUrl, - siteUrl, -} from '@/constant/env'; -import ConfirmSubscriptionEmail from '@/emails/confirm-subscription'; +import { altchaHmacSecret, listmonkListId } from '@/constant/env'; // In-Memory Rate-Limiting (pro IP, 3 Versuche pro Stunde) const rateLimitMap = new Map(); @@ -79,146 +77,9 @@ async function verifyAltcha(payload: string): Promise { } } -/** - * Create subscriber in Strapi database - * GDPR: Data minimization - only stores email and confirmation token - */ -interface Subscriber { - email: string; - token: string; - confirmed: boolean; - status: 'active' | 'unsubscribed'; -} - -async function createSubscriber(email: string): Promise { - const token = crypto.randomUUID(); - - // Create AbortController with 10s timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - try { - // Call Strapi API to create subscriber - const response = await fetch(`${cmsApiUrl}/api/subscribers`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${cmsApiToken}`, - }, - signal: controller.signal, - body: JSON.stringify({ - data: { - email, - token, - confirmed: false, // Requires confirmation (double opt-in) - status: 'active', - }, - }), - }); - - if (!response.ok) { - // Return null only for known duplicate/validation responses - if (response.status === 400 || response.status === 409) { - return null; - } - // All other errors (5xx, auth failures, etc.) bubble up to the POST handler - const errorText = await response.text().catch(() => ''); - throw new Error( - `Strapi responded with ${response.status} ${response.statusText}${errorText ? `: ${errorText}` : ''}`, - ); - } - - const data = await response.json(); - - // Strapi v5 returns data directly (not in attributes) - // null here means a Strapi-internal issue, not a duplicate — treat as error - if (!data.data) { - throw new Error('Strapi returned empty data after creating subscriber'); - } - - // Use the token we generated, as Strapi might not return it (if marked private) - return { - email: data.data.email, - token: data.data.token || token, // Fallback to generated token - confirmed: data.data.confirmed, - status: data.data.status, - }; - } finally { - clearTimeout(timeoutId); - } -} - -/** - * Delete a subscriber by token — called when confirmation email fails - * GDPR: Prevents orphaned unconfirmed records that block future retries - */ -async function deleteSubscriberByToken(token: string): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - try { - await fetch( - `${cmsApiUrl}/api/subscribers/by-token?token=${encodeURIComponent(token)}`, - { - method: 'DELETE', - headers: { - Authorization: `Bearer ${cmsApiToken}`, - }, - signal: controller.signal, - }, - ); - } finally { - clearTimeout(timeoutId); - } - } catch { - // Best-effort cleanup — do not throw, unsubscribe still matters - // eslint-disable-next-line no-console - console.error('Failed to clean up subscriber after email delivery failure'); - } -} - -/** - * Send double opt-in confirmation email - * GDPR Compliant: User must explicitly confirm subscription - */ -async function sendConfirmationEmail( - email: string, - token: string, -): Promise { - try { - // Create AbortController with 10s timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - try { - // Generate confirmation URL with token - const confirmUrl = `${siteUrl}/api/newsletter/confirm?token=${encodeURIComponent(token)}`; - - // Render email template - const emailHtml = await render(ConfirmSubscriptionEmail({ confirmUrl })); - - // Send email via Strapi email plugin - const response = await fetch(`${cmsApiUrl}/api/email`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${cmsApiToken}`, - }, - signal: controller.signal, - body: JSON.stringify({ - to: email, - subject: 'Confirm your newsletter subscription', - html: emailHtml, - }), - }); - - return response.ok; - } finally { - clearTimeout(timeoutId); - } - } catch { - return false; - } +function parseRequiredListId(raw: string | undefined): number | null { + const n = raw ? Number(raw) : NaN; + return Number.isInteger(n) && n > 0 ? n : null; } /** @@ -245,11 +106,10 @@ export async function POST(request: Request) { ); } - // Guard: siteUrl is required to build confirmation email links. - // If missing, emails would contain "undefined/api/..." links. - if (!siteUrl) { + const listId = parseRequiredListId(listmonkListId); + if (!listId) { return NextResponse.json( - { error: 'Missing site configuration' }, + { error: 'Missing newsletter configuration' }, { status: 500 }, ); } @@ -285,29 +145,25 @@ export async function POST(request: Request) { ); } - // Create subscriber (unconfirmed) - const subscriber = await createSubscriber(email); - - // Silent failure if email already exists - // GDPR: Prevents user enumeration attack - if (!subscriber) { - return NextResponse.json({ - message: - 'Thank you! If the email address is not yet registered, you will receive a confirmation email.', - }); - } - - // Send double opt-in confirmation email with error handling - const emailSent = await sendConfirmationEmail(email, subscriber.token); + try { + const subscriber = await createSubscriber({ email, listIds: [listId] }); + await sendOptInEmail(subscriber.id); + } catch (err) { + // Silent failure on duplicates/validation to avoid user enumeration. + if ( + err instanceof ListmonkError && + (err.status === 400 || err.status === 409) + ) { + return NextResponse.json({ + message: + 'Thank you! If the email address is not yet registered, you will receive a confirmation email.', + }); + } - // If email failed, delete the subscriber so the user can retry later - // GDPR: Prevents orphaned unconfirmed records that can never be confirmed - if (!emailSent) { - await deleteSubscriberByToken(subscriber.token); + // eslint-disable-next-line no-console + console.error('Newsletter subscribe failed', err); return NextResponse.json( - { - error: 'Email could not be sent. Please try again later.', - }, + { error: 'An internal error occurred' }, { status: 500 }, ); } diff --git a/src/app/api/newsletter/unsubscribe/route.ts b/src/app/api/newsletter/unsubscribe/route.ts index 9ba7d646..2c74bf0a 100644 --- a/src/app/api/newsletter/unsubscribe/route.ts +++ b/src/app/api/newsletter/unsubscribe/route.ts @@ -1,8 +1,11 @@ -import { render } from '@react-email/components'; import { NextResponse } from 'next/server'; -import { cmsApiToken, cmsApiUrl } from '@/constant/env'; -import GoodbyeEmail from '@/emails/goodbye'; +import { + findSubscriberByUuid, + unsubscribeSubscriberFromLists, +} from '@/lib/listmonk'; + +import { listmonkListId } from '@/constant/env'; /** * Unsubscribe user endpoint @@ -11,65 +14,26 @@ import GoodbyeEmail from '@/emails/goodbye'; export async function unsubscribeUser( token: string, ): Promise<{ success: boolean; email?: string }> { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - try { - // URL-encode token to safely handle special characters - const encodedToken = encodeURIComponent(token); - const response = await fetch( - `${cmsApiUrl}/api/subscribers/unsubscribe?token=${encodedToken}`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${cmsApiToken}`, - }, - signal: controller.signal, - }, - ); - - if (!response.ok) { + const listId = Number(listmonkListId); + if (!Number.isInteger(listId) || listId <= 0) { return { success: false }; } - const data = await response.json(); - return { success: true, email: data.email }; - } catch { - return { success: false }; - } finally { - clearTimeout(timeoutId); - } -} - -/** - * Send goodbye confirmation email - * GDPR Compliant: Confirms unsubscription action - */ -export async function sendGoodbyeEmail(email: string): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - try { - const emailHtml = await render(GoodbyeEmail()); + const subscriber = await findSubscriberByUuid(token); + if (!subscriber) { + return { success: false }; + } - await fetch(`${cmsApiUrl}/api/email`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${cmsApiToken}`, - }, - signal: controller.signal, - body: JSON.stringify({ - to: email, - subject: 'Newsletter unsubscription confirmed', - html: emailHtml, - }), + await unsubscribeSubscriberFromLists({ + subscriberIds: [subscriber.id], + targetListIds: [listId], }); + + // Email delivery is handled by listmonk (optional campaigns / templates). + return { success: true, email: subscriber.email }; } catch { - // Don't throw - unsubscribe still succeeded - } finally { - clearTimeout(timeoutId); + return { success: false }; } } @@ -89,13 +53,6 @@ export async function GET(request: Request) { const result = await unsubscribeUser(token); if (result.success) { - // Send confirmation email (fire-and-forget with error handling) - if (result.email) { - sendGoodbyeEmail(result.email).catch(() => { - // Error handling for fire-and-forget promise - user still redirected successfully - }); - } - // Redirect to unsubscribed confirmation page return NextResponse.redirect( new URL('/newsletter/unsubscribed', request.url), diff --git a/src/constant/env.ts b/src/constant/env.ts index 7ae401fe..635eff92 100644 --- a/src/constant/env.ts +++ b/src/constant/env.ts @@ -3,6 +3,12 @@ export const cmsApiUrl = process.env.CMS_API_URL; export const cmsApiToken = process.env.CMS_API_TOKEN; export const altchaHmacSecret = process.env.ALTCHA_HMAC_SECRET; +// Server-side only — listmonk newsletter integration +export const listmonkBaseUrl = process.env.LISTMONK_BASE_URL; +export const listmonkApiUser = process.env.LISTMONK_API_USER; +export const listmonkApiKey = process.env.LISTMONK_API_KEY; +export const listmonkListId = process.env.LISTMONK_LIST_ID; + // Public — accessible in both server and client export const siteUrl = process.env.NEXT_PUBLIC_SITE_URL; export const cmsPublicUrl = process.env.NEXT_PUBLIC_CMS_URL; diff --git a/src/lib/listmonk.ts b/src/lib/listmonk.ts new file mode 100644 index 00000000..6b02c230 --- /dev/null +++ b/src/lib/listmonk.ts @@ -0,0 +1,153 @@ +type ListmonkJson = { data: T }; + +export class ListmonkError extends Error { + public readonly status: number; + public readonly responseBody: unknown; + + constructor(message: string, status: number, responseBody: unknown) { + super(message); + this.name = 'ListmonkError'; + this.status = status; + this.responseBody = responseBody; + } +} + +function getRequiredEnv(name: string, value: string | undefined): string { + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function getListmonkConfig() { + // Read directly from process.env to avoid import-time capture of env values. + // This makes runtime configuration and tests (which set env per test) reliable. + const baseUrl = getRequiredEnv( + 'LISTMONK_BASE_URL', + process.env.LISTMONK_BASE_URL, + ).replace(/\/+$/, ''); + const apiUser = getRequiredEnv( + 'LISTMONK_API_USER', + process.env.LISTMONK_API_USER, + ); + const apiKey = getRequiredEnv( + 'LISTMONK_API_KEY', + process.env.LISTMONK_API_KEY, + ); + + return { baseUrl, apiUser, apiKey }; +} + +function getAuthHeader(): string { + const { apiUser, apiKey } = getListmonkConfig(); + const token = Buffer.from(`${apiUser}:${apiKey}`, 'utf8').toString('base64'); + return `Basic ${token}`; +} + +async function parseJsonBestEffort(res: Response): Promise { + const ct = res.headers.get('content-type') ?? ''; + if (ct.includes('application/json')) { + return res.json().catch(() => undefined); + } + return res.text().catch(() => undefined); +} + +export async function listmonkRequest( + path: string, + init: RequestInit & { timeoutMs?: number } = {}, +): Promise { + const { baseUrl } = getListmonkConfig(); + const url = `${baseUrl}${path.startsWith('/') ? '' : '/'}${path}`; + + const controller = new AbortController(); + const timeoutMs = init.timeoutMs ?? 10_000; + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { + ...init, + signal: controller.signal, + headers: { + Accept: 'application/json', + ...(init.body ? { 'Content-Type': 'application/json' } : undefined), + Authorization: getAuthHeader(), + ...(init.headers ?? {}), + }, + }); + + if (!res.ok) { + const body = await parseJsonBestEffort(res); + throw new ListmonkError( + `Listmonk request failed: ${res.status} ${res.statusText}`, + res.status, + body, + ); + } + + const json = (await res.json()) as ListmonkJson; + return json.data; + } finally { + clearTimeout(timeoutId); + } +} + +export interface ListmonkSubscriber { + id: number; + uuid: string; + email: string; + name: string; + status: 'enabled' | 'disabled' | 'blocklisted'; + lists?: unknown; +} + +export async function createSubscriber(params: { + email: string; + name?: string; + listIds: number[]; +}): Promise { + return listmonkRequest('/api/subscribers', { + method: 'POST', + body: JSON.stringify({ + email: params.email, + name: params.name ?? params.email, + status: 'enabled', + lists: params.listIds, + }), + }); +} + +export async function sendOptInEmail(subscriberId: number): Promise { + const result = await listmonkRequest( + `/api/subscribers/${subscriberId}/optin`, + { method: 'POST', body: JSON.stringify({}) }, + ); + return Boolean(result); +} + +export async function findSubscriberByUuid( + uuid: string, +): Promise { + const query = encodeURIComponent(`subscribers.uuid = '${uuid}'`); + const data = await listmonkRequest<{ + results: ListmonkSubscriber[]; + total: number; + }>(`/api/subscribers?query=${query}&page=1&per_page=100`); + + const match = data.results?.[0]; + return match ?? null; +} + +export async function unsubscribeSubscriberFromLists(params: { + subscriberIds: number[]; + targetListIds: number[]; +}): Promise { + const ok = await listmonkRequest('/api/subscribers/lists', { + method: 'PUT', + body: JSON.stringify({ + ids: params.subscriberIds, + action: 'unsubscribe', + target_list_ids: params.targetListIds, + }), + }); + return Boolean(ok); +} From 66aef9a4ae825c62da36b173cf23edd42a34de5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domenik=20T=C3=B6fflinger?= Date: Thu, 23 Apr 2026 22:03:09 +0200 Subject: [PATCH 02/10] chore: add listmonk logo asset --- .../listmonk/sentiment_listmonk-logo.png | Bin 0 -> 11750 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/images/services/listmonk/sentiment_listmonk-logo.png diff --git a/public/images/services/listmonk/sentiment_listmonk-logo.png b/public/images/services/listmonk/sentiment_listmonk-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c326db84ce16925b9a956cb15ac08c8e6e022213 GIT binary patch literal 11750 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 Date: Thu, 23 Apr 2026 22:03:46 +0200 Subject: [PATCH 03/10] style: reduce menu bar logo/nav gap --- src/components/layout/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index d2cbc0a9..de541671 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -61,7 +61,7 @@ export default function Header() { return (
-
+
Date: Thu, 23 Apr 2026 20:05:20 +0000 Subject: [PATCH 04/10] docs(env): add listmonk variables --- .env.example | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.env.example b/.env.example index c15a6df4..44a6fb9f 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,16 @@ NEXT_PUBLIC_CMS_URL=https://cms.your-domain.com CMS_API_URL=https://cms.your-domain.com CMS_API_TOKEN=your_strapi_api_token +# Server-side only — listmonk newsletter integration +# Base URL of listmonk instance (no trailing slash preferred) +LISTMONK_BASE_URL=https://newsletter.project-sentiment.org +# listmonk API user (basic auth username) +LISTMONK_API_USER=auth +# listmonk API key (basic auth password) +LISTMONK_API_KEY=your_listmonk_api_key +# list ID to subscribe/unsubscribe against +LISTMONK_LIST_ID=1 + # ALTCHA bot protection — server-side only # Must match ALTCHA_HMAC_SECRET in the Strapi CMS .env # Generate with: openssl rand -hex 32 From 02b4e19f5db057618dff09e8e332695c4f64427b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 20:10:33 +0000 Subject: [PATCH 05/10] docs(altcha): clarify nextjs-only usage --- .env.example | 2 +- src/__tests__/app/api/newsletter/unsubscribe.test.ts | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 44a6fb9f..66039394 100644 --- a/.env.example +++ b/.env.example @@ -24,7 +24,7 @@ LISTMONK_API_KEY=your_listmonk_api_key LISTMONK_LIST_ID=1 # ALTCHA bot protection — server-side only -# Must match ALTCHA_HMAC_SECRET in the Strapi CMS .env +# Used by Next.js API routes (`/api/newsletter/challenge` and `/api/newsletter/subscribe`) # Generate with: openssl rand -hex 32 ALTCHA_HMAC_SECRET=your_altcha_hmac_secret diff --git a/src/__tests__/app/api/newsletter/unsubscribe.test.ts b/src/__tests__/app/api/newsletter/unsubscribe.test.ts index 482d05fb..66e728ae 100644 --- a/src/__tests__/app/api/newsletter/unsubscribe.test.ts +++ b/src/__tests__/app/api/newsletter/unsubscribe.test.ts @@ -4,12 +4,6 @@ // Mock @/constant/env with getters so values are read at call time (after beforeEach sets process.env) jest.mock('@/constant/env', () => ({ - get cmsApiUrl() { - return process.env.CMS_API_URL; - }, - get cmsApiToken() { - return process.env.CMS_API_TOKEN; - }, get altchaHmacSecret() { return process.env.ALTCHA_HMAC_SECRET; }, From 7ee5c0f43f67e291a636f5ec31e2c42b441745e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 20:36:48 +0000 Subject: [PATCH 06/10] chore(altcha): upgrade to widget v3 and lib v2 --- package.json | 4 +- pnpm-lock.yaml | 85 +++++++++++++------ .../app/api/newsletter/subscribe.test.ts | 32 ++++--- src/app/api/newsletter/challenge/route.ts | 24 ++++-- src/app/api/newsletter/subscribe/route.ts | 30 +++++-- src/components/helpers/AltchaScript.tsx | 1 + src/components/templates/NewsletterForm.tsx | 17 ++-- src/lib/newsletter-schema.ts | 5 +- src/types/altcha.d.ts | 25 ++++++ tsconfig.json | 2 +- 10 files changed, 158 insertions(+), 67 deletions(-) create mode 100644 src/types/altcha.d.ts diff --git a/package.json b/package.json index 641c17aa..d2cf0e2a 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,8 @@ "@hookform/resolvers": "5.2.2", "@react-email/components": "1.0.12", "@strapi/blocks-react-renderer": "1.0.2", - "altcha": "2.3.0", - "altcha-lib": "1.4.1", + "altcha": "3.0.4", + "altcha-lib": "2.0.3", "clsx": "2.1.1", "lucide-react": "1.9.0", "next": "16.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adb27640..b07e2c78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,11 +22,11 @@ importers: specifier: 1.0.2 version: 1.0.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) altcha: - specifier: 2.3.0 - version: 2.3.0 + specifier: 3.0.4 + version: 3.0.4 altcha-lib: - specifier: 1.4.1 - version: 1.4.1 + specifier: 2.0.3 + version: 2.0.3 clsx: specifier: 2.1.1 version: 2.1.1 @@ -170,9 +170,6 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@altcha/crypto@0.0.1': - resolution: {integrity: sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==} - '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -1118,12 +1115,6 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc - '@rollup/rollup-linux-x64-gnu@4.18.0': - resolution: {integrity: sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1420,11 +1411,53 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - altcha-lib@1.4.1: - resolution: {integrity: sha512-MAXP9tkQOA2SE9Gwoe3LAcZbcDpp3XzYc5GDVej/y3eMNaFG/eVnRY1/7SGFW0RPsViEjPf+hi5eANjuZrH1xA==} + altcha-lib@2.0.3: + resolution: {integrity: sha512-rbBOrVcg5DiBPfn5dfhT+7J5k/Dqtj4seR5m1BB+w0QK4pZOBSS0ZpZ5PkolypmR315/SVbpXHM/QuL0kH5Seg==} + hasBin: true + peerDependencies: + '@fastify/cookie': ^11.0.0 + '@fastify/cors': ^11.0.0 + '@fastify/formbody': ^8.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/platform-express': ^10.0.0 || ^11.0.0 + '@sveltejs/kit': ^2.0.0 + cookie-parser: ^1.0.0 + cors: ^2.0.0 + express: ^4.0.0 || ^5.0.0 + fastify: ^5.0.0 + h3: ^2.0.0 + hono: ^4.0.0 + peerDependenciesMeta: + '@fastify/cookie': + optional: true + '@fastify/cors': + optional: true + '@fastify/formbody': + optional: true + '@nestjs/common': + optional: true + '@nestjs/core': + optional: true + '@nestjs/platform-express': + optional: true + '@sveltejs/kit': + optional: true + cookie-parser: + optional: true + cors: + optional: true + express: + optional: true + fastify: + optional: true + h3: + optional: true + hono: + optional: true - altcha@2.3.0: - resolution: {integrity: sha512-vl8I0dQvSQB7/Mx09XuWZ1+LdSP7vEda6OLbg9kUQ2ZO2LT7MzgUyLK7Iips+GAV6c0ntVcS1XWOqhEPpwbDhQ==} + altcha@3.0.4: + resolution: {integrity: sha512-TA1N0vnKR7aI/usDN2y3dPDcg9DdsDQKvRuCwApMtw3kJHjxH7NRtt+hSV8NZ01tkfHqXEJOtCYRFr8sxw8Fbg==} ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} @@ -2403,6 +2436,9 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hash-wasm@4.12.0: + resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -4105,8 +4141,6 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@altcha/crypto@0.0.1': {} - '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.5 @@ -5078,9 +5112,6 @@ snapshots: dependencies: react: 19.2.5 - '@rollup/rollup-linux-x64-gnu@4.18.0': - optional: true - '@rtsao/scc@1.1.0': {} '@selderee/plugin-htmlparser2@0.11.0': @@ -5387,13 +5418,11 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - altcha-lib@1.4.1: {} + altcha-lib@2.0.3: {} - altcha@2.3.0: + altcha@3.0.4: dependencies: - '@altcha/crypto': 0.0.1 - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': 4.18.0 + hash-wasm: 4.12.0 ansi-escapes@4.3.2: dependencies: @@ -6622,6 +6651,8 @@ snapshots: dependencies: has-symbols: 1.1.0 + hash-wasm@4.12.0: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 diff --git a/src/__tests__/app/api/newsletter/subscribe.test.ts b/src/__tests__/app/api/newsletter/subscribe.test.ts index f645c602..81e9c9c9 100644 --- a/src/__tests__/app/api/newsletter/subscribe.test.ts +++ b/src/__tests__/app/api/newsletter/subscribe.test.ts @@ -31,8 +31,11 @@ jest.mock('@/constant/env', () => ({ }, })); -// altcha-lib: verifySolution is controlled per-test -jest.mock('altcha-lib', () => ({ verifySolution: jest.fn() })); +// altcha-lib (v2): verifySolution + deriveHmacKeySecret are controlled per-test +jest.mock('altcha-lib', () => ({ + deriveHmacKeySecret: jest.fn(), + verifySolution: jest.fn(), +})); // listmonk client wrapper jest.mock('@/lib/listmonk', () => ({ @@ -58,17 +61,24 @@ beforeAll(async () => { POST = mod.POST; }); -// Valid ALTCHA payload: Base64-encoded JSON with all required fields -// Schema requires min(100) chars + valid Base64-JSON structure +// Valid ALTCHA payload: Base64-encoded JSON with {challenge, solution} +// Schema requires min(100) chars + valid Base64-JSON structure. const VALID_ALTCHA_PAYLOAD = btoa( JSON.stringify({ - algorithm: 'SHA-256', - challenge: 'test-challenge-string-abc', - number: 12345, - salt: 'test-salt-abc-123', - signature: 'test-signature-abc-xyz', + challenge: { + algorithm: 'PBKDF2/SHA-256', + challenge: 'c', + salt: 's', + signature: 'sig', + cost: 5000, + counter: 7000, + expiresAt: Math.floor(Date.now() / 1000) + 300, + }, + solution: { + counter: 7000, + }, }), -); +).padEnd(100, 'x'); describe('Newsletter Subscribe Validation', () => { describe('Input Schema Validation', () => { @@ -419,7 +429,7 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { it('calls listmonk and returns 200 when verifySolution returns true', async () => { const { verifySolution } = await import('altcha-lib'); - (verifySolution as jest.Mock).mockResolvedValueOnce(true); + (verifySolution as jest.Mock).mockResolvedValueOnce({ verified: true }); const { createSubscriber, sendOptInEmail } = await import('@/lib/listmonk'); (createSubscriber as jest.Mock).mockResolvedValueOnce({ diff --git a/src/app/api/newsletter/challenge/route.ts b/src/app/api/newsletter/challenge/route.ts index c1978ef6..b6f7a600 100644 --- a/src/app/api/newsletter/challenge/route.ts +++ b/src/app/api/newsletter/challenge/route.ts @@ -1,25 +1,33 @@ -import { createChallenge } from 'altcha-lib'; +import { createChallenge, randomInt } from 'altcha-lib'; +import { deriveKey } from 'altcha-lib/algorithms/pbkdf2'; +import { deriveHmacKeySecret } from 'altcha-lib/frameworks/nextjs'; import { NextResponse } from 'next/server'; import { altchaHmacSecret } from '@/constant/env'; export async function GET() { try { - const hmacKey = altchaHmacSecret; + const hmacSignatureSecret = altchaHmacSecret; - if (!hmacKey) { + if (!hmacSignatureSecret) { return NextResponse.json( { error: 'Server configuration error' }, { status: 500 }, ); } - // Generate ALTCHA challenge with proper HMAC signature + const hmacKeySignatureSecret = + await deriveHmacKeySecret(hmacSignatureSecret); + + // Generate ALTCHA v2 (PoW v2) challenge for Widget v3. const challenge = await createChallenge({ - hmacKey, - maxNumber: 100000, // Maximum number for proof-of-work - algorithm: 'SHA-256', - expires: new Date(Date.now() + 5 * 60 * 1000), // Challenge expires in 5 minutes + algorithm: 'PBKDF2/SHA-256', + cost: 5_000, + counter: randomInt(5_000, 10_000), + deriveKey, + hmacSignatureSecret, + hmacKeySignatureSecret, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), }); return NextResponse.json(challenge); diff --git a/src/app/api/newsletter/subscribe/route.ts b/src/app/api/newsletter/subscribe/route.ts index b87089d0..20508372 100644 --- a/src/app/api/newsletter/subscribe/route.ts +++ b/src/app/api/newsletter/subscribe/route.ts @@ -1,3 +1,6 @@ +import { verifySolution } from 'altcha-lib'; +import { deriveKey } from 'altcha-lib/algorithms/pbkdf2'; +import { deriveHmacKeySecret } from 'altcha-lib/frameworks/nextjs'; import { NextResponse } from 'next/server'; import { @@ -46,9 +49,9 @@ function checkRateLimit(ip: string): boolean { async function verifyAltcha(payload: string): Promise { try { // Validate ALTCHA_HMAC_SECRET environment variable - const hmacKey = altchaHmacSecret; + const hmacSignatureSecret = altchaHmacSecret; - if (!hmacKey) { + if (!hmacSignatureSecret) { return false; } @@ -65,13 +68,26 @@ async function verifyAltcha(payload: string): Promise { return false; } - // Import verifySolution from altcha-lib for server-side verification - const { verifySolution } = await import('altcha-lib'); + const parsed = JSON.parse(atob(payload)) as { + challenge?: unknown; + solution?: unknown; + }; + if (!parsed.challenge || !parsed.solution) { + return false; + } - // Verify the ALTCHA solution with signature and expiration check - const isValid = await verifySolution(payload, hmacKey, true); + const hmacKeySignatureSecret = + await deriveHmacKeySecret(hmacSignatureSecret); + + const result = await verifySolution({ + challenge: parsed.challenge as never, + solution: parsed.solution as never, + deriveKey, + hmacSignatureSecret, + hmacKeySignatureSecret, + }); - return isValid; + return result.verified === true; } catch { return false; } diff --git a/src/components/helpers/AltchaScript.tsx b/src/components/helpers/AltchaScript.tsx index dbcd4d11..20b5923a 100644 --- a/src/components/helpers/AltchaScript.tsx +++ b/src/components/helpers/AltchaScript.tsx @@ -1,5 +1,6 @@ 'use client'; +import type {} from 'altcha/types/react'; import { useEffect } from 'react'; export function AltchaScript() { diff --git a/src/components/templates/NewsletterForm.tsx b/src/components/templates/NewsletterForm.tsx index 85301af6..32cd3e85 100644 --- a/src/components/templates/NewsletterForm.tsx +++ b/src/components/templates/NewsletterForm.tsx @@ -1,6 +1,9 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; +import type { WidgetAttributes } from 'altcha/types'; +import type {} from 'altcha/types/react'; +import type {} from 'altcha/types/react'; import { useTheme } from 'next-themes'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -20,7 +23,11 @@ interface AltchaStateChangeEvent extends Event { }; } -const altchaStyleLight: AltchaWidgetCSSProperties = { +type AltchaWidgetCssVars = Partial< + Record, string> +>; + +const altchaStyleLight: AltchaWidgetCssVars = { '--altcha-border-width': '1px', '--altcha-border-radius': '8px', '--altcha-color-base': '#ffffff', @@ -32,7 +39,7 @@ const altchaStyleLight: AltchaWidgetCSSProperties = { '--altcha-max-width': '100%', }; -const altchaStyleDark: AltchaWidgetCSSProperties = { +const altchaStyleDark: AltchaWidgetCssVars = { '--altcha-border-width': '1px', '--altcha-border-radius': '8px', '--altcha-color-base': '#171717', @@ -191,12 +198,8 @@ export function NewsletterForm() {
diff --git a/src/lib/newsletter-schema.ts b/src/lib/newsletter-schema.ts index a55bc698..7f1f3e46 100644 --- a/src/lib/newsletter-schema.ts +++ b/src/lib/newsletter-schema.ts @@ -16,11 +16,8 @@ export const newsletterSubscribeSchema = z.object({ return ( typeof parsed === 'object' && parsed !== null && - 'algorithm' in parsed && 'challenge' in parsed && - 'number' in parsed && - 'salt' in parsed && - 'signature' in parsed + 'solution' in parsed ); } catch { return false; diff --git a/src/types/altcha.d.ts b/src/types/altcha.d.ts new file mode 100644 index 00000000..9b15aa88 --- /dev/null +++ b/src/types/altcha.d.ts @@ -0,0 +1,25 @@ +import type React from 'react'; + +declare global { + namespace JSX { + interface IntrinsicElements { + 'altcha-widget': React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLElement + > & { + // v3: `challenge` can be a URL or inline JSON challenge. + challenge?: string; + // Legacy / alternative spellings used across versions/examples. + challengeurl?: string; + challengeUrl?: string; + hideLogo?: boolean; + hidelogo?: boolean; + hideFooter?: boolean; + hidefooter?: boolean; + name?: string; + strings?: string; + style?: React.CSSProperties; + }; + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 33bba74c..f729beea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "moduleDetection": "force", From 0b19550b1622b4ff713931b6248c2b9b5cf1b098 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 20:51:18 +0000 Subject: [PATCH 07/10] fix(newsletter): avoid double opt-in emails --- src/__tests__/app/api/newsletter/subscribe.test.ts | 3 +-- src/app/api/newsletter/subscribe/route.ts | 11 ++++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/__tests__/app/api/newsletter/subscribe.test.ts b/src/__tests__/app/api/newsletter/subscribe.test.ts index 81e9c9c9..5d0fcc8d 100644 --- a/src/__tests__/app/api/newsletter/subscribe.test.ts +++ b/src/__tests__/app/api/newsletter/subscribe.test.ts @@ -439,7 +439,6 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { name: 'user@example.com', status: 'enabled', }); - (sendOptInEmail as jest.Mock).mockResolvedValueOnce(true); const res = await POST( makeRequest( @@ -456,7 +455,7 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { const json = await res.json(); expect(json.message).toMatch(/confirm/i); expect(createSubscriber).toHaveBeenCalledTimes(1); - expect(sendOptInEmail).toHaveBeenCalledTimes(1); + expect(sendOptInEmail).not.toHaveBeenCalled(); }); it('returns 400 for missing privacy consent before ALTCHA check runs', async () => { diff --git a/src/app/api/newsletter/subscribe/route.ts b/src/app/api/newsletter/subscribe/route.ts index 20508372..9fc23454 100644 --- a/src/app/api/newsletter/subscribe/route.ts +++ b/src/app/api/newsletter/subscribe/route.ts @@ -3,11 +3,7 @@ import { deriveKey } from 'altcha-lib/algorithms/pbkdf2'; import { deriveHmacKeySecret } from 'altcha-lib/frameworks/nextjs'; import { NextResponse } from 'next/server'; -import { - createSubscriber, - ListmonkError, - sendOptInEmail, -} from '@/lib/listmonk'; +import { createSubscriber, ListmonkError } from '@/lib/listmonk'; import { newsletterSubscribeSchema } from '@/lib/newsletter-schema'; import { altchaHmacSecret, listmonkListId } from '@/constant/env'; @@ -162,8 +158,9 @@ export async function POST(request: Request) { } try { - const subscriber = await createSubscriber({ email, listIds: [listId] }); - await sendOptInEmail(subscriber.id); + // Do not explicitly trigger opt-in email here to avoid duplicates. + // listmonk sends the confirmation email automatically for double opt-in lists. + await createSubscriber({ email, listIds: [listId] }); } catch (err) { // Silent failure on duplicates/validation to avoid user enumeration. if ( From 257f6db166bdbe11c5bead33f7bce90451176791 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 21:10:02 +0000 Subject: [PATCH 08/10] refactor(newsletter): remove confirm/unsubscribe routes --- .../app/api/newsletter/confirm.test.ts | 108 -------- .../app/api/newsletter/unsubscribe.test.ts | 240 ------------------ src/app/api/newsletter/confirm/route.ts | 48 ---- src/app/api/newsletter/unsubscribe/route.ts | 71 ------ src/app/newsletter/success/page.tsx | 22 -- src/app/newsletter/unsubscribed/page.tsx | 23 -- src/emails/confirm-subscription.tsx | 233 ----------------- src/emails/goodbye.tsx | 192 -------------- 8 files changed, 937 deletions(-) delete mode 100644 src/__tests__/app/api/newsletter/confirm.test.ts delete mode 100644 src/__tests__/app/api/newsletter/unsubscribe.test.ts delete mode 100644 src/app/api/newsletter/confirm/route.ts delete mode 100644 src/app/api/newsletter/unsubscribe/route.ts delete mode 100644 src/app/newsletter/success/page.tsx delete mode 100644 src/app/newsletter/unsubscribed/page.tsx delete mode 100644 src/emails/confirm-subscription.tsx delete mode 100644 src/emails/goodbye.tsx diff --git a/src/__tests__/app/api/newsletter/confirm.test.ts b/src/__tests__/app/api/newsletter/confirm.test.ts deleted file mode 100644 index e9401d25..00000000 --- a/src/__tests__/app/api/newsletter/confirm.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @jest-environment node - */ - -// Mock @/constant/env with getters so values are read at call time (after beforeEach sets process.env) -jest.mock('@/constant/env', () => ({ - get altchaHmacSecret() { - return process.env.ALTCHA_HMAC_SECRET; - }, - get siteUrl() { - return process.env.NEXT_PUBLIC_SITE_URL; - }, - get cmsPublicUrl() { - return process.env.NEXT_PUBLIC_CMS_URL; - }, - get listmonkBaseUrl() { - return process.env.LISTMONK_BASE_URL; - }, - get listmonkApiUser() { - return process.env.LISTMONK_API_USER; - }, - get listmonkApiKey() { - return process.env.LISTMONK_API_KEY; - }, - get listmonkListId() { - return process.env.LISTMONK_LIST_ID; - }, -})); - -// Import route module dynamically after polyfills (avoid hoisted import timing issues) -let GET: (request: Request) => Promise; -let getListmonkOptInUrl: (token: string) => string | null; - -beforeAll(async () => { - const mod = await import('@/app/api/newsletter/confirm/route'); - GET = mod.GET; - getListmonkOptInUrl = mod.getListmonkOptInUrl; -}); - -describe('Newsletter confirm route (unit)', () => { - beforeEach(() => { - jest.resetAllMocks(); - process.env.LISTMONK_BASE_URL = 'https://newsletter.project-sentiment.org'; - }); - - afterEach(() => { - delete process.env.LISTMONK_BASE_URL; - }); - - describe('getListmonkOptInUrl helper', () => { - it('returns null when LISTMONK_BASE_URL is missing', () => { - delete process.env.LISTMONK_BASE_URL; - expect(getListmonkOptInUrl('uuid')).toBeNull(); - }); - - it('builds opt-in URL with encoded token', () => { - expect(getListmonkOptInUrl('a/b+c')).toBe( - 'https://newsletter.project-sentiment.org/subscription/optin/a%2Fb%2Bc', - ); - }); - }); - - describe('GET handler', () => { - it('redirects to missing-token when token param is absent', async () => { - const req = new Request('http://localhost/api/newsletter/confirm'); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')!).toContain( - '/newsletter/error?reason=missing-token', - ); - }); - - it('redirects to invalid-token when token param is empty', async () => { - const req = new Request('http://localhost/api/newsletter/confirm?token='); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')!).toContain( - '/newsletter/error?reason=invalid-token', - ); - }); - - it('redirects to listmonk opt-in page when token is present', async () => { - const req = new Request( - 'http://localhost/api/newsletter/confirm?token=ok-token', - ); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')!).toBe( - 'https://newsletter.project-sentiment.org/subscription/optin/ok-token', - ); - }); - - it('redirects to server-error when LISTMONK_BASE_URL is missing', async () => { - delete process.env.LISTMONK_BASE_URL; - const req = new Request( - 'http://localhost/api/newsletter/confirm?token=ok-token', - ); - const res = await GET(req); - expect(res.status).toBe(307); - expect(res.headers.get('location')!).toContain( - '/newsletter/error?reason=server-error', - ); - }); - }); -}); diff --git a/src/__tests__/app/api/newsletter/unsubscribe.test.ts b/src/__tests__/app/api/newsletter/unsubscribe.test.ts deleted file mode 100644 index 66e728ae..00000000 --- a/src/__tests__/app/api/newsletter/unsubscribe.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * @jest-environment node - */ - -// Mock @/constant/env with getters so values are read at call time (after beforeEach sets process.env) -jest.mock('@/constant/env', () => ({ - get altchaHmacSecret() { - return process.env.ALTCHA_HMAC_SECRET; - }, - get siteUrl() { - return process.env.NEXT_PUBLIC_SITE_URL; - }, - get cmsPublicUrl() { - return process.env.NEXT_PUBLIC_CMS_URL; - }, - get listmonkBaseUrl() { - return process.env.LISTMONK_BASE_URL; - }, - get listmonkApiUser() { - return process.env.LISTMONK_API_USER; - }, - get listmonkApiKey() { - return process.env.LISTMONK_API_KEY; - }, - get listmonkListId() { - return process.env.LISTMONK_LIST_ID; - }, -})); - -// Import route module dynamically after polyfills to avoid hoisting issues -let GET: (request: Request) => Promise; -let unsubscribeUser: ( - token: string, -) => Promise<{ success: boolean; email?: string }>; - -beforeAll(async () => { - const mod = await import('@/app/api/newsletter/unsubscribe/route'); - GET = mod.GET; - unsubscribeUser = mod.unsubscribeUser; -}); - -describe('Newsletter unsubscribe (unit)', () => { - beforeEach(() => { - jest.resetAllMocks(); - global.fetch = jest.fn() as jest.Mock; - process.env.LISTMONK_BASE_URL = 'http://listmonk.test'; - process.env.LISTMONK_API_USER = 'api_username'; - process.env.LISTMONK_API_KEY = 'api_key'; - process.env.LISTMONK_LIST_ID = '1'; - }); - - afterEach(() => { - delete process.env.LISTMONK_BASE_URL; - delete process.env.LISTMONK_API_USER; - delete process.env.LISTMONK_API_KEY; - delete process.env.LISTMONK_LIST_ID; - }); - - describe('unsubscribeUser', () => { - it('returns success + email when subscriber found and list update succeeds', async () => { - (global.fetch as jest.Mock) - // findSubscriberByUuid - .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ - data: { - results: [ - { - id: 99, - uuid: 'uuid', - email: 'user@example.com', - name: 'n', - status: 'enabled', - }, - ], - total: 1, - }, - }), - }) - // unsubscribeSubscriberFromLists - .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ data: true }), - }); - - const token = 't/oken+value'; - const result = await unsubscribeUser(token); - - expect(result).toEqual({ success: true, email: 'user@example.com' }); - expect(global.fetch).toHaveBeenCalledTimes(2); - }); - - it('returns success:false when subscriber lookup fails', async () => { - (global.fetch as jest.Mock) = jest.fn().mockResolvedValueOnce({ - ok: true, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ data: { results: [], total: 0 } }), - }); - - const res = await unsubscribeUser('bad'); - expect(res).toEqual({ success: false }); - }); - - it('returns success:false on network error', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockRejectedValueOnce(new Error('network')); - - const res = await unsubscribeUser('any'); - expect(res).toEqual({ success: false }); - }); - }); - - describe('GET handler', () => { - it('redirects to missing-token when token is absent', async () => { - const req = new Request('http://localhost/api/newsletter/unsubscribe'); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')).toContain( - '/newsletter/error?reason=missing-token', - ); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('redirects to unsubscribed when unsubscribe succeeds', async () => { - (global.fetch as jest.Mock) - // findSubscriberByUuid - .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ - data: { - results: [ - { - id: 99, - uuid: 'uuid', - email: 'x@y.z', - name: 'n', - status: 'enabled', - }, - ], - total: 1, - }, - }), - }) - // unsubscribeSubscriberFromLists - .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ data: true }), - }); - - const req = new Request( - 'http://localhost/api/newsletter/unsubscribe?token=ok', - ); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')).toContain('/newsletter/unsubscribed'); - expect(global.fetch).toHaveBeenCalledTimes(2); - }); - - it('redirects to invalid-token when unsubscribe fails', async () => { - (global.fetch as jest.Mock) = jest.fn().mockResolvedValueOnce({ - ok: true, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ data: { results: [], total: 0 } }), - }); - - const req = new Request( - 'http://localhost/api/newsletter/unsubscribe?token=bad', - ); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')).toContain( - '/newsletter/error?reason=invalid-token', - ); - }); - - it('redirects to server-error when NextResponse.redirect inside try throws', async () => { - // Cause the first NextResponse.redirect call (inside try) to throw, then ensure - // the outer catch returns the server-error redirect. - const nextServer = await import('next/server'); - const originalRedirect = nextServer.NextResponse.redirect; - let calls = 0; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (nextServer.NextResponse as any).redirect = (url: any, init?: any) => { - calls += 1; - if (calls === 1) throw new Error('boom'); - return originalRedirect(url, init); - }; - - try { - // Make unsubscribe succeed so GET attempts a redirect inside try - (global.fetch as jest.Mock) - .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ - data: { - results: [ - { - id: 99, - uuid: 'uuid', - email: 'x@y.z', - name: 'n', - status: 'enabled', - }, - ], - total: 1, - }, - }), - }) - .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ data: true }), - }); - - const req = new Request( - 'http://localhost/api/newsletter/unsubscribe?token=ok', - ); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')).toContain( - '/newsletter/error?reason=server-error', - ); - } finally { - // Always restore — even if assertions above throw - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (nextServer.NextResponse as any).redirect = originalRedirect; - } - }); - }); -}); diff --git a/src/app/api/newsletter/confirm/route.ts b/src/app/api/newsletter/confirm/route.ts deleted file mode 100644 index 2ae32239..00000000 --- a/src/app/api/newsletter/confirm/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextResponse } from 'next/server'; - -import { listmonkBaseUrl } from '@/constant/env'; - -/** - * Confirm subscription endpoint helper - * listmonk handles double opt-in confirmation on its public opt-in page. - */ -export function getListmonkOptInUrl(token: string): string | null { - const base = listmonkBaseUrl?.replace(/\/+$/, ''); - if (!base) return null; - return `${base}/subscription/optin/${encodeURIComponent(token)}`; -} - -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - - // Distinguish between missing token parameter and empty token value - if (!searchParams.has('token')) { - return NextResponse.redirect( - new URL('/newsletter/error?reason=missing-token', request.url), - ); - } - - const token = searchParams.get('token') ?? ''; - - if (!token) { - return NextResponse.redirect( - new URL('/newsletter/error?reason=invalid-token', request.url), - ); - } - - const optInUrl = getListmonkOptInUrl(token); - if (!optInUrl) { - return NextResponse.redirect( - new URL('/newsletter/error?reason=server-error', request.url), - ); - } - - // Redirect user to listmonk's opt-in confirmation page. - return NextResponse.redirect(optInUrl); - } catch { - return NextResponse.redirect( - new URL('/newsletter/error?reason=server-error', request.url), - ); - } -} diff --git a/src/app/api/newsletter/unsubscribe/route.ts b/src/app/api/newsletter/unsubscribe/route.ts deleted file mode 100644 index 2c74bf0a..00000000 --- a/src/app/api/newsletter/unsubscribe/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { NextResponse } from 'next/server'; - -import { - findSubscriberByUuid, - unsubscribeSubscriberFromLists, -} from '@/lib/listmonk'; - -import { listmonkListId } from '@/constant/env'; - -/** - * Unsubscribe user endpoint - * GDPR Compliant: Allows users to easily opt-out at any time - */ -export async function unsubscribeUser( - token: string, -): Promise<{ success: boolean; email?: string }> { - try { - const listId = Number(listmonkListId); - if (!Number.isInteger(listId) || listId <= 0) { - return { success: false }; - } - - const subscriber = await findSubscriberByUuid(token); - if (!subscriber) { - return { success: false }; - } - - await unsubscribeSubscriberFromLists({ - subscriberIds: [subscriber.id], - targetListIds: [listId], - }); - - // Email delivery is handled by listmonk (optional campaigns / templates). - return { success: true, email: subscriber.email }; - } catch { - return { success: false }; - } -} - -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const token = searchParams.get('token'); - - // Validate token parameter - if (!token) { - return NextResponse.redirect( - new URL('/newsletter/error?reason=missing-token', request.url), - ); - } - - // Unsubscribe the user - const result = await unsubscribeUser(token); - - if (result.success) { - // Redirect to unsubscribed confirmation page - return NextResponse.redirect( - new URL('/newsletter/unsubscribed', request.url), - ); - } else { - // Redirect to error page - return NextResponse.redirect( - new URL('/newsletter/error?reason=invalid-token', request.url), - ); - } - } catch { - return NextResponse.redirect( - new URL('/newsletter/error?reason=server-error', request.url), - ); - } -} diff --git a/src/app/newsletter/success/page.tsx b/src/app/newsletter/success/page.tsx deleted file mode 100644 index 0ca95b5a..00000000 --- a/src/app/newsletter/success/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Container } from '@/components/layout/Container'; -import { Button } from '@/components/ui/Button'; -import { Paragraph, Title } from '@/components/ui/typography'; - -export default function NewsletterSuccessPage() { - return ( -
- -
- - Subscription <span className='text-primary'>confirmed</span> - - - Thank you! Your newsletter subscription has been successfully - confirmed. You will now receive our latest updates. - - -
-
-
- ); -} diff --git a/src/app/newsletter/unsubscribed/page.tsx b/src/app/newsletter/unsubscribed/page.tsx deleted file mode 100644 index a016cd07..00000000 --- a/src/app/newsletter/unsubscribed/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Container } from '@/components/layout/Container'; -import { Button } from '@/components/ui/Button'; -import { Paragraph, Title } from '@/components/ui/typography'; - -export default function NewsletterUnsubscribedPage() { - return ( -
- -
- - Unsubscription <span className='text-primary'>confirmed</span> - - - You have been successfully unsubscribed from the newsletter. We are - sorry to see you go! If you change your mind, you can subscribe - again at any time. - - -
-
-
- ); -} diff --git a/src/emails/confirm-subscription.tsx b/src/emails/confirm-subscription.tsx deleted file mode 100644 index c0fd6bb7..00000000 --- a/src/emails/confirm-subscription.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { - Body, - Button, - Container, - Head, - Heading, - Hr, - Html, - Preview, - Section, - Text, -} from '@react-email/components'; - -interface ConfirmSubscriptionEmailProps { - confirmUrl: string; -} - -export default function ConfirmSubscriptionEmail({ - confirmUrl, -}: ConfirmSubscriptionEmailProps) { - return ( - - - Confirm your newsletter subscription - SENTIMENT - - - {/* Logo/Branding */} -
- - - - -
- - - - - - - - - - - - - - - - -
- SENTIMENT - - Creating Safe & Supportive Spaces for Intimate Communication with - Human-Chatbot Interactions - -
- -
- - {/* Main Content */} - Confirm newsletter subscription - - Thank you for your interest in our newsletter! - - - To complete your subscription and receive regular updates about the - SENTIMENT project, please click the button below: - - - {/* Call-to-Action Button */} -
- -
- - {/* Alternative Link */} - Or copy this link into your browser: - {confirmUrl} - -
- - {/* Footer */} - - If you did not sign up for our newsletter, you can ignore this - email. No further emails will be sent. - - - This email was sent as part of the SENTIMENT research project. - - - SENTIMENT is a research project under the funding guideline - "Platform Privacy - IT security protects privacy and supports - democracy" of the Federal Government's IT Security Research Program - "Digital. Secure. Sovereign". - -
- - - ); -} - -const main = { - backgroundColor: '#ffffff', - fontFamily: - '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', -}; - -const container = { - backgroundColor: '#ffffff', - margin: '0 auto', - padding: '20px 0 48px', - marginBottom: '64px', - maxWidth: '600px', -}; - -const header = { - textAlign: 'center' as const, - marginBottom: '32px', -}; - -const logo = { - color: '#1F35A5', // Secondary brand color - fontSize: '32px', - fontWeight: '900', - margin: '20px 0 8px', - padding: '0', - letterSpacing: '-0.5px', -}; - -const tagline = { - color: '#8b9094', // Tertiary color - fontSize: '12px', - lineHeight: '18px', - margin: '0 0 20px', - padding: '0 20px', -}; - -const divider = { - borderColor: '#f2f2f2', - margin: '24px 0', -}; - -const h1 = { - color: '#000000', - fontSize: '24px', - fontWeight: '700', - margin: '32px 0 24px', - padding: '0', - lineHeight: '32px', -}; - -const text = { - color: '#000000', - fontSize: '16px', - lineHeight: '26px', - marginBottom: '16px', -}; - -const buttonContainer = { - padding: '32px 0', - textAlign: 'center' as const, -}; - -const button = { - backgroundColor: '#FF5C24', // Primary brand color (Orange) - borderRadius: '8px', - color: '#ffffff', - fontSize: '16px', - fontWeight: '600', - textDecoration: 'none', - textAlign: 'center' as const, - display: 'inline-block', - padding: '14px 32px', - lineHeight: '24px', -}; - -const link = { - color: '#1F35A5', // Secondary brand color - fontSize: '14px', - textDecoration: 'underline', - wordBreak: 'break-all' as const, - marginTop: '8px', -}; - -const footer = { - color: '#666666', - fontSize: '14px', - lineHeight: '22px', - marginTop: '16px', -}; - -const disclaimer = { - color: '#8b9094', - fontSize: '11px', - lineHeight: '18px', - marginTop: '24px', - paddingTop: '16px', - borderTop: '1px solid #f2f2f2', -}; diff --git a/src/emails/goodbye.tsx b/src/emails/goodbye.tsx deleted file mode 100644 index 67ebc7b3..00000000 --- a/src/emails/goodbye.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { - Body, - Container, - Head, - Heading, - Hr, - Html, - Preview, - Section, - Text, -} from '@react-email/components'; - -export default function GoodbyeEmail() { - return ( - - - - You have been unsubscribed from the SENTIMENT newsletter - - - - {/* Logo/Branding */} -
- - - - -
- - - - - - - - - - - - - - - - -
- SENTIMENT - - Creating Safe & Supportive Spaces for Intimate Communication with - Human-Chatbot Interactions - -
- -
- - {/* Main Content */} - Unsubscription confirmed - - You have been successfully unsubscribed from the SENTIMENT - newsletter. - - - We are sorry to see you go! If you change your mind, you can - subscribe again anytime on our website. - - -
- - {/* Footer */} - - If you did not request this unsubscription, please contact us - immediately. - - - Thank you for your interest in the SENTIMENT project. - - - SENTIMENT is a research project under the funding guideline - "Platform Privacy - IT security protects privacy and supports - democracy" of the Federal Government's IT Security Research Program - "Digital. Secure. Sovereign". - -
- - - ); -} - -const main = { - backgroundColor: '#ffffff', - fontFamily: - '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', -}; - -const container = { - backgroundColor: '#ffffff', - margin: '0 auto', - padding: '20px 0 48px', - marginBottom: '64px', - maxWidth: '600px', -}; - -const header = { - textAlign: 'center' as const, - marginBottom: '32px', -}; - -const logo = { - color: '#1F35A5', // Secondary brand color - fontSize: '32px', - fontWeight: '900', - margin: '20px 0 8px', - padding: '0', - letterSpacing: '-0.5px', -}; - -const tagline = { - color: '#8b9094', // Tertiary color - fontSize: '12px', - lineHeight: '18px', - margin: '0 0 20px', - padding: '0 20px', -}; - -const divider = { - borderColor: '#f2f2f2', - margin: '24px 0', -}; - -const h1 = { - color: '#000000', - fontSize: '24px', - fontWeight: '700', - margin: '32px 0 24px', - padding: '0', - lineHeight: '32px', -}; - -const text = { - color: '#000000', - fontSize: '16px', - lineHeight: '26px', - marginBottom: '16px', -}; - -const footer = { - color: '#666666', - fontSize: '14px', - lineHeight: '22px', - marginTop: '16px', -}; - -const disclaimer = { - color: '#8b9094', - fontSize: '11px', - lineHeight: '18px', - marginTop: '24px', - paddingTop: '16px', - borderTop: '1px solid #f2f2f2', -}; From 417bc3eff07a93674807b25ecc45008303d4b26e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 21:29:19 +0000 Subject: [PATCH 09/10] fix: harden listmonk uuid query and adjust altcha imports --- .../app/api/newsletter/subscribe.test.ts | 74 ++++++++++++++++--- src/__tests__/lib/listmonk.test.ts | 12 ++- src/app/api/newsletter/challenge/route.ts | 2 +- src/app/api/newsletter/subscribe/route.ts | 18 +++-- src/lib/listmonk.ts | 17 ++++- src/types/altcha.d.ts | 2 +- 6 files changed, 102 insertions(+), 23 deletions(-) diff --git a/src/__tests__/app/api/newsletter/subscribe.test.ts b/src/__tests__/app/api/newsletter/subscribe.test.ts index 5d0fcc8d..f1c8eda6 100644 --- a/src/__tests__/app/api/newsletter/subscribe.test.ts +++ b/src/__tests__/app/api/newsletter/subscribe.test.ts @@ -31,16 +31,18 @@ jest.mock('@/constant/env', () => ({ }, })); -// altcha-lib (v2): verifySolution + deriveHmacKeySecret are controlled per-test +// altcha-lib (v2): verifySolution is controlled per-test jest.mock('altcha-lib', () => ({ - deriveHmacKeySecret: jest.fn(), verifySolution: jest.fn(), })); +jest.mock('altcha-lib/frameworks/shared', () => ({ + deriveHmacKeySecret: jest.fn(), +})); + // listmonk client wrapper jest.mock('@/lib/listmonk', () => ({ createSubscriber: jest.fn(), - sendOptInEmail: jest.fn(), ListmonkError: class ListmonkError extends Error { public readonly status: number; public readonly responseBody: unknown; @@ -341,13 +343,17 @@ describe('Newsletter Subscribe Validation', () => { // --------------------------------------------------------------------------- describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { - beforeEach(() => { + beforeEach(async () => { jest.resetAllMocks(); process.env.ALTCHA_HMAC_SECRET = 'test-secret-hex'; process.env.LISTMONK_BASE_URL = 'http://listmonk.test'; process.env.LISTMONK_API_USER = 'api'; process.env.LISTMONK_API_KEY = 'key'; process.env.LISTMONK_LIST_ID = '1'; + + const { deriveHmacKeySecret } = + await import('altcha-lib/frameworks/shared'); + (deriveHmacKeySecret as jest.Mock).mockResolvedValue('derived-secret'); }); afterEach(() => { @@ -387,9 +393,8 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { expect(res.status).toBe(400); const json = await res.json(); expect(json.error).toMatch(/bot verification failed/i); - const { createSubscriber, sendOptInEmail } = await import('@/lib/listmonk'); + const { createSubscriber } = await import('@/lib/listmonk'); expect(createSubscriber).not.toHaveBeenCalled(); - expect(sendOptInEmail).not.toHaveBeenCalled(); }); it('returns 400 "Bot verification failed" when ALTCHA_HMAC_SECRET is not configured', async () => { @@ -422,16 +427,15 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { // Schema rejects first (400 invalid input), which is fine — bot still blocked expect(res.status).toBe(400); - const { createSubscriber, sendOptInEmail } = await import('@/lib/listmonk'); + const { createSubscriber } = await import('@/lib/listmonk'); expect(createSubscriber).not.toHaveBeenCalled(); - expect(sendOptInEmail).not.toHaveBeenCalled(); }); it('calls listmonk and returns 200 when verifySolution returns true', async () => { const { verifySolution } = await import('altcha-lib'); (verifySolution as jest.Mock).mockResolvedValueOnce({ verified: true }); - const { createSubscriber, sendOptInEmail } = await import('@/lib/listmonk'); + const { createSubscriber } = await import('@/lib/listmonk'); (createSubscriber as jest.Mock).mockResolvedValueOnce({ id: 123, uuid: 'sub-uuid', @@ -455,7 +459,54 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { const json = await res.json(); expect(json.message).toMatch(/confirm/i); expect(createSubscriber).toHaveBeenCalledTimes(1); - expect(sendOptInEmail).not.toHaveBeenCalled(); + }); + + it('returns 200 with generic message when listmonk reports duplicate (409)', async () => { + const { verifySolution } = await import('altcha-lib'); + (verifySolution as jest.Mock).mockResolvedValueOnce({ verified: true }); + + const { createSubscriber, ListmonkError } = await import('@/lib/listmonk'); + (createSubscriber as jest.Mock).mockRejectedValueOnce( + new ListmonkError('dup', 409, {}), + ); + + const res = await POST( + makeRequest( + { + email: 'dup@example.com', + altcha: VALID_ALTCHA_PAYLOAD, + privacy: true, + }, + '10.0.0.6', + ), + ); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(String(json.message)).toMatch(/thank you/i); + }); + + it('returns 500 when listmonk returns 400 from createSubscriber', async () => { + const { verifySolution } = await import('altcha-lib'); + (verifySolution as jest.Mock).mockResolvedValueOnce({ verified: true }); + + const { createSubscriber, ListmonkError } = await import('@/lib/listmonk'); + (createSubscriber as jest.Mock).mockRejectedValueOnce( + new ListmonkError('bad', 400, { reason: 'validation' }), + ); + + const res = await POST( + makeRequest( + { + email: 'bad@example.com', + altcha: VALID_ALTCHA_PAYLOAD, + privacy: true, + }, + '10.0.0.7', + ), + ); + + expect(res.status).toBe(500); }); it('returns 400 for missing privacy consent before ALTCHA check runs', async () => { @@ -475,8 +526,7 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { expect(res.status).toBe(400); // verifySolution must never be called if privacy is not accepted expect(verifySolution).not.toHaveBeenCalled(); - const { createSubscriber, sendOptInEmail } = await import('@/lib/listmonk'); + const { createSubscriber } = await import('@/lib/listmonk'); expect(createSubscriber).not.toHaveBeenCalled(); - expect(sendOptInEmail).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/lib/listmonk.test.ts b/src/__tests__/lib/listmonk.test.ts index a699fcb5..dcc8ff71 100644 --- a/src/__tests__/lib/listmonk.test.ts +++ b/src/__tests__/lib/listmonk.test.ts @@ -120,7 +120,7 @@ describe('listmonk client', () => { results: [ { id: 7, - uuid: 'uuid', + uuid: '11111111-1111-4111-8111-111111111111', email: 'x@y.z', name: 'x', status: 'enabled', @@ -131,10 +131,18 @@ describe('listmonk client', () => { }), }); - const sub = await findSubscriberByUuid('uuid'); + const sub = await findSubscriberByUuid( + '11111111-1111-4111-8111-111111111111', + ); expect(sub?.id).toBe(7); }); + it('findSubscriberByUuid returns null for invalid uuid', async () => { + const sub = await findSubscriberByUuid("x'); DROP--"); + expect(sub).toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + it('unsubscribeSubscriberFromLists calls /api/subscribers/lists', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, diff --git a/src/app/api/newsletter/challenge/route.ts b/src/app/api/newsletter/challenge/route.ts index b6f7a600..72f8f38f 100644 --- a/src/app/api/newsletter/challenge/route.ts +++ b/src/app/api/newsletter/challenge/route.ts @@ -1,6 +1,6 @@ import { createChallenge, randomInt } from 'altcha-lib'; import { deriveKey } from 'altcha-lib/algorithms/pbkdf2'; -import { deriveHmacKeySecret } from 'altcha-lib/frameworks/nextjs'; +import { deriveHmacKeySecret } from 'altcha-lib/frameworks/shared'; import { NextResponse } from 'next/server'; import { altchaHmacSecret } from '@/constant/env'; diff --git a/src/app/api/newsletter/subscribe/route.ts b/src/app/api/newsletter/subscribe/route.ts index 9fc23454..7cd67bac 100644 --- a/src/app/api/newsletter/subscribe/route.ts +++ b/src/app/api/newsletter/subscribe/route.ts @@ -1,6 +1,6 @@ import { verifySolution } from 'altcha-lib'; import { deriveKey } from 'altcha-lib/algorithms/pbkdf2'; -import { deriveHmacKeySecret } from 'altcha-lib/frameworks/nextjs'; +import { deriveHmacKeySecret } from 'altcha-lib/frameworks/shared'; import { NextResponse } from 'next/server'; import { createSubscriber, ListmonkError } from '@/lib/listmonk'; @@ -162,17 +162,23 @@ export async function POST(request: Request) { // listmonk sends the confirmation email automatically for double opt-in lists. await createSubscriber({ email, listIds: [listId] }); } catch (err) { - // Silent failure on duplicates/validation to avoid user enumeration. - if ( - err instanceof ListmonkError && - (err.status === 400 || err.status === 409) - ) { + // Only duplicate / conflict: silent success to avoid user enumeration. + if (err instanceof ListmonkError && err.status === 409) { return NextResponse.json({ message: 'Thank you! If the email address is not yet registered, you will receive a confirmation email.', }); } + if (err instanceof ListmonkError && err.status === 400) { + // eslint-disable-next-line no-console + console.error('Newsletter subscribe: listmonk validation error', err); + return NextResponse.json( + { error: 'An internal error occurred' }, + { status: 500 }, + ); + } + // eslint-disable-next-line no-console console.error('Newsletter subscribe failed', err); return NextResponse.json( diff --git a/src/lib/listmonk.ts b/src/lib/listmonk.ts index 6b02c230..280e606a 100644 --- a/src/lib/listmonk.ts +++ b/src/lib/listmonk.ts @@ -124,10 +124,25 @@ export async function sendOptInEmail(subscriberId: number): Promise { return Boolean(result); } +/** listmonk subscriber UUID (8-4-4-4-12 hex, version/variant per RFC 4122) */ +const SUBSCRIBER_UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isListmonkSubscriberUuid(value: string): boolean { + return SUBSCRIBER_UUID_RE.test(value.trim()); +} + export async function findSubscriberByUuid( uuid: string, ): Promise { - const query = encodeURIComponent(`subscribers.uuid = '${uuid}'`); + const trimmed = uuid.trim(); + if (!isListmonkSubscriberUuid(trimmed)) { + return null; + } + + const query = encodeURIComponent( + `subscribers.uuid = '${trimmed.replace(/'/g, "''")}'`, + ); const data = await listmonkRequest<{ results: ListmonkSubscriber[]; total: number; diff --git a/src/types/altcha.d.ts b/src/types/altcha.d.ts index 9b15aa88..e5505cd6 100644 --- a/src/types/altcha.d.ts +++ b/src/types/altcha.d.ts @@ -1,6 +1,6 @@ import type React from 'react'; -declare global { +declare module 'react/jsx-runtime' { namespace JSX { interface IntrinsicElements { 'altcha-widget': React.DetailedHTMLProps< From b8d5d853622abd4525cd82448f54cf6abe06666d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 21:32:05 +0000 Subject: [PATCH 10/10] style(newsletter): remove duplicate altcha types import --- src/components/templates/NewsletterForm.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/templates/NewsletterForm.tsx b/src/components/templates/NewsletterForm.tsx index 32cd3e85..700fc8c3 100644 --- a/src/components/templates/NewsletterForm.tsx +++ b/src/components/templates/NewsletterForm.tsx @@ -3,7 +3,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import type { WidgetAttributes } from 'altcha/types'; import type {} from 'altcha/types/react'; -import type {} from 'altcha/types/react'; import { useTheme } from 'next-themes'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form';