@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/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
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](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
|
package/bin/yuemail.mjs
ADDED
|
@@ -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
|
+
});
|