7 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 cedd6845ce Files: sturdier Seafile pass-through (timeouts, retries, stop on disconnect), in-place sign-in, saved folder lists, upload name clashes, undo and recycle bin, library search, zip downloads, share link options, text viewer and editor
build-container / build (push) Successful in 20m19s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 09:16:40 -04:00
nmemmertandClaude Sonnet 5.5 004209ed4f Mail: Gmail and iCloud (IMAP/SMTP with app passwords) alongside Outlook.com, several accounts per person with an account switcher, From choice and combined unread
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 09:06:52 -04:00
nmemmertandClaude Sonnet 5.5 4173d2c058 Planner: one compact toolbar (title, Calendar | Tasks, view switch, agenda range, Add event)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:59:29 -04:00
nmemmertandClaude Sonnet 5.5 b8ab84a009 Planner: Add event sits on the same row as the Calendar | Tasks switch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:57:18 -04:00
nmemmertandClaude Sonnet 5.5 8d38584d37 Planner: Calendar and Tasks in one menu entry, due tasks shown on the calendar; menu, More sheet and Home tiles grouped by kind
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:56:31 -04:00
nmemmertandClaude Sonnet 5.5 b305ccac51 Reminders: style the morning time box like the other inputs (it was unreadable in dark mode)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:53:01 -04:00
nmemmertandClaude Sonnet 5.5 7ac843fea8 Settings: fix a stray closing tag that made the lower sections wider than the rest
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:52:11 -04:00
26 changed files with 1798 additions and 235 deletions

No files matched your search

