Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cedd6845ce | ||
|
|
004209ed4f | ||
|
|
4173d2c058 | ||
|
|
b8ab84a009 | ||
|
|
8d38584d37 | ||
|
|
b305ccac51 | ||
|
|
7ac843fea8 |
No files matched your search
@@ -26,6 +26,8 @@ jobs:
|
||||
- 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/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
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
|
||||
@@ -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.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -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/`.
|
||||
- **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.
|
||||
- **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).
|
||||
**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.
|
||||
- **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.
|
||||
- **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)
|
||||
|
||||
|
||||
+253
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
|
||||
@@ -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).
|
||||
// Each person connects their own account: they sign in on Microsoft's page, the hub keeps only the tokens (encrypted, in their own folder),
|
||||
// and every mail request runs as that person. Needs an app registration (MS_CLIENT_ID, see README -> Mail).
|
||||
// Mail: several accounts per person. Outlook.com (personal) goes through Microsoft Graph (sign in on Microsoft's page, needs an app registration,
|
||||
// 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
|
||||
// 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 fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -8,6 +8,7 @@ const atomicWrite = require('./atomic');
|
||||
const users = require('./users');
|
||||
const audit = require('./audit');
|
||||
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 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 clientId = () => ENV_ID || loadCfg().clientId || '';
|
||||
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. */
|
||||
function secretStatus() {
|
||||
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 fail = (status, message) => Object.assign(new Error(message), { status });
|
||||
|
||||
// ---- per-person token file (encrypted)
|
||||
const file = () => path.join(users.dir(), 'mail.json');
|
||||
const load = () => { try { return vault.unseal(JSON.parse(fs.readFileSync(file(), 'utf8')).data) || null; } catch { return null; } };
|
||||
const save = o => atomicWrite(file(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 });
|
||||
const wipe = () => { try { fs.unlinkSync(file()); } catch {} };
|
||||
// ---- each person's accounts (encrypted): [{ id, type: 'outlook' | 'gmail' | 'icloud', address, name, ...credentials }]
|
||||
const accFile = () => path.join(users.dir(), 'mail-accounts.json');
|
||||
const legacyFile = () => path.join(users.dir(), 'mail.json'); // one Outlook.com account, from before there were several
|
||||
const accounts = () => {
|
||||
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)
|
||||
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');
|
||||
await users.runAs(user, async () => {
|
||||
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
|
||||
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)))); }
|
||||
}
|
||||
|
||||
const refreshing = new Map(); // uid -> 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. */
|
||||
async function token(force, stale) {
|
||||
const st = load(); if (!st) throw fail(409, 'Mail is not connected');
|
||||
const refreshing = new Map(); // account id -> promise: parallel requests share one refresh (refresh tokens can rotate)
|
||||
/** A working access token for an Outlook account, refreshed when needed. `stale` is the token that just failed. */
|
||||
async function token(acct, force, stale) {
|
||||
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.access !== stale) return st.access; // another request already refreshed it
|
||||
const uid = users.current().id;
|
||||
if (refreshing.has(uid)) return refreshing.get(uid);
|
||||
if (refreshing.has(st.id)) return refreshing.get(st.id);
|
||||
const job = (async () => {
|
||||
try {
|
||||
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;
|
||||
} 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);
|
||||
}
|
||||
})().finally(() => refreshing.delete(uid));
|
||||
refreshing.set(uid, job);
|
||||
})().finally(() => refreshing.delete(st.id));
|
||||
refreshing.set(st.id, job);
|
||||
return job;
|
||||
}
|
||||
async function graph(p, opts = {}, stale) {
|
||||
const used = await token(stale !== undefined, stale);
|
||||
async function graph(acct, p, opts = {}, 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) });
|
||||
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 (opts.raw && r.ok) return r;
|
||||
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 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 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 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 }
|
||||
async function folders() {
|
||||
const uid = users.current().id, c = folderCache.get(uid);
|
||||
if (c && Date.now() - c.t < 15e3) return c.v;
|
||||
let folderCache = new Map(); // account id -> { t, v }
|
||||
const dropFolderCache = id => id ? folderCache.delete(id) : folderCache.clear();
|
||||
|
||||
// ---- 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(`/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 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(`/me/mailFolders${sel}&$top=100`).catch(() => ({ value: [] }))).value || [];
|
||||
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(uid, { t: Date.now(), v }); return v;
|
||||
}
|
||||
const dropFolderCache = () => folderCache.delete(users.current().id);
|
||||
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(html, attachments, mid) {
|
||||
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)
|
||||
const inl = (attachments || []).filter(a => a.isInline && /^image\//i.test(a.contentType || '') && (a.size || 0) < 1.5 * 1024 * 1024).slice(0, 15);
|
||||
for (const a of inl) {
|
||||
const inl = (attachments || []).filter(x => x.isInline && /^image\//i.test(x.contentType || '') && (x.size || 0) < 1.5 * 1024 * 1024).slice(0, 15);
|
||||
for (const x of inl) {
|
||||
try {
|
||||
const full = await graph(`/me/messages/${id(mid)}/attachments/${id(a.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}`);
|
||||
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 || x.contentType};base64,${full.contentBytes}`);
|
||||
} catch { /* leave that picture out */ }
|
||||
}
|
||||
return html;
|
||||
@@ -186,72 +246,77 @@ async function adminHandle(req, res, p) {
|
||||
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) {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
|
||||
try {
|
||||
if (p === '/api/mail/admin' || p.startsWith('/api/mail/admin/')) return await adminHandle(req, res, p);
|
||||
if (p === '/api/mail/status') {
|
||||
const st = load();
|
||||
if (!st) return json(res, 200, { connected: false });
|
||||
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 }); }
|
||||
return json(res, 200, { connected: true, address: st.address, name: st.name, unread });
|
||||
if (p === '/api/mail/status') return json(res, 200, { ...(await overview()), outlook: outlookOn(), providers: imap.PRESETS });
|
||||
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 (p === '/api/mail/folders') {
|
||||
const a = findAccount(q.get('acct') || undefined);
|
||||
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') {
|
||||
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 sel = '$select=id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview,flag';
|
||||
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'));
|
||||
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 });
|
||||
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 r = await providerOf(a).list(a, { folder: fid || 'inbox', skip, q: search });
|
||||
return json(res, 200, { ...r, items: r.items.map(m => ({ ...m, id: pack(a, m.id) })) });
|
||||
}
|
||||
if (p === '/api/mail/message') {
|
||||
const mid = q.get('id');
|
||||
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)`);
|
||||
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 })) });
|
||||
const [a, nid] = unpack(q.get('id')), m = await providerOf(a).message(a, nid);
|
||||
return json(res, 200, { ...m, id: pack(a, m.id), account: publicAcct(a) });
|
||||
}
|
||||
if (p === '/api/mail/attachment') {
|
||||
const mid = q.get('id'), aid = q.get('aid');
|
||||
const meta = await graph(`/me/messages/${id(mid)}/attachments/${id(aid)}?$select=name,contentType,size`);
|
||||
const r = await graph(`/me/messages/${id(mid)}/attachments/${id(aid)}/$value`, { raw: true });
|
||||
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()));
|
||||
const [a, nid] = unpack(q.get('id')), f = await providerOf(a).attachment(a, nid, q.get('aid'));
|
||||
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' });
|
||||
return res.end(f.buffer);
|
||||
}
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||
const b = JSON.parse((await readBody(req)) || '{}');
|
||||
if (p === '/api/mail/update') {
|
||||
const patch = {}; if (typeof b.read === 'boolean') patch.isRead = b.read; if (typeof b.flagged === 'boolean') patch.flag = { flagStatus: b.flagged ? 'flagged' : 'notFlagged' };
|
||||
if (!Object.keys(patch).length) throw fail(400, 'Nothing to change');
|
||||
await graph(`/me/messages/${id(b.id)}`, { method: 'PATCH', body: JSON.stringify(patch) }); dropFolderCache(); return json(res, 200, { ok: true });
|
||||
const b = JSON.parse((await readBody(req)) || '{}'), me = users.current();
|
||||
if (p === '/api/mail/accounts') { // add a Gmail or iCloud account (an app password; checked against the server before it is saved)
|
||||
const a = await imap.connectNew({ type: b.type, address: b.address, password: b.password });
|
||||
const same = accounts().find(x => x.type === a.type && x.address.toLowerCase() === a.address.toLowerCase());
|
||||
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/delete') {
|
||||
if (b.permanent) await graph(`/me/messages/${id(b.id)}`, { method: 'DELETE' });
|
||||
else await graph(`/me/messages/${id(b.id)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: 'deleteditems' }) });
|
||||
dropFolderCache(); return json(res, 200, { ok: true });
|
||||
if (p === '/api/mail/disconnect') {
|
||||
const list = accounts(), gone = b.acct ? list.filter(x => x.id === b.acct) : list;
|
||||
if (b.acct && !gone.length) throw fail(404, 'That account is not connected');
|
||||
for (const x of gone) dropAccount(x.id);
|
||||
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') {
|
||||
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');
|
||||
const subject = String(b.subject || '').slice(0, 255), text = String(b.body || ''); if (text.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 || '') }; });
|
||||
if (total > MAX_ATTACH) throw fail(400, 'Attachments can total about 3 MB');
|
||||
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 });
|
||||
const subject = String(b.subject || '').slice(0, 255), body = String(b.body || ''); if (body.length > 200000) throw fail(400, 'That message is too long');
|
||||
await providerOf(a).send(a, { to, cc, bcc, subject, body, attachments: cleanAttachments(b.attachments) });
|
||||
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 });
|
||||
}
|
||||
if (p === '/api/mail/reply') {
|
||||
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) };
|
||||
if (mode === 'forward') { body.toRecipients = people(b.to, 'To'); if (!body.toRecipients.length) throw fail(400, 'Add at least one recipient'); }
|
||||
await graph(`/me/messages/${id(b.id)}/${mode}`, { method: 'POST', body: JSON.stringify(body) });
|
||||
audit.log('mail sent', { who: users.current().name, detail: mode }, req); dropFolderCache(); return json(res, 200, { ok: true });
|
||||
const [a, nid] = unpack(b.id), to = mode === 'forward' ? recipients(b.to, 'To') : [];
|
||||
if (mode === 'forward' && !to.length) throw fail(400, 'Add at least one recipient');
|
||||
await providerOf(a).reply(a, { id: nid, mode, comment: String(b.comment || '').slice(0, 200000), to });
|
||||
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' });
|
||||
} 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 };
|
||||
Generated
+652
@@ -10,7 +10,14 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^14.0.3",
|
||||
"imapflow": "^2.9.0",
|
||||
"mailparser": "^3.12.2",
|
||||
"nodemailer": "^10.2.0",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"hoodiecrow-imap": "^3.3.1",
|
||||
"smtp-server": "^3.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
@@ -258,6 +265,28 @@
|
||||
"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": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.3.tgz",
|
||||
@@ -279,6 +308,20 @@
|
||||
"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": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -314,6 +357,15 @@
|
||||
"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": {
|
||||
"version": "4.12.5",
|
||||
"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": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
@@ -352,6 +478,129 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
@@ -374,12 +623,73 @@
|
||||
"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": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
@@ -401,6 +711,110 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
@@ -422,6 +836,108 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"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": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
@@ -440,6 +956,21 @@
|
||||
"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": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
@@ -466,12 +997,127 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"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": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -496,6 +1142,12 @@
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"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": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
|
||||
+8
-1
@@ -5,7 +5,7 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -14,6 +14,13 @@
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^14.0.3",
|
||||
"imapflow": "^2.9.0",
|
||||
"mailparser": "^3.12.2",
|
||||
"nodemailer": "^10.2.0",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"hoodiecrow-imap": "^3.3.1",
|
||||
"smtp-server": "^3.21.0"
|
||||
}
|
||||
}
|
||||
+59
-27
@@ -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.
|
||||
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). */
|
||||
async function loadSettings() {
|
||||
try {
|
||||
@@ -100,11 +100,23 @@ let cfg = { mail: '', podcast: 'https://podcast.necloud.us/', verse: 'https://ve
|
||||
// ---- Seafile client (via same-origin proxy)
|
||||
const sf = {
|
||||
token: () => store.get('sf_token'),
|
||||
async req(path, opts = {}) {
|
||||
const headers = { Authorization: 'Token ' + sf.token(), ...(opts.headers || {}) };
|
||||
const r = await fetch('/sf' + path, { ...opts, headers });
|
||||
/** 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
|
||||
* password again in place (sfReauth) and then carries on, so you do not lose where you were. */
|
||||
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.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();
|
||||
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 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']);
|
||||
@@ -130,9 +147,18 @@ const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: '
|
||||
|
||||
// ---- 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 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 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)');
|
||||
let activeTab = 'home';
|
||||
|
||||
@@ -143,26 +169,27 @@ function renderNav(active = activeTab) {
|
||||
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 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) {
|
||||
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('') +
|
||||
(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(); };
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
phone.addEventListener('change', () => renderNav());
|
||||
|
||||
function openMore() {
|
||||
const el = openSheet('More', '', `<div class="list">${TABS.filter(t => !PRIMARY.includes(t[0]) && allowed(t[0])).map(([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>`).join('')}</div>`);
|
||||
const rest = navTabs().filter(t => !PRIMARY.includes(t[0]));
|
||||
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));
|
||||
}
|
||||
|
||||
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;
|
||||
// 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 {}; } };
|
||||
@@ -206,7 +233,7 @@ function route() {
|
||||
document.body.classList.remove('inedit', 'onaudio');
|
||||
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
|
||||
renderNav(name === 'edit' ? 'files' : name);
|
||||
renderNav(name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : name);
|
||||
drawNav();
|
||||
routes[name](rest.map(decodeURIComponent));
|
||||
sgDockSync(name);
|
||||
@@ -219,6 +246,23 @@ function tile(href, color, ic, title, sub, external) {
|
||||
}
|
||||
|
||||
// ---- 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() {
|
||||
const h = new Date().getHours();
|
||||
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="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>` : ''}
|
||||
<h2>Apps</h2><div class="grid">
|
||||
${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>
|
||||
${homeApps()}
|
||||
${allowed('mail') && cfg.mail ? '<div id="mcard"></div>' : ''}</div>`;
|
||||
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
|
||||
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
${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></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)); }
|
||||
|
||||
+29
-14
@@ -1,11 +1,12 @@
|
||||
'use strict';
|
||||
// ---- 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')}`;
|
||||
async function loadRange(start, days) {
|
||||
async function loadRange(start, days, withTasks = true) {
|
||||
const end = new Date(start); end.setDate(end.getDate() + days);
|
||||
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 { 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 put = (k, e) => { if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(e); };
|
||||
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); }
|
||||
} 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 };
|
||||
}
|
||||
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 evStore = []; // events currently on screen, so a tap can open the right one
|
||||
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>
|
||||
<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);
|
||||
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;
|
||||
try {
|
||||
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())) || [];
|
||||
if (cals.length < 2 && !list.length) return; // only the empty NE Hub calendar: say nothing
|
||||
if (!document.body.contains(el)) return;
|
||||
@@ -44,12 +54,13 @@ const calState = { y: now0.getFullYear(), m: now0.getMonth(), sel: ymd(now0) };
|
||||
async function viewCalendar() {
|
||||
const mode = store.get('cal_view', 'month');
|
||||
const v = $('#view');
|
||||
v.innerHTML = `<div class="wrap"><h1>Calendar</h1>
|
||||
<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>
|
||||
<div class="bar" style="margin:0 0 8px"><button class="btn" id="ne">${icon('cal')}Add event</button></div>
|
||||
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>`
|
||||
+ (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>` : '');
|
||||
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>`;
|
||||
$('#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;
|
||||
try {
|
||||
const cals = await jfetch('/api/cal/list');
|
||||
@@ -63,10 +74,8 @@ async function renderAgendaList() {
|
||||
const { byDay, errors } = await loadAgenda(range);
|
||||
if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
|
||||
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>`
|
||||
+ (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>')
|
||||
$('#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>')
|
||||
+ 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() {
|
||||
@@ -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">
|
||||
<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="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>
|
||||
<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) {
|
||||
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);
|
||||
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>
|
||||
|
||||
+4
-14
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
// ---- 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 FORM = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||
const encP = p => p.split('/').map(encodeURIComponent).join('/');
|
||||
@@ -21,6 +21,7 @@ function openFile(repo, path, name, dir) {
|
||||
|
||||
async function previewFile(repo, path, name) {
|
||||
addRecent(repo, path, name);
|
||||
if (previewKind(name) === 'text') return textPreview(repo, path, name).catch(e => toast(e.message));
|
||||
try {
|
||||
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">`
|
||||
@@ -32,19 +33,7 @@ async function previewFile(repo, path, name) {
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
async function shareLink(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); }
|
||||
}
|
||||
const shareLink = (repo, path, isDir) => shareSheet(repo, path, isDir);
|
||||
|
||||
function fileSheet(repo, path, name, dir) {
|
||||
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', [
|
||||
{ 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: '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]) }]),
|
||||
{ label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) },
|
||||
...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: () => deleteItems(repo, dir, [it]) }]),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
let fv = null, libsP = Promise.resolve(), libNames = {}, libPerm = {};
|
||||
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) {
|
||||
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
|
||||
@@ -97,8 +98,7 @@ function deleteItems(repo, dir, items) {
|
||||
const one = items.length === 1;
|
||||
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 () => {
|
||||
try { await removeItems(repo, dir, items); toast(one ? 'Deleted' : items.length + ' items deleted'); } catch (e) { toast(e.message); }
|
||||
afterChange(repo, dir);
|
||||
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); }
|
||||
});
|
||||
}
|
||||
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
|
||||
const uq = { items: [], running: 0 };
|
||||
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;
|
||||
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();
|
||||
}
|
||||
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
|
||||
await new Promise((ok, no) => {
|
||||
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.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 }));
|
||||
@@ -162,8 +164,10 @@ async function sendUpload(it) {
|
||||
return;
|
||||
} catch (e) {
|
||||
if (it.cancelled) throw e;
|
||||
if (e.fatal || !sf.token() || it.tries >= 3) throw new Error(it.file.name + ': ' + e.message);
|
||||
await sleep(1500 * it.tries);
|
||||
if (e.fatal || !sf.token()) throw new Error(it.file.name + ': ' + e.message);
|
||||
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) {
|
||||
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);
|
||||
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(); };
|
||||
} 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>`;
|
||||
@@ -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 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 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 byName = n => v.items.find(x => x.name === n);
|
||||
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>`}
|
||||
<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>`}
|
||||
<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>
|
||||
${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="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>`;
|
||||
};
|
||||
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>
|
||||
<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>`; };
|
||||
box.innerHTML = list.length ? `<div class="list">${list.map(row).join('')}</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>`;
|
||||
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>`);
|
||||
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);
|
||||
$('#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) }];
|
||||
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 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) });
|
||||
return a;
|
||||
};
|
||||
@@ -292,6 +298,13 @@ async function loadDir(repo, dir) {
|
||||
done: () => { v.selecting = false; v.sel.clear(); redraw(); },
|
||||
sel: () => { v.selecting = true; redraw(); },
|
||||
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',
|
||||
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() }]),
|
||||
@@ -368,15 +381,27 @@ async function loadDir(repo, dir) {
|
||||
};
|
||||
|
||||
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 {
|
||||
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
|
||||
if (v.tok !== tok || !document.contains(main)) return;
|
||||
v.items = items; for (const n of [...v.sel]) if (!byName(n)) v.sel.delete(n);
|
||||
redraw();
|
||||
v.items = items; v.stale = false; for (const n of [...v.sel]) if (!byName(n)) v.sel.delete(n);
|
||||
dcPut(repo, dir, items);
|
||||
redraw(); restore();
|
||||
} catch (e) {
|
||||
if (v.tok !== tok || !document.contains(main)) return;
|
||||
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>`;
|
||||
$('#fretry').onclick = () => v.reload();
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<script src="/webauthn.js"></script>
|
||||
<script src="/links.js"></script>
|
||||
<script src="/palette.js"></script>
|
||||
<script src="/planner.js"></script>
|
||||
<script src="/reminders.js"></script>
|
||||
<script src="/file-history.js"></script>
|
||||
<script src="/music-extras.js"></script>
|
||||
@@ -69,6 +70,7 @@
|
||||
<script src="/lyrics-view.js"></script>
|
||||
<script src="/settings.js"></script>
|
||||
<script src="/files.js"></script>
|
||||
<script src="/files-extra.js"></script>
|
||||
<script src="/sheets.js"></script>
|
||||
<script src="/file-actions.js"></script>
|
||||
<script src="/signal-view.js"></script>
|
||||
|
||||
+66
-27
@@ -2,7 +2,7 @@
|
||||
// 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.
|
||||
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 mailWhen = iso => {
|
||||
@@ -11,7 +11,7 @@ const mailWhen = iso => {
|
||||
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' });
|
||||
};
|
||||
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. */
|
||||
function mailDoc(html, images) {
|
||||
@@ -31,24 +31,55 @@ async function renderMailCard(el) {
|
||||
try {
|
||||
const s = mail.st || await jfetch('/api/mail/status'); mail.st = s;
|
||||
if (!s.connected || !document.body.contains(el)) return;
|
||||
const r = await jfetch('/api/mail/messages?folder=inbox'); if (!document.body.contains(el)) return;
|
||||
const top = r.items.filter(m => !m.read).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)}</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 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(() => [])));
|
||||
if (!document.body.contains(el)) return;
|
||||
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 {}
|
||||
}
|
||||
const MAIL_LABEL = { outlook: 'Outlook.com', gmail: 'Gmail', icloud: 'iCloud Mail' };
|
||||
function mailSettings() {
|
||||
const box = $('#mailst'); if (!box) return;
|
||||
if (allowed('mail') && cfg.mail) jfetch('/api/mail/status').then(s => {
|
||||
if (!$('#mailst')) return;
|
||||
box.innerHTML = s.connected
|
||||
? `<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"><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>`;
|
||||
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.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"></span><button class="btn ghost" id="madd" style="min-height:36px">Add an account</button></div>`;
|
||||
$('#madd').onclick = () => mailAddAccount(s);
|
||||
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>'; });
|
||||
else box.innerHTML = '';
|
||||
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. */
|
||||
async function mailAdminCard() {
|
||||
let host = $('#mailadm');
|
||||
@@ -93,16 +124,20 @@ async function mailAdminCard() {
|
||||
// ---- the tab
|
||||
async function viewMail(args = []) {
|
||||
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>';
|
||||
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;
|
||||
mail.st = st;
|
||||
mail.st = st; mail.accts = st.accounts || [];
|
||||
if (!st.connected) return mailConnectScreen(st);
|
||||
if (typeof st.unread === 'number' && st.unread !== mailUnread) { mailUnread = st.unread; renderNav(); }
|
||||
const [kind, key] = args;
|
||||
if (kind === 'f' && key) mail.folder = key;
|
||||
if (!mail.folders.length) { try { mail.folders = await jfetch('/api/mail/folders'); } catch {} }
|
||||
const idAcct = x => String(x || '').includes('~') ? String(x).split('~')[0] : '';
|
||||
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');
|
||||
if (!mail.items.length || kind === 'f' || kind === undefined) mailLoadList(true);
|
||||
else mailDrawList();
|
||||
@@ -110,22 +145,24 @@ async function viewMail(args = []) {
|
||||
}
|
||||
|
||||
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>
|
||||
<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>' : ''}
|
||||
<div class="bar"><button class="btn" id="mcon">${icon('mail')}Connect Outlook.com</button></div><div class="err" id="mce"></div>
|
||||
<p class="sub">Personal Outlook.com, Hotmail and Live accounts only.</p></div>`;
|
||||
$('#mcon').onclick = async () => {
|
||||
try { const r = await jfetch('/api/mail/connect'); location.href = r.url; }
|
||||
catch (e) { $('#mce').textContent = e.message; }
|
||||
};
|
||||
<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>
|
||||
${st.outlook ? '' : '<p class="sub">Outlook.com needs a one-time Microsoft app set up by an admin (Settings → Microsoft app).</p>'}</div>`;
|
||||
$('#view').querySelectorAll('[data-p]').forEach(b => b.onclick = async () => {
|
||||
if (b.dataset.p === 'outlook') { try { const r = await jfetch('/api/mail/connect'); location.href = r.url; } catch (e) { $('#mce').textContent = e.message; } }
|
||||
else mailPasswordForm(b.dataset.p);
|
||||
});
|
||||
}
|
||||
|
||||
function mailShell(mode) {
|
||||
const fl = mail.folders.length ? mail.folders : [{ id: 'inbox', name: 'Inbox', unread: mailUnread }];
|
||||
$('#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">
|
||||
${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>
|
||||
<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>
|
||||
@@ -133,8 +170,10 @@ function mailShell(mode) {
|
||||
</section>
|
||||
<section class="mread" id="mread" aria-label="Message"></section></div>`;
|
||||
$('#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();
|
||||
$('#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); };
|
||||
}
|
||||
|
||||
@@ -168,7 +207,7 @@ async function mailOpen(id) {
|
||||
if (!m.read) { // opening a message marks it read
|
||||
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();
|
||||
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 (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) {
|
||||
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 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 {
|
||||
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 === '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 === '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();
|
||||
}
|
||||
} catch (e) { toast(e.message); }
|
||||
@@ -216,7 +255,7 @@ function mailCompose(o = {}) {
|
||||
const reply = o.mode, m = o.msg, files = [];
|
||||
const title = reply === 'reply' ? 'Reply' : reply === 'replyAll' ? 'Reply all' : reply === 'forward' ? 'Forward' : 'New message';
|
||||
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>
|
||||
<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>
|
||||
@@ -234,7 +273,7 @@ function mailCompose(o = {}) {
|
||||
try {
|
||||
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 }) });
|
||||
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) }))) }) });
|
||||
closeSheet(); toast('Sent');
|
||||
} catch (err) { btn.disabled = false; el.querySelector('#mer').textContent = err.message; }
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ function openPalette() {
|
||||
let items = [], sel = 0, seq = 0, timer, live = [];
|
||||
const go = h => () => { location.hash = h; };
|
||||
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('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' });
|
||||
|
||||
@@ -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
@@ -18,7 +18,7 @@ async function remindersInit() {
|
||||
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>
|
||||
${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>
|
||||
<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>`;
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ function viewSettings() {
|
||||
</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><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>
|
||||
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
|
||||
<label for="ab-p">Password</label>
|
||||
|
||||
+24
-1
@@ -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;
|
||||
transition: .2s; max-width: 90vw; z-index: 10; }
|
||||
#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 */
|
||||
@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; }
|
||||
}
|
||||
|
||||
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; }
|
||||
.chk { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 16px; margin: 14px 0 0; }
|
||||
.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; }
|
||||
.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
@@ -6,13 +6,13 @@ const taskStore = []; // tasks on screen, so a tap can open the right one
|
||||
|
||||
async function viewTasks() {
|
||||
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 {
|
||||
const { lists, icloud } = await jfetch('/api/tasks/lists');
|
||||
if (!location.hash.startsWith('#/tasks')) return;
|
||||
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);
|
||||
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>
|
||||
<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>
|
||||
@@ -27,7 +27,7 @@ async function viewTasks() {
|
||||
catch (err) { toast(err.message); }
|
||||
};
|
||||
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) {
|
||||
|
||||
@@ -9,6 +9,7 @@ const { Readable } = require('stream');
|
||||
|
||||
const PORT = process.env.PORT || 8080;
|
||||
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 AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
|
||||
const AUDIOBOOKS_SSO_URL = process.env.AUDIOBOOKS_SSO_URL || AUDIOBOOKS_URL + '/sso';
|
||||
@@ -46,24 +47,47 @@ const TYPES = {
|
||||
'.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) {
|
||||
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
|
||||
tu.searchParams.delete('hubinline');
|
||||
const target = tu.toString();
|
||||
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];
|
||||
}
|
||||
const hasBody = !['GET', 'HEAD'].includes(req.method);
|
||||
const hasBody = !['GET', 'HEAD'].includes(req.method), canRetry = !hasBody;
|
||||
const wait = /^\/seafhttp\//.test(tu.pathname) ? 120000 : SEAFILE_TIMEOUT;
|
||||
let gone = false, ac = null; res.on('close', () => { if (!res.writableFinished) { gone = true; if (ac) ac.abort(); } });
|
||||
let upstream, lastErr;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (gone) return;
|
||||
ac = new AbortController(); let timedOut = false;
|
||||
const timer = setTimeout(() => { timedOut = true; ac.abort(); }, wait);
|
||||
try {
|
||||
const upstream = await fetch(target, {
|
||||
method: req.method, headers, redirect: 'manual',
|
||||
body: hasBody ? Readable.toWeb(req) : undefined,
|
||||
duplex: hasBody ? 'half' : undefined,
|
||||
});
|
||||
upstream = await fetch(target, { method: req.method, headers, redirect: 'manual', signal: ac.signal,
|
||||
body: hasBody ? Readable.toWeb(req) : undefined, duplex: hasBody ? 'half' : undefined });
|
||||
clearTimeout(timer);
|
||||
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;
|
||||
}
|
||||
}
|
||||
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']) {
|
||||
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);
|
||||
@@ -76,13 +100,7 @@ async function proxySeafile(req, res) {
|
||||
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.body) Readable.fromWeb(upstream.body).on('error', () => res.destroy()).pipe(res); else res.end();
|
||||
}
|
||||
|
||||
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 (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 === '/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
|
||||
// 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).
|
||||
@@ -467,7 +485,7 @@ async function handleRequest(req, res) {
|
||||
const c = t => users.can(user, t);
|
||||
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 : '',
|
||||
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 (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.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('/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);
|
||||
|
||||
@@ -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 }); }
|
||||
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
|
||||
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++; });
|
||||
@@ -161,7 +166,7 @@ async function handle(req, res) {
|
||||
/** Every task from every list (NE Hub and iCloud), with its list name. */
|
||||
async function allTasks() {
|
||||
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 {} }
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -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); });
|
||||
@@ -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) && /<b>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
@@ -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 sign-in link works only once', r.status === 400, r.status);
|
||||
r = await call('unate', '/api/mail/status'); ok('now connected, with the address and the unread count', r.json.connected && r.json.address === 'nate@outlook.com' && r.json.unread === 3, r.json);
|
||||
const raw = fs.readFileSync(path.join(dir, 'users', 'unate', 'mail.json'), 'utf8'); ok('the tokens are saved encrypted', !/AT1|RT1/.test(raw), raw.slice(0, 80));
|
||||
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/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);
|
||||
r = await call('unate', '/api/mail/messages?folder=inbox'); ok('the inbox lists messages, newest first, with who, read state and attachment flag', r.status === 200 && r.json.items.length === 2 && r.json.items[0].from.address === 'ann@example.com' && r.json.items[0].read === false && r.json.items[0].attach === true && r.json.items[1].flagged === true, r.json);
|
||||
ok('and says there may be more', r.json.more === true && r.json.next === 2, r.json);
|
||||
r = await call('unate', '/api/mail/messages?folder=inbox&q=second'); ok('search finds a message', r.json.items.length === 1 && r.json.items[0].id === 'M2', r.json);
|
||||
r = await call('unate', '/api/mail/messages?folder=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/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);
|
||||
@@ -138,12 +138,11 @@ const SECRET = 'mail-test-secret-'.repeat(3);
|
||||
ms.revoked = true; ms.goodTokens = new Set();
|
||||
r = await call('unate', '/api/mail/messages'); ok('if Microsoft revokes access, the person is told to connect again (409)', r.status === 409 && /Connect again/.test(r.json.error), r);
|
||||
r = await call('unate', '/api/mail/status'); ok('and shown as not connected', r.json.connected === false, r.json);
|
||||
ok('the dead sign-in is removed from disk', !fs.existsSync(path.join(dir, 'users', 'unate', 'mail.json')));
|
||||
|
||||
// reconnect, then disconnect
|
||||
ms.revoked = false; r = await call('unate', '/api/mail/connect'); const au3 = new URL(r.json.url); ms.challenge = au3.searchParams.get('code_challenge');
|
||||
await call(null, `/api/mail/callback?code=GOODCODE&state=${au3.searchParams.get('state')}`); r = await call('unate', '/api/mail/status'); ok('connecting again works', r.json.connected === true, r.json);
|
||||
r = await post('unate', '/api/mail/disconnect'); r = await call('unate', '/api/mail/status'); ok('disconnect removes the sign-in', r.json.connected === false && !fs.existsSync(path.join(dir, 'users', 'unate', 'mail.json')), r.json);
|
||||
r = await 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', '/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) =================
|
||||
@@ -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', () => {});
|
||||
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 }; };
|
||||
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('uann', '/api/mail/status'); ok('and ordinary mail requests say it is not set up', r.status === 403, r.status);
|
||||
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/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', { 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);
|
||||
@@ -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('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/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');
|
||||
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)]);
|
||||
@@ -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/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('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);
|
||||
child2.kill(); fs.rmSync(dir2, { recursive: true, force: true });
|
||||
|
||||
|
||||
@@ -36,6 +36,15 @@ const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||
ok('a missing parent is refused', r.status === 400, r);
|
||||
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);
|
||||
// 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);
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user