From 65b241805e50de902cac718df9ddc41d29ccde90 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Mon, 27 Apr 2026 15:40:28 +0200 Subject: [PATCH] test(portal): IMAP full-lifecycle activation E2E + dev probe helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New realapi spec walks the entire portal activation loop over real network: invite via the admin endpoint → wait for the activation email to land in the IMAP mailbox → extract the token from the body link → activate the portal user via the public API → sign in with the new password. The match logic deliberately doesn't filter on the TO header — the combination of EMAIL_REDIRECT_TO rewriting and +addressing made TO matching brittle. Instead we discriminate by sender (noreply@…), subject keyword, and body link pattern, which is unique enough to find exactly the email this test triggered. Companion script scripts/dev-imap-probe.ts dumps the most recent ~10 messages with from/to/subject/date — useful for debugging when an IMAP match goes wrong. Skips when IMAP_HOST / IMAP_USER / IMAP_PASS are absent so the suite stays portable. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/dev-imap-probe.ts | 66 ++++++++ .../realapi/portal-imap-activation.spec.ts | 155 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 scripts/dev-imap-probe.ts create mode 100644 tests/e2e/realapi/portal-imap-activation.spec.ts diff --git a/scripts/dev-imap-probe.ts b/scripts/dev-imap-probe.ts new file mode 100644 index 0000000..b8c9512 --- /dev/null +++ b/scripts/dev-imap-probe.ts @@ -0,0 +1,66 @@ +/** + * Dev diagnostic: connect to IMAP and print the most recent ~10 messages, + * showing TO/FROM/subject/date so we can see what the dev mailbox is + * actually receiving. + * + * Run: pnpm tsx scripts/dev-imap-probe.ts + */ + +import 'dotenv/config'; +import { ImapFlow } from 'imapflow'; +import { simpleParser } from 'mailparser'; + +async function main(): Promise { + const host = process.env.IMAP_HOST!; + const port = Number(process.env.IMAP_PORT ?? 993); + const user = process.env.IMAP_USER!; + const pass = process.env.IMAP_PASS!; + + if (!host || !user || !pass) { + throw new Error('IMAP_HOST / IMAP_USER / IMAP_PASS not set'); + } + + console.log(`Connecting to ${user}@${host}:${port}…`); + const client = new ImapFlow({ + host, + port, + secure: port === 993, + auth: { user, pass }, + logger: false, + }); + + await client.connect(); + console.log('Connected. Inbox status:'); + const lock = await client.getMailboxLock('INBOX'); + try { + const status = await client.status('INBOX', { messages: true, recent: true }); + console.log(' total:', status.messages, '| recent:', status.recent); + + // Pull the last 10 by UID + const since = new Date(Date.now() - 30 * 60 * 1000); // last 30 min + const result = await client.search({ since }); + const uids = Array.isArray(result) ? result.slice(-10).reverse() : []; + console.log(`Found ${uids.length} messages in last 30min:`); + for (const uid of uids) { + const msg = await client.fetchOne(String(uid), { source: true, envelope: true }); + if (!msg || !msg.source) continue; + const parsed = await simpleParser(msg.source); + const tos = (Array.isArray(parsed.to) ? parsed.to : parsed.to ? [parsed.to] : []) + .flatMap((a) => a.value.map((v) => v.address ?? '')) + .join(', '); + console.log( + ` uid=${uid} date=${parsed.date?.toISOString()} from=${parsed.from?.text} to=${tos} subject=${parsed.subject}`, + ); + } + } finally { + lock.release(); + } + await client.logout(); + console.log('Done.'); + process.exit(0); +} + +main().catch((err) => { + console.error('Probe failed:', err); + process.exit(1); +}); diff --git a/tests/e2e/realapi/portal-imap-activation.spec.ts b/tests/e2e/realapi/portal-imap-activation.spec.ts new file mode 100644 index 0000000..9a429fa --- /dev/null +++ b/tests/e2e/realapi/portal-imap-activation.spec.ts @@ -0,0 +1,155 @@ +import 'dotenv/config'; +import { test, expect } from '@playwright/test'; +import { ImapFlow } from 'imapflow'; +import { simpleParser, type ParsedMail } from 'mailparser'; + +import { login, apiHeaders } from '../smoke/helpers'; + +/** + * Real-API end-to-end test for the portal activation flow over real SMTP+IMAP. + * + * Walks the full loop: + * 1. Create a fresh client with a +addressed test email. + * 2. Admin invites the client to the portal, triggering the activation email. + * 3. Connect to IMAP and poll until the activation email lands. + * 4. Parse the email, extract the activation token from the link. + * 5. POST /api/portal/auth/activate with the token + new password. + * 6. POST /api/portal/auth/sign-in with the same email + password. + * 7. Assert the sign-in succeeds. + * + * Requires IMAP_HOST + IMAP_USER + IMAP_PASS in the environment to read the + * destination mailbox. The test sends to +imap-test- which + * routes back to the same inbox via standard +addressing. + * + * NOTE: this test bypasses EMAIL_REDIRECT_TO by sending to the IMAP user + * directly. If EMAIL_REDIRECT_TO is set, the redirect still applies — but + * the redirect target is also IMAP_USER in our dev setup, so it works out. + */ + +const IMAP_HOST = process.env.IMAP_HOST; +const IMAP_PORT = Number(process.env.IMAP_PORT ?? 993); +const IMAP_USER = process.env.IMAP_USER; +const IMAP_PASS = process.env.IMAP_PASS; + +const MAILBOX = 'INBOX'; +const POLL_TIMEOUT_MS = 60_000; +const POLL_INTERVAL_MS = 5_000; + +function plusAddress(base: string, tag: string): string { + const [local, domain] = base.split('@'); + return `${local}+${tag}@${domain}`; +} + +async function fetchActivationToken(args: { + recipientEmail: string; + notBefore: Date; +}): Promise { + if (!IMAP_HOST || !IMAP_USER || !IMAP_PASS) { + throw new Error('IMAP credentials not configured'); + } + + const client = new ImapFlow({ + host: IMAP_HOST, + port: IMAP_PORT, + secure: IMAP_PORT === 993, + auth: { user: IMAP_USER, pass: IMAP_PASS }, + logger: false, + }); + + await client.connect(); + try { + const lock = await client.getMailboxLock(MAILBOX); + try { + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + // Walk recent messages; first one whose body carries an + // /portal/activate?token=... link wins. We don't filter on TO header + // because EMAIL_REDIRECT_TO may rewrite it and +addressing may or may + // not be preserved depending on the server config. + const searchResult = await client.search({ since: args.notBefore }); + const uids = Array.isArray(searchResult) ? [...searchResult].reverse() : []; + for (const uid of uids) { + const msg = await client.fetchOne(String(uid), { source: true }); + if (!msg || !msg.source) continue; + const parsed: ParsedMail = await simpleParser(msg.source); + // Skip anything that isn't our send (sender + subject discriminator). + const fromAddr = parsed.from?.value?.[0]?.address?.toLowerCase() ?? ''; + const subject = (parsed.subject ?? '').toLowerCase(); + if (!fromAddr.includes('noreply@letsbe.solutions') || !subject.includes('activate')) { + continue; + } + const body = parsed.html || parsed.text || ''; + const match = body.match(/\/portal\/activate\?token=([A-Za-z0-9_\-]+)/); + if (match?.[1]) { + return match[1]; + } + } + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } + throw new Error( + `Activation email for ${args.recipientEmail} not found within ${POLL_TIMEOUT_MS}ms`, + ); + } finally { + lock.release(); + } + } finally { + await client.logout(); + } +} + +test.describe('Portal activation: SMTP + IMAP round-trip', () => { + test.skip(!IMAP_HOST || !IMAP_USER || !IMAP_PASS, 'IMAP_HOST / IMAP_USER / IMAP_PASS not set'); + + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('full activation loop via real SMTP delivery + IMAP retrieval', async ({ page }) => { + const stamp = Date.now(); + const headers = await apiHeaders(page); + const recipientEmail = plusAddress(IMAP_USER!, `imap-test-${stamp}`); + const notBefore = new Date(Date.now() - 60_000); // 1-min skew tolerance + + // ─── 1. Create a fresh client ──────────────────────────────────────────── + const clientRes = await page.request.post('/api/v1/clients', { + headers, + data: { + fullName: `IMAP Test Client ${stamp}`, + contacts: [{ channel: 'email', value: recipientEmail, isPrimary: true }], + }, + }); + expect(clientRes.ok(), `client create: ${clientRes.status()}`).toBe(true); + const clientId = (await clientRes.json()).data.id as string; + + // ─── 2. Admin invites the client ───────────────────────────────────────── + const inviteRes = await page.request.post(`/api/v1/clients/${clientId}/portal-user`, { + headers, + data: { email: recipientEmail, name: `IMAP Test Client ${stamp}` }, + }); + expect(inviteRes.ok(), `invite: ${inviteRes.status()} ${await inviteRes.text()}`).toBe(true); + + // ─── 3-4. Poll IMAP and extract the activation token ───────────────────── + // Match on the +addressed recipient — that's what's preserved in the TO + // header even after the mailserver delivers it back to IMAP_USER's inbox. + const token = await fetchActivationToken({ + recipientEmail: recipientEmail.toLowerCase(), + notBefore, + }); + expect(token, 'activation token extracted from email').toBeTruthy(); + + // ─── 5. Activate the account ───────────────────────────────────────────── + const password = `Imap-Test-${stamp}!`; + const activateRes = await page.request.post('/api/portal/auth/activate', { + data: { token, password }, + }); + expect(activateRes.ok(), `activate: ${activateRes.status()} ${await activateRes.text()}`).toBe( + true, + ); + + // ─── 6-7. Sign in with the new credentials ─────────────────────────────── + const signInRes = await page.request.post('/api/portal/auth/sign-in', { + data: { email: recipientEmail, password }, + }); + expect(signInRes.ok(), `sign-in: ${signInRes.status()} ${await signInRes.text()}`).toBe(true); + }); +});