+2
View File
@@ -26,6 +26,8 @@ jobs:
- run: node tools/player-test.js # music player: queue, shuffle/repeat, stall recovery - run: node tools/player-test.js # music player: queue, shuffle/repeat, stall recovery
- run: node tools/lyrics-test.js # lyrics: formats, wrong matches refused, remembered - run: node tools/lyrics-test.js # lyrics: formats, wrong matches refused, remembered
- run: node tools/mail-test.js # Outlook.com mail through Microsoft Graph: connect, refresh, read, send, privacy - run: node tools/mail-test.js # Outlook.com mail through Microsoft Graph: connect, refresh, read, send, privacy
- run: node tools/mail-imap-test.js # Gmail and iCloud mail over IMAP/SMTP: connect, read, send, reply, privacy
- run: node tools/files-test.js # the Seafile pass-through: timeouts, retries, closed downloads, uploads
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates - run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
- uses: docker/setup-qemu-action@v3 - uses: docker/setup-qemu-action@v3
+7 -2
View File
@@ -66,6 +66,8 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
not connected for about a month; the hub stays connected, so this only matters if the stack is switched off. not connected for about a month; the hub stays connected, so this only matters if the stack is switched off.
- **Links**: your own bookmarks page. Add a name, address and optional group; filter as you type; opens in a new tab. Saved with your settings on the server, private to you, same on every device. Only http(s) addresses are accepted. - **Links**: your own bookmarks page. Add a name, address and optional group; filter as you type; opens in a new tab. Saved with your settings on the server, private to you, same on every device. Only http(s) addresses are accepted.
- **Security and backups**: every response carries `nosniff`, a `frame-ancestors 'self'` policy and a same-origin referrer policy; writes that come from another website are refused; JSON files are written atomically (temp file, then rename). Admins get **Settings -> Storage -> Download a backup** (a .tar.gz of `/data`; keep it private). `/healthz` answers without signing in and the container has a health check. In-app OnlyOffice editing now requires `ONLYOFFICE_JWT_SECRET` (or `ALLOW_UNSIGNED_OO=1` for testing). `npm test` runs every test script; CI also runs `npm audit`. - **Security and backups**: every response carries `nosniff`, a `frame-ancestors 'self'` policy and a same-origin referrer policy; writes that come from another website are refused; JSON files are written atomically (temp file, then rename). Admins get **Settings -> Storage -> Download a backup** (a .tar.gz of `/data`; keep it private). `/healthz` answers without signing in and the container has a health check. In-app OnlyOffice editing now requires `ONLYOFFICE_JWT_SECRET` (or `ALLOW_UNSIGNED_OO=1` for testing). `npm test` runs every test script; CI also runs `npm audit`.
- **Planner and grouped menu**: Calendar and Tasks are one menu entry, **Planner**, with a Calendar | Tasks switch at the top (each still needs its own permission in Settings -> People, and `#/calendar` and `#/tasks` still work as addresses). Tasks with a due date appear on the calendar (month chips, week/day all-day strip, agenda) with a tick to complete them; tapping one opens the task. The menu, the phone "More" sheet and the Home tiles are grouped by kind: Work (Planner, Files, Office, Links), Communicate (Mail, Signal), Media (Music, Audiobooks, Podcast, Podcast Admin), with Home/Search at the top and Settings at the bottom.
- **Files, sturdier**: the Seafile pass-through gives Seafile 30 seconds to answer (2 minutes to start a file transfer), retries a failed read twice, says plainly when Seafile is slow or down, and stops reading from Seafile as soon as you close a download. In the app, an expired Seafile sign-in asks for your password again in a sheet and then carries on where you were. Each folder you open is saved (names, sizes, dates) so it shows instantly while the real list loads, and stays visible with a note when you are offline or Seafile cannot be reached; very large folders show 300 rows at a time with Show more, and the scroll position comes back when you return. Uploads wait for the connection instead of failing, retry with growing pauses (5 tries), and when a name is already there you choose Replace (the old version stays in the file's history), Keep both, Skip or Cancel. Deleting offers **Undo** (from Seafile's recycle bin), and **Recycle bin** in the folder menu (the three-dots button in the toolbar) restores anything deleted from the library. The same menu has **Search this library** and **Download as zip** (also in Select mode and on a folder's menu). **Share links** now offer a password and an expiry, list the links that already exist and can delete them. Text files (txt, md, json, csv, logs, config files, code) open in a viewer, and small ones can be edited and saved there. Not included: resumable chunked uploads (a dropped connection restarts that file; I could not confirm Seafile's chunk protocol without a server to test), editing files over 256 KB in the viewer. The recycle bin, Undo, zip, share options and text saving use Seafile's documented API but have only been tried against a fake Seafile, not a real one.
- **Command palette and quick add**: press **Ctrl/Cmd-K** anywhere (or the Command palette button on Home) to jump to any page, create things, open a saved link or find a task/event. Typing a sentence offers **Add task**, and understands a date at the end ("Call mom tomorrow", "Pay rent in 3 days", "Dentist fri", "2026-12-25"). **Quick add** on Home does the same plus new event and new link. Search also finds your links. - **Command palette and quick add**: press **Ctrl/Cmd-K** anywhere (or the Command palette button on Home) to jump to any page, create things, open a saved link or find a task/event. Typing a sentence offers **Add task**, and understands a date at the end ("Call mom tomorrow", "Pay rent in 3 days", "Dentist fri", "2026-12-25"). **Quick add** on Home does the same plus new event and new link. Search also finds your links.
- **Tasks**: repeating tasks (daily, weekdays, weekly, monthly, yearly; finishing one moves it to its next date) and one level of subtasks, in NE Hub lists (iCloud reminder lists stay as Apple stores them). - **Tasks**: repeating tasks (daily, weekdays, weekly, monthly, yearly; finishing one moves it to its next date) and one level of subtasks, in NE Hub lists (iCloud reminder lists stay as Apple stores them).
- **Calendar**: Month, Week, Day and Agenda. Week and Day show a time grid with overlapping events side by side, an all-day strip and a "now" line; tap an empty slot to add an event. - **Calendar**: Month, Week, Day and Agenda. Week and Day show a time grid with overlapping events side by side, an all-day strip and a "now" line; tap an empty slot to add an event.
@@ -77,8 +79,11 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **Code layout**: `public/` holds one script per feature (`tasks.js`, `calendar.js`, `music.js`, `player.js`, `files.js`, `settings.js`, ...). They are plain scripts that share one global scope and load in the order listed in `index.html` (`boot.js` last); the versioned `?v=` addresses and the cache version are computed from every file in `public/`. - **Code layout**: `public/` holds one script per feature (`tasks.js`, `calendar.js`, `music.js`, `player.js`, `files.js`, `settings.js`, ...). They are plain scripts that share one global scope and load in the order listed in `index.html` (`boot.js` last); the versioned `?v=` addresses and the cache version are computed from every file in `public/`.
- **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs. - **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs.
- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing. - **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
- **Mail**: your **Outlook.com** (personal) mailbox inside the app: folders with unread counts, message list with search, reader, compose with attachments (up to about 3 MB), reply, reply all, forward, archive, delete, flag, mark unread, an unread badge on the Mail tab and a Home card. It uses Microsoft Graph, because Outlook.com no longer allows IMAP passwords. Each person connects their own mailbox (Mail tab -> Connect Outlook.com, sign-in is on Microsoft's own page); the hub keeps only the sign-in token, encrypted, in that person's folder, and everything is private to them. Message text is shown in a sandboxed frame with no scripts, and pictures from the internet stay hidden until you press Show pictures. Not included: drafts autosave, contacts, several mailboxes per person, Microsoft 365 work accounts, Gmail/iCloud (those can be added the same way later). - **Mail**: several mail accounts per person in one place: **Gmail**, **iCloud Mail** and (optionally) **Outlook.com** (personal). Folders with unread counts, message list with search, reader, compose with attachments (up to about 3 MB) and a From choice, reply, reply all, forward, archive, delete, flag, mark unread, an unread badge on the Mail tab (all accounts added up) and a Home card with the latest unread across accounts. Each person adds their own accounts (Mail tab, or Settings -> Mail -> Add an account); everything is private to them and stored encrypted in their own folder.
**One-time setup** (step by step, with troubleshooting, in [docs/mail-setup.md](docs/mail-setup.md); needs a free Microsoft account and takes five minutes): go to entra.microsoft.com -> App registrations -> New registration. Name it NE Hub; supported account types **Personal Microsoft accounts only**; Redirect URI: platform **Web**, value `<your hub address>/api/mail/callback` (for example `https://hub.necloud.us/api/mail/callback`; it must match `PUBLIC_URL` exactly). Then API permissions -> Add -> Microsoft Graph -> Delegated: `Mail.ReadWrite`, `Mail.Send`, `User.Read`, `offline_access`. Copy the **Application (client) ID** and paste it, with the secret, into **Settings -> Microsoft app (admin)** (or set `MS_CLIENT_ID` / `MS_CLIENT_SECRET` in the environment, which win). If you registered it as a Web app, also make a client secret (Certificates & secrets) and put its value in `MS_CLIENT_SECRET`; if you used platform "Mobile and desktop applications" instead, no secret is needed. Leave `MS_CLIENT_ID` empty to switch Mail off. (The old bundled Cypht webmail and its `nehub-cypht` volume are gone; you can delete that volume.) Connecting from the installed iPhone/iPad app may open Microsoft's page in Safari; if the app does not notice, reopen it. - **Gmail and iCloud** use IMAP/SMTP with an **app password**, so there is nothing to set up on the server. Gmail: turn on 2-Step Verification, then make an app password at myaccount.google.com/apppasswords. iCloud: appleid.apple.com -> Sign-In and Security -> App-Specific Passwords. The password is checked with the provider before it is saved, and wrong ones are explained in plain words. iCloud does not keep a copy of mail sent over SMTP, so NE Hub files one in Sent itself (not yet confirmed against a real iCloud account).
- **Outlook.com** goes through Microsoft Graph (Outlook.com no longer allows IMAP passwords), which needs the one-time Microsoft app below; without it the Outlook.com button simply is not offered.
- Message text is shown in a sandboxed frame with no scripts, and pictures from the internet stay hidden until you press Show pictures. Not included: a combined inbox across accounts (switch account at the top), message previews in the list for Gmail/iCloud, drafts autosave, contacts, Microsoft 365 work accounts, other IMAP providers (easy to add the same way).
**One-time Outlook.com setup** (step by step, with troubleshooting, in [docs/mail-setup.md](docs/mail-setup.md); needs a free Microsoft account and takes five minutes): go to entra.microsoft.com -> App registrations -> New registration. Name it NE Hub; supported account types **Personal Microsoft accounts only**; Redirect URI: platform **Web**, value `<your hub address>/api/mail/callback` (for example `https://hub.necloud.us/api/mail/callback`; it must match `PUBLIC_URL` exactly). Then API permissions -> Add -> Microsoft Graph -> Delegated: `Mail.ReadWrite`, `Mail.Send`, `User.Read`, `offline_access`. Copy the **Application (client) ID** and paste it, with the secret, into **Settings -> Microsoft app (admin)** (or set `MS_CLIENT_ID` / `MS_CLIENT_SECRET` in the environment, which win). If you registered it as a Web app, also make a client secret (Certificates & secrets) and put its value in `MS_CLIENT_SECRET`; if you used platform "Mobile and desktop applications" instead, no secret is needed. Leave `MS_CLIENT_ID` empty to switch Mail off. (The old bundled Cypht webmail and its `nehub-cypht` volume are gone; you can delete that volume.) Connecting from the installed iPhone/iPad app may open Microsoft's page in Safari; if the app does not notice, reopen it.
## Install on iPhone / iPad (PWA) ## Install on iPhone / iPad (PWA)
+253
View File
@@ -0,0 +1,253 @@
// Gmail and iCloud mail through IMAP (reading, folders, flags, moving) and SMTP (sending), with an app password.
// Same interface as the Outlook provider in mail.js: every function takes the account and the provider's own ids.
// Message ids are base64url JSON [folder path, uid]; folders are the same aliases as Outlook (inbox, drafts, sentitems, archive, junkemail,
// deleteditems) or "p-<base64url path>" for the rest.
const { ImapFlow } = require('imapflow');
const nodemailer = require('nodemailer');
const MailComposer = require('nodemailer/lib/mail-composer');
const { simpleParser } = require('mailparser');
const PRESETS = {
gmail: { name: 'Gmail', host: 'imap.gmail.com', port: 993, secure: true, smtpHost: 'smtp.gmail.com', smtpPort: 465, smtpSecure: true, appendSent: false },
icloud: { name: 'iCloud Mail', host: 'imap.mail.me.com', port: 993, secure: true, smtpHost: 'smtp.mail.me.com', smtpPort: 587, smtpSecure: false, appendSent: true },
};
// Only the tests set these (a fake server on localhost, no TLS).
const TEST = process.env.MAIL_TEST_SERVERS ? JSON.parse(process.env.MAIL_TEST_SERVERS) : null;
const fail = (status, message) => Object.assign(new Error(message), { status });
const EMAIL = /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/;
const MAX_SOURCE = 25 * 1024 * 1024;
const escHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const textToHtml = t => '<div style="white-space:pre-wrap;font-family:sans-serif">' + escHtml(t).replace(/\r?\n/g, '<br>') + '</div>';
const enc = o => Buffer.from(JSON.stringify(o)).toString('base64url');
const dec = s => { try { const v = JSON.parse(Buffer.from(String(s), 'base64url').toString()); if (Array.isArray(v) && typeof v[0] === 'string' && Number.isInteger(v[1])) return v; } catch {} throw fail(400, 'Bad id'); };
const cfgOf = a => {
const p = PRESETS[a.type]; if (!p) throw fail(400, 'Unknown mail provider');
return TEST ? { ...p, ...TEST.imap, smtpHost: TEST.smtp.host, smtpPort: TEST.smtp.port, smtpSecure: false, smtpIgnoreTLS: true, test: true } : p;
};
/** What went wrong, in words a person can act on. */
function friendly(a, e) {
const t = (e && (e.responseText || e.message) || '') + ' ' + (e && e.code || '');
const p = PRESETS[a.type] || {};
if (e && (e.authenticationFailed || /AUTHENTICATIONFAILED|Invalid credentials|auth.*fail|535|534|Username and Password not accepted/i.test(t)))
return a.type === 'gmail' ? 'Google rejected the password. Use an app password (Google account -> Security -> 2-Step Verification -> App passwords), not your normal password.'
: 'Apple rejected the password. Use an app-specific password (appleid.apple.com -> Sign-In and Security -> App-Specific Passwords), not your Apple ID password.';
if (/ENOTFOUND|EAI_AGAIN/.test(t)) return `Could not find ${p.name || 'the mail server'} (no internet from the server, or DNS is down).`;
if (/ECONNREFUSED|ETIMEDOUT|ECONNRESET|timed out|Timeout/i.test(t)) return `Could not reach ${p.name || 'the mail server'}. Try again in a moment.`;
if (/IMAP.*disabled|not enabled/i.test(t)) return 'IMAP is turned off for this account. Turn it on in the account\'s mail settings.';
return (e && e.responseText) || (e && e.message) || 'The mail server refused the request';
}
// ---- connections: one per account, kept for a minute after the last use
const pool = new Map(); // account id -> { client, timer, key }
const forget = id => { const e = pool.get(id); if (e) { clearTimeout(e.timer); try { e.client.close(); } catch {} pool.delete(id); } };
const keyOf = a => a.address + '\n' + a.pass;
async function open(a) {
const e = pool.get(a.id);
if (e && e.key === keyOf(a) && e.client.usable) { clearTimeout(e.timer); e.timer = setTimeout(() => forget(a.id), 60e3).unref(); return e.client; }
forget(a.id);
const c = cfgOf(a);
const client = new ImapFlow({ host: c.host, port: c.port, secure: c.secure, ...(c.test ? { doSTARTTLS: false, tls: { rejectUnauthorized: false } } : {}), auth: { user: a.address, pass: a.pass }, logger: false, emitLogs: false, socketTimeout: 60e3, greetingTimeout: 20e3, connectionTimeout: 20e3 });
client.on('error', () => {}); client.on('close', () => { const cur = pool.get(a.id); if (cur && cur.client === client) pool.delete(a.id); });
try { await client.connect(); } catch (err) { throw fail(err.authenticationFailed ? 401 : 502, friendly(a, err)); }
pool.set(a.id, { client, key: keyOf(a), timer: setTimeout(() => forget(a.id), 60e3).unref() });
return client;
}
/** Run `fn(client)`; if the connection had gone stale, reconnect once. */
async function withClient(a, fn) {
for (let attempt = 0; ; attempt++) {
const client = await open(a);
try { return await fn(client); }
catch (err) {
if (err.status) throw err;
if (attempt === 0 && (!client.usable || /closed|ECONNRESET|socket|EPIPE|Connection not available/i.test(err.message || ''))) { forget(a.id); continue; }
throw fail(502, friendly(a, err));
}
}
}
// ---- folders
const boxCache = new Map(); // account id -> { t, v }
const SPECIAL = { inbox: ['\\Inbox'], sentitems: ['\\Sent'], drafts: ['\\Drafts'], deleteditems: ['\\Trash'], junkemail: ['\\Junk'], archive: ['\\Archive', '\\All'] };
const GUESS = { sentitems: /^(\[.*\]\/)?sent( (mail|items|messages))?$/i, drafts: /^(\[.*\]\/)?drafts?$/i, deleteditems: /^(\[.*\]\/)?(trash|deleted( (items|messages))?|bin)$/i, junkemail: /^(\[.*\]\/)?(junk( e-?mail)?|spam)$/i, archive: /^(\[.*\]\/)?(archive|all mail)$/i };
const NAMES = { inbox: 'Inbox', drafts: 'Drafts', sentitems: 'Sent', archive: 'Archive', junkemail: 'Junk', deleteditems: 'Deleted' };
async function boxes(a, client) {
const c = boxCache.get(a.id); if (c && Date.now() - c.t < 60e3) return c.v;
const list = (await client.list()).filter(b => !b.flags.has('\\Noselect') && !b.flags.has('\\NonExistent'));
const alias = {}; // alias -> path
const take = (k, b) => { if (b && !alias[k]) alias[k] = b.path; };
take('inbox', list.find(b => b.path.toUpperCase() === 'INBOX'));
for (const k of Object.keys(SPECIAL)) for (const f of SPECIAL[k]) take(k, list.find(b => b.specialUse === f));
for (const k of Object.keys(GUESS)) take(k, list.find(b => GUESS[k].test(b.path) || GUESS[k].test(b.name)));
const v = { list, alias };
boxCache.set(a.id, { t: Date.now(), v }); return v;
}
async function pathOf(a, client, folder) {
const { list, alias } = await boxes(a, client);
if (alias[folder]) return alias[folder];
if (/^p-/.test(folder)) { try { const p = Buffer.from(folder.slice(2), 'base64url').toString(); if (list.some(b => b.path === p)) return p; } catch {} }
throw fail(404, folder === 'archive' ? 'This account has no Archive folder' : 'That folder does not exist');
}
const addrOf = x => x ? { name: x.name || '', address: x.address || '' } : { name: '', address: '' };
const hasAttachment = n => !!n && (((n.disposition || '').toLowerCase() === 'attachment') || (n.childNodes || []).some(hasAttachment));
async function folders(a) {
return withClient(a, async client => {
const { list, alias } = await boxes(a, client), byPath = {}; for (const [k, p] of Object.entries(alias)) byPath[p] = k;
const skip = b => b.flags.has('\\Noselect') || ['\\Flagged', '\\Important'].includes(b.specialUse);
const rows = [];
for (const b of list) {
if (skip(b)) continue;
let st = {}; try { st = await client.status(b.path, { messages: true, unseen: true }); } catch {}
const k = byPath[b.path];
rows.push({ id: k || 'p-' + Buffer.from(b.path).toString('base64url'), name: k ? NAMES[k] : b.path.replace(/^\[.*?\]\//, ''), unread: st.unseen || 0, total: st.messages || 0, well: !!k });
}
const order = Object.keys(NAMES);
return rows.sort((x, y) => (x.well ? order.indexOf(x.id) : 99) - (y.well ? order.indexOf(y.id) : 99) || x.name.localeCompare(y.name));
});
}
async function list(a, { folder, skip, q }) {
return withClient(a, async client => {
const path = await pathOf(a, client, folder || 'inbox');
const lock = await client.getMailboxLock(path);
try {
const total = client.mailbox.exists, query = { uid: true, flags: true, envelope: true, bodyStructure: true, internalDate: true };
let range, opts = {}, more = false;
if (q) {
const uids = (await client.search({ or: [{ subject: q }, { from: q }, { to: q }] }, { uid: true })) || [];
if (!uids.length) return { items: [], more: false, next: 0 };
range = uids.slice(-50).join(','); opts = { uid: true };
} else {
const hi = total - skip; if (hi < 1) return { items: [], more: false, next: skip };
range = `${Math.max(1, hi - 29)}:${hi}`; more = hi - 30 >= 1;
}
const items = [];
for await (const m of client.fetch(range, query, opts)) {
const env = m.envelope || {};
items.push({ id: enc([path, m.uid]), subject: env.subject || '(No subject)', from: addrOf((env.from || [])[0]), date: (env.date || m.internalDate || new Date()).toISOString(), read: m.flags.has('\\Seen'), attach: hasAttachment(m.bodyStructure), preview: '', flagged: m.flags.has('\\Flagged') });
}
items.sort((x, y) => String(y.date).localeCompare(String(x.date)));
return { items, more, next: skip + items.length };
} finally { lock.release(); }
});
}
/** Download and parse one message (refusing anything huge). */
async function source(a, mid) {
const [path, uid] = dec(mid);
return withClient(a, async client => {
const lock = await client.getMailboxLock(path);
try {
const dl = await client.download(String(uid), undefined, { uid: true });
if (!dl || !dl.content) throw fail(404, 'That message is gone');
const chunks = []; let n = 0;
for await (const ch of dl.content) { n += ch.length; if (n > MAX_SOURCE) throw fail(400, 'That message is too large to open here'); chunks.push(ch); }
return { parsed: await simpleParser(Buffer.concat(chunks)), path, uid };
} finally { lock.release(); }
});
}
const pick = (list, f) => (list || []).map(x => f(x));
const addrs = v => (v && v.value ? v.value : []).map(x => ({ name: x.name || '', address: x.address || '' }));
async function message(a, mid) {
const { parsed: p, path, uid } = await source(a, mid);
let html = p.html || (p.textAsHtml ? p.textAsHtml : textToHtml(p.text || ''));
const atts = p.attachments || [];
for (const x of atts) if (x.cid && /^image\//i.test(x.contentType || '') && x.size < 1.5 * 1024 * 1024) html = html.split('cid:' + x.cid).join(`data:${x.contentType};base64,${x.content.toString('base64')}`);
const flags = await withClient(a, async client => { const lock = await client.getMailboxLock(path); try { const m = await client.fetchOne(String(uid), { flags: true }, { uid: true }); return m ? m.flags : new Set(); } finally { lock.release(); } });
return { id: mid, subject: p.subject || '(No subject)', from: addrs(p.from)[0] || { name: '', address: '' }, to: addrs(p.to), cc: addrs(p.cc), date: (p.date || new Date()).toISOString(), read: flags.has('\\Seen'), flagged: flags.has('\\Flagged'),
html, attachments: atts.map((x, i) => ({ x, i })).filter(({ x }) => !x.related).map(({ x, i }) => ({ id: String(i), name: x.filename || 'attachment', type: x.contentType, size: x.size })) };
}
async function attachment(a, mid, aid) {
const { parsed: p } = await source(a, mid), x = (p.attachments || [])[parseInt(aid, 10)];
if (!x) throw fail(404, 'Attachment not found');
return { name: x.filename || 'attachment', type: x.contentType, buffer: x.content };
}
async function update(a, mid, { read, flagged }) {
const [path, uid] = dec(mid);
if (typeof read !== 'boolean' && typeof flagged !== 'boolean') throw fail(400, 'Nothing to change');
return withClient(a, async client => {
const lock = await client.getMailboxLock(path);
try {
if (typeof read === 'boolean') await (read ? client.messageFlagsAdd : client.messageFlagsRemove).call(client, String(uid), ['\\Seen'], { uid: true });
if (typeof flagged === 'boolean') await (flagged ? client.messageFlagsAdd : client.messageFlagsRemove).call(client, String(uid), ['\\Flagged'], { uid: true });
} finally { lock.release(); }
});
}
async function move(a, mid, to) {
const [path, uid] = dec(mid);
return withClient(a, async client => {
const dest = await pathOf(a, client, to);
const lock = await client.getMailboxLock(path);
try { await client.messageMove(String(uid), dest, { uid: true }); } finally { lock.release(); }
});
}
async function del(a, mid, permanent) {
if (!permanent) return move(a, mid, 'deleteditems');
const [path, uid] = dec(mid);
return withClient(a, async client => { const lock = await client.getMailboxLock(path); try { await client.messageDelete(String(uid), { uid: true }); } finally { lock.release(); } });
}
async function unread(a) { return withClient(a, async client => (await client.status('INBOX', { unseen: true })).unseen || 0); }
// ---- sending
function transport(a) {
const c = cfgOf(a);
return nodemailer.createTransport({ host: c.smtpHost, port: c.smtpPort, secure: c.smtpSecure, ...(c.smtpIgnoreTLS ? { ignoreTLS: true } : { requireTLS: !c.smtpSecure }), auth: { user: a.address, pass: a.pass }, tls: c.test ? { rejectUnauthorized: false } : undefined, connectionTimeout: 20e3, greetingTimeout: 20e3, socketTimeout: 60e3 });
}
const fromOf = a => ({ name: a.name || '', address: a.address });
async function deliver(a, mail) {
const opts = { from: fromOf(a), ...mail };
const raw = await new MailComposer(opts).compile().build();
const all = [...(opts.to || []), ...(opts.cc || []), ...(opts.bcc || [])].map(x => x.address || x);
try { await transport(a).sendMail({ envelope: { from: a.address, to: all }, raw }); }
catch (err) { throw fail(err.responseCode === 535 || err.code === 'EAUTH' ? 401 : 502, friendly(a, err)); }
if (cfgOf(a).appendSent) { // iCloud does not keep a copy of what you send through SMTP, so put it in Sent ourselves
try { await withClient(a, async client => { const { alias } = await boxes(a, client); if (alias.sentitems) await client.append(alias.sentitems, raw, ['\\Seen']); }); } catch { /* the mail was sent; only the copy is missing */ }
}
}
async function send(a, { to, cc, bcc, subject, body, attachments }) {
await deliver(a, { to, cc, bcc, subject, text: body, html: textToHtml(body), attachments: attachments.map(x => ({ filename: x.name, contentType: x.type, content: Buffer.from(x.data, 'base64') })) });
}
const quote = t => String(t || '').split(/\r?\n/).map(l => '> ' + l).join('\n');
/** Reply, reply all or forward: built here (IMAP has no such call) so the thread headers and the quoted original are right. */
async function reply(a, { id: mid, mode, comment, to }) {
const { parsed: p } = await source(a, mid), me = a.address.toLowerCase();
const orig = { from: addrs(p.from), replyTo: addrs(p.replyTo), to: addrs(p.to), cc: addrs(p.cc) };
const when = (p.date || new Date()).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }), who = orig.from[0] ? (orig.from[0].name || orig.from[0].address) : 'someone';
const subj = String(p.subject || '').replace(/^((re|fwd?):\s*)+/i, '');
const origHtml = (p.html || textToHtml(p.text || '')).replace(/<script[\s\S]*?<\/script>/gi, '');
const headers = {};
let mail;
if (mode === 'forward') {
const note = `---------- Forwarded message ----------\nFrom: ${who}\nDate: ${when}\nSubject: ${p.subject || ''}\n\n`;
const files = (p.attachments || []).filter(x => !x.related && x.size < 3 * 1024 * 1024).slice(0, 10).map(x => ({ filename: x.filename || 'attachment', contentType: x.contentType, content: x.content }));
mail = { to, subject: 'Fwd: ' + subj, text: (comment ? comment + '\n\n' : '') + note + (p.text || ''), html: (comment ? textToHtml(comment) : '') + `<p>${escHtml(note).replace(/\n/g, '<br>')}</p>` + origHtml, attachments: files };
} else {
const first = (orig.replyTo[0] || orig.from[0]); if (!first) throw fail(400, 'That message has no sender to reply to');
let rcpt = [first];
if (mode === 'replyAll') for (const x of [...orig.to, ...orig.cc]) if (x.address && x.address.toLowerCase() !== me && !rcpt.some(r => r.address.toLowerCase() === x.address.toLowerCase())) rcpt.push(x);
if (p.messageId) { headers.inReplyTo = p.messageId; headers.references = [].concat(p.references || [], p.messageId).join(' '); }
mail = { to: rcpt.filter(x => x.address.toLowerCase() !== me || rcpt.length === 1), subject: 'Re: ' + subj, text: (comment ? comment + '\n\n' : '') + `On ${when}, ${who} wrote:\n` + quote(p.text || ''), html: textToHtml(comment || '') + `<p>On ${escHtml(when)}, ${escHtml(who)} wrote:</p><blockquote style="margin:0 0 0 8px;padding-left:10px;border-left:3px solid #ccc">${origHtml}</blockquote>`, ...headers };
}
await deliver(a, mail);
}
// ---- adding an account: check the password against the real server first
async function connectNew({ type, address, password }) {
const preset = PRESETS[type]; if (!preset) throw fail(400, 'Choose Gmail or iCloud Mail');
const addr = String(address || '').trim(), pass = String(password || '').replace(/\s+/g, '');
if (!EMAIL.test(addr)) throw fail(400, 'Enter the full email address');
if (pass.length < 8) throw fail(400, 'Enter the app password (not your normal password)');
const a = { id: 'new-' + Math.random().toString(36).slice(2, 8), type, address: addr, pass, name: '' };
try {
const client = await open(a);
await client.list();
await transport(a).verify().catch(err => { throw fail(err.responseCode === 535 || err.code === 'EAUTH' ? 401 : 502, friendly(a, err)); });
} finally { forget(a.id); }
return { type, address: addr, pass, name: '' };
}
module.exports = { PRESETS: Object.fromEntries(Object.entries(PRESETS).map(([k, v]) => [k, { name: v.name }])), folders, list, message, attachment, update, move, del, unread, send, reply, connectNew, forget };
+151 -86
View File
@@ -1,6 +1,6 @@
// Mail for a personal Outlook.com account, through Microsoft Graph (the supported way now that Outlook.com has switched off IMAP passwords). // Mail: several accounts per person. Outlook.com (personal) goes through Microsoft Graph (sign in on Microsoft's page, needs an app registration,
// Each person connects their own account: they sign in on Microsoft's page, the hub keeps only the tokens (encrypted, in their own folder), // see README -> Mail); Gmail and iCloud go through IMAP/SMTP with an app password (mail-imap.js). Every account's credentials are kept encrypted in
// and every mail request runs as that person. Needs an app registration (MS_CLIENT_ID, see README -> Mail). // that person's own folder, and every request runs as that person. Message and folder ids carry the account: "<account>~<id>".
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@@ -8,6 +8,7 @@ const atomicWrite = require('./atomic');
const users = require('./users'); const users = require('./users');
const audit = require('./audit'); const audit = require('./audit');
const vault = require('./vault')('nehub-mail'); const vault = require('./vault')('nehub-mail');
const imap = require('./mail-imap');
const ENV_ID = process.env.MS_CLIENT_ID || '', ENV_SECRET = process.env.MS_CLIENT_SECRET || ''; const ENV_ID = process.env.MS_CLIENT_ID || '', ENV_SECRET = process.env.MS_CLIENT_SECRET || '';
const TENANT = process.env.MS_TENANT || 'consumers'; // personal Microsoft accounts only const TENANT = process.env.MS_TENANT || 'consumers'; // personal Microsoft accounts only
@@ -23,7 +24,8 @@ const loadCfg = () => { if (cfgCache !== undefined) return cfgCache; try { cfgCa
const saveCfg = o => { atomicWrite(CONFIG_FILE, JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 }); cfgCache = o; }; const saveCfg = o => { atomicWrite(CONFIG_FILE, JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 }); cfgCache = o; };
const clientId = () => ENV_ID || loadCfg().clientId || ''; const clientId = () => ENV_ID || loadCfg().clientId || '';
const clientSecret = () => ENV_ID ? ENV_SECRET : loadCfg().clientSecret || ''; const clientSecret = () => ENV_ID ? ENV_SECRET : loadCfg().clientSecret || '';
const enabled = () => !!clientId(); const outlookOn = () => !!clientId();
const enabled = () => true; // Gmail and iCloud need no server setup; only the Outlook.com button needs the Microsoft app
/** How long the saved client secret lasts: { expires, days } or null. */ /** How long the saved client secret lasts: { expires, days } or null. */
function secretStatus() { function secretStatus() {
const e = loadCfg().secretExpires; if (!e || ENV_ID) return null; const e = loadCfg().secretExpires; if (!e || ENV_ID) return null;
@@ -37,11 +39,25 @@ const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.o
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(obj)); }; const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(obj)); };
const fail = (status, message) => Object.assign(new Error(message), { status }); const fail = (status, message) => Object.assign(new Error(message), { status });
// ---- per-person token file (encrypted) // ---- each person's accounts (encrypted): [{ id, type: 'outlook' | 'gmail' | 'icloud', address, name, ...credentials }]
const file = () => path.join(users.dir(), 'mail.json'); const accFile = () => path.join(users.dir(), 'mail-accounts.json');
const load = () => { try { return vault.unseal(JSON.parse(fs.readFileSync(file(), 'utf8')).data) || null; } catch { return null; } }; const legacyFile = () => path.join(users.dir(), 'mail.json'); // one Outlook.com account, from before there were several
const save = o => atomicWrite(file(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 }); const accounts = () => {
const wipe = () => { try { fs.unlinkSync(file()); } catch {} }; try { return (vault.unseal(JSON.parse(fs.readFileSync(accFile(), 'utf8')).data) || {}).accounts || []; } catch {}
try { // migrate the old single-account file
const old = vault.unseal(JSON.parse(fs.readFileSync(legacyFile(), 'utf8')).data);
if (old) { const list = [{ id: crypto.randomBytes(3).toString('hex'), type: 'outlook', ...old }]; saveAccounts(list); fs.rmSync(legacyFile(), { force: true }); return list; }
} catch {}
return [];
};
const saveAccounts = list => atomicWrite(accFile(), JSON.stringify({ data: vault.seal({ accounts: list }) }), { mode: 0o600 });
const putAccount = a => { const list = accounts(), i = list.findIndex(x => x.id === a.id); if (i >= 0) list[i] = a; else list.push(a); saveAccounts(list); return a; };
const dropAccount = id => { saveAccounts(accounts().filter(x => x.id !== id)); imap.forget(id); folderCache.clear(); };
const findAccount = id => { const list = accounts(); const a = id ? list.find(x => x.id === id) : list[0]; if (!a) throw fail(id ? 404 : 409, id ? 'That account is not connected' : 'Mail is not connected'); return a; };
const publicAcct = a => ({ id: a.id, type: a.type, address: a.address || '', name: a.name || '' });
/** "<account>~<native id>" -> [account, native id]. An id with no account (older links) belongs to the first account. */
const unpack = v => { const s = String(v || ''), i = s.indexOf('~'); return i > 0 ? [findAccount(s.slice(0, i)), s.slice(i + 1)] : [findAccount(), s]; };
const pack = (a, nid) => a.id + '~' + nid;
// ---- sign-in with Microsoft (authorization code + PKCE) // ---- sign-in with Microsoft (authorization code + PKCE)
const states = new Map(); // state -> { uid, verifier, redirect, exp } const states = new Map(); // state -> { uid, verifier, redirect, exp }
@@ -78,7 +94,8 @@ async function callback(req, res) {
if (!t.refresh_token) throw new Error('Microsoft did not allow ongoing access'); if (!t.refresh_token) throw new Error('Microsoft did not allow ongoing access');
await users.runAs(user, async () => { await users.runAs(user, async () => {
const me = await (await fetch(GRAPH + '/me?$select=displayName,mail,userPrincipalName', { headers: { authorization: 'Bearer ' + t.access_token } })).json().catch(() => ({})); const me = await (await fetch(GRAPH + '/me?$select=displayName,mail,userPrincipalName', { headers: { authorization: 'Bearer ' + t.access_token } })).json().catch(() => ({}));
save({ access: t.access_token, refresh: t.refresh_token, exp: Date.now() + (t.expires_in || 3600) * 1000, address: me.mail || me.userPrincipalName || '', name: me.displayName || '' }); const address = me.mail || me.userPrincipalName || '', same = accounts().find(x => x.type === 'outlook' && x.address && x.address.toLowerCase() === address.toLowerCase());
putAccount({ id: same ? same.id : crypto.randomBytes(3).toString('hex'), type: 'outlook', access: t.access_token, refresh: t.refresh_token, exp: Date.now() + (t.expires_in || 3600) * 1000, address, name: me.displayName || '' });
saveCfg({ ...loadCfg(), verifiedAt: Date.now() }); // proof that the app registration works saveCfg({ ...loadCfg(), verifiedAt: Date.now() }); // proof that the app registration works
audit.log('mail connected', { who: user.name, detail: me.mail || me.userPrincipalName || '' }, req); audit.log('mail connected', { who: user.name, detail: me.mail || me.userPrincipalName || '' }, req);
}); });
@@ -86,31 +103,30 @@ async function callback(req, res) {
} catch (e) { console.error('[mail] sign-in failed:', e.message); send(400, page('Could not connect', escHtml(friendly(e)))); } } catch (e) { console.error('[mail] sign-in failed:', e.message); send(400, page('Could not connect', escHtml(friendly(e)))); }
} }
const refreshing = new Map(); // uid -> promise: parallel requests share one refresh (refresh tokens can rotate) const refreshing = new Map(); // account id -> promise: parallel requests share one refresh (refresh tokens can rotate)
/** A working access token for the current person, refreshed when needed. `stale` is the token that just failed. */ /** A working access token for an Outlook account, refreshed when needed. `stale` is the token that just failed. */
async function token(force, stale) { async function token(acct, force, stale) {
const st = load(); if (!st) throw fail(409, 'Mail is not connected'); const st = accounts().find(x => x.id === acct.id); if (!st) throw fail(409, 'Mail is not connected');
if (!force && st.exp - 60e3 > Date.now()) return st.access; if (!force && st.exp - 60e3 > Date.now()) return st.access;
if (force && st.access !== stale) return st.access; // another request already refreshed it if (force && st.access !== stale) return st.access; // another request already refreshed it
const uid = users.current().id; if (refreshing.has(st.id)) return refreshing.get(st.id);
if (refreshing.has(uid)) return refreshing.get(uid);
const job = (async () => { const job = (async () => {
try { try {
const t = await tokenCall({ grant_type: 'refresh_token', refresh_token: st.refresh }); const t = await tokenCall({ grant_type: 'refresh_token', refresh_token: st.refresh });
save({ ...st, access: t.access_token, refresh: t.refresh_token || st.refresh, exp: Date.now() + (t.expires_in || 3600) * 1000 }); putAccount({ ...st, access: t.access_token, refresh: t.refresh_token || st.refresh, exp: Date.now() + (t.expires_in || 3600) * 1000 });
return t.access_token; return t.access_token;
} catch (e) { } catch (e) {
if (e.code === 'invalid_grant' || e.status === 400 || e.status === 401) { wipe(); throw fail(409, 'Microsoft signed you out of mail. Connect again.'); } if (e.code === 'invalid_grant' || e.status === 400 || e.status === 401) { dropAccount(st.id); throw fail(409, 'Microsoft signed you out of mail. Connect again.'); }
throw fail(502, 'Could not reach Microsoft: ' + e.message); throw fail(502, 'Could not reach Microsoft: ' + e.message);
} }
})().finally(() => refreshing.delete(uid)); })().finally(() => refreshing.delete(st.id));
refreshing.set(uid, job); refreshing.set(st.id, job);
return job; return job;
} }
async function graph(p, opts = {}, stale) { async function graph(acct, p, opts = {}, stale) {
const used = await token(stale !== undefined, stale); const used = await token(acct, stale !== undefined, stale);
const r = await fetch(GRAPH + p, { ...opts, headers: { authorization: 'Bearer ' + used, ...(opts.body && typeof opts.body === 'string' ? { 'content-type': 'application/json' } : {}), ...(opts.headers || {}) }, signal: AbortSignal.timeout(30000) }); const r = await fetch(GRAPH + p, { ...opts, headers: { authorization: 'Bearer ' + used, ...(opts.body && typeof opts.body === 'string' ? { 'content-type': 'application/json' } : {}), ...(opts.headers || {}) }, signal: AbortSignal.timeout(30000) });
if (r.status === 401 && stale === undefined) return graph(p, opts, used); if (r.status === 401 && stale === undefined) return graph(acct, p, opts, used);
if (r.status === 204 || r.status === 202) return null; if (r.status === 204 || r.status === 202) return null;
if (opts.raw && r.ok) return r; if (opts.raw && r.ok) return r;
const j = await r.json().catch(() => ({})); const j = await r.json().catch(() => ({}));
@@ -125,30 +141,74 @@ const id = v => { if (!ID.test(String(v || ''))) throw fail(400, 'Bad id'); retu
const addr = a => a && a.emailAddress ? { name: a.emailAddress.name || '', address: a.emailAddress.address || '' } : { name: '', address: '' }; const addr = a => a && a.emailAddress ? { name: a.emailAddress.name || '', address: a.emailAddress.address || '' } : { name: '', address: '' };
const row = m => ({ id: m.id, subject: m.subject || '(No subject)', from: addr(m.from), date: m.receivedDateTime, read: !!m.isRead, attach: !!m.hasAttachments, preview: m.bodyPreview || '', flagged: !!(m.flag && m.flag.flagStatus === 'flagged') }); const row = m => ({ id: m.id, subject: m.subject || '(No subject)', from: addr(m.from), date: m.receivedDateTime, read: !!m.isRead, attach: !!m.hasAttachments, preview: m.bodyPreview || '', flagged: !!(m.flag && m.flag.flagStatus === 'flagged') });
const EMAIL = /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/; const EMAIL = /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/;
const people = (list, label) => { const out = []; for (const x of Array.isArray(list) ? list : String(list || '').split(/[,;]/)) { const a = String(x).trim(); if (!a) continue; const m = /<([^>]+)>/.exec(a), e = (m ? m[1] : a).trim(); if (!EMAIL.test(e)) throw fail(400, `“${a}” is not an email address (${label})`); out.push({ emailAddress: { address: e } }); } return out; }; /** "a@b.co, Ann <ann@x.org>" -> [{ name, address }], refusing anything that is not an address. */
const recipients = (list, label) => { const out = []; for (const x of Array.isArray(list) ? list : String(list || '').split(/[,;]/)) { const a = String(x).trim(); if (!a) continue; const m = /^(.*?)\s*<([^>]+)>$/.exec(a), e = (m ? m[2] : a).trim(); if (!EMAIL.test(e)) throw fail(400, `“${a}” is not an email address (${label})`); out.push({ name: m ? m[1].replace(/^"|"$/g, '') : '', address: e }); } return out; };
const toGraph = list => list.map(p => ({ emailAddress: { address: p.address } }));
const textToHtml = t => '<div style="white-space:pre-wrap;font-family:sans-serif">' + escHtml(t).replace(/\r?\n/g, '<br>') + '</div>'; const textToHtml = t => '<div style="white-space:pre-wrap;font-family:sans-serif">' + escHtml(t).replace(/\r?\n/g, '<br>') + '</div>';
const safeName = n => String(n || 'attachment').replace(/[\r\n"\\/]+/g, '_').slice(0, 150); const safeName = n => String(n || 'attachment').replace(/[\r\n"\\/]+/g, '_').slice(0, 150);
const cleanAttachments = list => { let total = 0; const out = (Array.isArray(list) ? list : []).slice(0, 10).map(a => { const data = String(a.data || ''); total += Buffer.from(data, 'base64').length; return { name: safeName(a.name), type: String(a.type || 'application/octet-stream').slice(0, 100), data }; }); if (total > MAX_ATTACH) throw fail(400, 'Attachments can total about 3 MB'); return out; };
let folderCache = new Map(); // uid -> { t, v } let folderCache = new Map(); // account id -> { t, v }
async function folders() { const dropFolderCache = id => id ? folderCache.delete(id) : folderCache.clear();
const uid = users.current().id, c = folderCache.get(uid);
if (c && Date.now() - c.t < 15e3) return c.v;
const sel = '?$select=id,displayName,unreadItemCount,totalItemCount';
const wk = await Promise.all(Object.keys(WELL).map(k => graph(`/me/mailFolders/${k}${sel}`).then(f => ({ id: k, realId: f.id, name: WELL[k], unread: f.unreadItemCount || 0, total: f.totalItemCount || 0, well: true })).catch(() => null)));
const known = new Set(wk.filter(Boolean).map(f => f.realId));
const rest = (await graph(`/me/mailFolders${sel}&$top=100`).catch(() => ({ value: [] }))).value || [];
const v = [...wk.filter(Boolean).map(({ realId, ...f }) => f), ...rest.filter(f => !known.has(f.id) && !['Outbox', 'Conversation History', 'Sync Issues', 'RSS Feeds'].includes(f.displayName)).map(f => ({ id: f.id, name: f.displayName, unread: f.unreadItemCount || 0, total: f.totalItemCount || 0 }))];
folderCache.set(uid, { t: Date.now(), v }); return v;
}
const dropFolderCache = () => folderCache.delete(users.current().id);
async function withInline(html, attachments, mid) { // ---- Outlook.com through Microsoft Graph. Every function takes the account and Graph's own ids.
const outlook = {
async folders(a) {
const c = folderCache.get(a.id); if (c && Date.now() - c.t < 15e3) return c.v;
const sel = '?$select=id,displayName,unreadItemCount,totalItemCount';
const wk = await Promise.all(Object.keys(WELL).map(k => graph(a, `/me/mailFolders/${k}${sel}`).then(f => ({ id: k, realId: f.id, name: WELL[k], unread: f.unreadItemCount || 0, total: f.totalItemCount || 0, well: true })).catch(() => null)));
const known = new Set(wk.filter(Boolean).map(f => f.realId));
const rest = (await graph(a, `/me/mailFolders${sel}&$top=100`).catch(() => ({ value: [] }))).value || [];
const v = [...wk.filter(Boolean).map(({ realId, ...f }) => f), ...rest.filter(f => !known.has(f.id) && !['Outbox', 'Conversation History', 'Sync Issues', 'RSS Feeds'].includes(f.displayName)).map(f => ({ id: f.id, name: f.displayName, unread: f.unreadItemCount || 0, total: f.totalItemCount || 0 }))];
folderCache.set(a.id, { t: Date.now(), v }); return v;
},
async list(a, { folder, skip, q }) {
const fid = id(folder || 'inbox'), sel = '$select=id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview,flag';
const r = await graph(a, q ? `/me/mailFolders/${fid}/messages?${sel}&$top=50&$search="${encodeURIComponent(q)}"` : `/me/mailFolders/${fid}/messages?${sel}&$top=30&$skip=${skip}&$orderby=receivedDateTime desc`.replace(/ /g, '%20'));
const items = (r.value || []).map(row); if (q) items.sort((x, y) => String(y.date).localeCompare(String(x.date)));
return { items, more: !q && !!r['@odata.nextLink'], next: skip + items.length };
},
async message(a, mid) {
const m = await graph(a, `/me/messages/${id(mid)}?$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,body,hasAttachments,flag,parentFolderId&$expand=attachments($select=id,name,contentType,size,isInline)`);
let html = m.body && m.body.contentType === 'html' ? m.body.content || '' : textToHtml((m.body && m.body.content) || '');
html = await withInline(a, html, m.attachments, mid);
return { id: m.id, subject: m.subject || '(No subject)', from: addr(m.from), to: (m.toRecipients || []).map(addr), cc: (m.ccRecipients || []).map(addr), date: m.receivedDateTime, read: !!m.isRead, flagged: !!(m.flag && m.flag.flagStatus === 'flagged'),
html, attachments: (m.attachments || []).filter(x => !x.isInline).map(x => ({ id: x.id, name: x.name, type: x.contentType, size: x.size })) };
},
async attachment(a, mid, aid) {
const meta = await graph(a, `/me/messages/${id(mid)}/attachments/${id(aid)}?$select=name,contentType,size`);
const r = await graph(a, `/me/messages/${id(mid)}/attachments/${id(aid)}/$value`, { raw: true });
return { name: meta.name, type: meta.contentType, buffer: Buffer.from(await r.arrayBuffer()) };
},
async update(a, mid, { read, flagged }) {
const patch = {}; if (typeof read === 'boolean') patch.isRead = read; if (typeof flagged === 'boolean') patch.flag = { flagStatus: flagged ? 'flagged' : 'notFlagged' };
if (!Object.keys(patch).length) throw fail(400, 'Nothing to change');
await graph(a, `/me/messages/${id(mid)}`, { method: 'PATCH', body: JSON.stringify(patch) });
},
async move(a, mid, to) { await graph(a, `/me/messages/${id(mid)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: decodeURIComponent(id(to)) }) }); },
async del(a, mid, permanent) {
if (permanent) await graph(a, `/me/messages/${id(mid)}`, { method: 'DELETE' });
else await graph(a, `/me/messages/${id(mid)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: 'deleteditems' }) });
},
async send(a, { to, cc, bcc, subject, body, attachments }) {
await graph(a, '/me/sendMail', { method: 'POST', body: JSON.stringify({ message: { subject, body: { contentType: 'HTML', content: textToHtml(body) }, toRecipients: toGraph(to), ccRecipients: toGraph(cc), bccRecipients: toGraph(bcc),
attachments: attachments.map(x => ({ '@odata.type': '#microsoft.graph.fileAttachment', name: x.name, contentType: x.type, contentBytes: x.data })) }, saveToSentItems: true }) });
},
async reply(a, { id: mid, mode, comment, to }) {
const body = { comment }; if (mode === 'forward') body.toRecipients = toGraph(to);
await graph(a, `/me/messages/${id(mid)}/${mode}`, { method: 'POST', body: JSON.stringify(body) });
},
async unread(a) { return (await graph(a, '/me/mailFolders/inbox?$select=unreadItemCount')).unreadItemCount || 0; },
};
const providerOf = a => a.type === 'outlook' ? outlook : imap;
async function withInline(a, html, attachments, mid) {
// (contentId only exists on file attachments, so it cannot be selected in the list: fetch each small inline picture whole) // (contentId only exists on file attachments, so it cannot be selected in the list: fetch each small inline picture whole)
const inl = (attachments || []).filter(a => a.isInline && /^image\//i.test(a.contentType || '') && (a.size || 0) < 1.5 * 1024 * 1024).slice(0, 15); const inl = (attachments || []).filter(x => x.isInline && /^image\//i.test(x.contentType || '') && (x.size || 0) < 1.5 * 1024 * 1024).slice(0, 15);
for (const a of inl) { for (const x of inl) {
try { try {
const full = await graph(`/me/messages/${id(mid)}/attachments/${id(a.id)}`); const full = await graph(a, `/me/messages/${id(mid)}/attachments/${id(x.id)}`);
if (full && full.contentBytes && full.contentId) html = html.split('cid:' + String(full.contentId).replace(/^<|>$/g, '')).join(`data:${full.contentType || a.contentType};base64,${full.contentBytes}`); if (full && full.contentBytes && full.contentId) html = html.split('cid:' + String(full.contentId).replace(/^<|>$/g, '')).join(`data:${full.contentType || x.contentType};base64,${full.contentBytes}`);
} catch { /* leave that picture out */ } } catch { /* leave that picture out */ }
} }
return html; return html;
@@ -186,72 +246,77 @@ async function adminHandle(req, res, p) {
json(res, 404, { error: 'Not found' }); json(res, 404, { error: 'Not found' });
} }
/** Every unread count in one go (an account that cannot be reached is just left out of the total). */
async function overview() {
const list = accounts();
const rows = await Promise.all(list.map(async a => {
try { return { ...publicAcct(a), unread: await providerOf(a).unread(a) }; }
catch (e) { return { ...publicAcct(a), unread: null, error: e.message, expired: e.status === 409 }; }
}));
const live = accounts(); // (an account whose sign-in Microsoft revoked was dropped meanwhile)
const shown = rows.filter(r => live.some(x => x.id === r.id) || !r.expired);
const expired = rows.some(r => r.expired);
return { connected: shown.length > 0, accounts: shown, expired: expired && !shown.length, address: (shown[0] || {}).address, name: (shown[0] || {}).name, unread: shown.reduce((n, r) => n + (r.unread || 0), 0) };
}
async function handle(req, res) { async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams; const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
try { try {
if (p === '/api/mail/admin' || p.startsWith('/api/mail/admin/')) return await adminHandle(req, res, p); if (p === '/api/mail/admin' || p.startsWith('/api/mail/admin/')) return await adminHandle(req, res, p);
if (p === '/api/mail/status') { if (p === '/api/mail/status') return json(res, 200, { ...(await overview()), outlook: outlookOn(), providers: imap.PRESETS });
const st = load(); if (p === '/api/mail/connect' && req.method === 'GET') { if (!outlookOn()) throw fail(403, 'Outlook.com is not set up on this server (an admin adds the Microsoft app in Settings)'); return json(res, 200, { url: startUrl(req), redirect: redirectUri(req) }); }
if (!st) return json(res, 200, { connected: false }); if (p === '/api/mail/folders') {
let unread = null; try { unread = (await graph('/me/mailFolders/inbox?$select=unreadItemCount')).unreadItemCount; } catch (e) { if (e.status === 409) return json(res, 200, { connected: false, expired: true }); } const a = findAccount(q.get('acct') || undefined);
return json(res, 200, { connected: true, address: st.address, name: st.name, unread }); return json(res, 200, (await providerOf(a).folders(a)).map(f => ({ ...f, id: pack(a, f.id) })));
} }
if (p === '/api/mail/connect' && req.method === 'GET') return json(res, 200, { url: startUrl(req), redirect: redirectUri(req) });
if (p === '/api/mail/disconnect' && req.method === 'POST') { wipe(); dropFolderCache(); audit.log('mail disconnected', { who: users.current().name }, req); return json(res, 200, { ok: true }); }
if (p === '/api/mail/folders') return json(res, 200, await folders());
if (p === '/api/mail/messages') { if (p === '/api/mail/messages') {
const folder = id(q.get('folder') || 'inbox'), search = (q.get('q') || '').replace(/["\\]/g, ' ').trim().slice(0, 100), skip = Math.max(0, parseInt(q.get('skip'), 10) || 0); const [a, fid] = unpack(q.get('folder') || 'inbox'), search = (q.get('q') || '').replace(/["\\]/g, ' ').trim().slice(0, 100), skip = Math.max(0, parseInt(q.get('skip'), 10) || 0);
const sel = '$select=id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview,flag'; const r = await providerOf(a).list(a, { folder: fid || 'inbox', skip, q: search });
const r = await graph(search ? `/me/mailFolders/${folder}/messages?${sel}&$top=50&$search="${encodeURIComponent(search)}"` : `/me/mailFolders/${folder}/messages?${sel}&$top=30&$skip=${skip}&$orderby=receivedDateTime desc`.replace(/ /g, '%20')); return json(res, 200, { ...r, items: r.items.map(m => ({ ...m, id: pack(a, m.id) })) });
const items = (r.value || []).map(row); if (search) items.sort((a, b) => String(b.date).localeCompare(String(a.date)));
return json(res, 200, { items, more: !search && !!r['@odata.nextLink'], next: skip + items.length });
} }
if (p === '/api/mail/message') { if (p === '/api/mail/message') {
const mid = q.get('id'); const [a, nid] = unpack(q.get('id')), m = await providerOf(a).message(a, nid);
const m = await graph(`/me/messages/${id(mid)}?$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,body,hasAttachments,flag,parentFolderId&$expand=attachments($select=id,name,contentType,size,isInline)`); return json(res, 200, { ...m, id: pack(a, m.id), account: publicAcct(a) });
let html = m.body && m.body.contentType === 'html' ? m.body.content || '' : textToHtml((m.body && m.body.content) || '');
html = await withInline(html, m.attachments, mid);
return json(res, 200, { id: m.id, subject: m.subject || '(No subject)', from: addr(m.from), to: (m.toRecipients || []).map(addr), cc: (m.ccRecipients || []).map(addr), date: m.receivedDateTime, read: !!m.isRead, flagged: !!(m.flag && m.flag.flagStatus === 'flagged'),
html, attachments: (m.attachments || []).filter(a => !a.isInline).map(a => ({ id: a.id, name: a.name, type: a.contentType, size: a.size })) });
} }
if (p === '/api/mail/attachment') { if (p === '/api/mail/attachment') {
const mid = q.get('id'), aid = q.get('aid'); const [a, nid] = unpack(q.get('id')), f = await providerOf(a).attachment(a, nid, q.get('aid'));
const meta = await graph(`/me/messages/${id(mid)}/attachments/${id(aid)}?$select=name,contentType,size`); res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-disposition': `attachment; filename="${safeName(f.name)}"; filename*=UTF-8''${encodeURIComponent(safeName(f.name))}`, 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' });
const r = await graph(`/me/messages/${id(mid)}/attachments/${id(aid)}/$value`, { raw: true }); return res.end(f.buffer);
res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-disposition': `attachment; filename="${safeName(meta.name)}"; filename*=UTF-8''${encodeURIComponent(safeName(meta.name))}`, 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' });
return res.end(Buffer.from(await r.arrayBuffer()));
} }
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' }); if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
const b = JSON.parse((await readBody(req)) || '{}'); const b = JSON.parse((await readBody(req)) || '{}'), me = users.current();
if (p === '/api/mail/update') { if (p === '/api/mail/accounts') { // add a Gmail or iCloud account (an app password; checked against the server before it is saved)
const patch = {}; if (typeof b.read === 'boolean') patch.isRead = b.read; if (typeof b.flagged === 'boolean') patch.flag = { flagStatus: b.flagged ? 'flagged' : 'notFlagged' }; const a = await imap.connectNew({ type: b.type, address: b.address, password: b.password });
if (!Object.keys(patch).length) throw fail(400, 'Nothing to change'); const same = accounts().find(x => x.type === a.type && x.address.toLowerCase() === a.address.toLowerCase());
await graph(`/me/messages/${id(b.id)}`, { method: 'PATCH', body: JSON.stringify(patch) }); dropFolderCache(); return json(res, 200, { ok: true }); const saved = putAccount({ ...a, id: same ? same.id : crypto.randomBytes(3).toString('hex') }); dropFolderCache();
audit.log('mail connected', { who: me.name, detail: a.address }, req);
return json(res, 200, { ok: true, account: publicAcct(saved) });
} }
if (p === '/api/mail/move') { await graph(`/me/messages/${id(b.id)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: decodeURIComponent(id(b.to)) }) }); dropFolderCache(); return json(res, 200, { ok: true }); } if (p === '/api/mail/disconnect') {
if (p === '/api/mail/delete') { const list = accounts(), gone = b.acct ? list.filter(x => x.id === b.acct) : list;
if (b.permanent) await graph(`/me/messages/${id(b.id)}`, { method: 'DELETE' }); if (b.acct && !gone.length) throw fail(404, 'That account is not connected');
else await graph(`/me/messages/${id(b.id)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: 'deleteditems' }) }); for (const x of gone) dropAccount(x.id);
dropFolderCache(); return json(res, 200, { ok: true }); audit.log('mail disconnected', { who: me.name, detail: gone.map(x => x.address).join(', ') }, req); return json(res, 200, { ok: true });
} }
if (p === '/api/mail/update') { const [a, nid] = unpack(b.id); await providerOf(a).update(a, nid, { read: b.read, flagged: b.flagged }); dropFolderCache(a.id); return json(res, 200, { ok: true }); }
if (p === '/api/mail/move') { const [a, nid] = unpack(b.id), dest = String(b.to || ''); await providerOf(a).move(a, nid, dest.includes('~') ? dest.slice(dest.indexOf('~') + 1) : dest); dropFolderCache(a.id); return json(res, 200, { ok: true }); }
if (p === '/api/mail/delete') { const [a, nid] = unpack(b.id); await providerOf(a).del(a, nid, !!b.permanent); dropFolderCache(a.id); return json(res, 200, { ok: true }); }
if (p === '/api/mail/send') { if (p === '/api/mail/send') {
const to = people(b.to, 'To'), cc = people(b.cc, 'Cc'), bcc = people(b.bcc, 'Bcc'); const a = findAccount(b.acct || undefined), to = recipients(b.to, 'To'), cc = recipients(b.cc, 'Cc'), bcc = recipients(b.bcc, 'Bcc');
if (!to.length && !cc.length && !bcc.length) throw fail(400, 'Add at least one recipient'); if (!to.length && !cc.length && !bcc.length) throw fail(400, 'Add at least one recipient');
const subject = String(b.subject || '').slice(0, 255), text = String(b.body || ''); if (text.length > 200000) throw fail(400, 'That message is too long'); const subject = String(b.subject || '').slice(0, 255), body = String(b.body || ''); if (body.length > 200000) throw fail(400, 'That message is too long');
let total = 0; const attachments = (Array.isArray(b.attachments) ? b.attachments : []).slice(0, 10).map(a => { const bytes = Buffer.from(String(a.data || ''), 'base64').length; total += bytes; return { '@odata.type': '#microsoft.graph.fileAttachment', name: safeName(a.name), contentType: String(a.type || 'application/octet-stream').slice(0, 100), contentBytes: String(a.data || '') }; }); await providerOf(a).send(a, { to, cc, bcc, subject, body, attachments: cleanAttachments(b.attachments) });
if (total > MAX_ATTACH) throw fail(400, 'Attachments can total about 3 MB'); audit.log('mail sent', { who: me.name, detail: `${to.length + cc.length + bcc.length} recipient(s)` }, req); dropFolderCache(a.id); return json(res, 200, { ok: true });
await graph('/me/sendMail', { method: 'POST', body: JSON.stringify({ message: { subject, body: { contentType: 'HTML', content: textToHtml(text) }, toRecipients: to, ccRecipients: cc, bccRecipients: bcc, attachments }, saveToSentItems: true }) });
audit.log('mail sent', { who: users.current().name, detail: `${to.length + cc.length + bcc.length} recipient(s)` }, req); dropFolderCache(); return json(res, 200, { ok: true });
} }
if (p === '/api/mail/reply') { if (p === '/api/mail/reply') {
const mode = { reply: 'reply', replyAll: 'replyAll', forward: 'forward' }[b.mode]; if (!mode) throw fail(400, 'Unknown action'); const mode = { reply: 'reply', replyAll: 'replyAll', forward: 'forward' }[b.mode]; if (!mode) throw fail(400, 'Unknown action');
const body = { comment: String(b.comment || '').slice(0, 200000) }; const [a, nid] = unpack(b.id), to = mode === 'forward' ? recipients(b.to, 'To') : [];
if (mode === 'forward') { body.toRecipients = people(b.to, 'To'); if (!body.toRecipients.length) throw fail(400, 'Add at least one recipient'); } if (mode === 'forward' && !to.length) throw fail(400, 'Add at least one recipient');
await graph(`/me/messages/${id(b.id)}/${mode}`, { method: 'POST', body: JSON.stringify(body) }); await providerOf(a).reply(a, { id: nid, mode, comment: String(b.comment || '').slice(0, 200000), to });
audit.log('mail sent', { who: users.current().name, detail: mode }, req); dropFolderCache(); return json(res, 200, { ok: true }); audit.log('mail sent', { who: me.name, detail: mode }, req); dropFolderCache(a.id); return json(res, 200, { ok: true });
} }
json(res, 404, { error: 'Not found' }); json(res, 404, { error: 'Not found' });
} catch (e) { json(res, e.status || 400, { error: e.message }); } } catch (e) { json(res, e.status || 400, { error: e.message }); }
} }
module.exports = { enabled, handle, callback, secretStatus }; module.exports = { enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
+652
View File
@@ -10,7 +10,14 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@simplewebauthn/server": "^14.0.3", "@simplewebauthn/server": "^14.0.3",
"imapflow": "^2.9.0",
"mailparser": "^3.12.2",
"nodemailer": "^10.2.0",
"web-push": "^3.6.7" "web-push": "^3.6.7"
},
"devDependencies": {
"hoodiecrow-imap": "^3.3.1",
"smtp-server": "^3.21.0"
} }
}, },
"node_modules/@hexagon/base64": { "node_modules/@hexagon/base64": {
@@ -258,6 +265,28 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
"integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==",
"license": "MIT",
"dependencies": {
"domelementtype": "~2.3.0",
"domhandler": "~5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
},
"peerDependencies": {
"selderee": "~0.12.0"
}
},
"node_modules/@simplewebauthn/server": { "node_modules/@simplewebauthn/server": {
"version": "14.0.3", "version": "14.0.3",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.3.tgz", "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.3.tgz",
@@ -279,6 +308,20 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.27",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.27.tgz",
"integrity": "sha512-9008ddFqqKk7lMF2S1sLU7ExfJZP9zPw5tkZy24EVt45Ryme2x85f/5vJZZqQ+vCJkKcCY78dJ5yuEVG3ZrVZA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.4.1",
"libmime": "5.5.2",
"libqp": "3.0.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -314,6 +357,15 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/bn.js": { "node_modules/bn.js": {
"version": "4.12.5", "version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
@@ -343,6 +395,80 @@
} }
} }
}, },
"node_modules/deepmerge-ts": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
"integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
"funding": [
{
"type": "ko-fi",
"url": "https://ko-fi.com/rebeccastevens"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
}
],
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/ecdsa-sig-formatter": { "node_modules/ecdsa-sig-formatter": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -352,6 +478,129 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/encoding-japanese": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.4.0.tgz",
"integrity": "sha512-PE0KjlsDy9JSsoU4C2Q8NXb1kC4c+bZRac9ZvHXUE4q1Ht63gScKMdEy9jst9+Fi3M4eDkqzkEhBZvix4blP2A==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/hoodiecrow-imap": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/hoodiecrow-imap/-/hoodiecrow-imap-3.3.1.tgz",
"integrity": "sha512-0o+wjiIAkRZDJDHkays3mZZ92Qd1dmikPfOO8qbZ7QczIOCPBxvPJlkNjem3DzZkAB5O34uHS3u25yNS4YzV/w==",
"deprecated": "Renamed to imapkit, see https://imapkit.com",
"dev": true,
"license": "MIT",
"dependencies": {
"imap-handler": "1.3.1",
"smtp-server": "3.19.17"
},
"bin": {
"hoodiecrow": "bin/hoodiecrow.js"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/hoodiecrow-imap/node_modules/nodemailer": {
"version": "10.0.14",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.14.tgz",
"integrity": "sha512-eJoLFKg55fesSlzUekmfOv/SBXh4KUYT8oiQc2RLP0tfG0rbNMJBK7wgHJwXTJwqyKlSJMz9UzDZ4v+aHEvo8g==",
"dev": true,
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/hoodiecrow-imap/node_modules/smtp-server": {
"version": "3.19.17",
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.17.tgz",
"integrity": "sha512-/9RwyNTcR8osMQWOvC8NV8l57ddPyYXvuG+wa1Ua4cD/I41z7zCMsGtXLa9vxlrggjRrfejr50X0WS/9NuVWqQ==",
"dev": true,
"license": "MIT-0",
"dependencies": {
"ipv6-normalize": "1.0.1",
"nodemailer": "10.0.14",
"punycode.js": "2.3.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/html-to-text": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.1.tgz",
"integrity": "sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==",
"license": "MIT",
"dependencies": {
"@selderee/plugin-htmlparser2": "~0.12.0",
"deepmerge-ts": "^8.0.1",
"dom-serializer": "^2.0.0",
"htmlparser2": "^10.1.0",
"selderee": "~0.12.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http_ece": { "node_modules/http_ece": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
@@ -374,12 +623,73 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/imap-handler": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/imap-handler/-/imap-handler-1.3.1.tgz",
"integrity": "sha512-/VHETxcR5VMzLAxvb4CQw5d7hCTe4MlcvBlRoKO6z/7cEF650ha8Lwq1EbmUMutrxmb+DMB6piHbANv2vHQUAQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/imapflow": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-2.9.0.tgz",
"integrity": "sha512-8drBcDmYhDNkMHzoXVd46/XnZg3krcZyLsyOyVuAxTQPPKUc0u/bpb3ocl36u2b9ZD/8J6rz2YdUbzHyaj1Pmw==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.27",
"encoding-japanese": "2.4.0",
"iconv-lite": "0.7.3",
"libbase64": "1.4.1",
"libmime": "5.5.2",
"libqp": "3.0.1",
"pino": "10.3.1",
"socks": "2.8.10"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.7.4",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
"integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipv6-normalize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz",
"integrity": "sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==",
"dev": true,
"license": "MIT"
},
"node_modules/jwa": { "node_modules/jwa": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -401,6 +711,110 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/leac": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz",
"integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/libbase64": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.4.1.tgz",
"integrity": "sha512-Nyh3H90B3gRtrcxIe56Ayy7635J0jmh6AbxbQgQae8wrSPQMH5wFqJChiMTB13yKG512L3YspbA5DzumhFTITA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/libmime": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.5.2.tgz",
"integrity": "sha512-Byw3CchK4eSqbgzpttsUoC4hx2VPP+hnveAuYAYZNipEc+0WlDFjVb1ISNscjq/idvyDP/Xjg8mnBcdgbsgvzg==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.4.0",
"iconv-lite": "0.7.3",
"libbase64": "1.4.1",
"libqp": "3.0.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/libqp": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-3.0.1.tgz",
"integrity": "sha512-7QZxpPX25pga1WiMueduWxmrHMVBFrZPH6ZUaozGYa/H98JH6NVBSET3qVw+4NxzoraIW7rJ81YxSU9IalvCpg==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/linkify-it": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/mailparser": {
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.12.2.tgz",
"integrity": "sha512-JbOt+AjJIr7+bhMp8g7ILevleoDAD0bXVlAJmikrz0sbQqmt88YELKDwYQL5JNugkV9lcN+DFv32dyhPsvqpHQ==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.27",
"he": "1.2.0",
"html-to-text": "10.0.1",
"libmime": "5.6.0",
"linkify-it": "5.0.2",
"nodemailer": "10.1.7",
"punycode.js": "2.3.1",
"tlds": "1.261.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/mailparser/node_modules/libmime": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.6.0.tgz",
"integrity": "sha512-b5HR41QUt1whlvLnY3AhAYwba56DDiNv1xVTH5viVq86/m+wvW+HeooLx6x+MdNapho9ki7Lo/fTxuqwhEs/sQ==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.4.0",
"iconv-lite": "0.7.3",
"libbase64": "1.4.1",
"libqp": "3.0.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/mailparser/node_modules/nodemailer": {
"version": "10.1.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.7.tgz",
"integrity": "sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/minimalistic-assert": { "node_modules/minimalistic-assert": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -422,6 +836,108 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.0.tgz",
"integrity": "sha512-/XyGw6kX2stMdKGUJdAQmYAeY1FscWfdRydfd2NtK3A3KDfUUXCqZzExSNAt2xhh90X2qLCR+o2Gd48YPJcJwA==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/parseley": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
"integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==",
"license": "MIT",
"dependencies": {
"leac": "^0.7.0",
"peberminta": "^0.10.0"
},
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/peberminta": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz",
"integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz",
"integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pvtsutils": { "node_modules/pvtsutils": {
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
@@ -440,6 +956,21 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/reflect-metadata": { "node_modules/reflect-metadata": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -466,12 +997,127 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/selderee": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
"license": "MIT",
"dependencies": {
"parseley": "~0.13.1"
},
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/smtp-server": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.21.0.tgz",
"integrity": "sha512-vhJTg1JR6+mM5vlCD/TzUvFcQ5JQiCqP0FY3TgMJ0u3MPuvxawKD/6PA+TacPYEFVAgZIjLaCOSf87L4jcN0+w==",
"dev": true,
"license": "MIT-0",
"dependencies": {
"ipv6-normalize": "1.0.1",
"nodemailer": "10.1.7",
"punycode.js": "2.3.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/smtp-server/node_modules/nodemailer": {
"version": "10.1.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.7.tgz",
"integrity": "sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==",
"dev": true,
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/socks": {
"version": "2.8.10",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
"integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
"license": "MIT",
"dependencies": {
"real-require": "^1.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/thread-stream/node_modules/real-require": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"license": "MIT"
},
"node_modules/tlds": {
"version": "1.261.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
"license": "MIT",
"bin": {
"tlds": "bin.js"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -496,6 +1142,12 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/web-push": { "node_modules/web-push": {
"version": "3.6.7", "version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
+8 -1
View File
@@ -5,7 +5,7 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"test": "for t in smoke tasks push users plex player lyrics signal mail; do node tools/$t-test.js || exit 1; done", "test": "for t in smoke tasks push users plex player lyrics signal mail mail-imap files; do node tools/$t-test.js || exit 1; done",
"audit": "npm audit --omit=dev --audit-level=high" "audit": "npm audit --omit=dev --audit-level=high"
}, },
"keywords": [], "keywords": [],
@@ -14,6 +14,13 @@
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"@simplewebauthn/server": "^14.0.3", "@simplewebauthn/server": "^14.0.3",
"imapflow": "^2.9.0",
"mailparser": "^3.12.2",
"nodemailer": "^10.2.0",
"web-push": "^3.6.7" "web-push": "^3.6.7"
},
"devDependencies": {
"hoodiecrow-imap": "^3.3.1",
"smtp-server": "^3.21.0"
} }
} }
+59 -27
View File
@@ -37,7 +37,7 @@ async function loadMe() {
} }
// Signal is for desktop-class screens only (a window at least 900 px wide with a mouse or trackpad), and needs the server to have it set up. // Signal is for desktop-class screens only (a window at least 900 px wide with a mouse or trackpad), and needs the server to have it set up.
const sgMQ = matchMedia('(min-width: 900px) and (pointer: fine)'); const sgMQ = matchMedia('(min-width: 900px) and (pointer: fine)');
const allowed = id => (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up) const allowed = id => id === 'planner' ? (allowed('calendar') || allowed('tasks')) : (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up)
/** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */ /** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */
async function loadSettings() { async function loadSettings() {
try { try {
@@ -100,11 +100,23 @@ let cfg = { mail: '', podcast: 'https://podcast.necloud.us/', verse: 'https://ve
// ---- Seafile client (via same-origin proxy) // ---- Seafile client (via same-origin proxy)
const sf = { const sf = {
token: () => store.get('sf_token'), token: () => store.get('sf_token'),
async req(path, opts = {}) { /** A call to Seafile through the hub. Reads are retried; a slow or unreachable Seafile gives a clear message; an expired sign-in asks for the
const headers = { Authorization: 'Token ' + sf.token(), ...(opts.headers || {}) }; * password again in place (sfReauth) and then carries on, so you do not lose where you were. */
const r = await fetch('/sf' + path, { ...opts, headers }); async req(path, opts = {}, tries = 0) {
const { _reauthed, timeout, ...rest } = opts, method = (rest.method || 'GET').toUpperCase(), idem = method === 'GET' || method === 'HEAD';
const headers = { Authorization: 'Token ' + sf.token(), ...(rest.headers || {}) };
let r;
try { r = await fetch('/sf' + path, { ...rest, headers, signal: rest.signal || AbortSignal.timeout(timeout || 45000) }); }
catch (e) {
if (idem && tries < 2 && e.name !== 'AbortError' && navigator.onLine) { await sleep(600 * (tries + 1)); return sf.req(path, opts, tries + 1); }
throw new Error(!navigator.onLine ? 'You are offline' : e.name === 'TimeoutError' ? 'Seafile took too long to answer. Try again in a moment.' : 'Could not reach the server');
}
if (r.status === 401 && !_reauthed) { await sfReauth(); return sf.req(path, { ...opts, _reauthed: true }); }
if (r.status === 401) { store.del('sf_token'); throw new Error('Session expired, sign in again'); } if (r.status === 401) { store.del('sf_token'); throw new Error('Session expired, sign in again'); }
if (!r.ok) throw new Error((await r.text().catch(() => '')).slice(0, 200) || 'Request failed (' + r.status + ')'); if (!r.ok) {
let m = (await r.text().catch(() => '')).slice(0, 200); try { const j = JSON.parse(m); m = j.error || j.error_msg || j.detail || m; } catch {}
throw new Error(m || (r.status === 504 ? 'Seafile took too long to answer' : r.status >= 500 ? 'Seafile had a problem (' + r.status + ')' : 'Request failed (' + r.status + ')'));
}
const t = await r.text(); const t = await r.text();
try { return JSON.parse(t); } catch { return t; } try { return JSON.parse(t); } catch { return t; }
}, },
@@ -121,7 +133,12 @@ const sf = {
}, },
}; };
const toast = (m) => { const t = $('#toast'); t.textContent = m; t.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), 2600); }; /** A short message at the bottom; with `action` ({ label, run }) it has a button (Undo, say) and stays a little longer. */
const toast = (m, action) => {
const t = $('#toast'); t.textContent = m; t.classList.toggle('act', !!action);
if (action) { const b = document.createElement('button'); b.textContent = action.label; b.onclick = () => { t.classList.remove('on'); action.run(); }; t.appendChild(b); }
t.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), action ? 9000 : 2600);
};
const ext = n => (n.split('.').pop() || '').toLowerCase(); const ext = n => (n.split('.').pop() || '').toLowerCase();
const EDIT_EXT = new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt']); const EDIT_EXT = new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt']);
const OFFICE_EXT =new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt', 'pdf']); const OFFICE_EXT =new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt', 'pdf']);
@@ -130,9 +147,18 @@ const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: '
// ---- Navigation // ---- Navigation
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']]; const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link' }; const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry (each part still needs its own permission). */
const navTabs = () => { const out = []; let done = false; for (const t of TABS) { if (!allowed(t[0])) continue; if (t[0] === 'tasks' || t[0] === 'calendar') { if (!done) { out.push(['planner', 'Planner']); done = true; } continue; } out.push(t); } return out; };
/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Media', ['music', 'audio', 'podcast', 'verse']], ['', ['settings']]];
const navGroups = (tabs = navTabs()) => {
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
return out.filter(g => g.tabs.length);
};
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
const PRIMARY = ['home', 'files', 'tasks', 'music', 'mail']; // phone tab bar; the rest sit behind "More" const PRIMARY = ['home', 'files', 'planner', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
const phone = matchMedia('(max-width: 767px)'); const phone = matchMedia('(max-width: 767px)');
let activeTab = 'home'; let activeTab = 'home';
@@ -143,26 +169,27 @@ function renderNav(active = activeTab) {
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : ''; const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`; const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`; const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
const mine = TABS.filter(t => allowed(t[0])); const mine = navTabs();
if (phone.matches) { if (phone.matches) {
const rest = mine.filter(t => !PRIMARY.includes(t[0])), more = !PRIMARY.includes(active); const rest = mine.filter(t => !PRIMARY.includes(t[0])), more = !PRIMARY.includes(active);
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') + $('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : ''); (rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); }; const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
} else { } else {
$('#nav').innerHTML = brand + mine.map(tab).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`; $('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `<div class="ngrp" role="presentation">${g.label ? `<span>${esc(g.label)}</span>` : ''}</div>` : '') + g.tabs.map(tab).join('')).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
$('#navcol').onclick = () => setNav({ mini: !navPref().mini }); $('#navcol').onclick = () => setNav({ mini: !navPref().mini });
} }
} }
phone.addEventListener('change', () => renderNav()); phone.addEventListener('change', () => renderNav());
function openMore() { function openMore() {
const el = openSheet('More', '', `<div class="list">${TABS.filter(t => !PRIMARY.includes(t[0]) && allowed(t[0])).map(([id, label]) => ` const rest = navTabs().filter(t => !PRIMARY.includes(t[0]));
<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${(id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : 0) ? `<i class="nbadge" style="position:static">${id === 'signal' ? sg.unread : mailUnread}</i>` : ''}${icon('chev', 'chev')}</a>`).join('')}</div>`); const rowOf = ([id, label]) => `<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${(id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : 0) ? `<i class="nbadge" style="position:static">${id === 'signal' ? sg.unread : mailUnread}</i>` : ''}${icon('chev', 'chev')}</a>`;
const el = openSheet('More', '', navGroups(rest).map(g => `${g.label ? `<h2 style="margin:14px 0 6px">${esc(g.label)}</h2>` : ''}<div class="list" ${g.label ? '' : 'style="margin-top:10px"'}>${g.tabs.map(rowOf).join('')}</div>`).join(''));
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet)); el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
} }
const routesOf = () => ({ home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); const routesOf = () => ({ planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
let editor; let editor;
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device. // Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } }; const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
@@ -206,7 +233,7 @@ function route() {
document.body.classList.remove('inedit', 'onaudio'); document.body.classList.remove('inedit', 'onaudio');
sg.timers.forEach(clearInterval); sg.timers = []; // (Signal screens start their own) sg.timers.forEach(clearInterval); sg.timers = []; // (Signal screens start their own)
const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame
renderNav(name === 'edit' ? 'files' : name); renderNav(name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : name);
drawNav(); drawNav();
routes[name](rest.map(decodeURIComponent)); routes[name](rest.map(decodeURIComponent));
sgDockSync(name); sgDockSync(name);
@@ -219,6 +246,23 @@ function tile(href, color, ic, title, sub, external) {
} }
// ---- Views // ---- Views
/** The Home app tiles, grouped by kind like the menu. */
function homeApps() {
const T = {
files: () => tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile'),
music: () => tile('#/music', '#e5a00d', 'music', 'Music', 'Plex'),
planner: () => tile('#/planner', '#d9453a', 'cal', 'Planner', allowed('calendar') && allowed('tasks') ? 'Calendar & tasks' : allowed('tasks') ? 'To-do lists' : 'Agenda'),
office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'),
audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'),
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
verse: () => cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : '',
signal: () => tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages'),
links: () => tile('#/links', '#0f9d9a', 'link', 'Links', (store.get('links', []) || []).length + ' saved'),
mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
};
return navGroups().filter(g => g.tabs.some(t => T[t[0]])).map(g => `<h2>${esc(g.label || 'Apps')}</h2><div class="grid">${g.tabs.map(t => T[t[0]] ? T[t[0]]() : '').join('')}</div>`).join('');
}
function viewHome() { function viewHome() {
const h = new Date().getHours(); const h = new Date().getHours();
const greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening'; const greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
@@ -231,19 +275,7 @@ function viewHome() {
<div id="tcard"></div> <div id="tcard"></div>
<div id="agenda"></div> <div id="agenda"></div>
${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''} ${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}
<h2>Apps</h2><div class="grid"> ${homeApps()}
${allowed('files') ? tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile') : ''}
${allowed('music') ? tile('#/music', '#e5a00d', 'music', 'Music', 'Plex') : ''}
${allowed('tasks') ? tile('#/tasks', '#4f7cff', 'check', 'Tasks', 'To-do lists') : ''}
${allowed('calendar') ? tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda') : ''}
${allowed('office') ? tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice') : ''}
${allowed('audio') ? tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen') : ''}
${allowed('podcast') ? tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts') : ''}
${allowed('verse') && cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : ''}
${allowed('signal') ? tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages') : ''}
${allowed('links') ? tile('#/links', '#0f9d9a', 'link', 'Links', (store.get('links', []) || []).length + ' saved') : ''}
${allowed('mail') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com') : ''}
</div>
${allowed('mail') && cfg.mail ? '<div id="mcard"></div>' : ''}</div>`; ${allowed('mail') && cfg.mail ? '<div id="mcard"></div>' : ''}</div>`;
renderNpCard(); // the live Now Playing card (it follows the song and has the controls) renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => { $('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
+1 -1
View File
@@ -38,7 +38,7 @@ async function renderTimeGrid(nDays) {
<button class="ibtn" id="tn" aria-label="Next ${nDays === 1 ? 'day' : 'week'}" style="transform:scaleX(-1)">${icon('back')}</button><button class="btn ghost" id="tt" style="min-height:36px">Today</button></div> <button class="ibtn" id="tn" aria-label="Next ${nDays === 1 ? 'day' : 'week'}" style="transform:scaleX(-1)">${icon('back')}</button><button class="btn ghost" id="tt" style="min-height:36px">Today</button></div>
<div class="tgwrap"><div class="tg" style="min-width:${nDays === 1 ? 0 : 560}px"> <div class="tgwrap"><div class="tg" style="min-width:${nDays === 1 ? 0 : 560}px">
<div class="tgtop"><div class="tghead"><span class="tgg"></span>${days.map(d => `<button class="tgd${ymd(d) === today ? ' today' : ''}" data-day="${ymd(d)}" aria-label="${esc(d.toDateString())}"><small>${d.toLocaleDateString([], { weekday: 'short' })}</small><b>${d.getDate()}</b></button>`).join('')}</div> <div class="tgtop"><div class="tghead"><span class="tgg"></span>${days.map(d => `<button class="tgd${ymd(d) === today ? ' today' : ''}" data-day="${ymd(d)}" aria-label="${esc(d.toDateString())}"><small>${d.toLocaleDateString([], { weekday: 'short' })}</small><b>${d.getDate()}</b></button>`).join('')}</div>
${allDay.some(a => a.length) ? `<div class="tgad"><span class="tgg">all day</span>${allDay.map(a => `<div>${a.map(e => { const i = evStore.push(e) - 1; return `<button class="tgchip" data-ei="${i}" style="--c:${esc(e.color)}">${esc(e.title)}</button>`; }).join('')}</div>`).join('')}</div>` : ''}</div> ${allDay.some(a => a.length) ? `<div class="tgad"><span class="tgg">all day</span>${allDay.map(a => `<div>${a.map(e => { const i = evStore.push(e) - 1; return `<button class="tgchip" data-ei="${i}" style="--c:${esc(e.color)}">${e.task ? '☐ ' : ''}${esc(e.title)}</button>`; }).join('')}</div>`).join('')}</div>` : ''}</div>
<div class="tgbody" id="tgbody"><div class="tgg">${hours}</div>${days.map((d, i) => `<div class="tgcol${ymd(d) === today ? ' today' : ''}" data-day="${ymd(d)}" style="height:${24 * HOUR_PX}px">${cols[i]}${ymd(d) === today ? `<i class="tgnow" style="top:${nowTop}px"></i>` : ''}</div>`).join('')}</div> <div class="tgbody" id="tgbody"><div class="tgg">${hours}</div>${days.map((d, i) => `<div class="tgcol${ymd(d) === today ? ' today' : ''}" data-day="${ymd(d)}" style="height:${24 * HOUR_PX}px">${cols[i]}${ymd(d) === today ? `<i class="tgnow" style="top:${nowTop}px"></i>` : ''}</div>`).join('')}</div>
</div></div>${errors.map(e => `<p class="err">${esc(e)}</p>`).join('')}`; </div></div>${errors.map(e => `<p class="err">${esc(e)}</p>`).join('')}`;
const body = $('#tgbody'); if (body) { const wrap = body.parentElement.parentElement; wrap.scrollTop = Math.max(0, ((days.some(d => ymd(d) === today) ? now.getHours() - 1 : 7) * HOUR_PX)); } const body = $('#tgbody'); if (body) { const wrap = body.parentElement.parentElement; wrap.scrollTop = Math.max(0, ((days.some(d => ymd(d) === today) ? now.getHours() - 1 : 7) * HOUR_PX)); }
+29 -14
View File
@@ -1,11 +1,12 @@
'use strict'; 'use strict';
// ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server) // ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server)
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
async function loadRange(start, days) { async function loadRange(start, days, withTasks = true) {
const end = new Date(start); end.setDate(end.getDate() + days); const end = new Date(start); end.setDate(end.getDate() + days);
const last = new Date(end.getTime() - 1); const last = new Date(end.getTime() - 1);
const q = new URLSearchParams({ from: start.getTime(), to: end.getTime() - 1, fd: ymd(start), td: ymd(last) }); const q = new URLSearchParams({ from: start.getTime(), to: end.getTime() - 1, fd: ymd(start), td: ymd(last) });
const { events, errors } = await jfetch('/api/cal/events?' + q); const [{ events, errors }, due] = await Promise.all([jfetch('/api/cal/events?' + q),
withTasks && allowed('tasks') ? jfetch(`/api/tasks/due?from=${ymd(start)}&to=${ymd(last)}`).catch(() => []) : []]);
const byDay = new Map(); const byDay = new Map();
const put = (k, e) => { if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(e); }; const put = (k, e) => { if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(e); };
for (const e of events) { for (const e of events) {
@@ -13,16 +14,25 @@ async function loadRange(start, days) {
for (let d = new Date(e.start + 'T00:00:00'); ymd(d) < e.end; d.setDate(d.getDate() + 1)) { const k = ymd(d); if (k >= ymd(start) && k <= ymd(last)) put(k, e); } for (let d = new Date(e.start + 'T00:00:00'); ymd(d) < e.end; d.setDate(d.getDate() + 1)) { const k = ymd(d); if (k >= ymd(start) && k <= ymd(last)) put(k, e); }
} else put(ymd(new Date(e.start)), e); } else put(ymd(new Date(e.start)), e);
} }
for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start))); for (const t of due) { // tasks with a due date show up on that day, after the all-day events
if (!byDay.has(t.due)) byDay.set(t.due, []);
byDay.get(t.due).push({ task: true, t, title: t.title, color: '#8a93a6', allDay: true, start: t.due, end: t.due, cal: t.listName || 'Tasks', editable: true });
}
for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || (!!a.task - !!b.task) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start)));
return { byDay, errors, start }; return { byDay, errors, start };
} }
const loadAgenda = days => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days); }; const loadAgenda = (days, withTasks = true) => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days, withTasks); };
const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const evStore = []; // events currently on screen, so a tap can open the right one const evStore = []; // events currently on screen, so a tap can open the right one
const evRow = e => { const i = evStore.push(e) - 1; const evRow = e => { const i = evStore.push(e) - 1;
if (e.task) return `<div class="row it"><button class="ibtn tick" data-tt="${i}" aria-label="Mark done">${icon('check')}</button><button class="rmain" data-ei="${i}" style="text-align:left"><span class="grow"><b>${esc(e.title)}</b><small>Task due${e.t.repeat && e.t.repeat !== 'none' ? ' · repeats' : ''} · ${esc(e.cal)}</small></span></button></div>`;
return `<button class="row" data-ei="${i}" style="text-align:left"><span class="dot" style="background:${esc(e.color)}"></span><span class="grow"><b>${esc(e.title)}</b> return `<button class="row" data-ei="${i}" style="text-align:left"><span class="dot" style="background:${esc(e.color)}"></span><span class="grow"><b>${esc(e.title)}</b>
<small>${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.series ? ' · repeats' : ''}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; }; <small>${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.series ? ' · repeats' : ''}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; };
document.addEventListener('click', e => { const b = e.target.closest('[data-ei]'); if (b) openEvent(evStore[+b.dataset.ei]); }); document.addEventListener('click', e => {
const tk = e.target.closest('[data-tt]');
if (tk) { const ev = evStore[+tk.dataset.tt]; tk.disabled = true; jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: ev.t.list, id: ev.t.id, href: ev.t.href, done: true }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); route(); }).catch(err => { tk.disabled = false; toast(err.message); }); return; }
const b = e.target.closest('[data-ei]'); if (b) openEvent(evStore[+b.dataset.ei]);
});
const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1); const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1);
return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); }; return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); };
@@ -30,7 +40,7 @@ async function renderAgenda(el, mode) { // Home card (today only)
if (!el) return; if (!el) return;
try { try {
const cals = await jfetch('/api/cal/list'); const cals = await jfetch('/api/cal/list');
const { byDay } = await loadAgenda(1); const { byDay } = await loadAgenda(1, false); // (the Tasks card on Home already covers what is due)
const list = byDay.get(ymd(new Date())) || []; const list = byDay.get(ymd(new Date())) || [];
if (cals.length < 2 && !list.length) return; // only the empty NE Hub calendar: say nothing if (cals.length < 2 && !list.length) return; // only the empty NE Hub calendar: say nothing
if (!document.body.contains(el)) return; if (!document.body.contains(el)) return;
@@ -44,12 +54,13 @@ const calState = { y: now0.getFullYear(), m: now0.getMonth(), sel: ymd(now0) };
async function viewCalendar() { async function viewCalendar() {
const mode = store.get('cal_view', 'month'); const mode = store.get('cal_view', 'month');
const v = $('#view'); const v = $('#view');
v.innerHTML = `<div class="wrap"><h1>Calendar</h1> const views = `<div class="sgrp" role="tablist" aria-label="Calendar view">${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" role="tab" data-v="${k}" aria-selected="${k === mode}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div>`
<div class="seg">${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" data-v="${k}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div> + (mode === 'agenda' ? `<select id="crange" class="psel" aria-label="How far ahead">${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `<option value="${n}" ${n === store.get('cal_range', 7) ? 'selected' : ''}>${l}</option>`).join('')}</select>` : '');
<div class="bar" style="margin:0 0 8px"><button class="btn" id="ne">${icon('cal')}Add event</button></div> v.innerHTML = `<div class="wrap">${plannerHead('calendar', `<button class="btn" id="ne">${icon('cal')}Add event</button>`, views)}
<div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`; <div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
$('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel); $('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel);
v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); }); if ($('#crange')) $('#crange').onchange = e => { store.set('cal_range', +e.target.value); viewCalendar(); };
v.querySelectorAll('.sgrp a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
$('#mc').onclick = manageCalendars; $('#mc').onclick = manageCalendars;
try { try {
const cals = await jfetch('/api/cal/list'); const cals = await jfetch('/api/cal/list');
@@ -63,10 +74,8 @@ async function renderAgendaList() {
const { byDay, errors } = await loadAgenda(range); const { byDay, errors } = await loadAgenda(range);
if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return; if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
const keys = [...byDay.keys()].sort(); const keys = [...byDay.keys()].sort();
$('#cbody').innerHTML = `<div class="seg">${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `<a href="#" data-n="${n}" class="${n === range ? 'on' : ''}">${l}</a>`).join('')}</div>` $('#cbody').innerHTML = (keys.map(k => `<h2>${esc(dayLabel(k))}</h2><div class="list">${byDay.get(k).map(evRow).join('')}</div>`).join('') || '<p class="empty">Nothing coming up</p>')
+ (keys.map(k => `<h2>${esc(dayLabel(k))}</h2><div class="list">${byDay.get(k).map(evRow).join('')}</div>`).join('') || '<p class="empty">Nothing coming up</p>')
+ errors.map(e => `<p class="err">${esc(e)}</p>`).join(''); + errors.map(e => `<p class="err">${esc(e)}</p>`).join('');
$('#cbody').querySelectorAll('.seg a[data-n]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); });
} }
async function renderMonth() { async function renderMonth() {
@@ -83,7 +92,7 @@ async function renderMonth() {
cells.push(`<button class="cday${other ? ' dim' : ''}${k === today ? ' today' : ''}${k === calState.sel ? ' sel' : ''}" data-d="${k}" aria-label="${esc(d.toDateString())}, ${evs.length} events"> cells.push(`<button class="cday${other ? ' dim' : ''}${k === today ? ' today' : ''}${k === calState.sel ? ' sel' : ''}" data-d="${k}" aria-label="${esc(d.toDateString())}, ${evs.length} events">
<span class="dn">${d.getDate()}</span> <span class="dn">${d.getDate()}</span>
<span class="dots">${evs.slice(0, 4).map(e => `<i style="background:${esc(e.color)}"></i>`).join('')}</span> <span class="dots">${evs.slice(0, 4).map(e => `<i style="background:${esc(e.color)}"></i>`).join('')}</span>
<span class="chips">${evs.slice(0, 3).map(e => `<em style="--c:${esc(e.color)}">${esc(e.title)}</em>`).join('')}${evs.length > 3 ? `<small>+${evs.length - 3} more</small>` : ''}</span></button>`); <span class="chips">${evs.slice(0, 3).map(e => `<em style="--c:${esc(e.color)}">${e.task ? '☐ ' : ''}${esc(e.title)}</em>`).join('')}${evs.length > 3 ? `<small>+${evs.length - 3} more</small>` : ''}</span></button>`);
} }
$('#cbody').innerHTML = `<div class="mhdr"><button class="ibtn" id="pm" aria-label="Previous month">${icon('back')}</button> $('#cbody').innerHTML = `<div class="mhdr"><button class="ibtn" id="pm" aria-label="Previous month">${icon('back')}</button>
<h2 style="margin:0;flex:1;text-align:center;font-size:17px;text-transform:none;letter-spacing:0;color:var(--text)">${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}</h2> <h2 style="margin:0;flex:1;text-align:center;font-size:17px;text-transform:none;letter-spacing:0;color:var(--text)">${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}</h2>
@@ -109,6 +118,12 @@ const localStr = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.get
function openEvent(e) { function openEvent(e) {
if (!e) return; if (!e) return;
if (e.task) { // a task on the calendar: the normal task sheet, and the calendar refreshes when it closes
taskSheet({ ...e.t, repeat: e.t.repeat || 'none' }, e.t.list, { name: e.cal, local: String(e.t.list).startsWith('local:') });
const sh = document.getElementById('sheet'), mo = new MutationObserver(() => { if (!document.getElementById('sheet')) { mo.disconnect(); if (/^#\/calendar/.test(location.hash)) route(); } });
if (sh) mo.observe(document.body, { childList: true });
return;
}
if (e.editable) return editEvent(e); if (e.editable) return editEvent(e);
const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end); const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end);
const el = openSheet(e.title, e.cal, `<div class="list" style="background:var(--panel2);border:0"><div class="row"><span class="grow"><b>When</b><small style="white-space:normal">${esc(when)}</small></span></div> const el = openSheet(e.title, e.cal, `<div class="list" style="background:var(--panel2);border:0"><div class="row"><span class="grow"><b>When</b><small style="white-space:normal">${esc(when)}</small></span></div>
+4 -14
View File
@@ -1,6 +1,6 @@
'use strict'; 'use strict';
// ---- File actions // ---- File actions
const PREVIEW = { img: 'jpg jpeg png gif webp svg bmp heic', pdf: 'pdf', video: 'mp4 mov m4v webm', audio: 'mp3 m4a wav flac ogg aac' }; const PREVIEW = { img: 'jpg jpeg png gif webp svg bmp heic avif', pdf: 'pdf', video: 'mp4 mov m4v webm', audio: 'mp3 m4a wav flac ogg aac opus', text: 'txt md markdown json csv tsv log xml yml yaml ini conf cfg toml js ts css html htm py sh sql env gitignore' };
const previewKind = n => Object.keys(PREVIEW).find(k => PREVIEW[k].split(' ').includes(ext(n))); const previewKind = n => Object.keys(PREVIEW).find(k => PREVIEW[k].split(' ').includes(ext(n)));
const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' }; const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' };
const encP = p => p.split('/').map(encodeURIComponent).join('/'); const encP = p => p.split('/').map(encodeURIComponent).join('/');
@@ -21,6 +21,7 @@ function openFile(repo, path, name, dir) {
async function previewFile(repo, path, name) { async function previewFile(repo, path, name) {
addRecent(repo, path, name); addRecent(repo, path, name);
if (previewKind(name) === 'text') return textPreview(repo, path, name).catch(e => toast(e.message));
try { try {
const url = (await fileUrl(repo, path)) + '?hubinline=1', kind = previewKind(name); const url = (await fileUrl(repo, path)) + '?hubinline=1', kind = previewKind(name);
const media = kind === 'img' ? `<img src="${url}" alt="${esc(name)}" style="max-width:100%;max-height:70dvh;border-radius:12px;display:block;margin:8px auto">` const media = kind === 'img' ? `<img src="${url}" alt="${esc(name)}" style="max-width:100%;max-height:70dvh;border-radius:12px;display:block;margin:8px auto">`
@@ -32,19 +33,7 @@ async function previewFile(repo, path, name) {
} catch (e) { toast(e.message); } } catch (e) { toast(e.message); }
} }
async function shareLink(repo, path, isDir) { const shareLink = (repo, path, isDir) => shareSheet(repo, path, isDir);
const p = isDir ? path.replace(/\/?$/, '/') : path;
try {
let link;
try { link = (await sf.req('/api/v2.1/share-links/', { method: 'POST', headers: FORM, body: new URLSearchParams({ repo_id: repo, path: p }) })).link; }
catch { link = (await sf.req(`/api/v2.1/share-links/?repo_id=${encodeURIComponent(repo)}&path=${encodeURIComponent(p)}`))[0]?.link; }
if (!link) throw new Error('Could not create a share link');
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || '', `<input type="text" readonly id="sl" value="${esc(link)}" onfocus="this.select()">
<div class="bar"><button class="btn" id="cp1" style="flex:1;justify-content:center">${icon('copy')}Copy</button>${navigator.share ? '<button class="btn ghost" id="sh1" style="flex:1;justify-content:center">Share…</button>' : ''}</div>`);
el.querySelector('#cp1').onclick = async () => { try { await navigator.clipboard.writeText(link); toast('Link copied'); closeSheet(); } catch { el.querySelector('#sl').select(); toast('Press copy on the selected link'); } };
const sh = el.querySelector('#sh1'); if (sh) sh.onclick = () => navigator.share({ url: link }).catch(() => {});
} catch (e) { toast(e.message); }
}
function fileSheet(repo, path, name, dir) { function fileSheet(repo, path, name, dir) {
const enc = encodeURIComponent, acts = [], ro = libPerm[repo] === 'r', it = { name, type: 'file' }; const enc = encodeURIComponent, acts = [], ro = libPerm[repo] === 'r', it = { name, type: 'file' };
@@ -66,6 +55,7 @@ function dirSheet(repo, path, name, dir) {
actionSheet(name, 'Folder', [ actionSheet(name, 'Folder', [
{ label: 'Open', icon: 'files', run: () => { location.hash = '#/files/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); } }, { label: 'Open', icon: 'files', run: () => { location.hash = '#/files/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); } },
{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, true) }, { label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, true) },
{ label: 'Download as zip', icon: 'dl', run: () => zipDownload(repo, dir, [name]) },
...(ro ? [] : [{ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, true) }, { label: 'Move to…', icon: 'files', run: () => moveSheet('move', repo, dir, [it]) }]), ...(ro ? [] : [{ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, true) }, { label: 'Move to…', icon: 'files', run: () => moveSheet('move', repo, dir, [it]) }]),
{ label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) }, { label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) },
...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: () => deleteItems(repo, dir, [it]) }]), ...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: () => deleteItems(repo, dir, [it]) }]),
+209
View File
@@ -0,0 +1,209 @@
'use strict';
// Files, the sturdier parts: signing in again in place, a saved copy of each folder (instant, and still there offline), upload name clashes,
// undo and the recycle bin, search in a library, zip downloads, share link options, and text files you can read and edit.
// Uses sf, store, ME, jfetch, openSheet, closeSheet, confirmSheet, actionSheet, toast, esc, icon, fmtSize, fmtDate, joinP, libNames, libPerm, cfg from the other scripts.
// ---- Seafile sign-in expired: ask again without leaving the page, then the original request carries on
let reauthP = null;
function sfReauth() {
if (reauthP) return reauthP;
reauthP = new Promise((ok, no) => {
let settled = false;
const finish = (good, err) => { if (settled) return; settled = true; reauthP = null; good ? ok() : (store.del('sf_token'), no(err || new Error('Session expired, sign in again'))); };
const el = openSheet('Sign in to Seafile again', 'Your Seafile sign-in expired. You will stay where you are.', `<form id="rf"><label for="ru">Email / username</label><input type="text" id="ru" autocomplete="username" autocapitalize="off" required>
<label for="rp">Password</label><input type="password" id="rp" autocomplete="current-password" required><div id="rotp" hidden><label for="ro">2FA code</label><input type="text" id="ro" inputmode="numeric" autocomplete="one-time-code"></div>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Not now</button><button class="btn" id="rb" style="flex:1;justify-content:center">Sign in</button></div><div class="err" id="re" role="alert"></div></form>`);
el.querySelector('#sx').onclick = () => { closeSheet(); finish(false); };
el.querySelector('.scrim').addEventListener('click', () => finish(false));
el.querySelector('#rf').onsubmit = async e => {
e.preventDefault(); const b = el.querySelector('#rb'); b.disabled = true; el.querySelector('#re').textContent = '';
try { await sf.login(el.querySelector('#ru').value.trim(), el.querySelector('#rp').value, el.querySelector('#ro').value.trim() || undefined); closeSheet(); finish(true); }
catch (err) { b.disabled = false; if (err.otp) el.querySelector('#rotp').hidden = false; el.querySelector('#re').textContent = err.message; }
};
setTimeout(() => el.querySelector('#ru').focus(), 60);
});
return reauthP;
}
// ---- A saved copy of every folder you open (names, sizes, dates only): shown at once while the real list loads, and when Seafile cannot be reached
const dcIndex = () => 'dc:index:' + (ME ? ME.id : '');
const dcKey = (repo, dir) => 'dc:' + (ME ? ME.id : '') + ':' + repo + ':' + dir;
function dcGet(repo, dir) { try { const o = JSON.parse(localStorage.getItem(dcKey(repo, dir))); return o && Array.isArray(o.items) ? o : null; } catch { return null; } }
function dcPut(repo, dir, items) {
const key = dcKey(repo, dir), body = JSON.stringify({ t: Date.now(), items: items.slice(0, 1500).map(x => ({ id: x.id, name: x.name, type: x.type, size: x.size, mtime: x.mtime })) });
const touch = () => { const idx = (JSON.parse(localStorage.getItem(dcIndex()) || '[]')).filter(k => k !== key); idx.unshift(key); while (idx.length > 40) localStorage.removeItem(idx.pop()); localStorage.setItem(dcIndex(), JSON.stringify(idx)); };
try { localStorage.setItem(key, body); touch(); }
catch { try { const idx = JSON.parse(localStorage.getItem(dcIndex()) || '[]'); for (const k of idx.splice(Math.ceil(idx.length / 2))) localStorage.removeItem(k); localStorage.setItem(dcIndex(), JSON.stringify(idx)); localStorage.setItem(key, body); touch(); } catch { /* storage is full: go without */ } }
}
const fScroll = new Map(); // where each folder was scrolled to
document.addEventListener('scroll', e => { if (e.target && e.target.id === 'view' && /^#\/files\/./.test(location.hash)) fScroll.set(location.hash, e.target.scrollTop); }, true);
// ---- Uploading a file whose name is already there
async function resolveUploadConflicts(repo, dir, entries) {
let existing;
try { existing = new Set((await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).map(x => x.name.toLowerCase())); } catch { return entries; }
const clash = entries.filter(e => !e.rel && existing.has(e.file.name.toLowerCase())); // (files inside dropped folders keep Seafile's own renaming)
if (!clash.length) return entries;
const choice = await new Promise(ok => {
const el = openSheet(clash.length === 1 ? 'Replace “' + clash[0].file.name + '”?' : clash.length + ' files already exist here', 'Replacing keeps the old version in the file\'s history.',
`${clash.length > 1 ? `<div class="list" style="background:var(--panel2);border:0;max-height:30dvh;overflow:auto">${clash.map(c => `<div class="row"><span class="grow"><b>${esc(c.file.name)}</b></span></div>`).join('')}</div>` : ''}
<div class="bar"><button class="btn" id="cr" style="flex:1;justify-content:center">Replace</button><button class="btn ghost" id="ck" style="flex:1;justify-content:center">Keep both</button></div>
<div class="bar" style="margin-top:0"><button class="btn ghost" id="cs" style="flex:1;justify-content:center">Skip ${clash.length === 1 ? 'it' : 'those'}</button><button class="btn ghost" id="cc" style="flex:1;justify-content:center">Cancel upload</button></div>`);
let done = false; const pick = v => { if (done) return; done = true; closeSheet(); ok(v); };
el.querySelector('#cr').onclick = () => pick('replace'); el.querySelector('#ck').onclick = () => pick('both'); el.querySelector('#cs').onclick = () => pick('skip'); el.querySelector('#cc').onclick = () => pick('cancel');
el.querySelector('.scrim').addEventListener('click', () => pick('cancel'));
});
if (choice === 'cancel') return [];
if (choice === 'skip') return entries.filter(e => !clash.includes(e));
return entries.map(e => clash.includes(e) ? { ...e, replace: choice === 'replace' } : e);
}
const waitOnline = () => new Promise(ok => { if (navigator.onLine) return ok(); const f = () => { removeEventListener('online', f); ok(); }; addEventListener('online', f); });
// ---- Undo a delete, and the recycle bin (Seafile keeps deleted files for a while)
const trashWhen = t => typeof t === 'number' ? t * 1000 : Date.parse(t) || 0;
async function trashList(repo, path = '/', limit = 100) {
const r = await sf.req(`/api/v2.1/repos/${encodeURIComponent(repo)}/trash/?path=${encodeURIComponent(path)}&limit=${limit}`);
return (r && (r.items || r.data)) || [];
}
async function trashRestore(repo, t) {
const parent = (t.parent_dir || '/').replace(/\/+$/, '') || '';
await sf.req(`/api2/repos/${encodeURIComponent(repo)}/${t.is_dir ? 'dir' : 'file'}/revert/`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ p: parent + '/' + t.obj_name, commit_id: t.commit_id }) });
}
/** Right after a delete: find the just-deleted items in the recycle bin and offer Undo. (Quietly nothing if Seafile has no recycle bin API.) */
async function offerUndo(repo, dir, items, afterRestore) {
try {
const names = new Set(items.map(x => x.name)), newest = new Map();
for (const t of await trashList(repo, dir, 60)) if (names.has(t.obj_name) && Date.now() - trashWhen(t.deleted_time) < 5 * 60e3 && (!newest.has(t.obj_name) || trashWhen(t.deleted_time) > trashWhen(newest.get(t.obj_name).deleted_time))) newest.set(t.obj_name, t);
if (!newest.size) return false;
toast(items.length === 1 ? 'Deleted “' + items[0].name + '”' : items.length + ' items deleted', { label: 'Undo', run: async () => {
let bad = 0; for (const t of newest.values()) { try { await trashRestore(repo, t); } catch { bad++; } }
toast(bad ? 'Could not restore ' + bad + ' item' + (bad === 1 ? '' : 's') : 'Restored'); if (afterRestore) afterRestore();
} });
return true;
} catch { return false; /* no undo available */ }
}
async function recycleBin(repo, dir) {
const el = openSheet('Recycle bin', (libNames[repo] || '') + (dir && dir !== '/' ? ' · ' + dir : ''), '<p class="empty">Loading…</p>');
el.querySelector('.sheet').classList.add('wide');
try {
const list = (await trashList(repo, dir || '/', 100)).sort((a, b) => trashWhen(b.deleted_time) - trashWhen(a.deleted_time));
if (!document.body.contains(el)) return;
const body = el.querySelector('.empty');
body.outerHTML = list.length ? `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${list.map((t, i) => `<div class="row"><span class="ic" style="background:${t.is_dir ? '#e0a82b' : '#6a7280'}">${icon(t.is_dir ? 'files' : 'file')}</span>
<span class="grow"><b>${esc(t.obj_name)}</b><small style="white-space:normal">${esc((t.parent_dir || '/'))} · ${t.is_dir ? 'folder' : esc(fmtSize(t.size || 0))} · deleted ${esc(new Date(trashWhen(t.deleted_time)).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}</small></span>
${libPerm[repo] === 'r' ? '' : `<button class="btn ghost" data-rs="${i}" style="min-height:36px">Restore</button>`}</div>`).join('')}</div><p class="sub">Showing the 100 most recent. Seafile empties its recycle bin after a while.</p>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`
: '<p class="empty">Nothing here. Deleted files and folders from this library show up here so you can get them back.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>';
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-rs]').forEach(b => b.onclick = async () => {
b.disabled = true; try { await trashRestore(repo, list[+b.dataset.rs]); b.textContent = 'Restored'; if (fv && fv.repo === repo) fv.reload(true); } catch (e) { b.disabled = false; toast(e.message); }
});
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
}
// ---- Search a whole library (names, from the Seafile index when it has one, otherwise by walking the folders)
function libSearch(repo) {
textSheet('Search this library', 'File or folder name', 'Search', async term => {
const el = openSheet('Results for “' + term + '”', libNames[repo] || '', '<p class="empty">Searching…</p>'); el.querySelector('.sheet').classList.add('wide');
try {
const { results } = await searchFiles(term, [{ id: repo, name: libNames[repo] || '' }]);
const mine = results.filter(r => r.repo_id === repo).slice(0, 60);
if (!document.body.contains(el)) return;
el.querySelector('.empty').outerHTML = `${mine.length ? `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${mine.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${r.is_dir ? '#e0a82b' : '#6a7280'}">${icon(r.is_dir ? 'files' : 'file')}</span><span class="grow"><b>${esc(r.name)}</b><small style="white-space:normal">${esc((r.fullpath || '').replace(/\/[^/]*$/, '') || '/')}</small></span></button>`).join('')}</div>` : '<p class="empty">Nothing found.</p>'}<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`;
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-r]').forEach(b => b.onclick = () => {
const r = mine[+b.dataset.r], parent = (r.fullpath || '').replace(/\/[^/]*$/, '') || '/'; closeSheet();
if (r.is_dir) location.hash = '#/files/' + encodeURIComponent(repo) + (r.fullpath || '').split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join('');
else openFile(repo, r.fullpath, r.name, parent);
});
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
});
}
// ---- Download several items, or a whole folder, as one zip (Seafile builds it; we wait for it)
async function zipDownload(repo, dir, names) {
try {
const q = new URLSearchParams({ parent_dir: dir }); names.forEach(n => q.append('dirents', n));
const task = await sf.req(`/api/v2.1/repos/${encodeURIComponent(repo)}/zip-task/?${q}`), token = task.zip_token;
if (!token) throw new Error('Seafile could not make a zip of that');
for (let i = 0; i < 300; i++) {
const p = await sf.req(`/api/v2.1/query-zip-progress/?token=${encodeURIComponent(token)}`);
const total = +p.total || 0, zipped = +p.zipped || 0;
if (p.failed || p.failed_reason) throw new Error('Seafile could not make the zip' + (p.failed_reason ? ': ' + p.failed_reason : ''));
if (total && zipped >= total) break;
toast('Preparing the zip… ' + (total ? Math.round(100 * zipped / total) + '%' : ''));
await sleep(i < 5 ? 500 : 1500);
if (i === 299) throw new Error('The zip is taking too long. Try fewer files.');
}
const a = document.createElement('a'); a.href = '/sf/seafhttp/zip/' + encodeURIComponent(token); a.download = ''; document.body.appendChild(a); a.click(); a.remove();
toast('Download started');
} catch (e) { toast(e.message); }
}
// ---- Share links with a password and an expiry, and the ones that already exist
async function shareSheet(repo, path, isDir) {
const p = isDir ? path.replace(/\/?$/, '/') : path, enc = encodeURIComponent, FORMH = { 'Content-Type': 'application/x-www-form-urlencoded' };
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || (libNames[repo] || ''), '<p class="empty">Loading…</p>');
const draw = async () => {
let links = []; try { links = (await sf.req(`/api/v2.1/share-links/?repo_id=${enc(repo)}&path=${enc(p)}`)) || []; } catch (e) { if (!document.body.contains(el)) return; }
if (!document.body.contains(el)) return;
const host = el.querySelector('.empty') || el.querySelector('#shbody');
const html = `<div id="shbody">${links.length ? `<div class="list" style="background:var(--panel2);border:0">${links.map((l, i) => `<div class="row" style="flex-wrap:wrap"><span class="grow" style="min-width:0"><input type="text" readonly value="${esc(l.link)}" onfocus="this.select()" style="margin:0"><small style="white-space:normal">${l.expire_date ? (l.is_expired ? 'Expired ' : 'Expires ') + esc(new Date(l.expire_date).toLocaleDateString([], { dateStyle: 'medium' })) : 'Never expires'}${l.is_expired ? '' : ''}</small></span>
<button class="btn ghost" data-cp="${i}" style="min-height:36px">Copy</button><button class="btn danger" data-rm="${i}" style="min-height:36px">Delete</button></div>`).join('')}</div>` : ''}
<form id="shf"><h3 style="margin:14px 0 4px;font-size:15px">${links.length ? 'New link' : 'Create a link'}</h3>
<label for="shp">Password (optional, at least 8 characters)</label><input type="password" id="shp" autocomplete="new-password" placeholder="No password">
<label for="she">Expires</label><select id="she"><option value="">Never</option><option value="1">In 1 day</option><option value="7" selected>In 7 days</option><option value="30">In 30 days</option><option value="90">In 90 days</option></select>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" id="shc" style="flex:1;justify-content:center">Create link</button></div><div class="err" id="she2" role="alert"></div></form></div>`;
host.outerHTML = html;
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-cp]').forEach(b => b.onclick = async () => { const l = links[+b.dataset.cp]; try { await navigator.clipboard.writeText(l.link); toast('Link copied'); } catch { toast('Select the link and copy it'); } });
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete this link?', 'Anyone using it loses access.', 'Delete', async () => { try { await sf.req(`/api/v2.1/share-links/${enc(links[+b.dataset.rm].token)}/`, { method: 'DELETE' }); shareSheet(repo, path, isDir); } catch (e) { toast(e.message); } }));
el.querySelector('#shf').onsubmit = async e => {
e.preventDefault(); const pw = el.querySelector('#shp').value, days = el.querySelector('#she').value, btn = el.querySelector('#shc');
if (pw && pw.length < 8) { el.querySelector('#she2').textContent = 'A password needs at least 8 characters.'; return; }
btn.disabled = true;
try {
const body = new URLSearchParams({ repo_id: repo, path: p }); if (pw) body.set('password', pw); if (days) body.set('expire_days', days);
const made = await sf.req('/api/v2.1/share-links/', { method: 'POST', headers: FORMH, body });
try { await navigator.clipboard.writeText(made.link); toast('Link created and copied'); } catch { toast('Link created'); }
shareSheet(repo, path, isDir);
} catch (err) { btn.disabled = false; el.querySelector('#she2').textContent = err.message; }
};
};
draw();
}
// ---- Text files: read them here, and edit small ones (the old version stays in the file's history)
const TEXT_EXT = new Set('txt md markdown json csv tsv log xml yml yaml ini conf cfg toml js ts css html htm py sh sql env gitignore'.split(' '));
async function textPreview(repo, path, name) {
const dir = path.replace(/\/[^/]*$/, '') || '/', ro = libPerm[repo] === 'r', url = (await fileUrl(repo, path)) + '?hubinline=1';
const el = openSheet(name, '', '<p class="empty">Loading…</p>'); el.querySelector('.sheet').classList.add('wide');
const dl = `<a class="btn ghost" href="${url.replace('?hubinline=1', '')}" target="_blank" rel="noopener">${icon('dl')}Download</a>`;
try {
const r = await fetch(url); if (!r.ok) throw new Error('Could not open the file (' + r.status + ')');
const len = +r.headers.get('content-length') || 0;
if (len > 1024 * 1024) { await r.body.cancel(); el.querySelector('.empty').outerHTML = `<p class="sub">This file is ${esc(fmtSize(len))}, too big to show here.</p><div class="bar">${dl}<button class="btn ghost" id="sx">Close</button></div>`; el.querySelector('#sx').onclick = closeSheet; return; }
let text = await r.text(); if (text.length > 1024 * 1024) text = text.slice(0, 1024 * 1024);
const view = () => {
if (!document.body.contains(el)) return;
const host = el.querySelector('#tpb') || el.querySelector('.empty');
host.outerHTML = `<div id="tpb"><pre class="ptxt">${esc(text)}</pre><div class="bar">${ro || len > 256 * 1024 ? '' : '<button class="btn" id="ted">Edit</button>'}${dl}<button class="btn ghost" id="sx">Close</button></div></div>`;
el.querySelector('#sx').onclick = closeSheet; const ed = el.querySelector('#ted'); if (ed) ed.onclick = edit;
};
const edit = () => {
el.querySelector('#tpb').outerHTML = `<div id="tpb"><textarea id="tta" class="ptxt-edit" spellcheck="false">${esc(text)}</textarea><div class="bar"><button class="btn" id="tsv">Save</button><button class="btn ghost" id="tcx">Cancel</button></div><div class="err" id="ter" role="alert"></div></div>`;
el.querySelector('#tcx').onclick = view;
el.querySelector('#tsv').onclick = async () => {
const b = el.querySelector('#tsv'); b.disabled = true; const next = el.querySelector('#tta').value;
try {
const link = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/update-link/?p=${encodeURIComponent(dir)}`), fd = new FormData();
fd.append('target_file', path); fd.append('parent_dir', dir); fd.append('file', new Blob([next], { type: 'text/plain' }), name);
const up = await fetch('/sf' + new URL(link, cfg.seafile).pathname, { method: 'POST', body: fd });
if (!up.ok) throw new Error('Could not save (' + up.status + ')');
text = next; toast('Saved'); view(); if (fv && fv.repo === repo) fv.reload(true);
} catch (e) { b.disabled = false; el.querySelector('#ter').textContent = e.message; }
};
};
view();
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
}
+42 -17
View File
@@ -71,7 +71,8 @@ async function loadLibs(active) {
// ---- Files: browse, select, move/copy/delete, rename, upload (drag in from the computer, drag a file out to download) // ---- Files: browse, select, move/copy/delete, rename, upload (drag in from the computer, drag a file out to download)
let fv = null, libsP = Promise.resolve(), libNames = {}, libPerm = {}; let fv = null, libsP = Promise.resolve(), libNames = {}, libPerm = {};
const SORTS = [['name', 'Name'], ['mtime', 'Date modified'], ['size', 'Size'], ['type', 'Kind']]; const SORTS = [['name', 'Name'], ['mtime', 'Date modified'], ['size', 'Size'], ['type', 'Kind']];
const nameCmp = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); // (one collator is much faster than localeCompare in a big folder)
const nameCmp = (a, b) => collator.compare(a.name, b.name);
function sortItems(items, s) { function sortItems(items, s) {
const by = { name: nameCmp, mtime: (a, b) => a.mtime - b.mtime, size: (a, b) => (a.size || 0) - (b.size || 0), type: (a, b) => ext(a.name).localeCompare(ext(b.name)) }[s.k] || nameCmp; const by = { name: nameCmp, mtime: (a, b) => a.mtime - b.mtime, size: (a, b) => (a.size || 0) - (b.size || 0), type: (a, b) => ext(a.name).localeCompare(ext(b.name)) }[s.k] || nameCmp;
return [...items].sort((a, b) => a.type !== b.type ? (a.type === 'dir' ? -1 : 1) // folders stay on top return [...items].sort((a, b) => a.type !== b.type ? (a.type === 'dir' ? -1 : 1) // folders stay on top
@@ -97,8 +98,7 @@ function deleteItems(repo, dir, items) {
const one = items.length === 1; const one = items.length === 1;
confirmSheet(one ? (items[0].type === 'dir' ? 'Delete folder?' : 'Delete file?') : `Delete ${items.length} items?`, confirmSheet(one ? (items[0].type === 'dir' ? 'Delete folder?' : 'Delete file?') : `Delete ${items.length} items?`,
one ? items[0].name + (items[0].type === 'dir' ? ' and everything in it' : '') : 'Folders are deleted with everything inside them', 'Delete', async () => { one ? items[0].name + (items[0].type === 'dir' ? ' and everything in it' : '') : 'Folders are deleted with everything inside them', 'Delete', async () => {
try { await removeItems(repo, dir, items); toast(one ? 'Deleted' : items.length + ' items deleted'); } catch (e) { toast(e.message); } try { await removeItems(repo, dir, items); afterChange(repo, dir); if (!(await offerUndo(repo, dir, items, () => afterChange(repo, dir)))) toast(one ? 'Deleted' : items.length + ' items deleted'); } catch (e) { toast(e.message); afterChange(repo, dir); }
afterChange(repo, dir);
}); });
} }
async function transferItems(mode, repo, dir, names, dst) { async function transferItems(mode, repo, dir, names, dst) {
@@ -130,9 +130,11 @@ async function downloadItem(repo, path) {
// ---- Uploads: a queue that survives leaving the folder, with progress, retries and a way to cancel // ---- Uploads: a queue that survives leaving the folder, with progress, retries and a way to cancel
const uq = { items: [], running: 0 }; const uq = { items: [], running: 0 };
const upActive = () => uq.items.some(x => x.state === 'wait' || x.state === 'up'); const upActive = () => uq.items.some(x => x.state === 'wait' || x.state === 'up');
function enqueueUploads(repo, dir, entries) { async function enqueueUploads(repo, dir, entries) {
if (!entries.length) return; if (!entries.length) return;
for (const e of entries) uq.items.push({ repo, dir, file: e.file, rel: e.rel || '', pct: 0, state: 'wait', tries: 0 }); entries = await resolveUploadConflicts(repo, dir, entries); // a name that is already there: replace, keep both, or skip
if (!entries.length) return;
for (const e of entries) uq.items.push({ repo, dir, file: e.file, rel: e.rel || '', replace: !!e.replace, pct: 0, state: 'wait', tries: 0 });
drawUpBar(); pumpUploads(); drawUpBar(); pumpUploads();
} }
function pumpUploads() { function pumpUploads() {
@@ -151,7 +153,7 @@ async function sendUpload(it) {
const link = await sf.req(`/api2/repos/${enc(it.repo)}/upload-link/?p=${enc(it.dir)}`); // a fresh link each time, so an expired one never blocks a retry const link = await sf.req(`/api2/repos/${enc(it.repo)}/upload-link/?p=${enc(it.dir)}`); // a fresh link each time, so an expired one never blocks a retry
await new Promise((ok, no) => { await new Promise((ok, no) => {
const x = it.xhr = new XMLHttpRequest(), fd = new FormData(); const x = it.xhr = new XMLHttpRequest(), fd = new FormData();
fd.append('parent_dir', it.dir); fd.append('replace', '0'); if (it.rel) fd.append('relative_path', it.rel); fd.append('file', it.file, it.file.name); fd.append('parent_dir', it.dir); fd.append('replace', it.replace ? '1' : '0'); if (it.rel) fd.append('relative_path', it.rel); fd.append('file', it.file, it.file.name);
x.open('POST', '/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1'); x.open('POST', '/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1');
x.upload.onprogress = e => { if (e.lengthComputable) { it.pct = e.loaded / e.total; drawUpBar(); } }; x.upload.onprogress = e => { if (e.lengthComputable) { it.pct = e.loaded / e.total; drawUpBar(); } };
x.onload = () => x.status >= 200 && x.status < 300 ? ok() : no(Object.assign(new Error(x.status === 413 ? 'Too large for the server' : x.status === 403 ? 'Not allowed (or out of space)' : 'Upload failed (' + x.status + ')'), { fatal: x.status >= 400 && x.status < 500 })); x.onload = () => x.status >= 200 && x.status < 300 ? ok() : no(Object.assign(new Error(x.status === 413 ? 'Too large for the server' : x.status === 403 ? 'Not allowed (or out of space)' : 'Upload failed (' + x.status + ')'), { fatal: x.status >= 400 && x.status < 500 }));
@@ -162,8 +164,10 @@ async function sendUpload(it) {
return; return;
} catch (e) { } catch (e) {
if (it.cancelled) throw e; if (it.cancelled) throw e;
if (e.fatal || !sf.token() || it.tries >= 3) throw new Error(it.file.name + ': ' + e.message); if (e.fatal || !sf.token()) throw new Error(it.file.name + ': ' + e.message);
await sleep(1500 * it.tries); if (!navigator.onLine) { it.waiting = true; drawUpBar(); await waitOnline(); it.waiting = false; it.tries = Math.max(0, it.tries - 1); } // no connection: wait for it instead of failing
else if (it.tries >= 5) throw new Error(it.file.name + ': ' + e.message);
else await sleep(Math.min(15000, 1500 * 2 ** (it.tries - 1)));
} }
} }
} }
@@ -181,7 +185,7 @@ function drawUpBar() {
if (live.length) { if (live.length) {
const total = uq.items.length, doneN = uq.items.filter(x => x.state === 'done').length, cur = live.find(x => x.state === 'up') || live[0]; const total = uq.items.length, doneN = uq.items.filter(x => x.state === 'done').length, cur = live.find(x => x.state === 'up') || live[0];
const pct = Math.round(((doneN + live.reduce((a, x) => a + (x.state === 'up' ? x.pct : 0), 0)) / total) * 100); const pct = Math.round(((doneN + live.reduce((a, x) => a + (x.state === 'up' ? x.pct : 0), 0)) / total) * 100);
b.innerHTML = `<div class="upm"><b>Uploading ${esc(live.length === 1 ? cur.file.name : live.length + ' files')}</b><small>${pct}%</small><i style="width:${pct}%"></i></div><button class="btn ghost" id="upx">Cancel</button>`; b.innerHTML = `<div class="upm"><b>${live.some(x => x.waiting) ? 'Waiting for a connection…' : 'Uploading ' + esc(live.length === 1 ? cur.file.name : live.length + ' files')}</b><small>${pct}%${cur.tries > 1 && !cur.waiting ? ' · retrying' : ''}</small><i style="width:${pct}%"></i></div><button class="btn ghost" id="upx">Cancel</button>`;
$('#upx').onclick = () => { uq.items.forEach(x => { if (x.state === 'wait') x.state = 'cancel'; if (x.state === 'up') { x.cancelled = true; x.xhr && x.xhr.abort(); } }); uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar(); }; $('#upx').onclick = () => { uq.items.forEach(x => { if (x.state === 'wait') x.state = 'cancel'; if (x.state === 'up') { x.cancelled = true; x.xhr && x.xhr.abort(); } }); uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar(); };
} else { } else {
b.innerHTML = `<div class="upm"><b>${bad.length} ${bad.length === 1 ? 'upload' : 'uploads'} failed</b><small>${esc(bad[0].err || '')}</small></div><button class="btn" id="upr">Retry</button><button class="btn ghost" id="upd">Dismiss</button>`; b.innerHTML = `<div class="upm"><b>${bad.length} ${bad.length === 1 ? 'upload' : 'uploads'} failed</b><small>${esc(bad[0].err || '')}</small></div><button class="btn" id="upr">Retry</button><button class="btn ghost" id="upd">Dismiss</button>`;
@@ -221,7 +225,7 @@ async function loadDir(repo, dir) {
const hash = p => '#/files/' + enc(repo) + p.split('/').filter(Boolean).map(s => '/' + enc(s)).join(''); const hash = p => '#/files/' + enc(repo) + p.split('/').filter(Boolean).map(s => '/' + enc(s)).join('');
const segs = dir.split('/').filter(Boolean); const segs = dir.split('/').filter(Boolean);
const crumbs = `<button data-go="/">${esc(libNames[repo] || 'Library')}</button>` + segs.map((s, i) => `<span>/</span><button data-go="/${esc(segs.slice(0, i + 1).join('/'))}">${esc(s)}</button>`).join(''); const crumbs = `<button data-go="/">${esc(libNames[repo] || 'Library')}</button>` + segs.map((s, i) => `<span>/</span><button data-go="/${esc(segs.slice(0, i + 1).join('/'))}">${esc(s)}</button>`).join('');
const v = fv = { root: main, repo, dir, items: [], sel: new Set(), selecting: false, last: null, filter: '', tok: null }; const v = fv = { root: main, repo, dir, items: [], sel: new Set(), selecting: false, last: null, filter: '', tok: null, limit: 300, stale: false };
const sortCfg = () => store.get('files_sort', { k: 'name', d: 1 }); const sortCfg = () => store.get('files_sort', { k: 'name', d: 1 });
const byName = n => v.items.find(x => x.name === n); const byName = n => v.items.find(x => x.name === n);
const selItems = () => v.items.filter(x => v.sel.has(x.name)); const selItems = () => v.items.filter(x => v.sel.has(x.name));
@@ -239,12 +243,12 @@ async function loadDir(repo, dir) {
<div class="bar">${ro ? '' : `<button class="btn ghost" data-a="move" ${n ? '' : 'disabled'}>${icon('files')}Move</button>`} <div class="bar">${ro ? '' : `<button class="btn ghost" data-a="move" ${n ? '' : 'disabled'}>${icon('files')}Move</button>`}
<button class="btn ghost" data-a="copy" ${n ? '' : 'disabled'}>${icon('copy')}Copy</button> <button class="btn ghost" data-a="copy" ${n ? '' : 'disabled'}>${icon('copy')}Copy</button>
${ro ? '' : `<button class="btn ghost" data-a="del" ${n ? '' : 'disabled'} style="color:var(--danger)">${icon('trash')}Delete</button>`} ${ro ? '' : `<button class="btn ghost" data-a="del" ${n ? '' : 'disabled'} style="color:var(--danger)">${icon('trash')}Delete</button>`}
<button class="btn ghost" data-a="more" aria-label="More" ${one ? '' : 'disabled'}>${icon('more')}</button></div></div>` <button class="btn ghost" data-a="zip" ${n ? '' : 'disabled'}>${icon('dl')}Zip</button><button class="btn ghost" data-a="more" aria-label="More" ${one ? '' : 'disabled'}>${icon('more')}</button></div></div>`
: `<div class="bar"><a class="btn ghost backbtn" href="#/files">${icon('back')}Libraries</a> : `<div class="bar"><a class="btn ghost backbtn" href="#/files">${icon('back')}Libraries</a>
${ro ? '' : `<button class="btn ghost" data-a="up">${icon('up')}Upload</button><button class="btn ghost" data-a="new">${icon('newdir')}New…</button>`} ${ro ? '' : `<button class="btn ghost" data-a="up">${icon('up')}Upload</button><button class="btn ghost" data-a="new">${icon('newdir')}New…</button>`}
<button class="btn ghost" data-a="sel">${icon('check')}Select</button> <button class="btn ghost" data-a="sel">${icon('check')}Select</button>
<button class="btn ghost" data-a="sort" aria-label="Sort">${icon('sort')}</button> <button class="btn ghost" data-a="sort" aria-label="Sort">${icon('sort')}</button>
<button class="btn ghost" data-a="refresh" aria-label="Refresh">${icon('refresh')}</button> <button class="btn ghost" data-a="refresh" aria-label="Refresh">${icon('refresh')}</button><button class="btn ghost" data-a="lib" aria-label="More for this folder">${icon('more')}</button>
<input id="upf" type="file" multiple hidden><input id="upd2" type="file" webkitdirectory hidden></div>`; <input id="upf" type="file" multiple hidden><input id="upd2" type="file" webkitdirectory hidden></div>`;
}; };
const drawItems = () => { const drawItems = () => {
@@ -256,8 +260,9 @@ async function loadDir(repo, dir) {
<button class="rmain" aria-label="${d ? 'Open folder ' : 'Open '}${esc(it.name)}"><span class="ic" style="background:${d ? '#e0a82b' : OFFICE_EXT.has(ext(it.name)) ? '#e0742b' : '#6a7280'}">${icon(d ? 'files' : 'file')}</span> <button class="rmain" aria-label="${d ? 'Open folder ' : 'Open '}${esc(it.name)}"><span class="ic" style="background:${d ? '#e0a82b' : OFFICE_EXT.has(ext(it.name)) ? '#e0742b' : '#6a7280'}">${icon(d ? 'files' : 'file')}</span>
<span class="grow"><b>${esc(it.name)}</b><small>${d ? fmtDate(it.mtime) : fmtSize(it.size) + ' · ' + fmtDate(it.mtime)}</small></span></button> <span class="grow"><b>${esc(it.name)}</b><small>${d ? fmtDate(it.mtime) : fmtSize(it.size) + ' · ' + fmtDate(it.mtime)}</small></span></button>
${v.selecting ? '' : `<button class="ibtn" data-more aria-label="Actions for ${esc(it.name)}">${icon('more')}</button>`}</div>`; }; ${v.selecting ? '' : `<button class="ibtn" data-more aria-label="Actions for ${esc(it.name)}">${icon('more')}</button>`}</div>`; };
box.innerHTML = list.length ? `<div class="list">${list.map(row).join('')}</div>` box.innerHTML = (v.stale ? `<p class="sub" style="margin:0 0 8px" role="status">${v.stale === 'old' ? 'Showing the last saved list. ' : ''}${esc(v.staleWhy || '')}</p>` : '') + (list.length ? `<div class="list">${list.slice(0, v.limit).map(row).join('')}</div>${list.length > v.limit ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="fmore">Show ${Math.min(300, list.length - v.limit)} more (${list.length - v.limit} not shown)</button></div>` : ''}`
: `<p class="empty">${v.items.length ? 'Nothing matches “' + esc(v.filter) + '”' : ro ? 'This folder is empty' : 'This folder is empty. Drop files here or tap Upload.'}</p>`; : `<p class="empty">${v.items.length ? 'Nothing matches “' + esc(v.filter) + '”' : ro ? 'This folder is empty' : 'This folder is empty. Drop files here or tap Upload.'}</p>`);
const fm = $('#fmore'); if (fm) fm.onclick = () => { v.limit += 300; drawItems(); };
const dirs = v.items.filter(x => x.type === 'dir').length, files = v.items.length - dirs, size = v.items.reduce((a, x) => a + (x.size || 0), 0); const dirs = v.items.filter(x => x.type === 'dir').length, files = v.items.length - dirs, size = v.items.reduce((a, x) => a + (x.size || 0), 0);
$('#fsum').textContent = v.items.length ? [dirs && dirs + (dirs === 1 ? ' folder' : ' folders'), files && files + (files === 1 ? ' file' : ' files'), size && fmtSize(size)].filter(Boolean).join(' · ') : ''; $('#fsum').textContent = v.items.length ? [dirs && dirs + (dirs === 1 ? ' folder' : ' folders'), files && files + (files === 1 ? ' file' : ' files'), size && fmtSize(size)].filter(Boolean).join(' · ') : '';
}; };
@@ -275,6 +280,7 @@ async function loadDir(repo, dir) {
const p = joinP(dir, it.name), d = it.type === 'dir', a = [{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, p, d) }]; const p = joinP(dir, it.name), d = it.type === 'dir', a = [{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, p, d) }];
if (!ro) a.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, p, it.name, dir, d) }); if (!ro) a.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, p, it.name, dir, d) });
if (!d) a.push({ label: 'Download', icon: 'dl', run: () => downloadItem(repo, p) }); if (!d) a.push({ label: 'Download', icon: 'dl', run: () => downloadItem(repo, p) });
if (d) a.push({ label: 'Download as zip', icon: 'dl', run: () => zipDownload(repo, dir, [it.name]) });
if (!d) a.push({ label: 'Version history', icon: 'refresh', run: () => fileHistory(repo, p, it.name, ro) }); if (!d) a.push({ label: 'Version history', icon: 'refresh', run: () => fileHistory(repo, p, it.name, ro) });
return a; return a;
}; };
@@ -292,6 +298,13 @@ async function loadDir(repo, dir) {
done: () => { v.selecting = false; v.sel.clear(); redraw(); }, done: () => { v.selecting = false; v.sel.clear(); redraw(); },
sel: () => { v.selecting = true; redraw(); }, sel: () => { v.selecting = true; redraw(); },
refresh: () => v.reload(), refresh: () => v.reload(),
zip: () => v.sel.size && zipDownload(repo, dir, [...v.sel]),
lib: () => actionSheet(dir === '/' ? (libNames[repo] || 'Library') : dir.split('/').pop(), 'This folder', [
{ label: 'Search this library', icon: 'refresh', run: () => libSearch(repo) },
{ label: 'Recycle bin', icon: 'trash', run: () => recycleBin(repo, dir) },
...(dir === '/' ? [] : [{ label: 'Download this folder as zip', icon: 'dl', run: () => zipDownload(repo, dir.replace(/\/[^/]*$/, '') || '/', [dir.split('/').pop()]) }]),
...(v.items.length ? [{ label: 'Download everything here as zip', icon: 'dl', run: () => zipDownload(repo, dir, v.items.map(x => x.name)) }] : []),
]),
sort: () => { const c = sortCfg(); actionSheet('Sort by', 'Folders stay on top', SORTS.map(([k, l]) => ({ label: l + (c.k === k ? (c.d > 0 ? ' ↑' : ' ↓') : ''), icon: 'sort', sort: () => { const c = sortCfg(); actionSheet('Sort by', 'Folders stay on top', SORTS.map(([k, l]) => ({ label: l + (c.k === k ? (c.d > 0 ? ' ↑' : ' ↓') : ''), icon: 'sort',
run: () => { store.set('files_sort', { k, d: c.k === k ? -c.d : 1 }); drawItems(); } }))); }, run: () => { store.set('files_sort', { k, d: c.k === k ? -c.d : 1 }); drawItems(); } }))); },
up: () => actionSheet('Upload', '', [{ label: 'Files', icon: 'up', run: () => $('#upf').click() }, { label: 'A folder', icon: 'newdir', run: () => $('#upd2').click() }]), up: () => actionSheet('Upload', '', [{ label: 'Files', icon: 'up', run: () => $('#upf').click() }, { label: 'A folder', icon: 'newdir', run: () => $('#upd2').click() }]),
@@ -368,15 +381,27 @@ async function loadDir(repo, dir) {
}; };
v.reload = async quiet => { v.reload = async quiet => {
const tok = v.tok = Symbol(); if (!quiet) $('#items').innerHTML = '<p class="empty">Loading…</p>'; const tok = v.tok = Symbol(), saved = dcGet(repo, dir);
const restore = () => { if (v.scrolled) return; v.scrolled = true; const y = fScroll.get(location.hash); if (y) requestAnimationFrame(() => { const view = $('#view'); if (view) view.scrollTop = y; }); };
if (!quiet) {
if (!v.items.length && saved) { v.items = saved.items; v.stale = 'old'; v.staleWhy = 'Updating…'; redraw(); restore(); } // the saved list at once, the real one right behind it
else $('#items').innerHTML = '<p class="empty">Loading…</p>';
}
try { try {
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`); const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
if (v.tok !== tok || !document.contains(main)) return; if (v.tok !== tok || !document.contains(main)) return;
v.items = items; for (const n of [...v.sel]) if (!byName(n)) v.sel.delete(n); v.items = items; v.stale = false; for (const n of [...v.sel]) if (!byName(n)) v.sel.delete(n);
redraw(); dcPut(repo, dir, items);
redraw(); restore();
} catch (e) { } catch (e) {
if (v.tok !== tok || !document.contains(main)) return; if (v.tok !== tok || !document.contains(main)) return;
if (!sf.token()) return route(); if (!sf.token()) return route();
const why = navigator.onLine ? e.message : 'You are offline.';
if (saved && v.items.length) { // keep showing the saved list instead of an error
v.stale = 'old'; v.staleWhy = why + ' Saved ' + fmtDate(saved.t / 1000) + '. '; redraw(); restore();
const n = $('#items .sub'); if (n) { n.insertAdjacentHTML('beforeend', '<a href="#" id="fretry2" style="color:var(--accent)">Try again</a>'); $('#fretry2').onclick = ev => { ev.preventDefault(); v.reload(); }; }
return;
}
$('#items').innerHTML = `<p class="err">${esc(navigator.onLine ? e.message : 'You are offline. ' + e.message)}</p><div class="bar"><button class="btn ghost" id="fretry">Try again</button></div>`; $('#items').innerHTML = `<p class="err">${esc(navigator.onLine ? e.message : 'You are offline. ' + e.message)}</p><div class="bar"><button class="btn ghost" id="fretry">Try again</button></div>`;
$('#fretry').onclick = () => v.reload(); $('#fretry').onclick = () => v.reload();
} }
+2
View File
@@ -55,6 +55,7 @@
<script src="/webauthn.js"></script> <script src="/webauthn.js"></script>
<script src="/links.js"></script> <script src="/links.js"></script>
<script src="/palette.js"></script> <script src="/palette.js"></script>
<script src="/planner.js"></script>
<script src="/reminders.js"></script> <script src="/reminders.js"></script>
<script src="/file-history.js"></script> <script src="/file-history.js"></script>
<script src="/music-extras.js"></script> <script src="/music-extras.js"></script>
@@ -69,6 +70,7 @@
<script src="/lyrics-view.js"></script> <script src="/lyrics-view.js"></script>
<script src="/settings.js"></script> <script src="/settings.js"></script>
<script src="/files.js"></script> <script src="/files.js"></script>
<script src="/files-extra.js"></script>
<script src="/sheets.js"></script> <script src="/sheets.js"></script>
<script src="/file-actions.js"></script> <script src="/file-actions.js"></script>
<script src="/signal-view.js"></script> <script src="/signal-view.js"></script>
+66 -27
View File
@@ -2,7 +2,7 @@
// Mail tab: your Outlook.com mailbox (through the server, see mail.js there). Folders, message list, reader, compose, reply/forward, // Mail tab: your Outlook.com mailbox (through the server, see mail.js there). Folders, message list, reader, compose, reply/forward,
// search, unread badge. Message bodies are shown in a sandboxed frame with remote pictures blocked until you ask for them. // search, unread badge. Message bodies are shown in a sandboxed frame with remote pictures blocked until you ask for them.
let mailUnread = 0; let mailUnread = 0;
const mail = { st: null, folders: [], folder: 'inbox', items: [], more: false, next: 0, q: '', msg: null, images: false, loading: false }; const mail = { st: null, accts: [], acct: '', folders: [], folder: 'inbox', items: [], more: false, next: 0, q: '', msg: null, images: false, loading: false };
const mailFrom = f => (f && (f.name || f.address)) || ''; const mailFrom = f => (f && (f.name || f.address)) || '';
const mailWhen = iso => { const mailWhen = iso => {
@@ -11,7 +11,7 @@ const mailWhen = iso => {
if (now - d < 6 * 864e5) return d.toLocaleDateString([], { weekday: 'short' }); if (now - d < 6 * 864e5) return d.toLocaleDateString([], { weekday: 'short' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : '2-digit' }); return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : '2-digit' });
}; };
const mailFolderName = id => (mail.folders.find(f => f.id === id) || {}).name || (id === 'inbox' ? 'Inbox' : 'Folder'); const mailFolderName = id => (mail.folders.find(f => f.id === id) || {}).name || (/~inbox$/.test(id) || id === 'inbox' ? 'Inbox' : 'Folder');
/** The page a message is shown in: no scripts, nothing loaded from the internet unless the person allows pictures. */ /** The page a message is shown in: no scripts, nothing loaded from the internet unless the person allows pictures. */
function mailDoc(html, images) { function mailDoc(html, images) {
@@ -31,24 +31,55 @@ async function renderMailCard(el) {
try { try {
const s = mail.st || await jfetch('/api/mail/status'); mail.st = s; const s = mail.st || await jfetch('/api/mail/status'); mail.st = s;
if (!s.connected || !document.body.contains(el)) return; if (!s.connected || !document.body.contains(el)) return;
const r = await jfetch('/api/mail/messages?folder=inbox'); if (!document.body.contains(el)) return; const lists = await Promise.all(s.accounts.slice(0, 4).map(a => jfetch('/api/mail/messages?folder=' + encodeURIComponent(a.id + '~inbox')).then(r => r.items.map(x => ({ ...x, acct: a.address }))).catch(() => [])));
const top = r.items.filter(m => !m.read).slice(0, 3); if (!document.body.contains(el)) return;
el.innerHTML = `<h2>Mail${s.unread ? ' · ' + s.unread + ' unread' : ''}</h2><div class="list">${top.length ? top.map(m => `<a class="row" href="#/mail/m/${encodeURIComponent(m.id)}"><span class="ic" style="background:#4f7cff">${icon('mail')}</span><span class="grow"><b>${esc(mailFrom(m.from))}</b><small>${esc(m.subject)}</small></span></a>`).join('') : `<a class="row" href="#/mail"><span class="ic" style="background:#4f7cff">${icon('mail')}</span><span class="grow"><b>Inbox is clear</b><small>${esc(s.address || '')}</small></span></a>`}</div>`; const top = lists.flat().filter(m => !m.read).sort((a, b) => String(b.date).localeCompare(String(a.date))).slice(0, 3);
el.innerHTML = `<h2>Mail${s.unread ? ' · ' + s.unread + ' unread' : ''}</h2><div class="list">${top.length ? top.map(m => `<a class="row" href="#/mail/m/${encodeURIComponent(m.id)}"><span class="ic" style="background:#4f7cff">${icon('mail')}</span><span class="grow"><b>${esc(mailFrom(m.from))}</b><small>${esc(m.subject)}${s.accounts.length > 1 ? ' · ' + esc(m.acct) : ''}</small></span></a>`).join('') : `<a class="row" href="#/mail"><span class="ic" style="background:#4f7cff">${icon('mail')}</span><span class="grow"><b>Inbox is clear</b><small>${esc(s.accounts.map(a => a.address).join(', '))}</small></span></a>`}</div>`;
} catch {} } catch {}
} }
const MAIL_LABEL = { outlook: 'Outlook.com', gmail: 'Gmail', icloud: 'iCloud Mail' };
function mailSettings() { function mailSettings() {
const box = $('#mailst'); if (!box) return; const box = $('#mailst'); if (!box) return;
if (allowed('mail') && cfg.mail) jfetch('/api/mail/status').then(s => { if (allowed('mail') && cfg.mail) jfetch('/api/mail/status').then(s => {
if (!$('#mailst')) return; if (!$('#mailst')) return;
box.innerHTML = s.connected box.innerHTML = (s.accounts.length ? s.accounts.map(a => `<div class="row"><span class="grow"><b>${esc(MAIL_LABEL[a.type] || a.type)}</b><small>${esc(a.address || 'Connected')}${a.error ? ' · ' + esc(a.error) : ''}</small></span><button class="btn danger" data-dis="${esc(a.id)}" style="min-height:36px">Disconnect</button></div>`).join('') : `<div class="row"><span class="grow"><b>No mail account yet</b><small>${s.expired ? 'Microsoft signed you out. ' : ''}Add Outlook.com, Gmail or iCloud Mail</small></span></div>`)
? `<div class="row"><span class="grow"><b>Outlook.com</b><small>${esc(s.address || 'Connected')}</small></span><button class="btn danger" id="mdis" style="min-height:36px">Disconnect</button></div>` + `<div class="row"><span class="grow"></span><button class="btn ghost" id="madd" style="min-height:36px">Add an account</button></div>`;
: `<div class="row"><span class="grow"><b>Outlook.com</b><small>${s.expired ? 'Microsoft signed you out. ' : ''}Not connected</small></span><a class="btn ghost" href="#/mail" style="min-height:36px">Connect</a></div>`; $('#madd').onclick = () => mailAddAccount(s);
const d = $('#mdis'); if (d) d.onclick = () => confirmSheet('Disconnect mail?', 'NE Hub forgets your Outlook.com sign-in. Your mailbox is not touched.', 'Disconnect', async () => { try { await jfetch('/api/mail/disconnect', { method: 'POST', body: '{}' }); mail.st = null; mailUnread = 0; renderNav(); mailSettings(); } catch (e) { toast(e.message); } }); box.querySelectorAll('[data-dis]').forEach(d => d.onclick = () => { const a = s.accounts.find(x => x.id === d.dataset.dis); confirmSheet('Disconnect ' + (a ? a.address : 'this account') + '?', 'NE Hub forgets the sign-in. Your mailbox is not touched.', 'Disconnect', async () => { try { await jfetch('/api/mail/disconnect', { method: 'POST', body: JSON.stringify({ acct: d.dataset.dis }) }); mail.st = null; mail.items = []; mail.folders = []; mail.acct = ''; await mailPoll(); mailSettings(); } catch (e) { toast(e.message); } }); });
}).catch(() => { box.innerHTML = '<div class="row"><span class="grow"><small>Could not check</small></span></div>'; }); }).catch(() => { box.innerHTML = '<div class="row"><span class="grow"><small>Could not check</small></span></div>'; });
else box.innerHTML = ''; else box.innerHTML = '';
if (cfg.mailAdmin) mailAdminCard(); if (cfg.mailAdmin) mailAdminCard();
} }
/** Choose a provider; Outlook.com signs in on Microsoft's page, Gmail and iCloud take an app password. */
function mailAddAccount(st) {
const choices = [['gmail', 'Gmail', 'App password'], ['icloud', 'iCloud Mail', 'App-specific password'], ...(st && st.outlook ? [['outlook', 'Outlook.com', 'Sign in with Microsoft']] : [])];
const el = openSheet('Add a mail account', '', `<div class="list">${choices.map(([k, l, s]) => `<button class="row" data-p="${k}"><span class="ic" style="background:${k === 'gmail' ? '#d9453a' : k === 'icloud' ? '#3b97e8' : '#0a64c8'}">${icon('mail')}</span><span class="grow"><b>${l}</b><small>${s}</small></span>${icon('chev', 'chev')}</button>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button></div>`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-p]').forEach(b => b.onclick = async () => {
const k = b.dataset.p;
if (k === 'outlook') { try { const r = await jfetch('/api/mail/connect'); location.href = r.url; } catch (e) { toast(e.message); } return; }
mailPasswordForm(k);
});
}
function mailPasswordForm(type) {
const g = type === 'gmail';
const el = openSheet('Add ' + (g ? 'Gmail' : 'iCloud Mail'), '', `<form id="paf">
<p class="sub" style="margin-top:0">${g ? 'Google needs an <b>app password</b>, not your normal password. Turn on 2-Step Verification, then make one at <b>myaccount.google.com/apppasswords</b> (a 16-letter code).' : 'Apple needs an <b>app-specific password</b>. Go to <b>appleid.apple.com</b> → Sign-In and Security → App-Specific Passwords, and make one for NE Hub.'}</p>
<label for="pae">Email address</label><input type="text" id="pae" inputmode="email" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="${g ? 'you@gmail.com' : 'you@icloud.com'}" required>
<label for="pap">${g ? 'App password' : 'App-specific password'}</label><input type="password" id="pap" autocomplete="new-password" required>
<p class="sub" style="margin:6px 0 0">It is checked with ${g ? 'Google' : 'Apple'} before saving, and kept encrypted on your server. NE Hub never sees your normal password.</p>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="pab" style="flex:1;justify-content:center">Connect</button></div><div class="err" id="pax" role="alert"></div></form>`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#paf').onsubmit = async e => {
e.preventDefault(); const btn = el.querySelector('#pab'); btn.disabled = true; btn.textContent = 'Checking…'; el.querySelector('#pax').textContent = '';
try { await jfetch('/api/mail/accounts', { method: 'POST', body: JSON.stringify({ type, address: el.querySelector('#pae').value, password: el.querySelector('#pap').value }) }); closeSheet(); toast('Connected'); mail.st = null; mail.items = []; mail.folders = []; mail.acct = ''; await mailPoll(); if (location.hash.startsWith('#/mail')) route(); else if ($('#mailst')) mailSettings(); }
catch (err) { btn.disabled = false; btn.textContent = 'Connect'; el.querySelector('#pax').textContent = err.message; }
};
setTimeout(() => el.querySelector('#pae').focus(), 60);
}
/** Admin: the Microsoft app behind Mail (client ID, secret, expiry), with the redirect address to register and a test button. */ /** Admin: the Microsoft app behind Mail (client ID, secret, expiry), with the redirect address to register and a test button. */
async function mailAdminCard() { async function mailAdminCard() {
let host = $('#mailadm'); let host = $('#mailadm');
@@ -93,16 +124,20 @@ async function mailAdminCard() {
// ---- the tab // ---- the tab
async function viewMail(args = []) { async function viewMail(args = []) {
const v = $('#view'); const v = $('#view');
if (!cfg.mail) { v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Mail is not set up on this server. An admin can switch it on (see the README, Mail).</p></div>'; return; } if (!cfg.mail) { v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Mail is not available to you.</p></div>'; return; }
v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Loading…</p></div>'; v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Loading…</p></div>';
let st; try { st = await jfetch('/api/mail/status'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; return; } let st; try { st = await jfetch('/api/mail/status'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/mail')) return; if (!location.hash.startsWith('#/mail')) return;
mail.st = st; mail.st = st; mail.accts = st.accounts || [];
if (!st.connected) return mailConnectScreen(st); if (!st.connected) return mailConnectScreen(st);
if (typeof st.unread === 'number' && st.unread !== mailUnread) { mailUnread = st.unread; renderNav(); } if (typeof st.unread === 'number' && st.unread !== mailUnread) { mailUnread = st.unread; renderNav(); }
const [kind, key] = args; const [kind, key] = args;
if (kind === 'f' && key) mail.folder = key; const idAcct = x => String(x || '').includes('~') ? String(x).split('~')[0] : '';
if (!mail.folders.length) { try { mail.folders = await jfetch('/api/mail/folders'); } catch {} } const want = idAcct(kind === 'f' ? key : kind === 'm' ? key : '') || (mail.accts.some(a => a.id === mail.acct) ? mail.acct : mail.accts[0].id);
if (want !== mail.acct) { mail.acct = want; mail.folders = []; mail.items = []; if (!(kind === 'f' && idAcct(key))) mail.folder = want + '~inbox'; }
if (kind === 'f' && key) mail.folder = idAcct(key) ? key : mail.acct + '~' + key;
else if (!String(mail.folder).startsWith(mail.acct + '~')) mail.folder = mail.acct + '~inbox';
if (!mail.folders.length) { try { mail.folders = await jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)); } catch {} }
mailShell(kind === 'm' && key ? 'read' : 'list'); mailShell(kind === 'm' && key ? 'read' : 'list');
if (!mail.items.length || kind === 'f' || kind === undefined) mailLoadList(true); if (!mail.items.length || kind === 'f' || kind === undefined) mailLoadList(true);
else mailDrawList(); else mailDrawList();
@@ -110,22 +145,24 @@ async function viewMail(args = []) {
} }
function mailConnectScreen(st) { function mailConnectScreen(st) {
const choices = [['gmail', 'Gmail', 'Uses an app password', '#d9453a'], ['icloud', 'iCloud Mail', 'Uses an app-specific password', '#3b97e8'], ...(st.outlook ? [['outlook', 'Outlook.com', 'Sign in with Microsoft', '#0a64c8']] : [])];
$('#view').innerHTML = `<div class="wrap"><h1>Mail</h1> $('#view').innerHTML = `<div class="wrap"><h1>Mail</h1>
<p class="sub">Read and send mail from your Outlook.com account right here. You sign in on Microsoft's own page; NE Hub never sees your password and only keeps a sign-in token on your server (encrypted).</p> <p class="sub">Read and send mail from your own accounts right here, all in one place. Passwords are checked with the provider and kept encrypted on your server; Outlook.com signs in on Microsoft's own page.</p>
${st.expired ? '<p class="err">Microsoft signed you out of mail. Connect again to continue.</p>' : ''} ${st.expired ? '<p class="err">Microsoft signed you out of mail. Connect again to continue.</p>' : ''}
<div class="bar"><button class="btn" id="mcon">${icon('mail')}Connect Outlook.com</button></div><div class="err" id="mce"></div> <div class="list">${choices.map(([k, l, s, c]) => `<button class="row" data-p="${k}"><span class="ic" style="background:${c}">${icon('mail')}</span><span class="grow"><b>${l}</b><small>${s}</small></span>${icon('chev', 'chev')}</button>`).join('')}</div><div class="err" id="mce"></div>
<p class="sub">Personal Outlook.com, Hotmail and Live accounts only.</p></div>`; ${st.outlook ? '' : '<p class="sub">Outlook.com needs a one-time Microsoft app set up by an admin (Settings → Microsoft app).</p>'}</div>`;
$('#mcon').onclick = async () => { $('#view').querySelectorAll('[data-p]').forEach(b => b.onclick = async () => {
try { const r = await jfetch('/api/mail/connect'); location.href = r.url; } if (b.dataset.p === 'outlook') { try { const r = await jfetch('/api/mail/connect'); location.href = r.url; } catch (e) { $('#mce').textContent = e.message; } }
catch (e) { $('#mce').textContent = e.message; } else mailPasswordForm(b.dataset.p);
}; });
} }
function mailShell(mode) { function mailShell(mode) {
const fl = mail.folders.length ? mail.folders : [{ id: 'inbox', name: 'Inbox', unread: mailUnread }]; const fl = mail.folders.length ? mail.folders : [{ id: 'inbox', name: 'Inbox', unread: mailUnread }];
$('#view').innerHTML = `<div class="mailapp" data-mode="${mode}"> $('#view').innerHTML = `<div class="mailapp" data-mode="${mode}">
<aside class="mfold" aria-label="Folders">${fl.map(f => `<a href="#/mail/f/${encodeURIComponent(f.id)}" class="${f.id === mail.folder ? 'on' : ''}"><span class="grow">${esc(f.name)}</span>${f.unread ? `<i class="nbadge" style="position:static">${f.unread}</i>` : ''}</a>`).join('')}</aside> <aside class="mfold" aria-label="Folders">${mail.accts.length > 1 ? `<select id="macct2" aria-label="Account" style="margin-bottom:8px">${mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}</option>`).join('')}</select>` : ''}${fl.map(f => `<a href="#/mail/f/${encodeURIComponent(f.id)}" class="${f.id === mail.folder ? 'on' : ''}"><span class="grow">${esc(f.name)}</span>${f.unread ? `<i class="nbadge" style="position:static">${f.unread}</i>` : ''}</a>`).join('')}</aside>
<section class="mlist" aria-label="Messages"> <section class="mlist" aria-label="Messages">
${mail.accts.length > 1 ? `<div class="mtool" style="margin-bottom:4px"><select id="macct" aria-label="Account" style="max-width:100%;flex:1">${mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}${a.unread ? ' (' + a.unread + ')' : ''}</option>`).join('')}</select></div>` : ''}
<div class="mtool"><select id="mfsel" aria-label="Folder">${fl.map(f => `<option value="${esc(f.id)}" ${f.id === mail.folder ? 'selected' : ''}>${esc(f.name)}${f.unread ? ' (' + f.unread + ')' : ''}</option>`).join('')}</select> <div class="mtool"><select id="mfsel" aria-label="Folder">${fl.map(f => `<option value="${esc(f.id)}" ${f.id === mail.folder ? 'selected' : ''}>${esc(f.name)}${f.unread ? ' (' + f.unread + ')' : ''}</option>`).join('')}</select>
<input type="search" id="mq" placeholder="Search mail" value="${esc(mail.q)}" autocomplete="off" aria-label="Search mail"> <input type="search" id="mq" placeholder="Search mail" value="${esc(mail.q)}" autocomplete="off" aria-label="Search mail">
<button class="ibtn" id="mrf" aria-label="Refresh">${icon('refresh')}</button><button class="btn" id="mnew">${icon('edit')}New</button></div> <button class="ibtn" id="mrf" aria-label="Refresh">${icon('refresh')}</button><button class="btn" id="mnew">${icon('edit')}New</button></div>
@@ -133,8 +170,10 @@ function mailShell(mode) {
</section> </section>
<section class="mread" id="mread" aria-label="Message"></section></div>`; <section class="mread" id="mread" aria-label="Message"></section></div>`;
$('#mfsel').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value); }; $('#mfsel').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value); };
if ($('#macct2')) $('#macct2').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value + '~inbox'); };
if ($('#macct')) $('#macct').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value + '~inbox'); };
$('#mnew').onclick = () => mailCompose(); $('#mnew').onclick = () => mailCompose();
$('#mrf').onclick = () => { mailLoadList(true); jfetch('/api/mail/folders').then(f => { mail.folders = f; mailPoll(); }).catch(() => {}); }; $('#mrf').onclick = () => { mailLoadList(true); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailPoll(); }).catch(() => {}); };
let t; $('#mq').oninput = e => { clearTimeout(t); t = setTimeout(() => { mail.q = e.target.value.trim(); mailLoadList(true); }, 400); }; let t; $('#mq').oninput = e => { clearTimeout(t); t = setTimeout(() => { mail.q = e.target.value.trim(); mailLoadList(true); }, 400); };
} }
@@ -168,7 +207,7 @@ async function mailOpen(id) {
if (!m.read) { // opening a message marks it read if (!m.read) { // opening a message marks it read
jfetch('/api/mail/update', { method: 'POST', body: JSON.stringify({ id, read: true }) }).then(() => { jfetch('/api/mail/update', { method: 'POST', body: JSON.stringify({ id, read: true }) }).then(() => {
const it = mail.items.find(x => x.id === id); if (it) it.read = true; m.read = true; mailDrawList(); const it = mail.items.find(x => x.id === id); if (it) it.read = true; m.read = true; mailDrawList();
mailUnread = Math.max(0, mailUnread - 1); renderNav(); jfetch('/api/mail/folders').then(f => { mail.folders = f; }).catch(() => {}); mailUnread = Math.max(0, mailUnread - 1); renderNav(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; }).catch(() => {});
}).catch(() => {}); }).catch(() => {});
} }
} catch (e) { if ($('#mread')) $('#mread').innerHTML = `<p class="err" style="padding:16px">${esc(e.message)}</p>`; } } catch (e) { if ($('#mread')) $('#mread').innerHTML = `<p class="err" style="padding:16px">${esc(e.message)}</p>`; }
@@ -197,14 +236,14 @@ function mailDrawReader() {
async function mailAction(a) { async function mailAction(a) {
const m = mail.msg; if (!m) return; const m = mail.msg; if (!m) return;
const post = (p, body) => jfetch('/api/mail/' + p, { method: 'POST', body: JSON.stringify({ id: m.id, ...body }) }); const post = (p, body) => jfetch('/api/mail/' + p, { method: 'POST', body: JSON.stringify({ id: m.id, ...body }) });
const back = () => { mail.items = mail.items.filter(x => x.id !== m.id); mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); mailPoll(); jfetch('/api/mail/folders').then(f => { mail.folders = f; }).catch(() => {}); }; const back = () => { mail.items = mail.items.filter(x => x.id !== m.id); mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; }).catch(() => {}); };
try { try {
if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m }); if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m });
if (a === 'archive') { await post('move', { to: 'archive' }); toast('Archived'); return back(); } if (a === 'archive') { await post('move', { to: 'archive' }); toast('Archived'); return back(); }
if (a === 'unread') { await post('update', { read: false }); const it = mail.items.find(x => x.id === m.id); if (it) it.read = false; toast('Marked unread'); mailUnread++; renderNav(); return back(); } if (a === 'unread') { await post('update', { read: false }); const it = mail.items.find(x => x.id === m.id); if (it) it.read = false; toast('Marked unread'); mailUnread++; renderNav(); return back(); }
if (a === 'flag') { await post('update', { flagged: !m.flagged }); m.flagged = !m.flagged; const it = mail.items.find(x => x.id === m.id); if (it) it.flagged = m.flagged; mailDrawReader(); return mailDrawList(); } if (a === 'flag') { await post('update', { flagged: !m.flagged }); m.flagged = !m.flagged; const it = mail.items.find(x => x.id === m.id); if (it) it.flagged = m.flagged; mailDrawReader(); return mailDrawList(); }
if (a === 'delete') { if (a === 'delete') {
if (mail.folder === 'deleteditems') return confirmSheet('Delete for good?', m.subject, 'Delete', async () => { try { await post('delete', { permanent: true }); toast('Deleted'); back(); } catch (e) { toast(e.message); } }); if (/(^|~)deleteditems$/.test(mail.folder)) return confirmSheet('Delete for good?', m.subject, 'Delete', async () => { try { await post('delete', { permanent: true }); toast('Deleted'); back(); } catch (e) { toast(e.message); } });
await post('delete', {}); toast('Moved to Deleted'); return back(); await post('delete', {}); toast('Moved to Deleted'); return back();
} }
} catch (e) { toast(e.message); } } catch (e) { toast(e.message); }
@@ -216,7 +255,7 @@ function mailCompose(o = {}) {
const reply = o.mode, m = o.msg, files = []; const reply = o.mode, m = o.msg, files = [];
const title = reply === 'reply' ? 'Reply' : reply === 'replyAll' ? 'Reply all' : reply === 'forward' ? 'Forward' : 'New message'; const title = reply === 'reply' ? 'Reply' : reply === 'replyAll' ? 'Reply all' : reply === 'forward' ? 'Forward' : 'New message';
const el = openSheet(title, reply ? m.subject : '', `<form id="mf"> const el = openSheet(title, reply ? m.subject : '', `<form id="mf">
${reply && reply !== 'forward' ? `<p class="sub" style="margin:0">To ${esc(reply === 'reply' ? mailFrom(m.from) : 'everyone on the message')}. The original is quoted below your text automatically.</p>` : `<label for="mto">To</label><input type="text" id="mto" autocomplete="off" autocapitalize="off" inputmode="email" placeholder="name@example.com, another@example.com" required>`} ${reply && reply !== 'forward' ? `<p class="sub" style="margin:0">To ${esc(reply === 'reply' ? mailFrom(m.from) : 'everyone on the message')}. The original is quoted below your text automatically.</p>` : `${mail.accts.length > 1 ? `<label for="mfrom">From</label><select id="mfrom">${mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}</option>`).join('')}</select>` : ''}<label for="mto">To</label><input type="text" id="mto" autocomplete="off" autocapitalize="off" inputmode="email" placeholder="name@example.com, another@example.com" required>`}
${reply ? '' : `<div id="mccw"><a href="#" id="mcc" style="color:var(--accent);font-size:14px">Add Cc / Bcc</a></div><div id="mcb" hidden><label for="mcc2">Cc</label><input type="text" id="mcc2" autocomplete="off" autocapitalize="off"><label for="mbcc">Bcc</label><input type="text" id="mbcc" autocomplete="off" autocapitalize="off"></div> ${reply ? '' : `<div id="mccw"><a href="#" id="mcc" style="color:var(--accent);font-size:14px">Add Cc / Bcc</a></div><div id="mcb" hidden><label for="mcc2">Cc</label><input type="text" id="mcc2" autocomplete="off" autocapitalize="off"><label for="mbcc">Bcc</label><input type="text" id="mbcc" autocomplete="off" autocapitalize="off"></div>
<label for="msb">Subject</label><input type="text" id="msb" autocomplete="off">`} <label for="msb">Subject</label><input type="text" id="msb" autocomplete="off">`}
<label for="mbd">${reply ? 'Your message' : 'Message'}</label><textarea id="mbd" rows="${reply ? 6 : 9}"></textarea> <label for="mbd">${reply ? 'Your message' : 'Message'}</label><textarea id="mbd" rows="${reply ? 6 : 9}"></textarea>
@@ -234,7 +273,7 @@ function mailCompose(o = {}) {
try { try {
const body = el.querySelector('#mbd').value; const body = el.querySelector('#mbd').value;
if (reply) await jfetch('/api/mail/reply', { method: 'POST', body: JSON.stringify({ id: m.id, mode: reply, comment: body, to: reply === 'forward' ? el.querySelector('#mto').value : undefined }) }); if (reply) await jfetch('/api/mail/reply', { method: 'POST', body: JSON.stringify({ id: m.id, mode: reply, comment: body, to: reply === 'forward' ? el.querySelector('#mto').value : undefined }) });
else await jfetch('/api/mail/send', { method: 'POST', body: JSON.stringify({ to: el.querySelector('#mto').value, cc: el.querySelector('#mcc2').value, bcc: el.querySelector('#mbcc').value, subject: el.querySelector('#msb').value, body, else await jfetch('/api/mail/send', { method: 'POST', body: JSON.stringify({ acct: el.querySelector('#mfrom') ? el.querySelector('#mfrom').value : mail.acct || undefined, to: el.querySelector('#mto').value, cc: el.querySelector('#mcc2').value, bcc: el.querySelector('#mbcc').value, subject: el.querySelector('#msb').value, body,
attachments: await Promise.all(files.map(async f => ({ name: f.name, type: f.type, data: await b64(f) }))) }) }); attachments: await Promise.all(files.map(async f => ({ name: f.name, type: f.type, data: await b64(f) }))) }) });
closeSheet(); toast('Sent'); closeSheet(); toast('Sent');
} catch (err) { btn.disabled = false; el.querySelector('#mer').textContent = err.message; } } catch (err) { btn.disabled = false; el.querySelector('#mer').textContent = err.message; }
+2 -1
View File
@@ -51,7 +51,8 @@ function openPalette() {
let items = [], sel = 0, seq = 0, timer, live = []; let items = [], sel = 0, seq = 0, timer, live = [];
const go = h => () => { location.hash = h; }; const go = h => () => { location.hash = h; };
const base = () => { const base = () => {
const out = TABS.filter(t => allowed(t[0])).map(([id, label]) => ({ label: 'Go to ' + label, hint: 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label })); const out = navTabs().map(([id, label]) => ({ label: 'Go to ' + label, hint: (navGroups().find(g => g.tabs.some(t => t[0] === id)) || {}).label || 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
if (allowed('tasks') && allowed('calendar')) out.push({ label: 'Go to Tasks', hint: 'Planner', ic: 'check', run: go('#/tasks'), kw: 'todo' }, { label: 'Go to Calendar', hint: 'Planner', ic: 'cal', run: go('#/calendar'), kw: 'agenda events' });
if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' }); if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' });
if (allowed('calendar')) out.push({ label: 'New event', hint: 'Create', ic: 'plus', run: () => newEvent(ymd(new Date())), kw: 'add calendar' }); if (allowed('calendar')) out.push({ label: 'New event', hint: 'Create', ic: 'plus', run: () => newEvent(ymd(new Date())), kw: 'add calendar' });
if (allowed('mail') && cfg.mail) out.push({ label: 'New email', hint: 'Create', ic: 'mail', run: () => { location.hash = '#/mail'; setTimeout(() => mailCompose(), 300); }, kw: 'compose write message' }); if (allowed('mail') && cfg.mail) out.push({ label: 'New email', hint: 'Create', ic: 'mail', run: () => { location.hash = '#/mail'; setTimeout(() => mailCompose(), 300); }, kw: 'compose write message' });
+17
View File
@@ -0,0 +1,17 @@
'use strict';
// Planner: Calendar and Tasks in one menu entry. Each part keeps its own address (#/calendar, #/tasks) so links and reminders still work.
const PLANNER_PANES = [['calendar', 'Calendar'], ['tasks', 'Tasks']];
function viewPlanner(args = []) {
const ok = PLANNER_PANES.map(p => p[0]).filter(p => allowed(p));
const want = args[0] && ok.includes(args[0]) ? args[0] : store.get('planner_pane', ok[0]);
location.replace('#/' + (ok.includes(want) ? want : ok[0] || 'home'));
}
/** One compact toolbar instead of stacked rows: title, Calendar | Tasks switch, then `mid` (the view switch) and `extra` (a button) pushed to the right. */
function plannerHead(active, extra = '', mid = '') {
try { localStorage.setItem(nk('planner_pane'), JSON.stringify(active)); } catch {}
const panes = PLANNER_PANES.filter(p => allowed(p[0]));
const two = panes.length > 1;
const group = two ? `<div class="sgrp" role="tablist" aria-label="Planner">${panes.map(([k, l]) => `<a href="#/${k}" role="tab" aria-selected="${k === active}" class="${k === active ? 'on' : ''}">${l}</a>`).join('')}</div>` : '';
const title = two ? 'Planner' : (PLANNER_PANES.find(p => p[0] === active) || [0, 'Planner'])[1];
return `<div class="ptop"><h1>${esc(title)}</h1>${group}${mid}<span class="pspace"></span>${extra}</div>`;
}
+1 -1
View File
@@ -18,7 +18,7 @@ async function remindersInit() {
const post = (p, body) => jfetch('/api/push/' + p, { method: 'POST', body: JSON.stringify({ tz: myTz(), ...body }) }); const post = (p, body) => jfetch('/api/push/' + p, { method: 'POST', body: JSON.stringify({ tz: myTz(), ...body }) });
box.innerHTML = `<label class="row" style="cursor:pointer"><span class="grow"><b>Reminders on this device</b><small id="remst" style="white-space:normal">${Notification.permission === 'denied' ? 'Blocked in the browser settings for this site; allow notifications there first' : on ? 'On' : 'Off'}${info.devices ? ' · ' + info.devices + (info.devices === 1 ? ' device' : ' devices') + ' in total' : ''}</small></span><input type="checkbox" id="remon" ${on ? 'checked' : ''} ${Notification.permission === 'denied' ? 'disabled' : ''}></label> box.innerHTML = `<label class="row" style="cursor:pointer"><span class="grow"><b>Reminders on this device</b><small id="remst" style="white-space:normal">${Notification.permission === 'denied' ? 'Blocked in the browser settings for this site; allow notifications there first' : on ? 'On' : 'Off'}${info.devices ? ' · ' + info.devices + (info.devices === 1 ? ' device' : ' devices') + ' in total' : ''}</small></span><input type="checkbox" id="remon" ${on ? 'checked' : ''} ${Notification.permission === 'denied' ? 'disabled' : ''}></label>
${allowed('tasks') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Tasks due today</b><small>One summary each morning</small></span><input type="checkbox" id="remt" ${info.tasks ? 'checked' : ''}></label> ${allowed('tasks') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Tasks due today</b><small>One summary each morning</small></span><input type="checkbox" id="remt" ${info.tasks ? 'checked' : ''}></label>
<div class="row"><span class="grow"><b>Morning time</b></span><input type="time" id="remat" value="${esc(info.tasksAt)}" style="width:auto"></div>` : ''} <div class="row"><span class="grow"><b>Morning time</b></span><input type="time" id="remat" value="${esc(info.tasksAt)}" style="width:auto;min-width:130px"></div>` : ''}
${allowed('calendar') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Upcoming events</b><small>Shortly before they start</small></span><input type="checkbox" id="reme" ${info.events ? 'checked' : ''}></label> ${allowed('calendar') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Upcoming events</b><small>Shortly before they start</small></span><input type="checkbox" id="reme" ${info.events ? 'checked' : ''}></label>
<div class="row"><span class="grow"><b>How early</b></span><select id="remm" style="width:auto">${[5, 10, 15, 30, 60].map(m => `<option value="${m}" ${m === info.eventMins ? 'selected' : ''}>${m} min</option>`).join('')}</select></div>` : ''} <div class="row"><span class="grow"><b>How early</b></span><select id="remm" style="width:auto">${[5, 10, 15, 30, 60].map(m => `<option value="${m}" ${m === info.eventMins ? 'selected' : ''}>${m} min</option>`).join('')}</select></div>` : ''}
<div class="row"><span class="grow"><small>Send a test to every device that is on</small></span><button class="btn ghost" id="remtest" style="min-height:36px">Test</button></div>`; <div class="row"><span class="grow"><small>Send a test to every device that is on</small></span><button class="btn ghost" id="remtest" style="min-height:36px">Test</button></div>`;
+1 -1
View File
@@ -83,7 +83,7 @@ function viewSettings() {
</div> </div>
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div> ${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div>
<div id="sec-mail"><h2>Mail</h2><div class="list" id="mailst"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div> <div id="sec-mail"><h2>Mail</h2><div class="list" id="mailst"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>
</div><div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy button on the Audiobooks tab to paste them into the site's sign-in form.</p> <div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy button on the Audiobooks tab to paste them into the site's sign-in form.</p>
<div class="card" style="max-width:none"><label for="ab-u">Username</label> <div class="card" style="max-width:none"><label for="ab-u">Username</label>
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}"> <input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
<label for="ab-p">Password</label> <label for="ab-p">Password</label>
+24 -1
View File
@@ -83,6 +83,8 @@ label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px
background: var(--text); color: var(--bg); padding: 10px 16px; border-radius: 12px; opacity: 0; pointer-events: none; background: var(--text); color: var(--bg); padding: 10px 16px; border-radius: 12px; opacity: 0; pointer-events: none;
transition: .2s; max-width: 90vw; z-index: 10; } transition: .2s; max-width: 90vw; z-index: 10; }
#toast.on { opacity: 1; transform: translate(-50%, 0); } #toast.on { opacity: 1; transform: translate(-50%, 0); }
#toast.act { pointer-events: auto; display: flex; align-items: center; gap: 14px; }
#toast.act button { border: 0; background: none; color: var(--accent-fg, #7aa2ff); font-weight: 700; padding: 0 4px; font-size: 15px; }
/* Tablet / iPad (incl. 12.9" portrait 1024 + landscape 1366) and desktop: side rail */ /* Tablet / iPad (incl. 12.9" portrait 1024 + landscape 1366) and desktop: side rail */
@media (min-width: 768px) { @media (min-width: 768px) {
@@ -239,7 +241,7 @@ body.onaudio #view { flex: none; overflow: visible; padding-bottom: 0; }
.cday .chips small { color: var(--muted); font-size: 11px; text-align: left; padding-left: 6px; } .cday .chips small { color: var(--muted); font-size: 11px; text-align: left; padding-left: 6px; }
} }
select, textarea, input[type=date], input[type=datetime-local] { width: 100%; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-size: 16px; color: var(--text); } select, textarea, input[type=date], input[type=datetime-local], input[type=time] { width: 100%; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-size: 16px; color: var(--text); }
textarea { padding: 12px 14px; resize: vertical; font-family: inherit; } textarea { padding: 12px 14px; resize: vertical; font-family: inherit; }
.chk { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 16px; margin: 14px 0 0; } .chk { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 16px; margin: 14px 0 0; }
.chk input { width: 22px; height: 22px; } .chk input { width: 22px; height: 22px; }
@@ -553,3 +555,24 @@ body.lyropen { overflow: hidden; }
.mfold a.on { background: var(--panel2); color: var(--text); font-weight: 600; } .mfold a.on { background: var(--panel2); color: var(--text); font-weight: 600; }
.mtool select { display: none; } .mtool select { display: none; }
} }
/* Menu groups (desktop rail) */
.ngrp { margin: 8px 12px 2px; border-top: 1px solid var(--line); padding-top: 8px; }
.ngrp span { font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
body.nav-mini .ngrp { margin: 6px 14px; padding-top: 0; }
body.nav-mini .ngrp span { display: none; }
/* Planner toolbar: one row (wraps on phones) */
.ptop { display: flex; align-items: center; gap: 10px 12px; flex-wrap: wrap; margin: 0 0 12px; }
.ptop h1 { margin: 0 4px 0 0; font-size: 24px; }
.pspace { flex: 1; }
.sgrp { display: inline-flex; background: var(--panel2); border-radius: 999px; padding: 3px; gap: 2px; max-width: 100%; overflow-x: auto; }
.sgrp a { padding: 6px 14px; border-radius: 999px; color: var(--muted); font-size: 14px; white-space: nowrap; min-height: 32px; display: flex; align-items: center; }
.sgrp a.on { background: var(--accent); color: var(--accent-t); font-weight: 600; }
.psel { width: auto; min-height: 38px; padding: 0 12px; border-radius: 999px; font-size: 14px; }
@media (max-width: 560px) { .ptop h1 { width: 100%; } .pspace { display: none; } .ptop .btn { margin-left: auto; } }
/* Text preview and editing */
.ptxt { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 60dvh; overflow: auto; background: var(--panel2); border-radius: 12px; padding: 12px 14px; font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace; margin: 8px 0; }
.ptxt-edit { width: 100%; height: 55dvh; font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); resize: vertical; }
+3 -3
View File
@@ -6,13 +6,13 @@ const taskStore = []; // tasks on screen, so a tap can open the right one
async function viewTasks() { async function viewTasks() {
const v = $('#view'); const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Tasks</h1><p class="empty">Loading…</p></div>'; v.innerHTML = '<div class="wrap">' + plannerHead('tasks') + '<p class="empty">Loading…</p></div>';
try { try {
const { lists, icloud } = await jfetch('/api/tasks/lists'); const { lists, icloud } = await jfetch('/api/tasks/lists');
if (!location.hash.startsWith('#/tasks')) return; if (!location.hash.startsWith('#/tasks')) return;
let cur = store.get('task_list'); if (!lists.some(l => l.id === cur)) cur = lists[0].id; let cur = store.get('task_list'); if (!lists.some(l => l.id === cur)) cur = lists[0].id;
const list = lists.find(l => l.id === cur); const list = lists.find(l => l.id === cur);
v.innerHTML = `<div class="wrap"><h1>Tasks</h1> v.innerHTML = `<div class="wrap">${plannerHead('tasks')}
<div class="seg">${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div> <div class="seg">${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="Add a task" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form> <form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="Add a task" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
<div id="tl"><p class="empty">Loading…</p></div> <div id="tl"><p class="empty">Loading…</p></div>
@@ -27,7 +27,7 @@ async function viewTasks() {
catch (err) { toast(err.message); } catch (err) { toast(err.message); }
}; };
loadTaskList(cur, list); loadTaskList(cur, list);
} catch (e) { v.innerHTML = `<div class="wrap"><h1>Tasks</h1><p class="err">${esc(e.message)}</p></div>`; } } catch (e) { v.innerHTML = `<div class="wrap">${plannerHead('tasks')}<p class="err">${esc(e.message)}</p></div>`; }
} }
async function loadTaskList(cur, list) { async function loadTaskList(cur, list) {
+48 -30
View File
@@ -9,6 +9,7 @@ const { Readable } = require('stream');
const PORT = process.env.PORT || 8080; const PORT = process.env.PORT || 8080;
const SEAFILE_URL = (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, ''); const SEAFILE_URL = (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
const SEAFILE_TIMEOUT = Math.max(1000, parseInt(process.env.SEAFILE_TIMEOUT_MS, 10) || 30000); // (the tests shorten this)
const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').replace(/\/$/, ''); const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').replace(/\/$/, '');
const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, ''); const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
const AUDIOBOOKS_SSO_URL = process.env.AUDIOBOOKS_SSO_URL || AUDIOBOOKS_URL + '/sso'; const AUDIOBOOKS_SSO_URL = process.env.AUDIOBOOKS_SSO_URL || AUDIOBOOKS_URL + '/sso';
@@ -46,43 +47,60 @@ const TYPES = {
'.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon',
}; };
// Seafile calls get 30 s to start answering, file transfers (seafhttp) 2 minutes; a failed read is retried twice; and when the browser goes away
// (tab closed, download cancelled) the upstream request is stopped instead of being left running.
async function proxySeafile(req, res) { async function proxySeafile(req, res) {
const tu = new URL(SEAFILE_URL + req.url.slice('/sf'.length)); const tu = new URL(SEAFILE_URL + req.url.slice('/sf'.length));
const inline = tu.searchParams.has('hubinline'); // preview: show in the page instead of forcing a download const inline = tu.searchParams.has('hubinline'); // preview: show in the page instead of forcing a download
tu.searchParams.delete('hubinline'); tu.searchParams.delete('hubinline');
const target = tu.toString(); const target = tu.toString();
const headers = {}; const headers = {};
for (const h of ['authorization', 'content-type', 'content-length', 'accept', 'range']) { for (const h of ['authorization', 'content-type', 'content-length', 'accept', 'range', 'if-range']) {
if (req.headers[h]) headers[h] = req.headers[h]; if (req.headers[h]) headers[h] = req.headers[h];
} }
const hasBody = !['GET', 'HEAD'].includes(req.method); const hasBody = !['GET', 'HEAD'].includes(req.method), canRetry = !hasBody;
try { const wait = /^\/seafhttp\//.test(tu.pathname) ? 120000 : SEAFILE_TIMEOUT;
const upstream = await fetch(target, { let gone = false, ac = null; res.on('close', () => { if (!res.writableFinished) { gone = true; if (ac) ac.abort(); } });
method: req.method, headers, redirect: 'manual', let upstream, lastErr;
body: hasBody ? Readable.toWeb(req) : undefined, for (let attempt = 0; attempt < 3; attempt++) {
duplex: hasBody ? 'half' : undefined, if (gone) return;
}); ac = new AbortController(); let timedOut = false;
const out = {}; const timer = setTimeout(() => { timedOut = true; ac.abort(); }, wait);
for (const h of ['content-type', 'content-length', 'content-disposition', 'content-range', 'accept-ranges']) { try {
if (inline && h === 'content-disposition') continue; upstream = await fetch(target, { method: req.method, headers, redirect: 'manual', signal: ac.signal,
if (h === 'content-length' && upstream.headers.get('content-encoding')) continue; // decompressed by fetch, so the length changed body: hasBody ? Readable.toWeb(req) : undefined, duplex: hasBody ? 'half' : undefined });
const v = upstream.headers.get(h); clearTimeout(timer);
if (v) out[h] = v; if (canRetry && [502, 503, 504].includes(upstream.status) && attempt < 2) { try { await upstream.body.cancel(); } catch {} await new Promise(r => setTimeout(r, 300 * (attempt + 1) ** 2)); continue; }
break;
} catch (e) {
clearTimeout(timer); upstream = null; lastErr = timedOut ? Object.assign(new Error('Seafile took too long to answer'), { timeout: true }) : e;
if (gone) return;
if (canRetry && attempt < 2) { await new Promise(r => setTimeout(r, 300 * (attempt + 1) ** 2)); continue; }
break;
} }
// Rewrite redirects so they stay on the proxy and on the Seafile host.
const loc = upstream.headers.get('location');
if (loc) {
const u = new URL(loc, SEAFILE_URL);
if (u.origin === new URL(SEAFILE_URL).origin) out.location = '/sf' + u.pathname + u.search;
}
res.writeHead(upstream.status, out);
if (upstream.body) Readable.fromWeb(upstream.body).pipe(res); else res.end();
} catch (e) {
res.writeHead(502, { 'content-type': 'application/json' });
const why = e.cause ? (e.cause.code || e.cause.message) : '';
console.error('Seafile proxy:', target.split('?')[0], e.message, why);
res.end(JSON.stringify({ error: 'Seafile unreachable: ' + e.message + (why ? ' (' + why + ')' : '') }));
} }
if (!upstream) {
const e = lastErr, why = e && e.cause ? (e.cause.code || e.cause.message) : '';
console.error('Seafile proxy:', target.split('?')[0], e && e.message, why);
if (res.headersSent) return res.destroy();
res.writeHead(e && e.timeout ? 504 : 502, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ error: e && e.timeout ? 'Seafile took too long to answer. Try again in a moment.' : 'Seafile unreachable: ' + (e && e.message) + (why ? ' (' + why + ')' : '') }));
}
const out = {};
for (const h of ['content-type', 'content-length', 'content-disposition', 'content-range', 'accept-ranges', 'etag', 'last-modified', 'retry-after']) {
if (inline && h === 'content-disposition') continue;
if (h === 'content-length' && upstream.headers.get('content-encoding')) continue; // decompressed by fetch, so the length changed
const v = upstream.headers.get(h);
if (v) out[h] = v;
}
// Rewrite redirects so they stay on the proxy and on the Seafile host.
const loc = upstream.headers.get('location');
if (loc) {
const u = new URL(loc, SEAFILE_URL);
if (u.origin === new URL(SEAFILE_URL).origin) out.location = '/sf' + u.pathname + u.search;
}
res.writeHead(upstream.status, out);
if (upstream.body) Readable.fromWeb(upstream.body).on('error', () => res.destroy()).pipe(res); else res.end();
} }
function serveStatic(req, res) { function serveStatic(req, res) {
@@ -441,7 +459,7 @@ async function handleRequest(req, res) {
if (pathname === '/healthz') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end('{"ok":true}'); } if (pathname === '/healthz') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end('{"ok":true}'); }
if (crossSite(req) && pathname !== '/api/oo/callback') return forbidden(res, 'Cross-site request refused'); if (crossSite(req) && pathname !== '/api/oo/callback') return forbidden(res, 'Cross-site request refused');
if (pathname.startsWith('/api/auth/')) return auth.handle(req, res); if (pathname.startsWith('/api/auth/')) return auth.handle(req, res);
if (pathname === '/api/mail/callback' && mail.enabled()) return mail.callback(req, res); // Microsoft sends the person back here; the one-time state says who they are if (pathname === '/api/mail/callback' && mail.outlookOn()) return mail.callback(req, res); // Microsoft sends the person back here; the one-time state says who they are
if (pathname.startsWith('/cal/feed/')) return cal.feed(req, res); // subscribe feed: protected by its own secret token, not the session if (pathname.startsWith('/cal/feed/')) return cal.feed(req, res); // subscribe feed: protected by its own secret token, not the session
// Everything else needs a passkey session, except the sign-in page and its assets, // Everything else needs a passkey session, except the sign-in page and its assets,
// and OnlyOffice's save callback (which carries its own signed token). // and OnlyOffice's save callback (which carries its own signed token).
@@ -467,7 +485,7 @@ async function handleRequest(req, res) {
const c = t => users.can(user, t); const c = t => users.can(user, t);
res.writeHead(200, { 'content-type': 'application/json' }); res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '', return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', mail: c('mail') && mail.enabled() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD })); podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
} }
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server'); if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this'); if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
@@ -485,7 +503,7 @@ async function handleRequest(req, res) {
} }
if (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server'); if (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server');
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server'); if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
if (pathname.startsWith('/api/mail/')) return mail.enabled() || pathname.startsWith('/api/mail/admin') ? mail.handle(req, res) : forbidden(res, 'Mail is not set up on this server'); if (pathname.startsWith('/api/mail/')) return mail.handle(req, res);
if (req.url.startsWith('/api/plex/')) return plexApi(req, res); if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE.test(req.url)))) return plexProxy(req, res); if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE.test(req.url)))) return plexProxy(req, res);
if (req.url.startsWith('/sf/')) return proxySeafile(req, res); if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
+6 -1
View File
@@ -147,6 +147,11 @@ async function handle(req, res) {
if (isLocal(b.list)) { const d = load(); d.tasks = d.tasks.filter(x => x.id !== b.id && x.parent !== b.id); save(d); return json(res, 200, { ok: true }); } if (isLocal(b.list)) { const d = load(); d.tasks = d.tasks.filter(x => x.id !== b.id && x.parent !== b.id); save(d); return json(res, 200, { ok: true }); }
await caldav.deleteEvent(b.list, b.href, b.etag); return json(res, 200, { ok: true }); await caldav.deleteEvent(b.list, b.href, b.etag); return json(res, 200, { ok: true });
} }
if (p === '/api/tasks/due' && req.method === 'GET') { // open tasks (not subtasks) due in a date range, from every list: for the calendar views
const from = q.get('from') || '0000-00-00', to = q.get('to') || '9999-99-99';
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) throw new Error('Bad date range');
return json(res, 200, (await allTasks()).filter(x => !x.done && !x.parent && x.due && x.due >= from && x.due <= to).slice(0, 500));
}
if (p === '/api/tasks/summary' && req.method === 'GET') { // for the Home card: open tasks that are overdue or due today if (p === '/api/tasks/summary' && req.method === 'GET') { // for the Home card: open tasks that are overdue or due today
const today = q.get('today') || new Date().toISOString().slice(0, 10), out = { overdue: 0, today: 0, open: 0 }; const today = q.get('today') || new Date().toISOString().slice(0, 10), out = { overdue: 0, today: 0, open: 0 };
const count = list => list.forEach(t => { if (t.done) return; out.open++; if (t.due && t.due < today) out.overdue++; else if (t.due === today) out.today++; }); const count = list => list.forEach(t => { if (t.done) return; out.open++; if (t.due && t.due < today) out.overdue++; else if (t.due === today) out.today++; });
@@ -161,7 +166,7 @@ async function handle(req, res) {
/** Every task from every list (NE Hub and iCloud), with its list name. */ /** Every task from every list (NE Hub and iCloud), with its list name. */
async function allTasks() { async function allTasks() {
const d = load(), out = []; const d = load(), out = [];
for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done }); } for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '' }); }
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch {} } for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch {} }
return out; return out;
} }
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// The Seafile pass-through (/sf/*): sign-in passes through, slow or flaky Seafile is handled, closed downloads stop the upstream, uploads stream.
// Uses a fake Seafile. Run: node tools/files-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 = 'files-test-secret-'.repeat(3), UID = 'ufiles';
(async () => {
const seen = []; let flaky = 0, upstreamClosed = false, uploaded = 0;
const fake = http.createServer((rq, rs) => {
const u = new URL(rq.url, 'http://x'); seen.push(rq.method + ' ' + rq.url);
const json = (c, o) => { rs.writeHead(c, { 'content-type': 'application/json' }); rs.end(JSON.stringify(o)); };
if (u.pathname === '/api2/repos/') return json(200, [{ id: 'r1', name: 'Docs' }]);
if (u.pathname === '/api2/flaky/') { if (++flaky <= 2) { rs.writeHead(503); return rs.end('busy'); } return json(200, { ok: true, after: flaky }); }
if (u.pathname === '/api2/always503/') { rs.writeHead(503, { 'retry-after': '5' }); return rs.end('down'); }
if (u.pathname === '/api2/hang/') return; // never answers
if (u.pathname === '/api2/slowstart/') return setTimeout(() => json(200, { late: true }), 200);
if (u.pathname === '/api2/echo-auth/') return json(200, { auth: rq.headers.authorization || null });
if (u.pathname === '/seafhttp/files/big') { // a long download the browser may walk away from
rs.writeHead(200, { 'content-type': 'application/octet-stream' }); rs.on('close', () => { upstreamClosed = true; });
const t = setInterval(() => rs.write(Buffer.alloc(16384)), 20); rs.on('close', () => clearInterval(t)); return;
}
if (u.pathname === '/seafhttp/files/small') { rs.writeHead(200, { 'content-type': 'text/plain', 'content-disposition': 'attachment; filename="a.txt"', etag: '"e1"' }); return rs.end('hello'); }
if (u.pathname === '/seafhttp/upload-api/tok' && rq.method === 'POST') { let n = 0; rq.on('data', d => n += d.length); rq.on('end', () => { uploaded = n; json(200, [{ name: 'x' }]); }); return; }
if (u.pathname === '/api2/redir/') { rs.writeHead(302, { location: 'http://' + rq.headers.host + '/api2/repos/' }); return rs.end(); }
rs.writeHead(404); rs.end();
});
await new Promise(r => fake.listen(0, '127.0.0.1', r));
const fakeUrl = `http://127.0.0.1:${fake.address().port}`;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-files-')), port = 20000 + Math.floor(Math.random() * 20000), base = `http://localhost:${port}`;
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [{ id: UID, name: 'Admin', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 }] }));
const exp = Date.now() + 864e5, cookie = `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, SEAFILE_URL: fakeUrl, SEAFILE_TIMEOUT_MS: '1500' } });
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 get = (p, o = {}) => fetch(base + p, { redirect: 'manual', ...o, headers: { cookie, ...(o.headers || {}) } });
let r = await get('/sf/api2/repos/', { headers: { authorization: 'Token T1' } }); ok('a Seafile call passes through', r.status === 200 && (await r.json())[0].name === 'Docs');
r = await get('/sf/api2/echo-auth/', { headers: { authorization: 'Token T1' } }); ok('with the person\'s Seafile token, which is not changed', (await r.json()).auth === 'Token T1');
r = await fetch(base + '/sf/api2/repos/'); ok('and only for someone signed in to the hub', r.status === 401, r.status);
let n0 = seen.length; r = await get('/sf/api2/flaky/'); const fj = await r.json();
ok('a Seafile that answers 503 twice is retried and the third answer is used', r.status === 200 && fj.ok === true && seen.length - n0 === 3, [r.status, seen.length - n0]);
n0 = seen.length; r = await get('/sf/api2/always503/'); ok('one that stays down gives its 503 after three tries (not a crash)', r.status === 503 && seen.length - n0 === 3, [r.status, seen.length - n0]);
const t0 = Date.now(); r = await get('/sf/api2/hang/'); const hj = await r.json();
ok('one that never answers gives a clear 504 after the time limit, instead of hanging', r.status === 504 && /too long/i.test(hj.error) && Date.now() - t0 < 12000, [r.status, hj, Date.now() - t0]);
r = await get('/sf/api2/slowstart/'); ok('a slow but working answer is still delivered', r.status === 200 && (await r.json()).late === true);
r = await get('/sf/api2/redir/'); ok('redirects stay on the hub address', r.status === 302 && r.headers.get('location') === '/sf/api2/repos/', r.headers.get('location'));
r = await get('/sf/seafhttp/files/small'); ok('a download comes through with its headers (name, tag)', r.status === 200 && (await r.text()) === 'hello' && /a\.txt/.test(r.headers.get('content-disposition')) && r.headers.get('etag') === '"e1"');
r = await get('/sf/seafhttp/files/small?hubinline=1'); ok('a preview is shown in the page instead of downloaded', !r.headers.get('content-disposition'));
const ctl = new AbortController(); r = await get('/sf/seafhttp/files/big', { signal: ctl.signal }); const rd = r.body.getReader(); await rd.read(); ctl.abort(); try { await rd.cancel(); } catch {}
for (let i = 0; i < 30 && !upstreamClosed; i++) await wait(100);
ok('when the browser walks away from a big download, the hub stops reading from Seafile', upstreamClosed === true);
const body = Buffer.alloc(3 * 1024 * 1024, 7); r = await get('/sf/seafhttp/upload-api/tok', { method: 'POST', body, headers: { 'content-type': 'application/octet-stream' } });
ok('an upload is streamed through (all bytes arrive)', r.status === 200 && uploaded === body.length, [r.status, uploaded]);
fake.close(); await wait(100);
r = await get('/sf/api2/repos/', { headers: { authorization: 'Token T1' } }); const dj = await r.json();
ok('with Seafile switched off, the answer says so (502), and the hub keeps running', r.status === 502 && /unreachable/i.test(dj.error) && child.exitCode === null, [r.status, dj]);
child.kill(); fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll Files checks passed'); process.exit(failed ? 1 : 0);
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
// Gmail and iCloud mail (IMAP + SMTP with an app password): adding accounts, reading, flags, moving, sending, replies, privacy.
// Uses a fake IMAP server (hoodiecrow-imap) and a fake SMTP server (smtp-server) on localhost. Run: node tools/mail-imap-test.js
const { spawn } = require('child_process');
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const hoodiecrow = require('hoodiecrow-imap'), { SMTPServer } = require('smtp-server'), { simpleParser } = require('mailparser'), { ImapFlow } = require('imapflow');
const MailComposer = require('nodemailer/lib/mail-composer');
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 = 'imap-test-secret-'.repeat(3), PASS = 'testpass', ME = 'testuser@example.com', USER = ME;
const free = () => 21000 + Math.floor(Math.random() * 20000);
(async () => {
// ---- messages in the fake mailbox
const build = async o => (await new MailComposer(o).compile().build()).toString();
const m1 = await build({ from: 'Ann Lee <ann@example.com>', to: ME, cc: 'bob@example.com', subject: 'Hello there', text: 'Hi there\nsecond line', date: new Date('2026-10-22T10:00:00Z'), messageId: '<hello1@example.com>' });
const m2 = await build({ from: 'Bank <no-reply@bank.example>', to: ME, subject: 'Statement', text: 'see attached', html: '<p>Your statement <img src="cid:logo1"></p><script>alert(1)</script>', date: new Date('2026-10-21T10:00:00Z'), messageId: '<stmt@bank.example>',
attachments: [{ filename: 'logo.png', content: Buffer.from('PNG!'), contentType: 'image/png', cid: 'logo1' }, { filename: 'report.pdf', content: Buffer.from('%PDF-1'), contentType: 'application/pdf' }] });
const m3 = await build({ from: 'Old Friend <old@example.com>', to: ME, subject: 'Old news', text: 'long ago', date: new Date('2026-10-01T10:00:00Z'), messageId: '<old@example.com>' });
const imapPort = free(), smtpPort = free() + 1;
const imapSrv = hoodiecrow({ users: { [ME]: { password: PASS }, 'me@icloud.com': { password: PASS } }, plugins: ['SPECIAL-USE', 'MOVE', 'UIDPLUS'], storage: {
INBOX: { messages: [{ raw: m1, flags: [] }, { raw: m2, flags: ['\\Seen'] }, { raw: m3, flags: [] }] },
'': { separator: '/', folders: { Sent: { 'special-use': '\\Sent' }, Trash: { 'special-use': '\\Trash' }, Receipts: {}, '[Gmail]': { flags: ['\\Noselect'], folders: { Spam: { 'special-use': '\\Junk' }, 'All Mail': { 'special-use': '\\All' } } } } } } });
await new Promise(r => imapSrv.listen(imapPort, r));
const sent = [];
const smtp = new SMTPServer({ authOptional: false, allowInsecureAuth: true, disabledCommands: ['STARTTLS'], authMethods: ['PLAIN', 'LOGIN'],
onAuth(a, s, cb) { [ME, 'me@icloud.com'].includes(a.username) && a.password === PASS ? cb(null, { user: a.username }) : cb(new Error('Invalid credentials')); },
onData(stream, session, cb) { const c = []; stream.on('data', d => c.push(d)); stream.on('end', async () => { sent.push({ from: session.envelope.mailFrom.address, to: session.envelope.rcptTo.map(r => r.address), parsed: await simpleParser(Buffer.concat(c)) }); cb(); }); } });
await new Promise(r => smtp.listen(smtpPort, '127.0.0.1', r));
const peek = async (box) => { const c = new ImapFlow({ host: '127.0.0.1', port: imapPort, secure: false, doSTARTTLS: false, auth: { user: USER, pass: PASS }, logger: false }); await c.connect(); try { return (await c.status(box, { messages: true, unseen: true })); } finally { await c.logout(); } };
// ---- the hub, with Nate (admin) and Ann (a mail user with no accounts)
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-imap-')), port = free() + 2, 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: '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 env = { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, MAIL_TEST_SERVERS: JSON.stringify({ imap: { host: '127.0.0.1', port: imapPort, secure: false }, smtp: { host: '127.0.0.1', port: smtpPort } }) }; delete env.MS_CLIENT_ID;
const child = spawn(process.execPath, ['server.js'], { cwd: root, env }); 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, body, method) => { const r = await fetch(base + p, { method: method || (body ? 'POST' : 'GET'), headers: { cookie: ck(uid), '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, json: j, headers: r.headers, text: t }; };
const A = (p, b, m) => call('unate', p, b, m);
// ================= adding accounts =================
let r = await A('/api/mail/status'); ok('before adding anything: not connected, and the hub offers Gmail and iCloud', r.json.connected === false && r.json.providers.gmail && r.json.providers.icloud, r.json);
r = await A('/api/mail/accounts', { type: 'gmail', address: ME, password: 'wrongpassword1' }); ok('a wrong app password is refused with advice for Gmail', r.status === 401 && /app password/i.test(r.json.error) && /Google/.test(r.json.error), r);
r = await A('/api/mail/accounts', { type: 'icloud', address: ME, password: 'wrongpassword1' }); ok('and with advice for iCloud', r.status === 401 && /Apple/.test(r.json.error) && /app-specific/i.test(r.json.error), r);
r = await A('/api/mail/accounts', { type: 'gmail', address: 'nonsense', password: PASS }); ok('a malformed address is refused', r.status === 400, r.json);
r = await A('/api/mail/accounts', { type: 'yahoo', address: ME, password: PASS }); ok('an unknown provider is refused', r.status === 400, r.json);
r = await A('/api/mail/accounts', { type: 'gmail', address: ME, password: PASS }); const gm = r.json.account;
ok('the right app password connects a Gmail account', r.status === 200 && gm && gm.type === 'gmail' && gm.address === ME, r);
ok('the password is never sent back', !r.text.includes(PASS) && !(await A('/api/mail/status')).text.includes(PASS));
ok('and is stored encrypted', !fs.readFileSync(path.join(dir, 'users', 'unate', 'mail-accounts.json'), 'utf8').includes(PASS));
r = await A('/api/mail/status'); ok('status lists the account with its unread count (2 unread in the inbox)', r.json.connected && r.json.accounts.length === 1 && r.json.accounts[0].unread === 2 && r.json.unread === 2, r.json);
// ================= reading =================
r = await A('/api/mail/folders?acct=' + gm.id); const names = r.json.map(f => f.name);
ok('folders: Inbox, Sent, Archive (Gmail "All Mail"), Junk, Deleted, then your own; the [Gmail] container is hidden', names.join() === 'Inbox,Sent,Archive,Junk,Deleted,Receipts' && r.json[0].unread === 2 && r.json[0].id.startsWith(gm.id + '~'), r.json);
r = await A('/api/mail/messages?folder=' + gm.id + '~inbox'); const items = r.json.items;
ok('the inbox lists messages newest first with sender, read state and attachment flag', items.length === 3 && items[0].subject === 'Hello there' && items[0].from.address === 'ann@example.com' && items[0].read === false && items[1].subject === 'Statement' && items[1].read === true && items[1].attach === true && items[0].attach === false, items);
ok('ids carry the account', items.every(m => m.id.startsWith(gm.id + '~')), items.map(m => m.id));
r = await A('/api/mail/messages?folder=inbox'); ok('a folder with no account in it means the first account (older links)', r.json.items.length === 3, r.json);
r = await A('/api/mail/messages?folder=' + gm.id + '~inbox&q=statement'); ok('search finds a message', r.json.items.length === 1 && r.json.items[0].subject === 'Statement', r.json);
r = await A('/api/mail/messages?folder=' + gm.id + '~p-' + Buffer.from('Nope').toString('base64url')); ok('a folder that does not exist is a 404, not a crash', r.status === 404, r);
const helloId = items[0].id, stmtId = items[1].id, oldId = items[2].id;
r = await A('/api/mail/message?id=' + encodeURIComponent(helloId)); const hm = r.json;
ok('a message opens with sender, recipients (to and cc), date and text', hm.subject === 'Hello there' && hm.to[0].address === ME && hm.cc[0].address === 'bob@example.com' && /Hi there/.test(hm.html) && hm.attachments.length === 0, hm);
r = await A('/api/mail/message?id=' + encodeURIComponent(stmtId)); const sm = r.json;
ok('an inline picture is put in the page (cid -> data), and only real attachments are listed', /data:image\/png;base64,/.test(sm.html) && !/cid:logo1/.test(sm.html) && sm.attachments.length === 1 && sm.attachments[0].name === 'report.pdf', sm);
r = await A('/api/mail/attachment?id=' + encodeURIComponent(stmtId) + '&aid=' + sm.attachments[0].id); ok('an attachment downloads as a file', r.status === 200 && r.text === '%PDF-1' && /filename="report\.pdf"/.test(r.headers.get('content-disposition')), [r.status, r.text]);
r = await A('/api/mail/message?id=' + encodeURIComponent('abc~' + helloId.split('~')[1])); ok('an unknown account in an id is a 404', r.status === 404, r.status);
r = await A('/api/mail/message?id=' + encodeURIComponent(gm.id + '~garbage')); ok('a damaged id is a 400', r.status === 400, r.status);
// ================= flags, moving, deleting =================
await A('/api/mail/update', { id: helloId, read: true }); r = await A('/api/mail/messages?folder=' + gm.id + '~inbox'); ok('mark as read', r.json.items[0].read === true, r.json.items[0]);
await A('/api/mail/update', { id: helloId, read: false, flagged: true }); r = await A('/api/mail/messages?folder=' + gm.id + '~inbox'); ok('mark unread and flag', r.json.items[0].read === false && r.json.items[0].flagged === true, r.json.items[0]);
r = await A('/api/mail/update', { id: helloId }); ok('an update with nothing to change is refused', r.status === 400, r.status);
const archBefore = (await peek('[Gmail]/All Mail')).messages;
r = await A('/api/mail/move', { id: oldId, to: 'archive' }); ok('archive moves a message to All Mail', r.status === 200 && (await peek('[Gmail]/All Mail')).messages === archBefore + 1 && (await peek('INBOX')).messages === 2, r);
const trashBefore = (await peek('Trash')).messages;
r = await A('/api/mail/delete', { id: stmtId }); ok('delete moves a message to Trash', r.status === 200 && (await peek('Trash')).messages === trashBefore + 1 && (await peek('INBOX')).messages === 1, r);
r = await A('/api/mail/messages?folder=' + gm.id + '~deleteditems'); const trashed = r.json.items[0];
r = await A('/api/mail/delete', { id: trashed.id, permanent: true }); ok('and from Trash it is removed for good', r.status === 200 && (await peek('Trash')).messages === trashBefore, r);
// ================= sending =================
sent.length = 0; const sentBefore = (await peek('Sent')).messages;
r = await A('/api/mail/send', { acct: gm.id, to: 'bob@example.com, Carol <carol@example.com>', subject: 'Plans', body: 'Line 1\n<b>not bold</b>', attachments: [{ name: 'a.txt', type: 'text/plain', data: Buffer.from('hello').toString('base64') }] });
const s1 = sent[0];
ok('send goes out over SMTP from the account (several recipients, text turned into safe HTML, attachment kept)', r.status === 200 && s1 && s1.from === ME && s1.to.join() === 'bob@example.com,carol@example.com' && s1.parsed.subject === 'Plans' && /not bold/.test(s1.parsed.text) && /&lt;b&gt;not bold/.test(s1.parsed.html) && s1.parsed.attachments[0].filename === 'a.txt', [r, s1 && s1.to]);
ok('Gmail keeps its own copy, so none is added to Sent here', (await peek('Sent')).messages === sentBefore, (await peek('Sent')).messages);
r = await A('/api/mail/send', { acct: gm.id, to: 'nobody', subject: 'x', body: 'y' }); ok('a bad address is refused with its name', r.status === 400 && /nobody/.test(r.json.error), r.json);
r = await A('/api/mail/send', { acct: gm.id, to: '', subject: 'x', body: 'y' }); ok('no recipient is refused', r.status === 400, r.json);
r = await A('/api/mail/send', { acct: gm.id, to: 'a@b.co', subject: 'x', body: 'y', attachments: [{ name: 'big', 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);
// ================= replies =================
sent.length = 0; r = await A('/api/mail/reply', { id: helloId, mode: 'reply', comment: 'Thanks Ann' }); const rp = sent[0];
ok('reply goes to the sender, keeps the thread headers and quotes the original', r.status === 200 && rp.to.join() === 'ann@example.com' && rp.parsed.subject === 'Re: Hello there' && rp.parsed.inReplyTo === '<hello1@example.com>' && /Thanks Ann/.test(rp.parsed.text) && /> Hi there/.test(rp.parsed.text), [r, rp && rp.parsed.inReplyTo, rp && rp.parsed.text]);
sent.length = 0; r = await A('/api/mail/reply', { id: helloId, mode: 'replyAll', comment: 'All' }); const ra = sent[0];
ok('reply all adds the others on the message but not yourself', r.status === 200 && ra.to.includes('ann@example.com') && ra.to.includes('bob@example.com') && !ra.to.includes(ME), ra && ra.to);
sent.length = 0; r = await A('/api/mail/reply', { id: helloId, mode: 'forward', comment: 'FYI', to: 'x@y.org' }); const rf = sent[0];
ok('forward needs a recipient and sends the original', r.status === 200 && rf.to.join() === 'x@y.org' && rf.parsed.subject === 'Fwd: Hello there' && /Forwarded message/.test(rf.parsed.text) && /Hi there/.test(rf.parsed.text), rf && rf.parsed.text);
r = await A('/api/mail/reply', { id: helloId, mode: 'forward', comment: 'FYI' }); ok('forward with no recipient is refused', r.status === 400, r.json);
r = await A('/api/mail/reply', { id: helloId, mode: 'bogus' }); ok('an unknown action is refused', r.status === 400, r.json);
// ================= a second account (iCloud), several accounts =================
r = await A('/api/mail/accounts', { type: 'icloud', address: 'me@icloud.com', password: PASS }); ok('an iCloud account connects the same way', r.status === 200 && r.json.account.type === 'icloud', r);
const ic = r.json.account;
r = await A('/api/mail/status'); ok('both accounts are listed and the unread counts add up', r.json.accounts.length === 2 && r.json.unread === r.json.accounts.reduce((n, x) => n + x.unread, 0), r.json);
const sentB = (await peek('Sent')).messages; sent.length = 0;
r = await A('/api/mail/send', { acct: ic.id, to: 'bob@example.com', subject: 'From iCloud', body: 'hi' });
ok('iCloud sends as that address', r.status === 200 && sent[0].from === 'me@icloud.com', [r, sent[0] && sent[0].from]);
ok('and iCloud does not keep a copy itself, so one is added to Sent', (await peek('Sent')).messages === sentB + 1, [(await peek('Sent')).messages, sentB]);
// ================= privacy and removal =================
r = await call('uann', '/api/mail/status'); ok('another person sees no accounts', r.json.connected === false && r.json.accounts.length === 0, r.json);
r = await call('uann', '/api/mail/message?id=' + encodeURIComponent(helloId)); ok("and cannot open Nate's messages with his ids", r.status === 409 || r.status === 404, r.status);
r = await call('uann', '/api/mail/send', { acct: gm.id, to: 'a@b.co', subject: 'x', body: 'y' }); ok("or send from Nate's account", r.status === 404 || r.status === 409, r.status);
r = await A('/api/mail/disconnect', { acct: ic.id }); r = await A('/api/mail/status'); ok('disconnecting one account leaves the other', r.json.accounts.length === 1 && r.json.accounts[0].id === gm.id, r.json);
r = await A('/api/mail/disconnect', { acct: 'nope' }); ok('disconnecting an unknown account is a 404', r.status === 404, r.status);
r = await A('/api/mail/disconnect', {}); r = await A('/api/mail/status'); ok('disconnect with no account removes them all', r.json.connected === false, r.json);
r = await A('/api/audit'); const evs = (r.json.events || []).map(e => e.event); ok('connecting and sending are in the activity log (no passwords, no text)', evs.includes('mail connected') && evs.includes('mail sent') && !JSON.stringify(r.json).includes(PASS) && !JSON.stringify(r.json).includes('not bold'), evs);
ok('the server is still running', child.exitCode === null, log.slice(-300));
child.kill(); imapSrv.close(); smtp.close(); fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll IMAP mail checks passed'); process.exit(failed ? 1 : 0);
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });
+7 -8
View File
@@ -96,7 +96,7 @@ const SECRET = 'mail-test-secret-'.repeat(3);
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 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(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); 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)); const raw = fs.readFileSync(path.join(dir, 'users', 'unate', 'mail-accounts.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/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); r = await call('uann', '/api/mail/messages'); ok("and cannot read it", r.status === 409, r.status);
@@ -105,7 +105,7 @@ const SECRET = 'mail-test-secret-'.repeat(3);
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); 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); 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); 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=inbox&q=second'); ok('search finds a message', r.json.items.length === 1 && /~M2$/.test(r.json.items[0].id), 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/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; 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('a message opens with sender, recipients and subject', r.status === 200 && mm.subject === 'Hello there' && mm.to[0].address === 'nate@outlook.com', mm);
@@ -138,12 +138,11 @@ const SECRET = 'mail-test-secret-'.repeat(3);
ms.revoked = true; ms.goodTokens = new Set(); 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/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); 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 // 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'); 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); 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 post('unate', '/api/mail/disconnect'); r = await call('unate', '/api/mail/status'); ok('disconnect removes the sign-in', r.json.connected === false, 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', '/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); 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);
// ================= admin: Microsoft app settings (environment version) ================= // ================= admin: Microsoft app settings (environment version) =================
@@ -162,8 +161,8 @@ const SECRET = 'mail-test-secret-'.repeat(3);
const child2 = spawn(process.execPath, ['server.js'], { cwd: root, env: env2 }); child2.stderr.on('data', () => {}); child2.stdout.on('data', () => {}); const child2 = spawn(process.execPath, ['server.js'], { cwd: root, env: env2 }); child2.stderr.on('data', () => {}); child2.stdout.on('data', () => {});
for (let i = 0; i < 60; i++) { try { await fetch(base2 + '/login'); break; } catch { await wait(100); } } for (let i = 0; i < 60; i++) { try { await fetch(base2 + '/login'); break; } catch { await wait(100); } }
const call2 = async (uid, p, body, method) => { const rr = await fetch(base2 + p, { method: method || (body ? 'POST' : 'GET'), headers: { cookie: ck(uid), 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); const tx = await rr.text(); let j; try { j = JSON.parse(tx); } catch { j = tx; } return { status: rr.status, json: j }; }; const call2 = async (uid, p, body, method) => { const rr = await fetch(base2 + p, { method: method || (body ? 'POST' : 'GET'), headers: { cookie: ck(uid), 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); const tx = await rr.text(); let j; try { j = JSON.parse(tx); } catch { j = tx; } return { status: rr.status, json: j }; };
r = await call2('unate', '/api/config'); ok('without settings: Mail is off for everyone, but the admin can reach the setup', r.json.mail === '' && r.json.mailAdmin === '1', r.json); r = await call2('unate', '/api/config'); ok('without the Microsoft app: Mail stays on (Gmail and iCloud need no setup) but there is no Outlook.com button, and the admin can reach the setup', r.json.mail === '1' && r.json.outlook === '' && r.json.mailAdmin === '1', r.json);
r = await call2('uann', '/api/mail/status'); ok('and ordinary mail requests say it is not set up', r.status === 403, r.status); r = await call2('uann', '/api/mail/connect'); ok('and connecting Outlook.com says it is not set up', r.status === 403, r.status);
r = await call2('unate', '/api/mail/admin'); ok('the setup page reports nothing saved yet', r.json.source === 'none' && r.json.hasSecret === false, r.json); r = await call2('unate', '/api/mail/admin'); ok('the setup page reports nothing saved yet', r.json.source === 'none' && r.json.hasSecret === false, r.json);
r = await call2('unate', '/api/mail/admin', { clientId: 'not-a-guid' }); ok('a malformed client ID is refused with an explanation', r.status === 400 && /36-character/.test(r.json.error), r.json); r = await call2('unate', '/api/mail/admin', { clientId: 'not-a-guid' }); ok('a malformed client ID is refused with an explanation', r.status === 400 && /36-character/.test(r.json.error), r.json);
const soon = new Date(Date.now() + 10 * 864e5).toISOString().slice(0, 10); const soon = new Date(Date.now() + 10 * 864e5).toISOString().slice(0, 10);
@@ -171,7 +170,7 @@ const SECRET = 'mail-test-secret-'.repeat(3);
ok('the secret is never sent back', !JSON.stringify(r.json).includes('S3CRET') && !JSON.stringify((await call2('unate', '/api/mail/admin')).json).includes('S3CRET')); ok('the secret is never sent back', !JSON.stringify(r.json).includes('S3CRET') && !JSON.stringify((await call2('unate', '/api/mail/admin')).json).includes('S3CRET'));
ok('and is stored encrypted', !fs.readFileSync(path.join(dir2, 'mail-config.json'), 'utf8').includes('S3CRET')); ok('and is stored encrypted', !fs.readFileSync(path.join(dir2, 'mail-config.json'), 'utf8').includes('S3CRET'));
r = await call2('unate', '/api/mail/admin'); ok('days until the secret expires are reported (about 10)', r.json.days >= 9 && r.json.days <= 11, r.json); r = await call2('unate', '/api/mail/admin'); ok('days until the secret expires are reported (about 10)', r.json.days >= 9 && r.json.days <= 11, r.json);
r = await call2('unate', '/api/config'); ok('saving switches Mail on at once, with no restart', r.json.mail === '1', r.json); r = await call2('unate', '/api/config'); ok('saving switches the Outlook.com button on at once, with no restart', r.json.outlook === '1', r.json);
ms.secret = 'OTHER'; { const c1 = await call2('unate', '/api/mail/connect'); const q1 = new URL(c1.json.url); ms.challenge = q1.searchParams.get('code_challenge'); ms.secret = 'OTHER'; { const c1 = await call2('unate', '/api/mail/connect'); const q1 = new URL(c1.json.url); ms.challenge = q1.searchParams.get('code_challenge');
const rr = await fetch(base2 + '/api/mail/callback?code=GOODCODE&state=' + q1.searchParams.get('state'), { redirect: 'manual' }); const tx = await rr.text(); const rr = await fetch(base2 + '/api/mail/callback?code=GOODCODE&state=' + q1.searchParams.get('state'), { redirect: 'manual' }); const tx = await rr.text();
ok('a wrong secret shows up when connecting, explained in plain words', rr.status === 400 && /secret is wrong/i.test(tx) && /Value/.test(tx), [rr.status, tx.slice(-200)]); ok('a wrong secret shows up when connecting, explained in plain words', rr.status === 400 && /secret is wrong/i.test(tx) && /Value/.test(tx), [rr.status, tx.slice(-200)]);
@@ -186,7 +185,7 @@ const SECRET = 'mail-test-secret-'.repeat(3);
r = await call2('unate', '/api/mail/admin', { clientSecret: 'NEWSECRET' }); ok('but a new secret has to be proven again', !r.json.verifiedAt, r.json); } r = await call2('unate', '/api/mail/admin', { clientSecret: 'NEWSECRET' }); ok('but a new secret has to be proven again', !r.json.verifiedAt, r.json); }
r = await call2('unate', '/api/mail/admin/test', {}); ok('(the old test button endpoint is gone: Microsoft cannot check an app without a real sign-in)', r.status === 404 || r.status === 400, r.status); r = await call2('unate', '/api/mail/admin/test', {}); ok('(the old test button endpoint is gone: Microsoft cannot check an app without a real sign-in)', r.status === 404 || r.status === 400, r.status);
r = await call2('uann', '/api/mail/admin', { clientId: 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb' }); ok('only an admin can save', r.status === 403, r.status); r = await call2('uann', '/api/mail/admin', { clientId: 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb' }); ok('only an admin can save', r.status === 403, r.status);
r = await call2('unate', '/api/mail/admin', { clientId: '' }); r = await call2('unate', '/api/mail/admin', { clearSecret: true }); r = await call2('unate', '/api/config'); ok('removing the settings switches Mail off again', r.json.mail === '' && !fs.existsSync(path.join(dir2, 'mail-config.json')), r.json); r = await call2('unate', '/api/mail/admin', { clientId: '' }); r = await call2('unate', '/api/mail/admin', { clearSecret: true }); r = await call2('unate', '/api/config'); ok('removing the settings switches the Outlook.com button off again', r.json.outlook === '' && !fs.existsSync(path.join(dir2, 'mail-config.json')), r.json);
r = await call2('unate', '/api/audit'); ok('changes to the setup are in the activity log', (r.json.events || []).some(e => e.event === 'mail setup changed'), r.json); r = await call2('unate', '/api/audit'); ok('changes to the setup are in the activity log', (r.json.events || []).some(e => e.event === 'mail setup changed'), r.json);
child2.kill(); fs.rmSync(dir2, { recursive: true, force: true }); child2.kill(); fs.rmSync(dir2, { recursive: true, force: true });
+9
View File
@@ -36,6 +36,15 @@ const wait = ms => new Promise(r => setTimeout(r, ms));
ok('a missing parent is refused', r.status === 400, r); ok('a missing parent is refused', r.status === 400, r);
r = await call('/api/tasks/delete', { list: L, id: rep.id }); r = await call('/api/tasks/delete', { list: L, id: rep.id });
ts = await list(); ok('deleting the parent deletes its subtasks', !ts.some(t => t.id === rep.id || t.id === sub.id), ts); ts = await list(); ok('deleting the parent deletes its subtasks', !ts.some(t => t.id === rep.id || t.id === sub.id), ts);
// tasks due in a date range, for the calendar
await call('/api/tasks', { list: L, fields: { title: 'Due soon', notes: '', due: plus(2), done: false, repeat: 'weekly' } });
await call('/api/tasks', { list: L, fields: { title: 'Due later', notes: '', due: plus(40), done: false } });
await call('/api/tasks', { list: L, fields: { title: 'Done already', notes: '', due: plus(2), done: true } });
ts = await list(); const soonT = ts.find(t => t.title === 'Due soon');
await call('/api/tasks', { list: L, fields: { title: 'A subtask', notes: '', due: plus(2), done: false, parent: soonT.id } });
r = await call(`/api/tasks/due?from=${plus(0)}&to=${plus(7)}`);
ok('the calendar gets open tasks due in the range (not done ones, subtasks or other dates)', r.status === 200 && r.json.length === 1 && r.json[0].title === 'Due soon' && r.json[0].repeat === 'weekly' && r.json[0].listName, r.json);
r = await call('/api/tasks/due?from=bad&to=worse'); ok('a bad date range is refused', r.status === 400, r.status);
ok('the server is still running', child.exitCode === null); ok('the server is still running', child.exitCode === null);
child.kill(); fs.rmSync(dir, { recursive: true, force: true }); child.kill(); fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll task checks passed'); process.exit(failed ? 1 : 0); console.log(failed ? `\n${failed} check(s) failed` : '\nAll task checks passed'); process.exit(failed ? 1 : 0);