Mail: Outlook.com inside the app through Microsoft Graph (folders, reader, compose, reply, unread badge); Cypht webmail removed

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 08:09:11 -04:00
1 parent 1564941ea5
commit a9c277b497
23 files changed
+637 -405

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env node
// Mail (Outlook.com through Microsoft Graph): connecting, token refresh, reading, sending, privacy between people.
// Uses a fake Microsoft sign-in/token service and a fake Graph. Run: node tools/mail-test.js
const { spawn } = require('child_process');
const http = require('http');
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const root = path.join(__dirname, '..');
let failed = 0;
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
const wait = ms => new Promise(r => setTimeout(r, ms));
const SECRET = 'mail-test-secret-'.repeat(3);
(async () => {
// ---- fake Microsoft
const ms = { challenge: '', goodTokens: new Set(), refreshCalls: 0, revoked: false, tokenN: 0, calls: [], expiresIn: 3600 };
const body = rq => new Promise(r => { const c = []; rq.on('data', d => c.push(d)); rq.on('end', () => r(Buffer.concat(c).toString())); });
const out = (rs, code, o, h = {}) => { rs.writeHead(code, { 'content-type': 'application/json', ...h }); rs.end(o === undefined ? '' : JSON.stringify(o)); };
const folder = (id, name, unread, total) => ({ id: 'real-' + id, displayName: name, unreadItemCount: unread, totalItemCount: total });
const msgs = [
{ id: 'M1', subject: 'Hello there', from: { emailAddress: { name: 'Ann', address: 'ann@example.com' } }, receivedDateTime: '2026-10-22T10:00:00Z', isRead: false, hasAttachments: true, bodyPreview: 'Hi…', flag: { flagStatus: 'notFlagged' } },
{ id: 'M2', subject: 'Second', from: { emailAddress: { name: 'Bob', address: 'bob@example.com' } }, receivedDateTime: '2026-10-21T10:00:00Z', isRead: true, hasAttachments: false, bodyPreview: 'x', flag: { flagStatus: 'flagged' } },
];
const server = http.createServer(async (rq, rs) => {
const u = new URL(rq.url, 'http://x'), p = u.pathname, b = await body(rq);
ms.calls.push(rq.method + ' ' + decodeURIComponent(p + u.search));
if (p === '/consumers/oauth2/v2.0/token' && rq.method === 'POST') {
const f = new URLSearchParams(b);
if (f.get('client_id') !== 'test-client') return out(rs, 400, { error: 'invalid_client' });
if (f.get('grant_type') === 'authorization_code') {
if (f.get('code') !== 'GOODCODE') return out(rs, 400, { error: 'invalid_grant', error_description: 'bad code' });
if (crypto.createHash('sha256').update(f.get('code_verifier') || '').digest('base64url') !== ms.challenge) return out(rs, 400, { error: 'invalid_grant', error_description: 'PKCE failed' });
const at = 'AT' + (++ms.tokenN); ms.goodTokens = new Set([at]); return out(rs, 200, { access_token: at, refresh_token: 'RT1', expires_in: ms.expiresIn });
}
if (f.get('grant_type') === 'refresh_token') {
ms.refreshCalls++;
if (ms.revoked || !/^RT/.test(f.get('refresh_token'))) return out(rs, 400, { error: 'invalid_grant', error_description: 'revoked' });
const at = 'AT' + (++ms.tokenN); ms.goodTokens = new Set([at]); return out(rs, 200, { access_token: at, refresh_token: 'RT' + (ms.tokenN), expires_in: 3600 });
}
}
if (!p.startsWith('/v1.0/')) { rs.writeHead(404); return rs.end(); }
const tok = (rq.headers.authorization || '').replace('Bearer ', '');
if (!ms.goodTokens.has(tok)) return out(rs, 401, { error: { code: 'InvalidAuthenticationToken', message: 'expired' } });
const g = p.slice(5);
if (g === '/me') return out(rs, 200, { displayName: 'Nate', mail: 'nate@outlook.com' });
let m;
if ((m = /^\/me\/mailFolders\/(\w+)$/.exec(g))) { const f = { inbox: folder('inbox', 'Inbox', 3, 10), drafts: folder('drafts', 'Drafts', 0, 1), sentitems: folder('sentitems', 'Sent Items', 0, 4), archive: folder('archive', 'Archive', 0, 0), junkemail: folder('junkemail', 'Junk Email', 0, 0), deleteditems: folder('deleteditems', 'Deleted Items', 0, 2) }[m[1]]; return f ? out(rs, 200, f) : out(rs, 404, { error: { message: 'no folder' } }); }
if (g === '/me/mailFolders') return out(rs, 200, { value: [folder('inbox', 'Inbox', 3, 10), { id: 'CUSTOM1', displayName: 'Receipts', unreadItemCount: 1, totalItemCount: 5 }, { id: 'CUSTOM2', displayName: 'Outbox', unreadItemCount: 0, totalItemCount: 0 }] });
if ((m = /^\/me\/mailFolders\/([\w=-]+)\/messages$/.exec(g))) { const all = u.searchParams.get('$search') ? msgs.filter(x => x.subject.toLowerCase().includes(u.searchParams.get('$search').replace(/"/g, '').toLowerCase())) : msgs; return out(rs, 200, { value: all, ...(u.searchParams.get('$skip') === '0' && all.length >= 2 ? { '@odata.nextLink': 'x' } : {}) }); }
if ((m = /^\/me\/messages\/([\w=-]+)$/.exec(g))) {
if (rq.method === 'PATCH') { ms.patched = JSON.parse(b); return out(rs, 200, {}); }
if (rq.method === 'DELETE') { ms.deleted = m[1]; return out(rs, 204); }
const x = msgs.find(y => y.id === m[1]); if (!x) return out(rs, 404, { error: { message: 'gone' } });
return out(rs, 200, { ...x, toRecipients: [{ emailAddress: { name: 'Nate', address: 'nate@outlook.com' } }], ccRecipients: [], body: { contentType: 'html', content: '<p>Hi <img src="cid:logo1"></p><script>alert(1)</script>' },
attachments: [{ id: 'A1', name: 'report.pdf', contentType: 'application/pdf', size: 5, isInline: false }, { id: 'A2', name: 'logo.png', contentType: 'image/png', size: 4, isInline: true, contentId: '<logo1>' }] });
}
if ((m = /^\/me\/messages\/([\w=-]+)\/attachments\/(\w+)$/.exec(g))) return out(rs, 200, m[2] === 'A2' ? { contentBytes: Buffer.from('PNG!').toString('base64'), contentType: 'image/png' } : { name: 'rep"ort\r\n.pdf', contentType: 'application/pdf', size: 5 });
if ((m = /^\/me\/messages\/([\w=-]+)\/attachments\/(\w+)\/\$value$/.exec(g))) { rs.writeHead(200, { 'content-type': 'application/pdf' }); return rs.end('%PDF-'); }
if ((m = /^\/me\/messages\/([\w=-]+)\/move$/.exec(g))) { ms.moved = [m[1], JSON.parse(b).destinationId]; return out(rs, 201, {}); }
if (g === '/me/sendMail') { ms.sent = JSON.parse(b); return out(rs, 202); }
if ((m = /^\/me\/messages\/([\w=-]+)\/(reply|replyAll|forward)$/.exec(g))) { ms.replied = [m[1], m[2], JSON.parse(b)]; return out(rs, 202); }
rs.writeHead(404); rs.end();
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const msBase = `http://127.0.0.1:${server.address().port}`;
// ---- the hub with two people: Nate (admin) and Sam (no Mail tab)
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-mail-')), port = 20000 + Math.floor(Math.random() * 20000), base = `http://localhost:${port}`;
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [
{ id: 'unate', name: 'Nate', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 },
{ id: 'usam', name: 'Sam', role: 'user', tabs: ['tasks'], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 },
{ id: 'uann', name: 'Ann', role: 'user', tabs: ['mail'], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 }] }));
const exp = Date.now() + 864e5, ck = uid => `hub_session=${uid}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${uid}.1.${exp}`).digest('base64url')}`;
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, MS_CLIENT_ID: 'test-client', MS_AUTH_URL: msBase, MS_GRAPH_URL: msBase + '/v1.0', PUBLIC_URL: '' } });
let log = ''; child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d);
for (let i = 0; i < 60; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } }
const call = async (uid, p, o = {}) => { const r = await fetch(base + p, { redirect: 'manual', ...o, headers: { ...(uid ? { cookie: ck(uid) } : {}), 'content-type': 'application/json', ...(o.headers || {}) }, body: o.body && typeof o.body !== 'string' ? JSON.stringify(o.body) : o.body }); const t = await r.text(); let j; try { j = JSON.parse(t); } catch { j = t; } return { status: r.status, json: j, headers: r.headers, text: t }; };
const post = (uid, p, b) => call(uid, p, { method: 'POST', body: b || {} });
// ================= access =================
let r = await call('uann', '/api/config'); ok('the app is told Mail is set up', r.json.mail === '1', r.json);
r = await call('usam', '/api/config'); ok('a person without the Mail tab is not told', r.json.mail === '', r.json);
r = await call('usam', '/api/mail/status'); ok('and cannot use mail at all', r.status === 403, r.status);
r = await call(null, '/api/mail/status'); ok('nor can someone who is not signed in', r.status === 401, r.status);
r = await call('unate', '/api/mail/status'); ok('before connecting: not connected', r.status === 200 && r.json.connected === false, r.json);
r = await call('unate', '/api/mail/messages'); ok('reading mail before connecting says to connect (409)', r.status === 409, r);
// ================= connecting =================
r = await call('unate', '/api/mail/connect'); const au = new URL(r.json.url);
ok('connect gives a Microsoft sign-in link for personal accounts with PKCE and mail permissions', au.pathname === '/consumers/oauth2/v2.0/authorize' && au.searchParams.get('client_id') === 'test-client' && au.searchParams.get('code_challenge_method') === 'S256' && /Mail\.ReadWrite/.test(au.searchParams.get('scope')) && /Mail\.Send/.test(au.searchParams.get('scope')) && au.searchParams.get('redirect_uri') === base + '/api/mail/callback', r.json);
ms.challenge = au.searchParams.get('code_challenge'); const state = au.searchParams.get('state');
r = await call(null, `/api/mail/callback?code=GOODCODE&state=WRONG`); ok('a callback with an unknown state is refused', r.status === 400, r.status);
r = await call(null, `/api/mail/callback?code=BAD&state=${state}`); ok('a rejected code does not connect anyone', r.status === 400 && /Could not connect/.test(r.text), r.status);
r = await call('unate', '/api/mail/connect'); const au2 = new URL(r.json.url); ms.challenge = au2.searchParams.get('code_challenge');
r = await call(null, `/api/mail/callback?code=GOODCODE&state=${au2.searchParams.get('state')}`); ok('the real callback connects and sends the person back to the Mail tab', r.status === 302 && r.headers.get('location') === '/#/mail', [r.status, r.headers.get('location')]);
r = await call(null, `/api/mail/callback?code=GOODCODE&state=${au2.searchParams.get('state')}`); ok('the sign-in link works only once', r.status === 400, r.status);
r = await call('unate', '/api/mail/status'); ok('now connected, with the address and the unread count', r.json.connected && r.json.address === 'nate@outlook.com' && r.json.unread === 3, r.json);
const raw = fs.readFileSync(path.join(dir, 'users', 'unate', 'mail.json'), 'utf8'); ok('the tokens are saved encrypted', !/AT1|RT1/.test(raw), raw.slice(0, 80));
r = await call('uann', '/api/mail/status'); ok("another person is not connected to Nate's mailbox", r.json.connected === false, r.json);
r = await call('uann', '/api/mail/messages'); ok("and cannot read it", r.status === 409, r.status);
// ================= reading =================
r = await call('unate', '/api/mail/folders'); const names = (r.json || []).map(f => f.name);
ok('folders: the usual six first with unread counts, then your own; internal ones hidden', names.slice(0, 6).join() === 'Inbox,Drafts,Sent,Archive,Junk,Deleted' && names.includes('Receipts') && !names.includes('Outbox') && r.json[0].unread === 3 && names.filter(n => n === 'Inbox').length === 1, r.json);
r = await call('unate', '/api/mail/messages?folder=inbox'); ok('the inbox lists messages, newest first, with who, read state and attachment flag', r.status === 200 && r.json.items.length === 2 && r.json.items[0].from.address === 'ann@example.com' && r.json.items[0].read === false && r.json.items[0].attach === true && r.json.items[1].flagged === true, r.json);
ok('and says there may be more', r.json.more === true && r.json.next === 2, r.json);
r = await call('unate', '/api/mail/messages?folder=inbox&q=second'); ok('search finds a message', r.json.items.length === 1 && r.json.items[0].id === 'M2', r.json);
r = await call('unate', '/api/mail/messages?folder=../me'); ok('a folder name cannot break out of the mail path', r.status === 400, r.status);
r = await call('unate', '/api/mail/message?id=M1'); const mm = r.json;
ok('a message opens with sender, recipients and subject', r.status === 200 && mm.subject === 'Hello there' && mm.to[0].address === 'nate@outlook.com', mm);
ok('pictures that are part of the message are put inline (cid -> data)', /data:image\/png;base64,/.test(mm.html) && !/cid:logo1/.test(mm.html), mm.html);
ok('only real attachments are listed (not the inline picture)', mm.attachments.length === 1 && mm.attachments[0].name === 'report.pdf', mm.attachments);
r = await call('unate', '/api/mail/attachment?id=M1&aid=A1'); ok('an attachment downloads as a file, with a safe name', r.status === 200 && r.text === '%PDF-' && /attachment; filename="rep_ort__?\.pdf"/.test(r.headers.get('content-disposition')) && !/[\r\n]/.test(r.headers.get('content-disposition')), [r.status, r.headers.get('content-disposition')]);
// ================= changing things =================
await post('unate', '/api/mail/update', { id: 'M1', read: true }); ok('mark as read', ms.patched && ms.patched.isRead === true, ms.patched);
await post('unate', '/api/mail/update', { id: 'M1', flagged: true }); ok('flag', ms.patched.flag.flagStatus === 'flagged', ms.patched);
await post('unate', '/api/mail/move', { id: 'M1', to: 'archive' }); ok('archive moves to the Archive folder', ms.moved[0] === 'M1' && ms.moved[1] === 'archive', ms.moved);
await post('unate', '/api/mail/delete', { id: 'M2' }); ok('delete moves to Deleted Items', ms.moved[0] === 'M2' && ms.moved[1] === 'deleteditems', ms.moved);
await post('unate', '/api/mail/delete', { id: 'M2', permanent: true }); ok('and from Deleted Items it is removed for good', ms.deleted === 'M2', ms.deleted);
// ================= sending =================
r = await post('unate', '/api/mail/send', { to: 'bob@example.com, Ann <ann@example.com>', cc: '', subject: 'Hi', body: 'Line 1\n<b>not bold</b>' });
ok('send builds the message (several recipients, text turned into safe HTML)', r.status === 200 && ms.sent.message.toRecipients.length === 2 && ms.sent.message.toRecipients[1].emailAddress.address === 'ann@example.com' && /&lt;b&gt;not bold/.test(ms.sent.message.body.content) && ms.sent.saveToSentItems === true, ms.sent);
r = await post('unate', '/api/mail/send', { to: 'nobody', subject: 'x', body: 'y' }); ok('a bad address is refused with its name in the message', r.status === 400 && /nobody/.test(r.json.error), r.json);
r = await post('unate', '/api/mail/send', { to: '', subject: 'x', body: 'y' }); ok('no recipient is refused', r.status === 400, r.json);
r = await post('unate', '/api/mail/send', { to: 'a@b.co', subject: 'x', body: 'y', attachments: [{ name: 'big.bin', type: 'x/y', data: Buffer.alloc(3.5 * 1024 * 1024).toString('base64') }] }); ok('attachments over about 3 MB are refused', r.status === 400 && /3 MB/.test(r.json.error), r.json);
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'replyAll', comment: 'Thanks' }); ok('reply all goes through the thread', r.status === 200 && ms.replied[1] === 'replyAll' && ms.replied[2].comment === 'Thanks', ms.replied);
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'forward', comment: 'FYI', to: 'x@y.org' }); ok('forward needs a recipient and sends', r.status === 200 && ms.replied[2].toRecipients[0].emailAddress.address === 'x@y.org', ms.replied);
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'forward', comment: 'FYI' }); ok('forward with no recipient is refused', r.status === 400, r.json);
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'bogus' }); ok('an unknown action is refused', r.status === 400, r.json);
// ================= tokens =================
const refreshBefore = ms.refreshCalls;
ms.goodTokens = new Set(); // Microsoft no longer accepts the access token (expired early)
r = await call('unate', '/api/mail/folders'); ok('an expired access token is refreshed automatically and the request still works', r.status === 200 && ms.refreshCalls === refreshBefore + 1, [r.status, ms.refreshCalls]);
ms.revoked = true; ms.goodTokens = new Set();
r = await call('unate', '/api/mail/messages'); ok('if Microsoft revokes access, the person is told to connect again (409)', r.status === 409 && /Connect again/.test(r.json.error), r);
r = await call('unate', '/api/mail/status'); ok('and shown as not connected', r.json.connected === false, r.json);
ok('the dead sign-in is removed from disk', !fs.existsSync(path.join(dir, 'users', 'unate', 'mail.json')));
// reconnect, then disconnect
ms.revoked = false; r = await call('unate', '/api/mail/connect'); const au3 = new URL(r.json.url); ms.challenge = au3.searchParams.get('code_challenge');
await call(null, `/api/mail/callback?code=GOODCODE&state=${au3.searchParams.get('state')}`); r = await call('unate', '/api/mail/status'); ok('connecting again works', r.json.connected === true, r.json);
r = await post('unate', '/api/mail/disconnect'); r = await call('unate', '/api/mail/status'); ok('disconnect removes the sign-in', r.json.connected === false && !fs.existsSync(path.join(dir, 'users', 'unate', 'mail.json')), r.json);
r = await call('unate', '/api/audit'); const evs = (r.json.events || []).map(e => e.event); ok('connecting, sending and disconnecting are in the activity log (without any message text)', ['mail connected', 'mail sent', 'mail disconnected'].every(e => evs.includes(e)) && !JSON.stringify(r.json).includes('not bold'), evs);
r = await call('unate', '/webmail/'); ok('the old /webmail/ pass-through is gone', r.status === 404 || r.status === 200 && !/Cypht/i.test(r.text), r.status);
ok('the server is still running', child.exitCode === null, log.slice(-300));
child.kill(); server.close(); fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll mail checks passed'); process.exit(failed ? 1 : 0);
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });
+3 -3
View File
@@ -71,7 +71,7 @@ const T = (title, extra) => ({ title, notes: '', due: '', done: false, ...extra
await admin.call('/api/tasks', { list: 'local:inbox', fields: T('Admin private task') });
await admin.call('/api/cal/event', { cal: 'local', fields: { title: 'Admin private event', location: '', notes: '', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } });
await admin.call('/api/settings', { key: 'mail_hints', value: { gmail: 'admin@gmail.com' } }, 'PUT');
await admin.call('/api/settings', { key: 'office_loc', value: { gmail: 'admin@gmail.com' } }, 'PUT');
// ---- add Sam with only Tasks and Calendar
r = await admin.call('/api/users', { name: 'Sam', tabs: ['tasks', 'calendar', 'search'] }); const samUser = r.json.user, samCode = r.json.code;
@@ -99,13 +99,13 @@ const T = (title, extra) => ({ title, notes: '', due: '', done: false, ...extra
r = await sam.call('/api/tasks?list=local:inbox'); ok("Sam starts with an empty task list (the admin's task is not visible)", r.status === 200 && r.json.length === 0, r.json);
await sam.call('/api/tasks', { list: 'local:inbox', fields: T('Sam private task') });
await sam.call('/api/cal/event', { cal: 'local', fields: { title: 'Sam private event', location: '', notes: '', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } });
await sam.call('/api/settings', { key: 'mail_hints', value: { gmail: 'sam@gmail.com' } }, 'PUT');
await sam.call('/api/settings', { key: 'office_loc', value: { gmail: 'sam@gmail.com' } }, 'PUT');
const q = '/api/cal/events?from=' + Date.parse('2026-11-01T00:00:00Z') + '&to=' + Date.parse('2026-11-07T00:00:00Z') + '&fd=2026-11-01&td=2026-11-07';
const at = (await admin.call('/api/tasks?list=local:inbox')).json.map(t => t.title), st = (await sam.call('/api/tasks?list=local:inbox')).json.map(t => t.title);
ok('tasks are private to each person', at.join() === 'Admin private task' && st.join() === 'Sam private task', [at, st]);
const ae = (await admin.call(q)).json.events.map(e => e.title), se = (await sam.call(q)).json.events.map(e => e.title);
ok('NE Hub calendar events are private to each person', ae.join() === 'Admin private event' && se.join() === 'Sam private event', [ae, se]);
ok('settings are private to each person', (await admin.call('/api/settings')).json.values.mail_hints.gmail === 'admin@gmail.com' && (await sam.call('/api/settings')).json.values.mail_hints.gmail === 'sam@gmail.com');
ok('settings are private to each person', (await admin.call('/api/settings')).json.values.office_loc.gmail === 'admin@gmail.com' && (await sam.call('/api/settings')).json.values.office_loc.gmail === 'sam@gmail.com');
const fa = (await admin.call('/api/cal/feed')).json.url, fs_ = (await sam.call('/api/cal/feed')).json.url;
const fetchFeed = async u => { const x = await fetch(u.replace(/^https?:\/\/[^/]+/, base)); return { status: x.status, text: await x.text() }; };
const ta = await fetchFeed(fa), ts = await fetchFeed(fs_);
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env node
// The /webmail/ pass-through to the webmail (Cypht): sign-in required, paths, a 401 reply with a streamed body, redirects, cookies, the sign-in helper.
// Uses a fake webmail. Run: node tools/webmail-test.js
const { spawn } = require('child_process');
const http = require('http');
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const root = path.join(__dirname, '..');
let failed = 0;
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
const wait = ms => new Promise(r => setTimeout(r, ms));
const SECRET = 'webmail-test-secret-'.repeat(2);
(async () => {
const seen = [];
const rc = http.createServer((rq, rs) => {
const body = []; rq.on('data', d => body.push(d)); rq.on('end', () => {
seen.push({ method: rq.method, url: rq.url, cookie: rq.headers.cookie || '', host: rq.headers.host, body: Buffer.concat(body).toString(), fproto: rq.headers['x-forwarded-proto'] });
if (rq.url.startsWith('/webmail/?_task=login') && rq.method === 'POST') { rs.writeHead(401, { 'content-type': 'text/html' }); return rs.end('<p>Login failed.</p>'); }
if (rq.url.startsWith('/webmail/?_task=login&_action=oauth')) { rs.writeHead(302, { location: 'https://login.example.test/authorize?client_id=1&state=2' }); return rs.end(); }
if (rq.url.startsWith('/webmail/?page=home&state=nux_authorization')) { rs.writeHead(303, { location: '/?page=servers', 'set-cookie': 'hm_session=oauth; path=/webmail/' }); return rs.end(); }
if (rq.url === '/webmail/go') { rs.writeHead(302, { location: 'http://127.0.0.1:' + rc.address().port + '/webmail/?_task=mail' }); return rs.end(); }
rs.writeHead(200, { 'content-type': 'text/html', 'set-cookie': ['hm_session=abc; path=/webmail/; HttpOnly', 'hm_auth=def; path=/webmail/'] }); rs.end('<h1>the webmail</h1>');
});
});
await new Promise(r => rc.listen(0, '127.0.0.1', r));
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webmail-test-')), port = 20000 + Math.floor(Math.random() * 20000);
fs.writeFileSync(path.join(dataDir, 'users.json'), JSON.stringify({ users: [{ id: 'uadmin1', name: 'Admin', role: 'admin', tabs: ['mail'], handle: 'h', epoch: 1, disabled: false, created: 1 }] }));
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, WEBMAIL_BACKEND_URL: 'http://127.0.0.1:' + rc.address().port, CYPHT_USER: 'hub', CYPHT_PASSWORD: 'secret-pass', SEAFILE_URL: 'http://127.0.0.1:9' } });
let log = ''; child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d);
await wait(1500);
const base = 'http://127.0.0.1:' + port, exp = Date.now() + 864e5, cookie = `hub_session=${exp}.${crypto.createHmac('sha256', SECRET).update('s' + exp).digest('base64url')}`;
const call = (p, o = {}) => fetch(base + p, { redirect: 'manual', ...o, headers: { cookie: cookie + '; hm_session=keepme', ...(o.headers || {}) } });
let r = await fetch(base + '/webmail/', { redirect: 'manual' });
ok('without the hub sign-in, webmail goes to the sign-in page and the webmail is never contacted', r.status === 302 && r.headers.get('location') === '/login' && seen.length === 0, [r.status, seen.length]);
r = await call('/webmail/?_task=login');
ok('signed in: the page comes through, with the prefix removed', r.status === 200 && (await r.text()).includes('the webmail') && seen[0].url === '/webmail/?_task=login', seen[0]);
ok("the hub's own sign-in cookie is not passed on, but the webmail's is", !seen[0].cookie.includes('hub_session') && seen[0].cookie.includes('hm_session=keepme'), seen[0].cookie);
ok("the webmail's cookies come back (both of them)", (r.headers.getSetCookie() || []).length === 2, r.headers.getSetCookie());
r = await call('/webmail/?_task=login&_action=login', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '_user=a&_pass=b' });
ok('a wrong password (the webmail answers 401) shows its message instead of an error', r.status === 401 && (await r.text()).includes('Login failed'), r.status);
ok('the form data reached the webmail', seen[seen.length - 1].method === 'POST' && seen[seen.length - 1].body === '_user=a&_pass=b', seen[seen.length - 1]);
r = await call('/webmail/go');
ok('redirects stay under /webmail/', r.status === 302 && r.headers.get('location') === '/webmail/?_task=mail', r.headers.get('location'));
r = await call('/webmail/?_task=login&_action=oauth', { headers: { 'sec-fetch-dest': 'iframe' } }); let t0 = await r.text();
ok('a sign-in page elsewhere (Microsoft) is opened in the whole window, not inside the frame', r.status === 200 && t0.includes('top.location.href=\"https://login.example.test/authorize?client_id=1&state=2\"'), [r.status, t0.slice(0, 200)]);
r = await call('/webmail/?_task=login&_action=oauth', { headers: { 'sec-fetch-dest': 'document' } });
ok('opened on its own (new tab), the redirect to the sign-in page is passed on as it is', r.status === 302 && r.headers.get('location').startsWith('https://login.example.test/'), [r.status, r.headers.get('location')]);
r = await call('/webmail/?page=home&state=nux_authorization&code=1', { headers: { 'sec-fetch-dest': 'document' } });
ok('coming back from the sign-in page keeps the cookies and returns to the Mail tab', r.status === 302 && r.headers.get('location') === '/#/mail/webmail' && (r.headers.getSetCookie() || []).some(c => c.startsWith('hm_session=oauth')), [r.status, r.headers.get('location'), r.headers.getSetCookie()]);
r = await call('/api/webmail/login'); const lg = await r.json();
ok('the Mail tab can sign in to the webmail for you', lg.user === 'hub' && lg.pass === 'secret-pass', lg);
r = await fetch(base + '/api/webmail/login', { redirect: 'manual' });
ok('but only someone signed in to the hub', r.status === 302 || r.status === 401, r.status);
r = await call('/webmail'); ok('/webmail goes to /webmail/', r.status === 302 && r.headers.get('location') === '/webmail/', r.status);
r = await call('/api/config'); ok('the app is told where the webmail is', (await r.json()).webmail === '/webmail/');
rc.close(); await new Promise(r2 => setTimeout(r2, 100));
r = await call('/webmail/'); const t = await r.text();
ok('with the webmail down, a friendly page (not a crash)', r.status === 502 && t.includes('not reachable'), r.status);
r = await call('/api/config'); ok('and the server is still running', r.status === 200);
child.kill(); fs.rmSync(dataDir, { recursive: true, force: true });
console.log(failed ? '\n' + failed + ' check(s) failed' : '\nAll webmail checks passed'); process.exit(failed ? 1 : 0);
})();