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); }); });