@yujinapp/yuemail 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/bin/yuemail.mjs +179 -0
- package/dist/assets/index-Bsp7rBsf.js +44 -0
- package/dist/assets/index-D0fPbil9.css +1 -0
- package/dist/index.html +25 -0
- package/package.json +66 -0
- package/server-dist/autoconfig.js +228 -0
- package/server-dist/documents.js +89 -0
- package/server-dist/docx-builder.js +46 -0
- package/server-dist/index.js +82 -0
- package/server-dist/routes/documents.js +56 -0
- package/server-dist/routes/email.js +109 -0
- package/server-dist/routes/inbox.js +81 -0
- package/server-dist/routes/settings.js +119 -0
- package/server-dist/routes/signature.js +31 -0
- package/server-dist/routes/vault.js +35 -0
- package/server-dist/signature.js +54 -0
- package/server-dist/vault.js +202 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ImapFlow } from 'imapflow';
|
|
2
|
+
import { getCategoryStatus, getKey } from '../vault.js';
|
|
3
|
+
export function registerInboxRoutes(app) {
|
|
4
|
+
app.get('/api/inbox/list', async (req, res) => {
|
|
5
|
+
const status = await getCategoryStatus();
|
|
6
|
+
if (!status.imap.configured) {
|
|
7
|
+
res.status(400).json({
|
|
8
|
+
ok: false,
|
|
9
|
+
error: 'IMAP not configured. Run `yuemail vault setup` to configure your IMAP server.',
|
|
10
|
+
missing: status.imap.missing,
|
|
11
|
+
});
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const host = await getKey('imap.host');
|
|
15
|
+
const portStr = await getKey('imap.port');
|
|
16
|
+
const user = await getKey('imap.user');
|
|
17
|
+
const pass = await getKey('imap.pass');
|
|
18
|
+
const secStr = await getKey('imap.secure');
|
|
19
|
+
if (!host || !portStr || !user || !pass) {
|
|
20
|
+
res.status(400).json({ ok: false, error: 'IMAP not configured (missing decrypted credential)' });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const port = Number(portStr);
|
|
24
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
25
|
+
res.status(400).json({ ok: false, error: 'imap.port is not a positive integer' });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const secure = (secStr ?? 'true').toLowerCase() !== 'false';
|
|
29
|
+
const limitRaw = Number(req.query['limit'] ?? 20);
|
|
30
|
+
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(Math.floor(limitRaw), 1), 100) : 20;
|
|
31
|
+
const client = new ImapFlow({
|
|
32
|
+
host,
|
|
33
|
+
port,
|
|
34
|
+
secure,
|
|
35
|
+
auth: { user, pass },
|
|
36
|
+
logger: false,
|
|
37
|
+
});
|
|
38
|
+
const envelopes = [];
|
|
39
|
+
try {
|
|
40
|
+
await client.connect();
|
|
41
|
+
const lock = await client.getMailboxLock('INBOX');
|
|
42
|
+
try {
|
|
43
|
+
const mbox = client.mailbox;
|
|
44
|
+
const total = (mbox && typeof mbox.exists === 'number') ? mbox.exists : 0;
|
|
45
|
+
if (total === 0) {
|
|
46
|
+
res.json({ ok: true, envelopes: [] });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const start = Math.max(1, total - limit + 1);
|
|
50
|
+
const range = start + ':' + total;
|
|
51
|
+
for await (const msg of client.fetch(range, { uid: true, envelope: true })) {
|
|
52
|
+
const env = msg.envelope;
|
|
53
|
+
const fromList = (env?.from ?? []);
|
|
54
|
+
const first = fromList[0] ?? {};
|
|
55
|
+
const fromStr = (first.name ?? '') + (first.address ? ' <' + first.address + '>' : '');
|
|
56
|
+
envelopes.push({
|
|
57
|
+
uid: Number(msg.uid ?? 0),
|
|
58
|
+
from: fromStr.trim(),
|
|
59
|
+
subject: env?.subject ?? '(sin asunto)',
|
|
60
|
+
date: env?.date ? new Date(env.date).toISOString() : '',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
envelopes.sort((a, b) => b.uid - a.uid);
|
|
64
|
+
res.json({ ok: true, envelopes });
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
lock.release();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
72
|
+
res.status(500).json({ ok: false, error: 'IMAP fetch failed: ' + message });
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
try {
|
|
76
|
+
await client.logout();
|
|
77
|
+
}
|
|
78
|
+
catch { /* ignore */ }
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
|
+
import { ImapFlow } from 'imapflow';
|
|
3
|
+
import { autoconfigure } from '../autoconfig.js';
|
|
4
|
+
import { getKey } from '../vault.js';
|
|
5
|
+
const VERIFY_TIMEOUT_MS = 10000;
|
|
6
|
+
function str(v) {
|
|
7
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
8
|
+
}
|
|
9
|
+
/** Merge form overrides with vault values, field by field. */
|
|
10
|
+
async function effectiveConfig(cat, over) {
|
|
11
|
+
const host = str(over?.host) ?? await getKey(cat + '.host');
|
|
12
|
+
const portRaw = str(String(over?.port ?? '')) ?? await getKey(cat + '.port');
|
|
13
|
+
const user = str(over?.user) ?? await getKey(cat + '.user');
|
|
14
|
+
const pass = str(over?.pass) ?? await getKey(cat + '.pass');
|
|
15
|
+
if (!host || !portRaw || !user || !pass)
|
|
16
|
+
return undefined;
|
|
17
|
+
const port = Number(portRaw);
|
|
18
|
+
if (!Number.isFinite(port) || port <= 0)
|
|
19
|
+
return undefined;
|
|
20
|
+
let secure;
|
|
21
|
+
if (typeof over?.secure === 'boolean') {
|
|
22
|
+
secure = over.secure;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
const secStr = await getKey(cat + '.secure');
|
|
26
|
+
/* Same defaults the send/inbox routes apply. */
|
|
27
|
+
secure = cat === 'imap'
|
|
28
|
+
? (secStr ?? 'true').toLowerCase() !== 'false'
|
|
29
|
+
: (secStr ?? '').toLowerCase() === 'true' || port === 465;
|
|
30
|
+
}
|
|
31
|
+
return { host, port, user, pass, secure };
|
|
32
|
+
}
|
|
33
|
+
function shortError(err) {
|
|
34
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
35
|
+
return message.split('\n')[0]?.slice(0, 200) ?? 'error desconocido';
|
|
36
|
+
}
|
|
37
|
+
async function verifySmtp(cfg) {
|
|
38
|
+
const transporter = nodemailer.createTransport({
|
|
39
|
+
host: cfg.host,
|
|
40
|
+
port: cfg.port,
|
|
41
|
+
secure: cfg.secure,
|
|
42
|
+
auth: { user: cfg.user, pass: cfg.pass },
|
|
43
|
+
connectionTimeout: VERIFY_TIMEOUT_MS,
|
|
44
|
+
greetingTimeout: VERIFY_TIMEOUT_MS,
|
|
45
|
+
socketTimeout: VERIFY_TIMEOUT_MS,
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
await transporter.verify();
|
|
49
|
+
return { ok: true };
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
return { ok: false, error: shortError(err) };
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
transporter.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function verifyImap(cfg) {
|
|
59
|
+
const client = new ImapFlow({
|
|
60
|
+
host: cfg.host,
|
|
61
|
+
port: cfg.port,
|
|
62
|
+
secure: cfg.secure,
|
|
63
|
+
auth: { user: cfg.user, pass: cfg.pass },
|
|
64
|
+
logger: false,
|
|
65
|
+
});
|
|
66
|
+
let timer;
|
|
67
|
+
const timeout = new Promise((_, reject) => {
|
|
68
|
+
timer = setTimeout(() => reject(new Error('timeout de conexion (' + VERIFY_TIMEOUT_MS / 1000 + 's)')), VERIFY_TIMEOUT_MS);
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
await Promise.race([client.connect(), timeout]);
|
|
72
|
+
return { ok: true };
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
return { ok: false, error: shortError(err) };
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
if (timer)
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
/* logout() can hang on a half-open connection (e.g. after the race
|
|
81
|
+
* timed out while connect() was still pending), so bound it and then
|
|
82
|
+
* destroy the socket unconditionally -- same hard cap SMTP gets via
|
|
83
|
+
* its socketTimeout. */
|
|
84
|
+
try {
|
|
85
|
+
await Promise.race([
|
|
86
|
+
client.logout(),
|
|
87
|
+
new Promise((resolve) => setTimeout(resolve, 2000)),
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
catch { /* ignore */ }
|
|
91
|
+
try {
|
|
92
|
+
client.close();
|
|
93
|
+
}
|
|
94
|
+
catch { /* ignore */ }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function registerSettingsRoutes(app) {
|
|
98
|
+
app.get('/api/email/autoconfig', async (req, res) => {
|
|
99
|
+
const email = typeof req.query['email'] === 'string' ? req.query['email'] : '';
|
|
100
|
+
const result = await autoconfigure(email);
|
|
101
|
+
if (!result.ok) {
|
|
102
|
+
res.status(422).json(result);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
res.json(result);
|
|
106
|
+
});
|
|
107
|
+
app.post('/api/email/verify', async (req, res) => {
|
|
108
|
+
const body = req.body;
|
|
109
|
+
const [imapCfg, smtpCfg] = await Promise.all([
|
|
110
|
+
effectiveConfig('imap', body?.imap),
|
|
111
|
+
effectiveConfig('smtp', body?.smtp),
|
|
112
|
+
]);
|
|
113
|
+
const [imap, smtp] = await Promise.all([
|
|
114
|
+
imapCfg ? verifyImap(imapCfg) : Promise.resolve({ ok: false, error: 'IMAP sin configurar' }),
|
|
115
|
+
smtpCfg ? verifySmtp(smtpCfg) : Promise.resolve({ ok: false, error: 'SMTP sin configurar' }),
|
|
116
|
+
]);
|
|
117
|
+
res.json({ ok: imap.ok && smtp.ok, imap, smtp });
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { hasSignature, saveSignature, readSignaturePngBase64, deleteSignature } from '../signature.js';
|
|
2
|
+
export function registerSignatureRoutes(app) {
|
|
3
|
+
app.get('/api/signature', async (_req, res) => {
|
|
4
|
+
if (!hasSignature()) {
|
|
5
|
+
res.json({ ok: true, exists: false });
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
const png = await readSignaturePngBase64();
|
|
9
|
+
res.json({ ok: true, exists: true, png_b64: png });
|
|
10
|
+
});
|
|
11
|
+
app.put('/api/signature', async (req, res) => {
|
|
12
|
+
const body = req.body;
|
|
13
|
+
const pngB64 = typeof body?.png_b64 === 'string' ? body.png_b64 : '';
|
|
14
|
+
if (pngB64.length === 0) {
|
|
15
|
+
res.status(400).json({ ok: false, error: 'png_b64 required' });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
await saveSignature(pngB64);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
res.status(204).end();
|
|
26
|
+
});
|
|
27
|
+
app.delete('/api/signature', async (_req, res) => {
|
|
28
|
+
const ok = await deleteSignature();
|
|
29
|
+
res.status(ok ? 204 : 404).end();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getAllKeys, getCategoryStatus, setKey, deleteKey, isValidVaultKey, passphraseSource } from '../vault.js';
|
|
2
|
+
export function registerVaultRoutes(app) {
|
|
3
|
+
app.get('/api/vault/keys', async (_req, res) => {
|
|
4
|
+
const keys = await getAllKeys();
|
|
5
|
+
res.json({ ok: true, keys });
|
|
6
|
+
});
|
|
7
|
+
app.get('/api/vault/status', async (_req, res) => {
|
|
8
|
+
const status = await getCategoryStatus();
|
|
9
|
+
res.json({ ok: true, status, key_source: passphraseSource() });
|
|
10
|
+
});
|
|
11
|
+
app.put('/api/vault/key', async (req, res) => {
|
|
12
|
+
const body = req.body;
|
|
13
|
+
const name = typeof body?.name === 'string' ? body.name : '';
|
|
14
|
+
const value = typeof body?.value === 'string' ? body.value : '';
|
|
15
|
+
if (!isValidVaultKey(name)) {
|
|
16
|
+
res.status(400).json({ ok: false, error: 'unknown vault key: ' + name });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (value.length === 0) {
|
|
20
|
+
res.status(400).json({ ok: false, error: 'value cannot be empty' });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
await setKey(name, value);
|
|
24
|
+
res.status(204).end();
|
|
25
|
+
});
|
|
26
|
+
app.delete('/api/vault/key/:name', async (req, res) => {
|
|
27
|
+
const name = req.params['name'] ?? '';
|
|
28
|
+
if (!isValidVaultKey(name)) {
|
|
29
|
+
res.status(400).json({ ok: false, error: 'unknown vault key: ' + name });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const ok = await deleteKey(name);
|
|
33
|
+
res.status(ok ? 204 : 404).end();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yuemail signature store (F4).
|
|
3
|
+
*
|
|
4
|
+
* Single saved signature PNG at ~/.yuemail/signatures/default.png.
|
|
5
|
+
* The frontend POSTs a base64 PNG; we decode and write. GET returns
|
|
6
|
+
* the bytes; HEAD returns 200 / 404 for existence checks.
|
|
7
|
+
*
|
|
8
|
+
* ASCII-only.
|
|
9
|
+
*/
|
|
10
|
+
import { promises as fs, existsSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
function homeDir() {
|
|
14
|
+
const env = process.env['YUEMAIL_HOME'];
|
|
15
|
+
return env ? path.resolve(env) : path.join(os.homedir(), '.yuemail');
|
|
16
|
+
}
|
|
17
|
+
function sigDir() { return path.join(homeDir(), 'signatures'); }
|
|
18
|
+
function sigFile() { return path.join(sigDir(), 'default.png'); }
|
|
19
|
+
function ensureSigDir() {
|
|
20
|
+
const d = sigDir();
|
|
21
|
+
if (!existsSync(d)) {
|
|
22
|
+
mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function signaturePath() {
|
|
26
|
+
return sigFile();
|
|
27
|
+
}
|
|
28
|
+
export function hasSignature() {
|
|
29
|
+
return existsSync(sigFile());
|
|
30
|
+
}
|
|
31
|
+
export async function saveSignature(pngBase64) {
|
|
32
|
+
ensureSigDir();
|
|
33
|
+
const png = Buffer.from(pngBase64, 'base64');
|
|
34
|
+
if (png.length < 8) {
|
|
35
|
+
throw new Error('signature PNG too small');
|
|
36
|
+
}
|
|
37
|
+
/* PNG magic: 89 50 4E 47 0D 0A 1A 0A. */
|
|
38
|
+
if (png[0] !== 0x89 || png[1] !== 0x50 || png[2] !== 0x4e || png[3] !== 0x47) {
|
|
39
|
+
throw new Error('not a PNG');
|
|
40
|
+
}
|
|
41
|
+
await fs.writeFile(sigFile(), png, { mode: 0o600 });
|
|
42
|
+
}
|
|
43
|
+
export async function readSignaturePngBase64() {
|
|
44
|
+
if (!existsSync(sigFile()))
|
|
45
|
+
return undefined;
|
|
46
|
+
const buf = await fs.readFile(sigFile());
|
|
47
|
+
return buf.toString('base64');
|
|
48
|
+
}
|
|
49
|
+
export async function deleteSignature() {
|
|
50
|
+
if (!existsSync(sigFile()))
|
|
51
|
+
return false;
|
|
52
|
+
await fs.unlink(sigFile());
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yuemail BYOK vault (F8 / acceptance #8).
|
|
3
|
+
*
|
|
4
|
+
* AES-256-GCM, scrypt-derived key from (passphrase, per-machine salt).
|
|
5
|
+
*
|
|
6
|
+
* Storage layout:
|
|
7
|
+
* ~/.yuemail/vault.salt -- 32 random bytes, written once at first launch.
|
|
8
|
+
* ~/.yuemail/vault.json -- map of key-name to {iv, ciphertext, tag}.
|
|
9
|
+
*
|
|
10
|
+
* The vault JSON file never contains plaintext values of any secret.
|
|
11
|
+
* Each stored value is encrypted independently with a fresh IV.
|
|
12
|
+
*
|
|
13
|
+
* Passphrase resolution order:
|
|
14
|
+
* 1. env YUEMAIL_VAULT_PASS
|
|
15
|
+
* 2. fallback: 'yuemail/' + os.hostname() + '/' + username
|
|
16
|
+
*
|
|
17
|
+
* Threat-model honesty: the fallback passphrase is PREDICTABLE. It
|
|
18
|
+
* protects against exfiltration of the vault files alone (backup leak,
|
|
19
|
+
* synced folder), but a local attacker who can read the files can also
|
|
20
|
+
* read hostname + username and re-derive the key. Real at-rest secrecy
|
|
21
|
+
* against local readers requires YUEMAIL_VAULT_PASS. The UI surfaces
|
|
22
|
+
* this via key_source ('env' | 'derived') in /api/vault/status.
|
|
23
|
+
*
|
|
24
|
+
* Public surface:
|
|
25
|
+
* - getAllKeys(): list configured key names (no values).
|
|
26
|
+
* - getCategoryStatus(): per-category configured-booleans for the UI.
|
|
27
|
+
* - setKey(name, value): encrypt + persist.
|
|
28
|
+
* - getKey(name): decrypt + return (server-internal use only).
|
|
29
|
+
* - deleteKey(name): remove the row.
|
|
30
|
+
* - clearAll(): reset (test helpers).
|
|
31
|
+
*
|
|
32
|
+
* The `/api/vault/...` HTTP routes expose getAllKeys + getCategoryStatus +
|
|
33
|
+
* setKey + deleteKey. They never expose getKey -- decrypted values stay
|
|
34
|
+
* inside the server process.
|
|
35
|
+
*
|
|
36
|
+
* ASCII-only.
|
|
37
|
+
*/
|
|
38
|
+
import { randomBytes, scryptSync, createCipheriv, createDecipheriv, } from 'node:crypto';
|
|
39
|
+
import { promises as fs, existsSync, mkdirSync } from 'node:fs';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import os from 'node:os';
|
|
42
|
+
/* Paths are resolved lazily so tests can swap YUEMAIL_HOME per test
|
|
43
|
+
* without bumping a module-level constant frozen at import time. */
|
|
44
|
+
function homeDir() {
|
|
45
|
+
const env = process.env['YUEMAIL_HOME'];
|
|
46
|
+
return env ? path.resolve(env) : path.join(os.homedir(), '.yuemail');
|
|
47
|
+
}
|
|
48
|
+
function saltFile() { return path.join(homeDir(), 'vault.salt'); }
|
|
49
|
+
function vaultFile() { return path.join(homeDir(), 'vault.json'); }
|
|
50
|
+
const SCRYPT_N = 16384;
|
|
51
|
+
const SCRYPT_R = 8;
|
|
52
|
+
const SCRYPT_P = 1;
|
|
53
|
+
const SCRYPT_KEYLEN = 32;
|
|
54
|
+
const IV_BYTES = 12;
|
|
55
|
+
export const VAULT_KEYS = [
|
|
56
|
+
'imap.host',
|
|
57
|
+
'imap.port',
|
|
58
|
+
'imap.user',
|
|
59
|
+
'imap.pass',
|
|
60
|
+
'imap.secure',
|
|
61
|
+
'smtp.host',
|
|
62
|
+
'smtp.port',
|
|
63
|
+
'smtp.user',
|
|
64
|
+
'smtp.pass',
|
|
65
|
+
'smtp.secure',
|
|
66
|
+
'identity.from',
|
|
67
|
+
'identity.name',
|
|
68
|
+
];
|
|
69
|
+
export function isValidVaultKey(name) {
|
|
70
|
+
return VAULT_KEYS.includes(name);
|
|
71
|
+
}
|
|
72
|
+
function ensureHomeDir() {
|
|
73
|
+
if (!existsSync(homeDir())) {
|
|
74
|
+
mkdirSync(homeDir(), { recursive: true, mode: 0o700 });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function getOrCreateSalt() {
|
|
78
|
+
ensureHomeDir();
|
|
79
|
+
if (existsSync(saltFile())) {
|
|
80
|
+
return fs.readFile(saltFile());
|
|
81
|
+
}
|
|
82
|
+
const salt = randomBytes(32);
|
|
83
|
+
await fs.writeFile(saltFile(), salt, { mode: 0o600 });
|
|
84
|
+
return salt;
|
|
85
|
+
}
|
|
86
|
+
/** Where the vault key comes from: a user-provided secret or the
|
|
87
|
+
* predictable machine-derived fallback. Exposed so the UI can warn. */
|
|
88
|
+
export function passphraseSource() {
|
|
89
|
+
const envPass = process.env['YUEMAIL_VAULT_PASS'];
|
|
90
|
+
return envPass && envPass.length > 0 ? 'env' : 'derived';
|
|
91
|
+
}
|
|
92
|
+
function resolvePassphrase() {
|
|
93
|
+
const envPass = process.env['YUEMAIL_VAULT_PASS'];
|
|
94
|
+
if (envPass && envPass.length > 0)
|
|
95
|
+
return envPass;
|
|
96
|
+
let userName = 'user';
|
|
97
|
+
try {
|
|
98
|
+
userName = os.userInfo().username;
|
|
99
|
+
}
|
|
100
|
+
catch { /* keep fallback */ }
|
|
101
|
+
return 'yuemail/' + os.hostname() + '/' + userName;
|
|
102
|
+
}
|
|
103
|
+
async function deriveKey() {
|
|
104
|
+
const salt = await getOrCreateSalt();
|
|
105
|
+
const pass = resolvePassphrase();
|
|
106
|
+
return scryptSync(pass, salt, SCRYPT_KEYLEN, {
|
|
107
|
+
N: SCRYPT_N,
|
|
108
|
+
r: SCRYPT_R,
|
|
109
|
+
p: SCRYPT_P,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async function readVaultFile() {
|
|
113
|
+
if (!existsSync(vaultFile()))
|
|
114
|
+
return {};
|
|
115
|
+
try {
|
|
116
|
+
const raw = await fs.readFile(vaultFile(), 'utf-8');
|
|
117
|
+
if (raw.trim().length === 0)
|
|
118
|
+
return {};
|
|
119
|
+
return JSON.parse(raw);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function writeVaultFile(data) {
|
|
126
|
+
ensureHomeDir();
|
|
127
|
+
const payload = JSON.stringify(data, null, 2);
|
|
128
|
+
await fs.writeFile(vaultFile(), payload, { mode: 0o600 });
|
|
129
|
+
}
|
|
130
|
+
export async function setKey(name, value) {
|
|
131
|
+
if (!isValidVaultKey(name)) {
|
|
132
|
+
throw new Error('unknown vault key: ' + name);
|
|
133
|
+
}
|
|
134
|
+
const key = await deriveKey();
|
|
135
|
+
const iv = randomBytes(IV_BYTES);
|
|
136
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
137
|
+
const ct = Buffer.concat([cipher.update(value, 'utf-8'), cipher.final()]);
|
|
138
|
+
const tag = cipher.getAuthTag();
|
|
139
|
+
const data = await readVaultFile();
|
|
140
|
+
data[name] = {
|
|
141
|
+
iv: iv.toString('base64'),
|
|
142
|
+
ct: ct.toString('base64'),
|
|
143
|
+
tag: tag.toString('base64'),
|
|
144
|
+
};
|
|
145
|
+
await writeVaultFile(data);
|
|
146
|
+
}
|
|
147
|
+
export async function getKey(name) {
|
|
148
|
+
const data = await readVaultFile();
|
|
149
|
+
const row = data[name];
|
|
150
|
+
if (!row)
|
|
151
|
+
return undefined;
|
|
152
|
+
const key = await deriveKey();
|
|
153
|
+
const iv = Buffer.from(row.iv, 'base64');
|
|
154
|
+
const ct = Buffer.from(row.ct, 'base64');
|
|
155
|
+
const tag = Buffer.from(row.tag, 'base64');
|
|
156
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
157
|
+
decipher.setAuthTag(tag);
|
|
158
|
+
const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
159
|
+
return pt.toString('utf-8');
|
|
160
|
+
}
|
|
161
|
+
export async function deleteKey(name) {
|
|
162
|
+
const data = await readVaultFile();
|
|
163
|
+
if (!(name in data))
|
|
164
|
+
return false;
|
|
165
|
+
delete data[name];
|
|
166
|
+
await writeVaultFile(data);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
export async function getAllKeys() {
|
|
170
|
+
const data = await readVaultFile();
|
|
171
|
+
return Object.keys(data).sort();
|
|
172
|
+
}
|
|
173
|
+
const IMAP_KEYS = ['imap.host', 'imap.port', 'imap.user', 'imap.pass'];
|
|
174
|
+
const SMTP_KEYS = ['smtp.host', 'smtp.port', 'smtp.user', 'smtp.pass'];
|
|
175
|
+
const IDENTITY_KEYS = ['identity.from', 'identity.name'];
|
|
176
|
+
export async function getCategoryStatus() {
|
|
177
|
+
const data = await readVaultFile();
|
|
178
|
+
const has = (k) => k in data;
|
|
179
|
+
const missing = (set) => set.filter((k) => !has(k));
|
|
180
|
+
return {
|
|
181
|
+
imap: { configured: IMAP_KEYS.every(has), missing: missing(IMAP_KEYS) },
|
|
182
|
+
smtp: { configured: SMTP_KEYS.every(has), missing: missing(SMTP_KEYS) },
|
|
183
|
+
identity: { configured: IDENTITY_KEYS.every(has), missing: missing(IDENTITY_KEYS) },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
export async function smtpReady() {
|
|
187
|
+
const data = await readVaultFile();
|
|
188
|
+
return SMTP_KEYS.every((k) => k in data);
|
|
189
|
+
}
|
|
190
|
+
export async function clearAll() {
|
|
191
|
+
if (existsSync(vaultFile())) {
|
|
192
|
+
await fs.unlink(vaultFile());
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export async function readRawVaultFile() {
|
|
196
|
+
if (!existsSync(vaultFile()))
|
|
197
|
+
return '';
|
|
198
|
+
return fs.readFile(vaultFile(), 'utf-8');
|
|
199
|
+
}
|
|
200
|
+
export function vaultPaths() {
|
|
201
|
+
return { home: homeDir(), salt: saltFile(), vault: vaultFile() };
|
|
202
|
+
}
|