@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
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yujinapp/yuemail",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Voice-first single-user email client. Dictate, sign, send. Local-only, BYOK encrypted vault.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": { "node": ">=18.0.0" },
|
|
8
|
+
"bin": { "yuemail": "./bin/yuemail.mjs" },
|
|
9
|
+
"main": "./server-dist/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"dist/",
|
|
13
|
+
"server-dist/",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"dev:web": "vite",
|
|
19
|
+
"dev:server": "tsc -p tsconfig.server.json --watch",
|
|
20
|
+
"build:web": "vite build",
|
|
21
|
+
"build:server": "tsc -p tsconfig.server.json",
|
|
22
|
+
"build": "npm run build:web && npm run build:server",
|
|
23
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"start": "node bin/yuemail.mjs start",
|
|
27
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"docx": "^8.5.0",
|
|
31
|
+
"express": "^4.19.2",
|
|
32
|
+
"imapflow": "^1.0.162",
|
|
33
|
+
"nodemailer": "^6.9.13",
|
|
34
|
+
"open": "^10.1.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/express": "^4.17.21",
|
|
38
|
+
"@types/node": "^20.12.7",
|
|
39
|
+
"@types/nodemailer": "^6.4.14",
|
|
40
|
+
"@types/react": "^18.3.3",
|
|
41
|
+
"@types/react-dom": "^18.3.0",
|
|
42
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
43
|
+
"react": "^18.3.1",
|
|
44
|
+
"react-dom": "^18.3.1",
|
|
45
|
+
"typescript": "^5.4.5",
|
|
46
|
+
"vite": "^5.2.11",
|
|
47
|
+
"vitest": "^1.6.0"
|
|
48
|
+
},
|
|
49
|
+
"keywords": [
|
|
50
|
+
"email",
|
|
51
|
+
"voice",
|
|
52
|
+
"accessibility",
|
|
53
|
+
"a11y",
|
|
54
|
+
"dictation",
|
|
55
|
+
"byok",
|
|
56
|
+
"local-only",
|
|
57
|
+
"yujin"
|
|
58
|
+
],
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
},
|
|
62
|
+
"repository": {
|
|
63
|
+
"type": "git",
|
|
64
|
+
"url": "https://github.com/yujinapp/yuemail-v3.git"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email server autoconfiguration (F10).
|
|
3
|
+
*
|
|
4
|
+
* Given just an email address, resolve IMAP + SMTP settings in three
|
|
5
|
+
* tiers (first hit wins):
|
|
6
|
+
*
|
|
7
|
+
* 1. known -- built-in table of major providers (Gmail, Outlook,
|
|
8
|
+
* Yahoo, iCloud, ...). Instant, offline, includes the
|
|
9
|
+
* app-password caveat each provider imposes.
|
|
10
|
+
* 2. ispdb -- Mozilla Thunderbird autoconfig database
|
|
11
|
+
* (autoconfig.thunderbird.net). Covers thousands of
|
|
12
|
+
* smaller ISPs. Network fetch with a 6s timeout.
|
|
13
|
+
* 3. guess -- convention fallback: imap.<domain>:993 (SSL) +
|
|
14
|
+
* smtp.<domain>:587 (STARTTLS), flagged as a guess so
|
|
15
|
+
* the UI tells the user to run the connection test.
|
|
16
|
+
*
|
|
17
|
+
* Providers with no public IMAP/SMTP (Proton without Bridge, Tuta) are
|
|
18
|
+
* reported as unsupported with a human explanation instead of a guess
|
|
19
|
+
* that can never work.
|
|
20
|
+
*
|
|
21
|
+
* The `secure` flag follows the vault convention: true = direct TLS
|
|
22
|
+
* (993/465), false = plaintext upgrade via STARTTLS (143/587).
|
|
23
|
+
*
|
|
24
|
+
* ASCII-only.
|
|
25
|
+
*/
|
|
26
|
+
const KNOWN_PROVIDERS = [
|
|
27
|
+
{
|
|
28
|
+
label: 'Gmail',
|
|
29
|
+
domains: ['gmail.com', 'googlemail.com'],
|
|
30
|
+
imap: { host: 'imap.gmail.com', port: 993, secure: true },
|
|
31
|
+
smtp: { host: 'smtp.gmail.com', port: 465, secure: true },
|
|
32
|
+
note: 'Gmail requiere una App Password: activa la verificacion en dos pasos en tu cuenta Google y genera una en myaccount.google.com/apppasswords. Tu contrasena normal no funciona.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
label: 'Outlook / Hotmail',
|
|
36
|
+
domains: ['outlook.com', 'outlook.es', 'hotmail.com', 'hotmail.es', 'live.com', 'msn.com'],
|
|
37
|
+
imap: { host: 'outlook.office365.com', port: 993, secure: true },
|
|
38
|
+
smtp: { host: 'smtp-mail.outlook.com', port: 587, secure: false },
|
|
39
|
+
note: 'Microsoft puede exigir una contrasena de aplicacion segun la configuracion de seguridad de la cuenta. Ademas esta migrando las cuentas personales a autenticacion moderna (OAuth): si el login con contrasena falla aunque sea correcta, es probable que tu cuenta ya no acepte IMAP con contrasena basica.',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
label: 'Yahoo',
|
|
43
|
+
domains: ['yahoo.com', 'yahoo.es', 'yahoo.com.ar', 'yahoo.com.mx', 'ymail.com'],
|
|
44
|
+
imap: { host: 'imap.mail.yahoo.com', port: 993, secure: true },
|
|
45
|
+
smtp: { host: 'smtp.mail.yahoo.com', port: 465, secure: true },
|
|
46
|
+
note: 'Yahoo requiere una App Password generada en la seccion de seguridad de la cuenta.',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
label: 'iCloud',
|
|
50
|
+
domains: ['icloud.com', 'me.com', 'mac.com'],
|
|
51
|
+
imap: { host: 'imap.mail.me.com', port: 993, secure: true },
|
|
52
|
+
smtp: { host: 'smtp.mail.me.com', port: 587, secure: false },
|
|
53
|
+
note: 'iCloud requiere una contrasena especifica de app, generada en appleid.apple.com.',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
label: 'AOL',
|
|
57
|
+
domains: ['aol.com'],
|
|
58
|
+
imap: { host: 'imap.aol.com', port: 993, secure: true },
|
|
59
|
+
smtp: { host: 'smtp.aol.com', port: 465, secure: true },
|
|
60
|
+
note: 'AOL requiere una App Password generada en la seguridad de la cuenta.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
label: 'GMX',
|
|
64
|
+
domains: ['gmx.com'],
|
|
65
|
+
imap: { host: 'imap.gmx.com', port: 993, secure: true },
|
|
66
|
+
smtp: { host: 'mail.gmx.com', port: 587, secure: false },
|
|
67
|
+
note: 'GMX trae IMAP desactivado por defecto: activalo primero en la web de GMX (Configuracion > POP3/IMAP > permitir acceso IMAP) o el login va a fallar.',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
label: 'GMX',
|
|
71
|
+
domains: ['gmx.net', 'gmx.de'],
|
|
72
|
+
imap: { host: 'imap.gmx.net', port: 993, secure: true },
|
|
73
|
+
smtp: { host: 'mail.gmx.net', port: 587, secure: false },
|
|
74
|
+
note: 'GMX trae IMAP desactivado por defecto: activalo primero en la web de GMX (Configuracion > POP3/IMAP > permitir acceso IMAP) o el login va a fallar.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
label: 'Zoho',
|
|
78
|
+
domains: ['zoho.com', 'zohomail.com'],
|
|
79
|
+
imap: { host: 'imap.zoho.com', port: 993, secure: true },
|
|
80
|
+
smtp: { host: 'smtp.zoho.com', port: 465, secure: true },
|
|
81
|
+
note: 'Zoho requiere activar IMAP en Mail Settings y, si tenes verificacion en dos pasos, una contrasena de aplicacion generada en la seccion de seguridad de la cuenta.',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
label: 'Fastmail',
|
|
85
|
+
domains: ['fastmail.com', 'fastmail.fm'],
|
|
86
|
+
imap: { host: 'imap.fastmail.com', port: 993, secure: true },
|
|
87
|
+
smtp: { host: 'smtp.fastmail.com', port: 465, secure: true },
|
|
88
|
+
note: 'Fastmail requiere una contrasena de app para clientes IMAP.',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
label: 'Yandex',
|
|
92
|
+
domains: ['yandex.com', 'yandex.ru'],
|
|
93
|
+
imap: { host: 'imap.yandex.com', port: 993, secure: true },
|
|
94
|
+
smtp: { host: 'smtp.yandex.com', port: 465, secure: true },
|
|
95
|
+
note: 'Yandex requiere una contrasena de aplicacion (creala en id.yandex.com, seccion de contrasenas de aplicacion) y tener IMAP habilitado en la configuracion del correo.',
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
/* Providers that do not expose IMAP/SMTP at all -- a convention guess
|
|
99
|
+
* would never connect, so fail with an explanation instead. */
|
|
100
|
+
const UNSUPPORTED_DOMAINS = {
|
|
101
|
+
'proton.me': 'Proton Mail no expone IMAP/SMTP directo. Necesitas instalar Proton Mail Bridge y cargar a mano los datos locales que el Bridge te muestra.',
|
|
102
|
+
'protonmail.com': 'Proton Mail no expone IMAP/SMTP directo. Necesitas instalar Proton Mail Bridge y cargar a mano los datos locales que el Bridge te muestra.',
|
|
103
|
+
'tuta.com': 'Tuta (Tutanota) no ofrece IMAP/SMTP, no se puede usar con Yuemail.',
|
|
104
|
+
'tutanota.com': 'Tuta (Tutanota) no ofrece IMAP/SMTP, no se puede usar con Yuemail.',
|
|
105
|
+
};
|
|
106
|
+
export function lookupKnownProvider(domain) {
|
|
107
|
+
const d = domain.toLowerCase();
|
|
108
|
+
return KNOWN_PROVIDERS.find((p) => p.domains.includes(d));
|
|
109
|
+
}
|
|
110
|
+
export function guessByConvention(domain) {
|
|
111
|
+
return {
|
|
112
|
+
imap: { host: 'imap.' + domain, port: 993, secure: true },
|
|
113
|
+
smtp: { host: 'smtp.' + domain, port: 587, secure: false },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/* --- ISPDB (Thunderbird autoconfig) XML parsing ----------------------
|
|
117
|
+
* The format is stable and flat; a tag-scoped regex extraction keeps us
|
|
118
|
+
* dependency-free. We only need the first imap incomingServer and the
|
|
119
|
+
* first smtp outgoingServer. */
|
|
120
|
+
function firstBlock(xml, tag, type) {
|
|
121
|
+
const re = new RegExp('<' + tag + '[^>]*type="' + type + '"[\\s\\S]*?</' + tag + '>');
|
|
122
|
+
const m = xml.match(re);
|
|
123
|
+
return m ? m[0] : undefined;
|
|
124
|
+
}
|
|
125
|
+
function tagValue(block, tag) {
|
|
126
|
+
const m = block.match(new RegExp('<' + tag + '>([^<]*)</' + tag + '>'));
|
|
127
|
+
return m && m[1] !== undefined ? m[1].trim() : undefined;
|
|
128
|
+
}
|
|
129
|
+
function socketTypeToSecure(socketType) {
|
|
130
|
+
/* SSL = direct TLS. STARTTLS / plain = not direct TLS. */
|
|
131
|
+
return (socketType ?? '').toUpperCase() === 'SSL';
|
|
132
|
+
}
|
|
133
|
+
function substituteUsername(template, email) {
|
|
134
|
+
if (!template || template.length === 0)
|
|
135
|
+
return email;
|
|
136
|
+
const at = email.lastIndexOf('@');
|
|
137
|
+
const localPart = at > 0 ? email.slice(0, at) : email;
|
|
138
|
+
return template
|
|
139
|
+
.replace(/%EMAILADDRESS%/g, email)
|
|
140
|
+
.replace(/%EMAILLOCALPART%/g, localPart);
|
|
141
|
+
}
|
|
142
|
+
export function parseIspdbXml(xml, email) {
|
|
143
|
+
const inc = firstBlock(xml, 'incomingServer', 'imap');
|
|
144
|
+
const out = firstBlock(xml, 'outgoingServer', 'smtp');
|
|
145
|
+
if (!inc || !out)
|
|
146
|
+
return undefined;
|
|
147
|
+
const imapHost = tagValue(inc, 'hostname');
|
|
148
|
+
const imapPort = Number(tagValue(inc, 'port'));
|
|
149
|
+
const smtpHost = tagValue(out, 'hostname');
|
|
150
|
+
const smtpPort = Number(tagValue(out, 'port'));
|
|
151
|
+
if (!imapHost || !smtpHost || !Number.isFinite(imapPort) || !Number.isFinite(smtpPort)) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
const result = {
|
|
155
|
+
ok: true,
|
|
156
|
+
source: 'ispdb',
|
|
157
|
+
username: substituteUsername(tagValue(inc, 'username'), email),
|
|
158
|
+
imap: { host: imapHost, port: imapPort, secure: socketTypeToSecure(tagValue(inc, 'socketType')) },
|
|
159
|
+
smtp: { host: smtpHost, port: smtpPort, secure: socketTypeToSecure(tagValue(out, 'socketType')) },
|
|
160
|
+
};
|
|
161
|
+
const label = tagValue(xml, 'displayShortName');
|
|
162
|
+
if (label)
|
|
163
|
+
result.provider = label;
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
const ISPDB_BASE = 'https://autoconfig.thunderbird.net/v1.1/';
|
|
167
|
+
const ISPDB_TIMEOUT_MS = 6000;
|
|
168
|
+
/**
|
|
169
|
+
* Resolve IMAP + SMTP configuration for an email address.
|
|
170
|
+
* `fetchImpl` is injectable so tests never hit the network.
|
|
171
|
+
*/
|
|
172
|
+
export async function autoconfigure(email, fetchImpl = fetch) {
|
|
173
|
+
const trimmed = email.trim().toLowerCase();
|
|
174
|
+
const at = trimmed.lastIndexOf('@');
|
|
175
|
+
if (at <= 0 || at === trimmed.length - 1 || !trimmed.slice(at + 1).includes('.')) {
|
|
176
|
+
return { ok: false, error: 'Direccion de correo invalida: ' + email };
|
|
177
|
+
}
|
|
178
|
+
const domain = trimmed.slice(at + 1);
|
|
179
|
+
const unsupported = UNSUPPORTED_DOMAINS[domain];
|
|
180
|
+
if (unsupported)
|
|
181
|
+
return { ok: false, error: unsupported };
|
|
182
|
+
/* Tier 1: built-in provider table. */
|
|
183
|
+
const known = lookupKnownProvider(domain);
|
|
184
|
+
if (known) {
|
|
185
|
+
const result = {
|
|
186
|
+
ok: true,
|
|
187
|
+
source: 'known',
|
|
188
|
+
provider: known.label,
|
|
189
|
+
username: trimmed,
|
|
190
|
+
imap: known.imap,
|
|
191
|
+
smtp: known.smtp,
|
|
192
|
+
};
|
|
193
|
+
if (known.note)
|
|
194
|
+
result.note = known.note;
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
/* Tier 2: Mozilla ISPDB. Any failure (offline, 404, malformed XML)
|
|
198
|
+
* falls through to the convention guess. */
|
|
199
|
+
try {
|
|
200
|
+
const ctrl = new AbortController();
|
|
201
|
+
const timer = setTimeout(() => ctrl.abort(), ISPDB_TIMEOUT_MS);
|
|
202
|
+
try {
|
|
203
|
+
const res = await fetchImpl(ISPDB_BASE + encodeURIComponent(domain), { signal: ctrl.signal });
|
|
204
|
+
if (res.ok) {
|
|
205
|
+
const xml = await res.text();
|
|
206
|
+
const parsed = parseIspdbXml(xml, trimmed);
|
|
207
|
+
if (parsed)
|
|
208
|
+
return parsed;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
/* network unavailable -- fall through */
|
|
217
|
+
}
|
|
218
|
+
/* Tier 3: convention guess. */
|
|
219
|
+
const guess = guessByConvention(domain);
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
source: 'guess',
|
|
223
|
+
username: trimmed,
|
|
224
|
+
imap: guess.imap,
|
|
225
|
+
smtp: guess.smtp,
|
|
226
|
+
note: 'Servidores estimados por convencion (imap./smtp. + dominio). Usa Probar conexion; si falla, consulta los datos exactos con tu proveedor.',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yuemail document store (F3 / F13).
|
|
3
|
+
*
|
|
4
|
+
* Documents persist as JSON under ~/.yuemail/documents/<id>.json. No DB,
|
|
5
|
+
* no user table, single-user. The store is process-local; callers are
|
|
6
|
+
* responsible for serialising writes per document id.
|
|
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
|
+
import { randomBytes } from 'node:crypto';
|
|
14
|
+
function homeDir() {
|
|
15
|
+
const env = process.env['YUEMAIL_HOME'];
|
|
16
|
+
return env ? path.resolve(env) : path.join(os.homedir(), '.yuemail');
|
|
17
|
+
}
|
|
18
|
+
function docsRoot() { return path.join(homeDir(), 'documents'); }
|
|
19
|
+
function ensureDocsDir() {
|
|
20
|
+
const d = docsRoot();
|
|
21
|
+
if (!existsSync(d)) {
|
|
22
|
+
mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function newId() {
|
|
26
|
+
return 'doc-' + randomBytes(6).toString('hex');
|
|
27
|
+
}
|
|
28
|
+
function nowIso() {
|
|
29
|
+
return new Date().toISOString();
|
|
30
|
+
}
|
|
31
|
+
export async function listDocuments() {
|
|
32
|
+
ensureDocsDir();
|
|
33
|
+
const files = (await fs.readdir(docsRoot())).filter((f) => f.endsWith('.json'));
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const f of files) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = await fs.readFile(path.join(docsRoot(), f), 'utf-8');
|
|
38
|
+
const d = JSON.parse(raw);
|
|
39
|
+
out.push({ id: d.id, title: d.title, updated_at: d.updated_at });
|
|
40
|
+
}
|
|
41
|
+
catch { /* skip corrupt */ }
|
|
42
|
+
}
|
|
43
|
+
out.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
export async function getDocument(id) {
|
|
47
|
+
ensureDocsDir();
|
|
48
|
+
const p = path.join(docsRoot(), id + '.json');
|
|
49
|
+
if (!existsSync(p))
|
|
50
|
+
return undefined;
|
|
51
|
+
const raw = await fs.readFile(p, 'utf-8');
|
|
52
|
+
return JSON.parse(raw);
|
|
53
|
+
}
|
|
54
|
+
export async function createDocument(input) {
|
|
55
|
+
ensureDocsDir();
|
|
56
|
+
const doc = {
|
|
57
|
+
id: newId(),
|
|
58
|
+
title: input.title ?? '',
|
|
59
|
+
blocks: input.blocks ?? [],
|
|
60
|
+
created_at: nowIso(),
|
|
61
|
+
updated_at: nowIso(),
|
|
62
|
+
};
|
|
63
|
+
await fs.writeFile(path.join(docsRoot(), doc.id + '.json'), JSON.stringify(doc, null, 2), { mode: 0o600 });
|
|
64
|
+
return doc;
|
|
65
|
+
}
|
|
66
|
+
export async function updateDocument(id, patch) {
|
|
67
|
+
const existing = await getDocument(id);
|
|
68
|
+
if (!existing)
|
|
69
|
+
return undefined;
|
|
70
|
+
const next = {
|
|
71
|
+
...existing,
|
|
72
|
+
title: patch.title ?? existing.title,
|
|
73
|
+
blocks: patch.blocks ?? existing.blocks,
|
|
74
|
+
updated_at: nowIso(),
|
|
75
|
+
};
|
|
76
|
+
await fs.writeFile(path.join(docsRoot(), id + '.json'), JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
79
|
+
export async function deleteDocument(id) {
|
|
80
|
+
const p = path.join(docsRoot(), id + '.json');
|
|
81
|
+
if (!existsSync(p))
|
|
82
|
+
return false;
|
|
83
|
+
await fs.unlink(p);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
export function docsDir() {
|
|
87
|
+
return docsRoot();
|
|
88
|
+
}
|
|
89
|
+
/* Backwards-compatible alias for an inadvertent reference. */
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yuemail .docx generator (F5 / acceptance #7).
|
|
3
|
+
*
|
|
4
|
+
* Converts a document JSON (title + ordered blocks of paragraph or
|
|
5
|
+
* signature image) into a real Office Open XML buffer using the
|
|
6
|
+
* `docx` library. The buffer's first two bytes are always `PK`
|
|
7
|
+
* (ZIP magic) because .docx is a ZIP container.
|
|
8
|
+
*
|
|
9
|
+
* Never persisted as a separate file -- rebuilt from JSON on demand.
|
|
10
|
+
*
|
|
11
|
+
* ASCII-only.
|
|
12
|
+
*/
|
|
13
|
+
import { Document, Packer, Paragraph, TextRun, HeadingLevel, ImageRun, } from 'docx';
|
|
14
|
+
export async function renderDocx(doc) {
|
|
15
|
+
const children = [];
|
|
16
|
+
children.push(new Paragraph({
|
|
17
|
+
heading: HeadingLevel.HEADING_1,
|
|
18
|
+
children: [new TextRun({ text: doc.title || 'Documento', bold: true })],
|
|
19
|
+
}));
|
|
20
|
+
for (const block of doc.blocks) {
|
|
21
|
+
if (block.type === 'paragraph') {
|
|
22
|
+
children.push(new Paragraph({
|
|
23
|
+
children: [new TextRun({ text: block.text })],
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
else if (block.type === 'signature_image') {
|
|
27
|
+
const png = Buffer.from(block.png_b64, 'base64');
|
|
28
|
+
children.push(new Paragraph({
|
|
29
|
+
children: [
|
|
30
|
+
new ImageRun({
|
|
31
|
+
data: png,
|
|
32
|
+
transformation: { width: 200, height: 80 },
|
|
33
|
+
}),
|
|
34
|
+
],
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const docxDoc = new Document({
|
|
39
|
+
sections: [{ properties: {}, children }],
|
|
40
|
+
});
|
|
41
|
+
return Packer.toBuffer(docxDoc);
|
|
42
|
+
}
|
|
43
|
+
/** Sanity helper for tests + the /api/document/:id/docx endpoint. */
|
|
44
|
+
export function isDocxBuffer(buf) {
|
|
45
|
+
return buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b; /* "PK" */
|
|
46
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yuemail HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Express 4. Binds 127.0.0.1:5180 (loopback only -- never LAN, never
|
|
5
|
+
* external interface). Mounts:
|
|
6
|
+
* /api/health
|
|
7
|
+
* /api/vault/* (BYOK -- read key names + statuses; write set/delete)
|
|
8
|
+
* /api/documents/* (CRUD + .docx render on demand)
|
|
9
|
+
* /api/signature/* (PNG read/write/delete)
|
|
10
|
+
* /api/email/send (POST -- rejects when SMTP unconfigured)
|
|
11
|
+
* /api/email/autoconfig (GET -- IMAP/SMTP discovery from the address)
|
|
12
|
+
* /api/email/verify (POST -- live IMAP/SMTP connection test)
|
|
13
|
+
* /api/inbox/list (GET -- imapflow last N envelopes)
|
|
14
|
+
* /* (static SPA from dist/, when present)
|
|
15
|
+
*
|
|
16
|
+
* ASCII-only.
|
|
17
|
+
*/
|
|
18
|
+
import express from 'express';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { existsSync } from 'node:fs';
|
|
22
|
+
import { registerVaultRoutes } from './routes/vault.js';
|
|
23
|
+
import { registerDocumentsRoutes } from './routes/documents.js';
|
|
24
|
+
import { registerSignatureRoutes } from './routes/signature.js';
|
|
25
|
+
import { registerEmailRoutes } from './routes/email.js';
|
|
26
|
+
import { registerInboxRoutes } from './routes/inbox.js';
|
|
27
|
+
import { registerSettingsRoutes } from './routes/settings.js';
|
|
28
|
+
export const HOST = '127.0.0.1';
|
|
29
|
+
export const PORT = 5180;
|
|
30
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
31
|
+
const __dirname = path.dirname(__filename);
|
|
32
|
+
export function buildApp(opts = {}) {
|
|
33
|
+
const app = express();
|
|
34
|
+
app.use(express.json({ limit: '10mb' }));
|
|
35
|
+
app.get('/api/health', (_req, res) => {
|
|
36
|
+
res.json({ ok: true, version: '0.3.0' });
|
|
37
|
+
});
|
|
38
|
+
registerVaultRoutes(app);
|
|
39
|
+
registerDocumentsRoutes(app);
|
|
40
|
+
registerSignatureRoutes(app);
|
|
41
|
+
registerEmailRoutes(app);
|
|
42
|
+
registerInboxRoutes(app);
|
|
43
|
+
registerSettingsRoutes(app);
|
|
44
|
+
/* Static SPA -- present in production builds, absent in dev. */
|
|
45
|
+
const staticRoot = opts.staticRoot ?? path.resolve(__dirname, '..', 'dist');
|
|
46
|
+
if (existsSync(staticRoot)) {
|
|
47
|
+
app.use(express.static(staticRoot));
|
|
48
|
+
app.get('*', (_req, res) => {
|
|
49
|
+
res.sendFile(path.join(staticRoot, 'index.html'));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/* Error fallback. Keeps the response shape stable for the SPA. */
|
|
53
|
+
app.use((err, _req, res, _next) => {
|
|
54
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
55
|
+
res.status(500).json({ ok: false, error: message });
|
|
56
|
+
});
|
|
57
|
+
return app;
|
|
58
|
+
}
|
|
59
|
+
export async function startServer(opts = {}) {
|
|
60
|
+
const app = buildApp(opts);
|
|
61
|
+
return await new Promise((resolve, reject) => {
|
|
62
|
+
const server = app.listen(PORT, HOST, () => {
|
|
63
|
+
const url = 'http://' + HOST + ':' + PORT;
|
|
64
|
+
resolve({
|
|
65
|
+
url,
|
|
66
|
+
close: () => new Promise((r) => { server.close(() => r()); }),
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
server.on('error', reject);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/* Direct-run entry: `node server-dist/index.js`. */
|
|
73
|
+
const isDirectRun = process.argv[1] !== undefined &&
|
|
74
|
+
path.resolve(process.argv[1]) === __filename;
|
|
75
|
+
if (isDirectRun) {
|
|
76
|
+
startServer().then((s) => {
|
|
77
|
+
process.stdout.write('Yuemail server listening at ' + s.url + '\n');
|
|
78
|
+
}).catch((err) => {
|
|
79
|
+
process.stderr.write('Failed to start: ' + (err instanceof Error ? err.message : String(err)) + '\n');
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { listDocuments, getDocument, createDocument, updateDocument, deleteDocument, } from '../documents.js';
|
|
2
|
+
import { renderDocx } from '../docx-builder.js';
|
|
3
|
+
export function registerDocumentsRoutes(app) {
|
|
4
|
+
app.get('/api/documents', async (_req, res) => {
|
|
5
|
+
const docs = await listDocuments();
|
|
6
|
+
res.json({ ok: true, documents: docs });
|
|
7
|
+
});
|
|
8
|
+
app.get('/api/documents/:id', async (req, res) => {
|
|
9
|
+
const id = req.params['id'] ?? '';
|
|
10
|
+
const doc = await getDocument(id);
|
|
11
|
+
if (!doc) {
|
|
12
|
+
res.status(404).json({ ok: false, error: 'not found' });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
res.json({ ok: true, document: doc });
|
|
16
|
+
});
|
|
17
|
+
app.post('/api/documents', async (req, res) => {
|
|
18
|
+
const body = req.body;
|
|
19
|
+
const title = typeof body?.title === 'string' ? body.title : '';
|
|
20
|
+
const blocks = Array.isArray(body?.blocks) ? body.blocks : [];
|
|
21
|
+
const doc = await createDocument({ title, blocks });
|
|
22
|
+
res.status(201).json({ ok: true, document: doc });
|
|
23
|
+
});
|
|
24
|
+
app.put('/api/documents/:id', async (req, res) => {
|
|
25
|
+
const id = req.params['id'] ?? '';
|
|
26
|
+
const body = req.body;
|
|
27
|
+
const patch = {};
|
|
28
|
+
if (typeof body?.title === 'string')
|
|
29
|
+
patch.title = body.title;
|
|
30
|
+
if (Array.isArray(body?.blocks))
|
|
31
|
+
patch.blocks = body.blocks;
|
|
32
|
+
const updated = await updateDocument(id, patch);
|
|
33
|
+
if (!updated) {
|
|
34
|
+
res.status(404).json({ ok: false, error: 'not found' });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
res.json({ ok: true, document: updated });
|
|
38
|
+
});
|
|
39
|
+
app.delete('/api/documents/:id', async (req, res) => {
|
|
40
|
+
const id = req.params['id'] ?? '';
|
|
41
|
+
const ok = await deleteDocument(id);
|
|
42
|
+
res.status(ok ? 204 : 404).end();
|
|
43
|
+
});
|
|
44
|
+
app.get('/api/documents/:id/docx', async (req, res) => {
|
|
45
|
+
const id = req.params['id'] ?? '';
|
|
46
|
+
const doc = await getDocument(id);
|
|
47
|
+
if (!doc) {
|
|
48
|
+
res.status(404).json({ ok: false, error: 'not found' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const buf = await renderDocx(doc);
|
|
52
|
+
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
|
53
|
+
res.setHeader('Content-Disposition', 'attachment; filename="' + (doc.title || 'documento') + '.docx"');
|
|
54
|
+
res.send(buf);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
|
+
import { getCategoryStatus, getKey } from '../vault.js';
|
|
3
|
+
import { getDocument } from '../documents.js';
|
|
4
|
+
import { renderDocx } from '../docx-builder.js';
|
|
5
|
+
function isValidEmail(s) {
|
|
6
|
+
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);
|
|
7
|
+
}
|
|
8
|
+
function parseRecipients(raw) {
|
|
9
|
+
const tokens = raw.split(',').map((t) => t.trim()).filter((t) => t.length > 0);
|
|
10
|
+
const ok = [];
|
|
11
|
+
const bad = [];
|
|
12
|
+
for (const t of tokens) {
|
|
13
|
+
if (isValidEmail(t))
|
|
14
|
+
ok.push(t.toLowerCase());
|
|
15
|
+
else
|
|
16
|
+
bad.push(t);
|
|
17
|
+
}
|
|
18
|
+
return { ok, bad };
|
|
19
|
+
}
|
|
20
|
+
export function registerEmailRoutes(app) {
|
|
21
|
+
app.post('/api/email/send', async (req, res) => {
|
|
22
|
+
/* 1. Gate on SMTP configuration (acceptance #4). */
|
|
23
|
+
const status = await getCategoryStatus();
|
|
24
|
+
if (!status.smtp.configured) {
|
|
25
|
+
res.status(400).json({
|
|
26
|
+
ok: false,
|
|
27
|
+
error: 'SMTP not configured. Run `yuemail vault setup` to configure your SMTP server before sending.',
|
|
28
|
+
missing: status.smtp.missing,
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
/* 2. Validate body. */
|
|
33
|
+
const body = req.body;
|
|
34
|
+
const to = typeof body?.to === 'string' ? body.to : '';
|
|
35
|
+
const subject = typeof body?.subject === 'string' ? body.subject : '';
|
|
36
|
+
const bodyText = typeof body?.body_text === 'string' ? body.body_text : '';
|
|
37
|
+
const attachId = typeof body?.attach_document_id === 'string' ? body.attach_document_id : '';
|
|
38
|
+
const { ok: validTo, bad: badTo } = parseRecipients(to);
|
|
39
|
+
if (validTo.length === 0) {
|
|
40
|
+
res.status(400).json({
|
|
41
|
+
ok: false,
|
|
42
|
+
error: badTo.length > 0
|
|
43
|
+
? 'No valid recipients. Invalid entries: ' + badTo.join(', ')
|
|
44
|
+
: 'No recipients provided.',
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
/* 3. Build transport from vault. */
|
|
49
|
+
const host = await getKey('smtp.host');
|
|
50
|
+
const portStr = await getKey('smtp.port');
|
|
51
|
+
const user = await getKey('smtp.user');
|
|
52
|
+
const pass = await getKey('smtp.pass');
|
|
53
|
+
const secStr = await getKey('smtp.secure');
|
|
54
|
+
const from = await getKey('identity.from');
|
|
55
|
+
const name = await getKey('identity.name');
|
|
56
|
+
if (!host || !portStr || !user || !pass || !from) {
|
|
57
|
+
res.status(400).json({ ok: false, error: 'SMTP not configured (missing decrypted credential)' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const port = Number(portStr);
|
|
61
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
62
|
+
res.status(400).json({ ok: false, error: 'smtp.port is not a positive integer' });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const secure = (secStr ?? '').toLowerCase() === 'true' || port === 465;
|
|
66
|
+
/* 4. Render attachment (when requested). */
|
|
67
|
+
const attachments = [];
|
|
68
|
+
if (attachId.length > 0) {
|
|
69
|
+
const doc = await getDocument(attachId);
|
|
70
|
+
if (!doc) {
|
|
71
|
+
res.status(404).json({ ok: false, error: 'attach_document_id not found' });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const buf = await renderDocx(doc);
|
|
75
|
+
attachments.push({
|
|
76
|
+
filename: (doc.title || 'documento') + '.docx',
|
|
77
|
+
content: buf,
|
|
78
|
+
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/* 5. Send. */
|
|
82
|
+
try {
|
|
83
|
+
const transporter = nodemailer.createTransport({
|
|
84
|
+
host,
|
|
85
|
+
port,
|
|
86
|
+
secure,
|
|
87
|
+
auth: { user, pass },
|
|
88
|
+
});
|
|
89
|
+
const fromHeader = name && name.length > 0 ? '"' + name + '" <' + from + '>' : from;
|
|
90
|
+
const info = await transporter.sendMail({
|
|
91
|
+
from: fromHeader,
|
|
92
|
+
to: validTo.join(', '),
|
|
93
|
+
subject: subject.length > 0 ? subject : '(sin asunto)',
|
|
94
|
+
text: bodyText.length > 0 ? bodyText : 'Adjunto el documento.',
|
|
95
|
+
attachments,
|
|
96
|
+
});
|
|
97
|
+
res.json({
|
|
98
|
+
ok: true,
|
|
99
|
+
message_id: info.messageId,
|
|
100
|
+
accepted: info.accepted,
|
|
101
|
+
rejected: info.rejected,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
106
|
+
res.status(500).json({ ok: false, error: 'SMTP send failed: ' + message });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|