@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yujinapp / Pablo Kuschniroff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # Yuemail
2
+
3
+ > Voice-first single-user email client. Dictate, sign, send.
4
+ > Local-only. BYOK encrypted vault. No telemetry.
5
+
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![Node >= 18](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen)](https://nodejs.org/)
8
+
9
+ Yuemail is for people who need to dictate a written document, sign
10
+ it, and send it by email from their own personal machine without
11
+ relying on a sighted helper or sustained-precision mouse interaction.
12
+
13
+ The north-star journey: *"Pablo dicta un informe, dice 'firmar', dice
14
+ 'enviar a ana arroba ejemplo punto com', y termina. El sistema desaparece."*
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install -g @yujinapp/yuemail
20
+ ```
21
+
22
+ Requires Node `>=18.0.0`. The browser side uses the Web Speech API,
23
+ which is supported by Chrome, Edge, and Safari. Other browsers
24
+ degrade to button-only mode.
25
+
26
+ ## Usage
27
+
28
+ ```bash
29
+ yuemail # start the server + open the browser
30
+ yuemail start # start the server only (no browser)
31
+ yuemail vault setup # interactive 12-field wizard (or use the in-app gear)
32
+ yuemail vault list # list configured key names
33
+ yuemail vault set <name> <val> # encrypt and store a value
34
+ yuemail vault delete <name> # remove a value
35
+ yuemail version
36
+ yuemail help
37
+ ```
38
+
39
+ The server binds `127.0.0.1:5180` only. Loopback. Never LAN.
40
+
41
+ ## Voice commands
42
+
43
+ The Spanish utterances Yuemail recognises (accent-insensitive,
44
+ filler-word-tolerant):
45
+
46
+ | You say | What happens |
47
+ |----------------------------------------|------------------------------------|
48
+ | `nuevo documento` / `documento nuevo` | Clear the editor, start fresh |
49
+ | `abrir documento [nombre]` | Load the latest or by partial name |
50
+ | `guardar firma` | Open the signature pad |
51
+ | `firmar` | Insert the saved signature |
52
+ | `iniciar dictado` | Begin transcription |
53
+ | `fin dictado` | Stop transcription |
54
+ | `enviar a <email>` | Open the send dialog |
55
+ | `leer bandeja` | List the latest envelopes |
56
+ | `abrir configuracion` / `ajustes` | Open the account settings |
57
+ | `detener voz` | Mute the microphone |
58
+
59
+ Plus the always-on mic toggle: `encender microfono` / `apagar microfono`.
60
+
61
+ Spoken `arroba` is treated as `@`, spoken `punto` as `.`, and the
62
+ extracted email is lowercased.
63
+
64
+ ### Inside a dialog
65
+
66
+ While a modal is open it owns the voice channel: the global phrases
67
+ above are suspended (so `firmar` cannot touch the document behind the
68
+ dialog) and these contextual phrases take over:
69
+
70
+ | Dialog open | You say | What happens |
71
+ |------------------|------------------------------------|----------------------------|
72
+ | Send dialog | `confirmar` / `enviar` / `mandar` | Confirm and send the email |
73
+ | Send dialog | `cancelar` / `cerrar` / `salir` | Close without sending |
74
+ | Signature pad | `guardar` / `listo` | Save the drawn signature |
75
+ | Signature pad | `borrar` / `limpiar` | Clear the canvas |
76
+ | Signature pad | `generar` / `cursiva` | Bake the typed name |
77
+ | Signature pad | `cancelar` / `cerrar` / `salir` | Close without saving |
78
+ | Settings | `detectar` / `automatica` | Autodetect the servers |
79
+ | Settings | `probar` / `verificar` | Live IMAP+SMTP test |
80
+ | Settings | `guardar` / `listo` | Save to the vault |
81
+ | Settings | `cancelar` / `cerrar` / `salir` | Close without saving |
82
+
83
+ `apagar microfono` and `detener voz` always work, dialog or not.
84
+
85
+ ### Dictating into dialog fields
86
+
87
+ Every modal input is dictatable: say `campo <nombre>` to arm a field
88
+ (send dialog: `destinatario`, `asunto`, `cuerpo`, `adjuntar`;
89
+ signature pad: `nombre`; settings: `correo`, `contrasena`, `servidor
90
+ imap`, ...), then speak the value. `borrar campo` empties the armed
91
+ field; `fin campo` releases it and restores the dialog verbs.
92
+
93
+ The message body APPENDS: each utterance lands as a new paragraph,
94
+ so long messages can be dictated sentence by sentence. While a field
95
+ is armed in the send dialog or the signature pad, free speech IS the
96
+ field value -- saying `enviar` mid-sentence does NOT send the email;
97
+ say `fin campo` first, then `enviar`. Recipients accept several
98
+ addresses ("ana arroba ejemplo punto com y pedro arroba test punto
99
+ org", or spoken `coma`). Passwords are never echoed back, only their
100
+ captured length.
101
+
102
+ ## Account setup (the gear)
103
+
104
+ Click the gear in the topbar (or say `abrir configuracion`), type
105
+ your email address, and Yuemail resolves the IMAP/SMTP servers for
106
+ you: major providers from a built-in table (Gmail, Outlook, Yahoo,
107
+ iCloud, AOL, GMX, Zoho, Fastmail, Yandex -- including each
108
+ provider's app-password caveat), everything else via the Mozilla
109
+ autoconfig database, with a convention guess as last resort. Run
110
+ `Probar conexion` to test the real IMAP+SMTP login before saving;
111
+ saving encrypts everything into the vault. Proton (without Bridge)
112
+ and Tuta do not expose IMAP/SMTP and are reported as such.
113
+
114
+ ## Privacy
115
+
116
+ - Single user. No login. No user table.
117
+ - No database. JSON-on-filesystem under `~/.yuemail/`.
118
+ - No outbound network beyond the IMAP/SMTP servers **you** configure.
119
+ - The vault is encrypted at rest (AES-256-GCM, scrypt-derived key from
120
+ a per-machine salt). The raw `vault.json` never contains the
121
+ plaintext value of any stored secret.
122
+
123
+ ## Vault keys
124
+
125
+ The in-app gear fills these for you from just your address. The
126
+ wizard prompts for the same 12 fields; skip any with Enter and fill
127
+ them later with `yuemail vault set`.
128
+
129
+ - `imap.host`, `imap.port`, `imap.user`, `imap.pass`, `imap.secure`
130
+ - `smtp.host`, `smtp.port`, `smtp.user`, `smtp.pass`, `smtp.secure`
131
+ - `identity.from`, `identity.name`
132
+
133
+ ## Repository layout
134
+
135
+ ```
136
+ bin/yuemail.mjs # CLI entry
137
+ server/ # Express + IMAP/SMTP + .docx
138
+ src/ # React frontend (Vite)
139
+ src/voice/ # Spanish command parser + Web Speech hook
140
+ src/styles/tokens.css # Yujin Design System tokens
141
+ tests/ # Vitest suites
142
+ docs/SPEC.md # The specification this build implements
143
+ ```
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * yuemail CLI (F12).
4
+ *
5
+ * Subcommands:
6
+ * yuemail launch server + open browser
7
+ * yuemail start server only (no browser)
8
+ * yuemail vault list list configured key names
9
+ * yuemail vault set <name> <v> set a key
10
+ * yuemail vault delete <name> delete a key
11
+ * yuemail vault setup interactive 12-field wizard
12
+ * yuemail version
13
+ * yuemail help
14
+ *
15
+ * Server binds 127.0.0.1 only. Loopback. Never LAN.
16
+ *
17
+ * ASCII-only.
18
+ */
19
+ import { createRequire } from 'node:module';
20
+ import { fileURLToPath } from 'node:url';
21
+ import path from 'node:path';
22
+ import readline from 'node:readline';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = path.dirname(__filename);
27
+
28
+ const VERSION = '0.3.0';
29
+
30
+ function printHelp() {
31
+ process.stdout.write(
32
+ 'yuemail v' + VERSION + '\n' +
33
+ 'Voice-first single-user email client.\n\n' +
34
+ 'Usage: yuemail [command]\n\n' +
35
+ 'Commands:\n' +
36
+ ' (no args) Start the server + open the browser.\n' +
37
+ ' start Start the server only.\n' +
38
+ ' vault list List configured vault key names.\n' +
39
+ ' vault set <name> <value> Encrypt and store a value.\n' +
40
+ ' vault delete <name> Remove a value.\n' +
41
+ ' vault setup Interactive 12-field wizard.\n' +
42
+ ' version Print the version.\n' +
43
+ ' help Show this message.\n' +
44
+ '\n' +
45
+ 'Vault keys:\n' +
46
+ ' imap.host, imap.port, imap.user, imap.pass, imap.secure\n' +
47
+ ' smtp.host, smtp.port, smtp.user, smtp.pass, smtp.secure\n' +
48
+ ' identity.from, identity.name\n' +
49
+ '\n' +
50
+ 'The server binds 127.0.0.1:5180. Loopback only. Never LAN.\n',
51
+ );
52
+ }
53
+
54
+ function resolveServerModule() {
55
+ /* Prefer the built JS, fall back to ts-node for dev runs. */
56
+ const builtPath = path.resolve(__dirname, '..', 'server-dist', 'index.js');
57
+ try {
58
+ return import('file://' + builtPath.replace(/\\/g, '/'));
59
+ } catch (err) {
60
+ process.stderr.write('Failed to load built server at ' + builtPath + '\n');
61
+ process.stderr.write('Did you run `npm run build`?\n');
62
+ throw err;
63
+ }
64
+ }
65
+
66
+ function resolveVaultModule() {
67
+ const builtPath = path.resolve(__dirname, '..', 'server-dist', 'vault.js');
68
+ return import('file://' + builtPath.replace(/\\/g, '/'));
69
+ }
70
+
71
+ async function cmdStart({ openBrowser }) {
72
+ const server = await (await resolveServerModule()).startServer();
73
+ process.stdout.write('Yuemail listening at ' + server.url + '\n');
74
+ if (openBrowser) {
75
+ try {
76
+ const { default: open } = await import('open');
77
+ await open(server.url);
78
+ } catch {
79
+ process.stdout.write('(Could not open browser automatically. Visit ' + server.url + ' manually.)\n');
80
+ }
81
+ }
82
+ /* Keep alive on SIGINT. */
83
+ const shutdown = async () => {
84
+ process.stdout.write('\nShutting down...\n');
85
+ await server.close();
86
+ process.exit(0);
87
+ };
88
+ process.on('SIGINT', shutdown);
89
+ process.on('SIGTERM', shutdown);
90
+ }
91
+
92
+ async function cmdVaultList() {
93
+ const { getAllKeys } = await resolveVaultModule();
94
+ const keys = await getAllKeys();
95
+ if (keys.length === 0) {
96
+ process.stdout.write('(vault is empty -- run `yuemail vault setup`)\n');
97
+ return;
98
+ }
99
+ for (const k of keys) process.stdout.write(k + '\n');
100
+ }
101
+
102
+ async function cmdVaultSet(name, value) {
103
+ const { setKey, isValidVaultKey } = await resolveVaultModule();
104
+ if (!isValidVaultKey(name)) {
105
+ process.stderr.write('Unknown vault key: ' + name + '\n');
106
+ process.stderr.write('Run `yuemail help` for the list of valid keys.\n');
107
+ process.exitCode = 2;
108
+ return;
109
+ }
110
+ await setKey(name, value);
111
+ process.stdout.write('OK: ' + name + ' set.\n');
112
+ }
113
+
114
+ async function cmdVaultDelete(name) {
115
+ const { deleteKey, isValidVaultKey } = await resolveVaultModule();
116
+ if (!isValidVaultKey(name)) {
117
+ process.stderr.write('Unknown vault key: ' + name + '\n');
118
+ process.exitCode = 2;
119
+ return;
120
+ }
121
+ const ok = await deleteKey(name);
122
+ if (ok) process.stdout.write('OK: ' + name + ' removed.\n');
123
+ else process.stdout.write('Not present: ' + name + '\n');
124
+ }
125
+
126
+ function ask(rl, prompt) {
127
+ return new Promise((resolve) => rl.question(prompt, (a) => resolve(a)));
128
+ }
129
+
130
+ async function cmdVaultSetup() {
131
+ const { setKey, VAULT_KEYS } = await resolveVaultModule();
132
+ process.stdout.write('Yuemail vault setup -- 12 fields. Press Enter to skip a field.\n');
133
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
134
+ let set = 0;
135
+ for (const key of VAULT_KEYS) {
136
+ const value = (await ask(rl, ' ' + key + ': ')).trim();
137
+ if (value.length > 0) {
138
+ await setKey(key, value);
139
+ set++;
140
+ }
141
+ }
142
+ rl.close();
143
+ process.stdout.write('\n' + set + ' / 12 fields configured.\n');
144
+ }
145
+
146
+ async function main() {
147
+ const args = process.argv.slice(2);
148
+ const cmd = args[0] ?? '';
149
+
150
+ if (cmd === '' ) return cmdStart({ openBrowser: true });
151
+ if (cmd === 'start') return cmdStart({ openBrowser: false });
152
+ if (cmd === 'version' || cmd === '--version' || cmd === '-v') {
153
+ process.stdout.write(VERSION + '\n');
154
+ return;
155
+ }
156
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
157
+ printHelp();
158
+ return;
159
+ }
160
+ if (cmd === 'vault') {
161
+ const sub = args[1] ?? '';
162
+ if (sub === 'list') return cmdVaultList();
163
+ if (sub === 'set') return cmdVaultSet(args[2] ?? '', args[3] ?? '');
164
+ if (sub === 'delete') return cmdVaultDelete(args[2] ?? '');
165
+ if (sub === 'setup') return cmdVaultSetup();
166
+ process.stderr.write('Unknown vault subcommand: ' + sub + '\n');
167
+ printHelp();
168
+ process.exitCode = 2;
169
+ return;
170
+ }
171
+ process.stderr.write('Unknown command: ' + cmd + '\n');
172
+ printHelp();
173
+ process.exitCode = 2;
174
+ }
175
+
176
+ main().catch((err) => {
177
+ process.stderr.write('Error: ' + (err instanceof Error ? err.message : String(err)) + '\n');
178
+ process.exitCode = 1;
179
+ });