Signs with SNAIL_KNOWN inside Actions (factory key owns get@ and uniqornlabz@) and exercises API send, API reply, MCP send_reply via /auth/mcp-token, the external/ineligible gates and two-way threading. Cleans up its test mail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVFi3wvLWKaEkpDw6eLnQC
103 lines
9.2 KiB
YAML
103 lines
9.2 KiB
YAML
name: mail-e2e
|
|
# End-to-end check of internal mail between @well-known.online names on production.
|
|
# Signs NIP-98 requests with the factory key (SNAIL_KNOWN) inside Actions; the key never leaves the runner.
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
base: { description: mail base URL, required: true, default: https://mail.well-known.online }
|
|
keep: { description: "keep test mail (true/false)", required: true, default: "false" }
|
|
jobs:
|
|
e2e:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: run
|
|
env:
|
|
K: ${{ secrets.SNAIL_KNOWN }}
|
|
B: ${{ inputs.base }}
|
|
KEEP: ${{ inputs.keep }}
|
|
run: |
|
|
mkdir -p /tmp/e2e && cd /tmp/e2e
|
|
npm init -y >/dev/null && npm i --silent @noble/curves@1.9.7 >/dev/null
|
|
cat > t.mjs <<'EOF'
|
|
import { schnorr } from '@noble/curves/secp256k1';
|
|
import { createHash } from 'node:crypto';
|
|
const B = process.env.B, CH = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
|
const toHex = (s) => { s = s.trim().toLowerCase(); if (/^[0-9a-f]{64}$/.test(s)) return s;
|
|
const d = [...s.slice(s.lastIndexOf('1') + 1)].map((c) => CH.indexOf(c)).slice(0, -6); let a = 0, b = 0; const o = [];
|
|
for (const v of d) { a = (a << 5) | v; b += 5; while (b >= 8) { b -= 8; o.push((a >> b) & 255); } } return Buffer.from(o).toString('hex'); };
|
|
const sk = toHex(process.env.K), pk = Buffer.from(schnorr.getPublicKey(sk)).toString('hex');
|
|
const nip98 = (url, method) => { const ev = { pubkey: pk, created_at: Math.floor(Date.now() / 1000), kind: 27235, tags: [['u', url.split('?')[0]], ['method', method]], content: '' };
|
|
ev.id = createHash('sha256').update(JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content])).digest('hex');
|
|
ev.sig = Buffer.from(schnorr.sign(ev.id, sk)).toString('hex'); return 'Nostr ' + Buffer.from(JSON.stringify(ev)).toString('base64'); };
|
|
const api = async (method, path, body) => { const url = B + path; const r = await fetch(url, { method, headers: { authorization: nip98(url, method), 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
|
|
const t = await r.text(); let j; try { j = JSON.parse(t); } catch { j = t; } return { status: r.status, j }; };
|
|
const GET = 'get@well-known.online', UNI = 'uniqornlabz@well-known.online', mb = (m) => `/api/v1/mailboxes/${encodeURIComponent(m)}`;
|
|
const tag = 'e2e-' + Date.now(); let fails = 0; const made = [];
|
|
const check = (name, ok, extra = '') => { console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' ' + extra : ''}`); if (!ok) fails++; };
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const find = async (m, folder, subj) => { for (let i = 0; i < 10; i++) { const r = await api('GET', `${mb(m)}/emails?folder=${folder}&limit=50`);
|
|
const e = (r.j.emails || []).find((x) => x.subject === subj); if (e) return e; await sleep(1500); } return null; };
|
|
|
|
// 1. API send get@ -> uniqornlabz@
|
|
const s1 = `${tag} hello`;
|
|
let r = await api('POST', `${mb(GET)}/emails`, { to: UNI, from: GET, subject: s1, text: 'internal hello', html: '<p>internal hello</p>' });
|
|
check('api send get@ -> uniqornlabz@ accepted', r.status === 202, `${r.status} ${JSON.stringify(r.j).slice(0, 120)}`);
|
|
const inb1 = await find(UNI, 'inbox', s1), sent1 = await find(GET, 'sent', s1);
|
|
check('delivered to uniqornlabz@ INBOX', !!inb1); check('sender keeps SENT copy', !!sent1);
|
|
if (inb1) made.push([UNI, inb1.id]); if (sent1) made.push([GET, sent1.id]);
|
|
if (inb1) check('from header is the sender mailbox', (inb1.sender || '').toLowerCase() === GET, inb1.sender);
|
|
|
|
// 2. gates
|
|
r = await api('POST', `${mb(GET)}/emails`, { to: 'someone@example.com', from: GET, subject: `${tag} ext`, text: 'x' });
|
|
check('external recipient refused', r.status === 422 && /external/i.test(JSON.stringify(r.j)), `${r.status} ${JSON.stringify(r.j).slice(0, 140)}`);
|
|
r = await api('POST', `${mb(GET)}/emails`, { to: [UNI, 'someone@example.com'], from: GET, subject: `${tag} mixed`, text: 'x' });
|
|
check('mixed internal+external refused whole send', r.status === 422);
|
|
check('nothing delivered for refused mixed send', !(await (async () => { await sleep(2000); const x = await api('GET', `${mb(UNI)}/emails?folder=inbox&limit=50`); return (x.j.emails || []).some((e) => e.subject === `${tag} mixed`); })()));
|
|
r = await api('POST', `${mb(GET)}/emails`, { to: `nobody-${tag}@well-known.online`, from: GET, subject: `${tag} nobody`, text: 'x' });
|
|
check('non-receiving name refused', r.status === 422 && /can't receive/i.test(JSON.stringify(r.j)), `${r.status} ${JSON.stringify(r.j).slice(0, 140)}`);
|
|
r = await api('POST', `${mb(GET)}/emails`, { to: UNI, from: UNI, subject: `${tag} spoof`, text: 'x' });
|
|
check('from must be the sending mailbox', r.status === 400, String(r.status));
|
|
|
|
// 3. API reply uniqornlabz@ -> get@ threads in both mailboxes
|
|
if (inb1) {
|
|
r = await api('POST', `${mb(UNI)}/emails/${inb1.id}/reply`, { to: GET, from: UNI, subject: `Re: ${s1}`, text: 'reply via api', html: '<p>reply via api</p>' });
|
|
check('api reply accepted', r.status === 202, `${r.status} ${JSON.stringify(r.j).slice(0, 120)}`);
|
|
const inb2 = await find(GET, 'inbox', `Re: ${s1}`), sent2 = await find(UNI, 'sent', `Re: ${s1}`);
|
|
check('reply delivered to get@ INBOX', !!inb2); if (inb2) made.push([GET, inb2.id]); if (sent2) made.push([UNI, sent2.id]);
|
|
check('reply threads with original in get@', !!(inb2 && sent1 && inb2.thread_id === sent1.thread_id), `${inb2?.thread_id} vs ${sent1?.thread_id}`);
|
|
check('reply threads with original in uniqornlabz@', !!(sent2 && sent2.thread_id === inb1.thread_id), `${sent2?.thread_id} vs ${inb1.thread_id}`);
|
|
|
|
// 4. MCP send_reply get@ -> uniqornlabz@ via the signer token flow
|
|
const tr = await fetch(B + '/auth/mcp-token', { method: 'POST', headers: { authorization: nip98(B + '/auth/mcp-token', 'POST') } });
|
|
const tj = await tr.json(); const tok = tj.access_token || tj.token;
|
|
check('mcp token issued', tr.status === 200 && !!tok, String(tr.status));
|
|
const H = { 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: 'Bearer ' + tok };
|
|
const rpc = async (body, sid) => { const x = await fetch(B + '/mcp', { method: 'POST', headers: sid ? { ...H, 'mcp-session-id': sid } : H, body: JSON.stringify(body) });
|
|
const t = await x.text(); const m = t.match(/data: (.*)/); return { x, j: m ? JSON.parse(m[1]) : (t ? JSON.parse(t) : null) }; };
|
|
let { x, j } = await rpc({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'mail-e2e', version: '0' } } });
|
|
const sid = x.headers.get('mcp-session-id'); await rpc({ jsonrpc: '2.0', method: 'notifications/initialized' }, sid);
|
|
if (inb2) {
|
|
({ j } = await rpc({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'send_reply', arguments: { mailboxId: GET, originalEmailId: inb2.id, to: UNI, subject: `Re: ${s1}`, bodyHtml: '<p>Thanks, this is a reply sent over MCP between two well-known names.</p>' } } }, sid));
|
|
const txt = j?.result?.content?.[0]?.text || JSON.stringify(j);
|
|
check('mcp send_reply ok', !j?.result?.isError && /sent/i.test(txt), txt.slice(0, 140));
|
|
await sleep(2500);
|
|
const list = await api('GET', `${mb(UNI)}/emails?folder=inbox&limit=50`);
|
|
const thread = (list.j.emails || []).filter((e) => e.thread_id === inb1.thread_id);
|
|
check('mcp reply lands in the same uniqornlabz@ thread', thread.length >= 2, `${thread.length} in thread`);
|
|
for (const e of thread) if (e.id !== inb1.id) made.push([UNI, e.id]);
|
|
const gs = await api('GET', `${mb(GET)}/emails?folder=sent&limit=50`);
|
|
for (const e of (gs.j.emails || []).filter((e) => e.subject === `Re: ${s1}`)) made.push([GET, e.id]);
|
|
}
|
|
({ j } = await rpc({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'send_email', arguments: { mailboxId: GET, to: 'someone@example.com', subject: `${tag} mcp ext`, bodyHtml: '<p>x</p>' } } }, sid));
|
|
const t3 = j?.result?.content?.[0]?.text || JSON.stringify(j);
|
|
check('mcp send_email to external refused', /external/i.test(t3), t3.slice(0, 140));
|
|
const gs2 = await api('GET', `${mb(GET)}/emails?folder=sent&limit=50`);
|
|
check('refused mcp send leaves no SENT copy', !(gs2.j.emails || []).some((e) => e.subject === `${tag} mcp ext`));
|
|
}
|
|
|
|
// 5. cleanup
|
|
if (process.env.KEEP !== 'true') { let n = 0; for (const [m, id] of made) { const d = await api('DELETE', `${mb(m)}/emails/${id}`); if (d.status < 300) n++; } console.log(`cleanup: deleted ${n}/${made.length}`); }
|
|
console.log(fails ? `FAILED: ${fails}` : 'ALL PASS'); process.exit(fails ? 1 : 0);
|
|
EOF
|
|
node t.mjs
|