salamailer 1.0.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 +43 -0
- package/README.md +159 -0
- package/cli/salamailer.mjs +795 -0
- package/package.json +45 -0
- package/public/js/crypto.js +319 -0
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Salamailer CLI + MCP server. Zero dependencies; reuses the exact client crypto
|
|
3
|
+
// module the web app serves (public/js/crypto.js — Node ships Web Crypto), so
|
|
4
|
+
// the zero-knowledge property is identical: plaintext and content keys exist
|
|
5
|
+
// only in THIS process; the server receives ciphertext and wrapped keys.
|
|
6
|
+
//
|
|
7
|
+
// salamailer init --origin https://… --email you@firm.fi (password prompted)
|
|
8
|
+
// salamailer send --to client@x.fi --message "…" [--file f.pdf] [--passphrase p | --sms +358…] [--burn] [--expiry 7] [--self-relay] [--save-passphrase]
|
|
9
|
+
// salamailer list | replies | reply <id> | status | contacts | receipt <id> | resend <id> | resend-code <id> | revoke <id>
|
|
10
|
+
// salamailer mcp ← stdio MCP server for AI agents (12 tools: send/list/status/receipt/resend/resend-code/revoke + save/list/delete_contact + list/read_reply)
|
|
11
|
+
//
|
|
12
|
+
// Credentials (~/.config/salamailer/config.json, 0600): an API key (transport
|
|
13
|
+
// auth) + the raw Vault Key. Treat the file like an SSH private key. The
|
|
14
|
+
// account password itself is never stored.
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync, mkdirSync, realpathSync, existsSync } from 'node:fs';
|
|
17
|
+
import { readFile } from 'node:fs/promises';
|
|
18
|
+
import { homedir, hostname } from 'node:os';
|
|
19
|
+
import { join, basename, dirname, sep } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { parseArgs } from 'node:util';
|
|
22
|
+
import { createInterface } from 'node:readline';
|
|
23
|
+
import { randomBytes } from 'node:crypto';
|
|
24
|
+
import * as C from '../public/js/crypto.js';
|
|
25
|
+
|
|
26
|
+
const VERSION = '1.0.0';
|
|
27
|
+
// Installs from before the Salamailer rename kept credentials under the old
|
|
28
|
+
// product name's config dir. Keep reading that path (and the old env var) if
|
|
29
|
+
// the new one doesn't exist yet, so the rename never locks an install out.
|
|
30
|
+
const LEGACY_CONFIG_PATH = join(homedir(), '.config', 'salaemail', 'config.json');
|
|
31
|
+
const DEFAULT_CONFIG_PATH = join(homedir(), '.config', 'salamailer', 'config.json');
|
|
32
|
+
const CONFIG_PATH = process.env.SALAMAILER_CONFIG || process.env.SALAEMAIL_CONFIG ||
|
|
33
|
+
(!existsSync(DEFAULT_CONFIG_PATH) && existsSync(LEGACY_CONFIG_PATH) ? LEGACY_CONFIG_PATH : DEFAULT_CONFIG_PATH);
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// config + api helpers
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
function loadConfig() {
|
|
39
|
+
try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); }
|
|
40
|
+
catch { die(`Not configured. Run: salamailer init --origin <url> --email <email>`); }
|
|
41
|
+
}
|
|
42
|
+
function saveConfig(cfg) {
|
|
43
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
|
|
44
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
|
45
|
+
}
|
|
46
|
+
function die(msg) { console.error(msg); process.exit(1); }
|
|
47
|
+
|
|
48
|
+
async function api(cfg, method, path, body, raw = false) {
|
|
49
|
+
const headers = { authorization: `Bearer ${cfg.apiKey}` };
|
|
50
|
+
if (body && !raw) headers['content-type'] = 'application/json';
|
|
51
|
+
if (raw) headers['content-type'] = 'application/octet-stream';
|
|
52
|
+
const res = await fetch(cfg.origin + path, { method, headers, body: raw ? body : body ? JSON.stringify(body) : undefined });
|
|
53
|
+
const data = await res.json().catch(() => ({}));
|
|
54
|
+
if (!res.ok) throw new Error(data.error || `${method} ${path} → ${res.status}`);
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function vaultKey(cfg) {
|
|
59
|
+
// Non-extractable in-process handle over the stored raw VK.
|
|
60
|
+
return crypto.subtle.importKey('raw', C.unb64(cfg.vk), { name: 'AES-GCM', length: 256 }, false, ['wrapKey', 'unwrapKey']);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function apiBytes(cfg, path) {
|
|
64
|
+
const res = await fetch(cfg.origin + path, { headers: { authorization: `Bearer ${cfg.apiKey}` } });
|
|
65
|
+
if (!res.ok) throw new Error(`GET ${path} → ${res.status}`);
|
|
66
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The account's reply PRIVATE key (wrapped under the VK, unwrapped locally).
|
|
70
|
+
// Returns null for accounts that haven't created one yet (web login migrates).
|
|
71
|
+
async function replySecretKey(cfg) {
|
|
72
|
+
const v = await api(cfg, 'GET', '/api/auth/vault');
|
|
73
|
+
if (!v.wrappedReplySk) return null;
|
|
74
|
+
return C.unwrapReplySK(v.wrappedReplySk, await vaultKey(cfg));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Agent guardrails (confused-deputy defence). A prompt-injected agent must not
|
|
79
|
+
// be able to exfiltrate arbitrary local files or send to arbitrary recipients.
|
|
80
|
+
// Configured in the credentials file:
|
|
81
|
+
// "contactsOnly": true — recipients must already be saved contacts,
|
|
82
|
+
// i.e. approved by a human beforehand. In this
|
|
83
|
+
// mode the MCP (agent) channel cannot add or
|
|
84
|
+
// remove contacts — approval stays out-of-band.
|
|
85
|
+
// "hideRecipients": true — privacy mode for the agent channel: the model
|
|
86
|
+
// never sees recipient emails/phones. Sends are
|
|
87
|
+
// addressed by saved-contact LABEL ("contact"
|
|
88
|
+
// arg); every tool output masks addresses.
|
|
89
|
+
// Implies contactsOnly. Interactive CLI use by
|
|
90
|
+
// the human is unaffected.
|
|
91
|
+
// "allowedRecipientDomains": ["client-firm.fi", …] — empty/absent = any
|
|
92
|
+
// "attachmentRoot": "/path" — MCP file_paths must resolve inside it
|
|
93
|
+
// (default: the cwd where `salamailer mcp` starts)
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
async function checkRecipientAllowed(cfg, to) {
|
|
96
|
+
const email = String(to).trim().toLowerCase();
|
|
97
|
+
if (cfg.contactsOnly) {
|
|
98
|
+
const { contacts } = await api(cfg, 'GET', '/api/contacts');
|
|
99
|
+
if (!contacts.some((c) => c.email === email))
|
|
100
|
+
throw new Error(
|
|
101
|
+
`recipient "${email}" is not an approved contact. A human can approve them with ` +
|
|
102
|
+
`"salamailer contacts add ${email}" or on the web Account page, then retry.`);
|
|
103
|
+
}
|
|
104
|
+
const domains = cfg.allowedRecipientDomains;
|
|
105
|
+
if (Array.isArray(domains) && domains.length) {
|
|
106
|
+
const d = email.split('@')[1];
|
|
107
|
+
if (!domains.some((x) => String(x).toLowerCase() === d))
|
|
108
|
+
throw new Error(
|
|
109
|
+
`recipient domain "${d}" is not approved. A human can approve it by adding it to ` +
|
|
110
|
+
`"allowedRecipientDomains" in ${CONFIG_PATH}, then retry.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- hideRecipients helpers ----------------------------------------------------
|
|
115
|
+
// In privacy mode the agent addresses people by the LABEL a human gave a saved
|
|
116
|
+
// contact; emails/phones never enter or leave the model's context. Labels are
|
|
117
|
+
// resolved to addresses inside THIS process (which holds the credentials anyway).
|
|
118
|
+
async function resolveContactByLabel(cfg, label) {
|
|
119
|
+
const { contacts } = await api(cfg, 'GET', '/api/contacts');
|
|
120
|
+
const l = String(label || '').trim().toLowerCase();
|
|
121
|
+
const matches = contacts.filter((c) => (c.label || '').trim().toLowerCase() === l);
|
|
122
|
+
if (matches.length === 1) return matches[0];
|
|
123
|
+
const available = contacts.map((c) => c.label).filter(Boolean).join(', ');
|
|
124
|
+
if (!matches.length)
|
|
125
|
+
throw new Error(`no saved contact labeled "${label}". Available labels: ${available || '(none — a human can label contacts on the web Account page)'}`);
|
|
126
|
+
throw new Error(`multiple saved contacts share the label "${label}" — a human should make the labels unique on the web Account page.`);
|
|
127
|
+
}
|
|
128
|
+
function maskEmail(email) {
|
|
129
|
+
const [local, domain = ''] = String(email).split('@');
|
|
130
|
+
const parts = domain.split('.');
|
|
131
|
+
const tld = parts.length > 1 ? '.' + parts.pop() : '';
|
|
132
|
+
return `${local.slice(0, 1)}***@${parts.join('.').slice(0, 1)}***${tld}`;
|
|
133
|
+
}
|
|
134
|
+
// email → label lookup for masking list/receipt output (labelled contacts show
|
|
135
|
+
// their label; anyone else shows a masked address).
|
|
136
|
+
async function recipientDisplayer(cfg) {
|
|
137
|
+
if (!cfg.hideRecipients) return (email) => email;
|
|
138
|
+
let labels = new Map();
|
|
139
|
+
try {
|
|
140
|
+
const { contacts } = await api(cfg, 'GET', '/api/contacts');
|
|
141
|
+
labels = new Map(contacts.filter((c) => c.label).map((c) => [c.email, c.label]));
|
|
142
|
+
} catch { /* mask-only fallback */ }
|
|
143
|
+
return (email) => labels.get(String(email || '').toLowerCase()) || maskEmail(email);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function checkAttachmentAllowed(path, root) {
|
|
147
|
+
let rp, rr;
|
|
148
|
+
try { rp = realpathSync(path); rr = realpathSync(root); }
|
|
149
|
+
catch { throw new Error(`cannot attach "${path}": not found or unreadable`); }
|
|
150
|
+
if (rp !== rr && !rp.startsWith(rr + sep))
|
|
151
|
+
throw new Error(`refusing to attach "${path}": outside the allowed attachment root (${rr})`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Human-friendly generated gate passphrase, e.g. "kx3f-9qtm-2bwp". */
|
|
155
|
+
function genPassphrase() {
|
|
156
|
+
const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'; // no lookalikes
|
|
157
|
+
const pick = () => [...randomBytes(4)].map((b) => alphabet[b % alphabet.length]).join('');
|
|
158
|
+
return `${pick()}-${pick()}-${pick()}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// core send (shared by CLI + MCP)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
async function sendSecure(cfg, { to, message, files = [], passphrase, smsPhone, waPhone, expiryDays = 30, burn = false, selfRelay = false, savePassphrase = false, paranoid = false, lang = 'fi' }) {
|
|
165
|
+
await checkRecipientAllowed(cfg, to);
|
|
166
|
+
if ([passphrase, smsPhone, waPhone].filter(Boolean).length > 1)
|
|
167
|
+
throw new Error('choose ONE gate: a passphrase, an SMS phone, or a WhatsApp phone');
|
|
168
|
+
const channel = waPhone ? 'whatsapp' : smsPhone ? 'sms' : 'passphrase';
|
|
169
|
+
if (paranoid && channel !== 'passphrase')
|
|
170
|
+
throw new Error('paranoid mode requires a passphrase gate (one-time codes rotate on resend)');
|
|
171
|
+
let generated = false;
|
|
172
|
+
let fromContact = false;
|
|
173
|
+
if (channel === 'passphrase' && !passphrase) {
|
|
174
|
+
// Reuse a standing per-recipient passphrase if one is saved; else generate one.
|
|
175
|
+
const saved = await getContactPassphrase(cfg, to);
|
|
176
|
+
if (saved) { passphrase = saved; fromContact = true; }
|
|
177
|
+
else { passphrase = genPassphrase(); generated = true; }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const ck = await C.generateCK();
|
|
181
|
+
// Paranoid mode (§5.4): content is encrypted under HKDF(CK, passphrase); the
|
|
182
|
+
// fragment and sender copy still carry the raw CK, so resend is unchanged.
|
|
183
|
+
const encKey = paranoid ? await C.deriveParanoidKey(ck, passphrase) : ck;
|
|
184
|
+
const vk = await vaultKey(cfg);
|
|
185
|
+
// Body envelope: carry our reply public key (derived locally from the wrapped
|
|
186
|
+
// private key) inside the E2E encryption so the recipient can reply securely.
|
|
187
|
+
let replyPub = null;
|
|
188
|
+
try { const sk = await replySecretKey(cfg); if (sk) replyPub = await C.replyPubJwk(sk); }
|
|
189
|
+
catch { /* reply key unavailable — send without the reply option */ }
|
|
190
|
+
const blobs = [];
|
|
191
|
+
const bodyEnc = await C.encryptBytes(encKey, C.utf8(C.packEnvelope(message, replyPub)));
|
|
192
|
+
blobs.push({ meta: { kind: 'body', iv: bodyEnc.iv, size: bodyEnc.ct.length }, ct: bodyEnc.ct });
|
|
193
|
+
for (const f of files) {
|
|
194
|
+
const bytes = new Uint8Array(await readFile(f));
|
|
195
|
+
const enc = await C.encryptBytes(encKey, bytes);
|
|
196
|
+
const nameEnc = await C.encryptBytes(encKey, C.utf8(basename(f)));
|
|
197
|
+
blobs.push({
|
|
198
|
+
meta: { kind: 'attachment', iv: enc.iv, size: enc.ct.length, filenameCipher: `${nameEnc.iv}.${C.b64(nameEnc.ct)}` },
|
|
199
|
+
ct: enc.ct,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const created = await api(cfg, 'POST', '/api/messages', {
|
|
204
|
+
recipientEmail: to, recipientPhone: waPhone || smsPhone || null, secondChannel: channel,
|
|
205
|
+
gateSecret: channel === 'passphrase' ? passphrase : null,
|
|
206
|
+
expiryDays, burnAfterRead: burn, wrappedCk: await C.wrapCK(ck, vk), paranoid, lang,
|
|
207
|
+
blobs: blobs.map((b) => b.meta),
|
|
208
|
+
});
|
|
209
|
+
for (let i = 0; i < created.uploads.length; i++)
|
|
210
|
+
await api(cfg, 'PUT', created.uploads[i].uploadUrl, blobs[i].ct, true);
|
|
211
|
+
|
|
212
|
+
const openUrl = `${cfg.origin}/m/${created.id}#${await C.exportCKFragment(ck)}`;
|
|
213
|
+
// Self-relay: finalize WITHOUT openUrl so the CK fragment never reaches the
|
|
214
|
+
// server even transiently (parity with the web compose checkbox, PLAN.md
|
|
215
|
+
// §5.5.1). The caller must then deliver `openUrl` over their own channel.
|
|
216
|
+
const fin = await api(cfg, 'POST', `/api/messages/${created.id}/finalize`, selfRelay ? {} : { openUrl });
|
|
217
|
+
|
|
218
|
+
// Optionally remember this passphrase for the recipient (encrypted under VK).
|
|
219
|
+
if (channel === 'passphrase' && savePassphrase && !fromContact) await saveContact(cfg, to, passphrase);
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
id: created.id, openUrl, channel, selfRelay, paranoid,
|
|
223
|
+
passphrase: channel === 'passphrase' ? passphrase : undefined,
|
|
224
|
+
passphraseGenerated: generated, passphraseFromContact: fromContact,
|
|
225
|
+
emailDelivered: fin.emailDelivered, smsDelivered: fin.smsDelivered,
|
|
226
|
+
expiryDays, burnAfterRead: burn,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- saved recipients (standing per-recipient passphrase) -------------------
|
|
231
|
+
// Stored server-side encrypted under the VK, so it syncs across web/CLI/MCP
|
|
232
|
+
// without the server ever reading it. Resolve/save via the shared crypto helpers.
|
|
233
|
+
async function getContactPassphrase(cfg, email) {
|
|
234
|
+
const { contacts } = await api(cfg, 'GET', '/api/contacts');
|
|
235
|
+
const c = contacts.find((x) => x.email === String(email).trim().toLowerCase());
|
|
236
|
+
if (!c) return null;
|
|
237
|
+
return C.unwrapPassphrase({ keyWrapped: c.keyWrapped, iv: c.iv, ct: c.ct }, await vaultKey(cfg));
|
|
238
|
+
}
|
|
239
|
+
async function saveContact(cfg, email, passphrase, label) {
|
|
240
|
+
const w = await C.wrapPassphrase(passphrase, await vaultKey(cfg));
|
|
241
|
+
return api(cfg, 'POST', '/api/contacts', {
|
|
242
|
+
email: String(email).trim().toLowerCase(), label: label || null,
|
|
243
|
+
keyWrapped: w.keyWrapped, iv: w.iv, ct: w.ct,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function sendReport(r, redactSecrets = false) {
|
|
248
|
+
let out;
|
|
249
|
+
if (r.selfRelay) {
|
|
250
|
+
out = `Sent (id ${r.id}). Self-relay mode: NO notification email sent — the server never received the key.\n`;
|
|
251
|
+
out += `Secure link (deliver this yourself, e.g. from your own email): ${r.openUrl}\n`;
|
|
252
|
+
} else {
|
|
253
|
+
out = `Sent (id ${r.id}). Notification email ${r.emailDelivered ? 'delivered to the recipient' : 'NOT delivered'}.\n`;
|
|
254
|
+
out += `Secure link: ${r.openUrl}\n`;
|
|
255
|
+
}
|
|
256
|
+
if (r.channel === 'passphrase') {
|
|
257
|
+
if (redactSecrets && r.passphraseFromContact) {
|
|
258
|
+
// Privacy mode: the standing passphrase is already known to the recipient
|
|
259
|
+
// and to the human on the Account page — the model does not need it.
|
|
260
|
+
out += `Gate: the recipient's standing passphrase (already known to them; hidden in privacy mode).`;
|
|
261
|
+
} else {
|
|
262
|
+
const tag = r.passphraseFromContact ? ' (saved for this recipient)' : r.passphraseGenerated ? ' (auto-generated)' : '';
|
|
263
|
+
out += `Gate passphrase${tag}: ${r.passphrase}\n`;
|
|
264
|
+
out += r.passphraseFromContact
|
|
265
|
+
? `This recipient's standing passphrase was reused — they already know it; no need to relay it again.`
|
|
266
|
+
: `IMPORTANT: relay the passphrase over a DIFFERENT channel than the link (phone, SMS, in person) — never in the same email/thread.`;
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
const via = r.channel === 'whatsapp' ? 'WhatsApp' : 'SMS';
|
|
270
|
+
out += `A one-time ${via} code was ${r.smsDelivered ? 'sent' : 'NOT sent'} to the recipient's phone (the code is the gate, not the key).`;
|
|
271
|
+
}
|
|
272
|
+
if (r.paranoid) out += `\nParanoid mode: the passphrase is mixed into the encryption key — the link alone (even plus a server breach) cannot decrypt.`;
|
|
273
|
+
if (r.burnAfterRead) out += `\nBurn-after-read: the message self-destructs after first open.`;
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Rebuild a message's open link from the sender's wrapped-CK copy (needs the
|
|
278
|
+
// VK). Used to resend a notification without ever storing the fragment server-side.
|
|
279
|
+
async function reconstructOpenUrl(cfg, id) {
|
|
280
|
+
const sk = await api(cfg, 'GET', `/api/messages/${id}/sender-key`);
|
|
281
|
+
const ck = await C.unwrapCK(sk.wrappedCk, await vaultKey(cfg));
|
|
282
|
+
return `${cfg.origin}/m/${id}#${await C.exportCKFragment(ck)}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --- secure replies (inbox) ---------------------------------------------------
|
|
286
|
+
// Decrypt an inbound reply locally: unwrap our reply private key under the VK,
|
|
287
|
+
// ECDH against the reply's ephemeral public key, HKDF → AES-GCM. Attachments are
|
|
288
|
+
// written only when a save dir is given, with sanitized names, contained inside
|
|
289
|
+
// the attachment root (an injected agent must not write outside it either).
|
|
290
|
+
async function readReplyDecrypted(cfg, id, saveDir) {
|
|
291
|
+
const sk = await replySecretKey(cfg);
|
|
292
|
+
if (!sk) throw new Error('no reply key on this account yet — log in once on the web app to create it');
|
|
293
|
+
const r = await api(cfg, 'GET', `/api/replies/${id}`);
|
|
294
|
+
const key = await C.deriveReplyKeyForRead(sk, r.ephPub, r.hkdfSalt);
|
|
295
|
+
let body = '';
|
|
296
|
+
const attachments = [];
|
|
297
|
+
for (const bl of r.blobs) {
|
|
298
|
+
const ct = await apiBytes(cfg, `/api/replies/${id}/blob/${bl.id}`);
|
|
299
|
+
if (bl.kind === 'body') { body = C.fromUtf8(await C.decryptBytes(key, bl.iv, ct)); continue; }
|
|
300
|
+
const [nameIv, nameCt] = bl.filenameCipher.split('.');
|
|
301
|
+
const filename = basename(C.fromUtf8(await C.decryptBytes(key, nameIv, C.unb64(nameCt)))).replace(/[\x00-\x1f/\\]/g, '_') || 'attachment';
|
|
302
|
+
const a = { filename, size: bl.size };
|
|
303
|
+
if (saveDir) {
|
|
304
|
+
const root = cfg.attachmentRoot || process.cwd();
|
|
305
|
+
mkdirSync(saveDir, { recursive: true });
|
|
306
|
+
checkAttachmentAllowed(saveDir, root);
|
|
307
|
+
const dest = join(saveDir, filename);
|
|
308
|
+
writeFileSync(dest, await C.decryptBytes(key, bl.iv, ct));
|
|
309
|
+
a.savedTo = dest;
|
|
310
|
+
}
|
|
311
|
+
attachments.push(a);
|
|
312
|
+
}
|
|
313
|
+
return { id: r.id, from: r.recipientEmail, messageId: r.messageId, createdAt: r.createdAt, body, attachments };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function replyReport(r) {
|
|
317
|
+
let out = `Reply ${r.id} · from ${r.from} · to message ${r.messageId} · ${new Date(r.createdAt).toISOString()}\n`;
|
|
318
|
+
out += `--- decrypted locally ---\n${r.body}\n`;
|
|
319
|
+
for (const a of r.attachments)
|
|
320
|
+
out += a.savedTo ? `Attachment saved: ${a.savedTo}\n` : `Attachment (not saved — pass a save dir): ${a.filename} (${a.size} B encrypted)\n`;
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function statusReport(s) {
|
|
325
|
+
const lines = [`Plan: ${s.plan}${s.status ? ` (${s.status})` : ''}`];
|
|
326
|
+
lines.push(`Can send: ${s.canSend ? 'yes' : `no — ${s.reason || 'unavailable'}`}`);
|
|
327
|
+
if (s.plan === 'trial') lines.push(`Trial sends left: ${s.trialSendsLeft ?? '—'}`);
|
|
328
|
+
lines.push(`SMS codes: ${s.canSms ? `included on every message (EU/EEA numbers; anti-fraud daily valve) · ${s.smsUsed ?? 0} used this period` : 'not available on trial — use passphrases'}`);
|
|
329
|
+
if (s.periodEnd) lines.push(`Billing period ends: ${new Date(s.periodEnd).toISOString().slice(0, 10)}`);
|
|
330
|
+
lines.push(`Billing account: ${s.hasBillingAccount ? 'linked' : 'none'} · Stripe checkout: ${s.stripeConfigured ? 'available' : 'off'}`);
|
|
331
|
+
return lines.join('\n');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// commands
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
async function cmdInit(args) {
|
|
338
|
+
const { values: v } = parseArgs({ args, options: {
|
|
339
|
+
origin: { type: 'string' }, email: { type: 'string' }, password: { type: 'string' },
|
|
340
|
+
'allow-domain': { type: 'string', multiple: true },
|
|
341
|
+
'contacts-only': { type: 'boolean' },
|
|
342
|
+
'hide-recipients': { type: 'boolean' },
|
|
343
|
+
} });
|
|
344
|
+
if (v.password)
|
|
345
|
+
console.error('⚠️ --password lands in shell history and process lists — prefer the interactive prompt.');
|
|
346
|
+
const origin = (v.origin || (await prompt('Server origin (https://…): '))).replace(/\/+$/, '');
|
|
347
|
+
const email = v.email || (await prompt('Email: '));
|
|
348
|
+
const password = v.password || (await prompt('Password: ', true));
|
|
349
|
+
|
|
350
|
+
// Derive locally, unwrap VK (validates the password), never send the password.
|
|
351
|
+
const saltsRes = await fetch(`${origin}/api/auth/login-salts`, {
|
|
352
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email }),
|
|
353
|
+
});
|
|
354
|
+
const salts = await saltsRes.json();
|
|
355
|
+
if (!saltsRes.ok) die(salts.error || 'server error');
|
|
356
|
+
let vkRaw;
|
|
357
|
+
try {
|
|
358
|
+
const kek = await C.deriveKEK(password, C.unb64(salts.vkSalt));
|
|
359
|
+
const vk = await C.unwrapVK(salts.wrappedVk, kek);
|
|
360
|
+
vkRaw = C.b64(await crypto.subtle.exportKey('raw', vk));
|
|
361
|
+
} catch { die('Wrong email or password.'); }
|
|
362
|
+
|
|
363
|
+
const verifier = await C.deriveAuthVerifier(password, C.unb64(salts.authSalt));
|
|
364
|
+
const loginRes = await fetch(`${origin}/api/auth/login`, {
|
|
365
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, authVerifier: verifier }),
|
|
366
|
+
});
|
|
367
|
+
if (!loginRes.ok) die('Wrong email or password.');
|
|
368
|
+
const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0];
|
|
369
|
+
|
|
370
|
+
const keyRes = await fetch(`${origin}/api/apikeys`, {
|
|
371
|
+
method: 'POST', headers: { 'content-type': 'application/json', cookie },
|
|
372
|
+
body: JSON.stringify({ name: `cli-${hostname()}` }),
|
|
373
|
+
});
|
|
374
|
+
const keyData = await keyRes.json();
|
|
375
|
+
if (!keyRes.ok) die(keyData.error || 'could not create API key');
|
|
376
|
+
|
|
377
|
+
// hideRecipients implies contactsOnly: label-addressing only works against
|
|
378
|
+
// the human-approved contact list, so the approval boundary comes with it.
|
|
379
|
+
saveConfig({
|
|
380
|
+
origin, email, apiKey: keyData.key, vk: vkRaw,
|
|
381
|
+
contactsOnly: Boolean(v['contacts-only']) || Boolean(v['hide-recipients']),
|
|
382
|
+
hideRecipients: Boolean(v['hide-recipients']),
|
|
383
|
+
allowedRecipientDomains: v['allow-domain'] || [],
|
|
384
|
+
attachmentRoot: null, // null = cwd of `salamailer mcp` at launch
|
|
385
|
+
});
|
|
386
|
+
console.log(`Configured. Credentials in ${CONFIG_PATH} (0600) — protect it like an SSH key.`);
|
|
387
|
+
if (!v['contacts-only'] && !v['hide-recipients'] && !(v['allow-domain'] || []).length)
|
|
388
|
+
console.log(`Tip (recommended before agent use): strictest is --hide-recipients (agents address\n` +
|
|
389
|
+
`people by contact label and never see emails/phones) or --contacts-only (agents may only\n` +
|
|
390
|
+
`send to recipients a human has saved as contacts); or restrict with --allow-domain <domain>.\n` +
|
|
391
|
+
`Also consider pinning "attachmentRoot" in the config.`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function prompt(q, hide = false) {
|
|
395
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
396
|
+
if (hide) {
|
|
397
|
+
process.stderr.write(q);
|
|
398
|
+
rl.output = { write() {} }; // suppress echo (approximate; fine for a CLI)
|
|
399
|
+
}
|
|
400
|
+
return new Promise((resolve) => rl.question(hide ? '' : q, (a) => { rl.close(); if (hide) process.stderr.write('\n'); resolve(a.trim()); }));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function cmdSend(args) {
|
|
404
|
+
const { values: v } = parseArgs({ args, options: {
|
|
405
|
+
to: { type: 'string' }, message: { type: 'string' }, 'message-file': { type: 'string' },
|
|
406
|
+
file: { type: 'string', multiple: true }, passphrase: { type: 'string' }, sms: { type: 'string' },
|
|
407
|
+
whatsapp: { type: 'string' },
|
|
408
|
+
burn: { type: 'boolean' }, expiry: { type: 'string' }, lang: { type: 'string' }, json: { type: 'boolean' },
|
|
409
|
+
'self-relay': { type: 'boolean' }, 'save-passphrase': { type: 'boolean' },
|
|
410
|
+
paranoid: { type: 'boolean' },
|
|
411
|
+
} });
|
|
412
|
+
if (!v.to) die('--to <email> is required');
|
|
413
|
+
let message = v.message;
|
|
414
|
+
if (!message && v['message-file']) message = readFileSync(v['message-file'], 'utf8');
|
|
415
|
+
if (!message && !process.stdin.isTTY) message = readFileSync(0, 'utf8');
|
|
416
|
+
if (!message) die('provide --message, --message-file, or pipe the body on stdin');
|
|
417
|
+
|
|
418
|
+
const r = await sendSecure(loadConfig(), {
|
|
419
|
+
to: v.to, message, files: v.file || [], passphrase: v.passphrase, smsPhone: v.sms,
|
|
420
|
+
waPhone: v.whatsapp,
|
|
421
|
+
expiryDays: v.expiry ? Number(v.expiry) : 30, burn: !!v.burn, selfRelay: !!v['self-relay'],
|
|
422
|
+
savePassphrase: !!v['save-passphrase'], paranoid: !!v.paranoid, lang: v.lang || 'fi',
|
|
423
|
+
});
|
|
424
|
+
console.log(v.json ? JSON.stringify(r, null, 2) : sendReport(r));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function cmdContacts(args) {
|
|
428
|
+
const [sub, ...rest] = args;
|
|
429
|
+
const cfg = loadConfig();
|
|
430
|
+
if (!sub || sub === 'list') {
|
|
431
|
+
const { contacts } = await api(cfg, 'GET', '/api/contacts');
|
|
432
|
+
if (!contacts.length) return console.log('No saved recipients. Add one with: salamailer contacts add --to <email> --passphrase <p>');
|
|
433
|
+
for (const c of contacts) console.log(`${c.email}${c.label ? ` (${c.label})` : ''} · saved passphrase`);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (sub === 'add') {
|
|
437
|
+
const { values: v } = parseArgs({ args: rest, options: {
|
|
438
|
+
to: { type: 'string' }, passphrase: { type: 'string' }, label: { type: 'string' },
|
|
439
|
+
} });
|
|
440
|
+
if (!v.to) die('usage: salamailer contacts add --to <email> --passphrase <p> [--label <name>]');
|
|
441
|
+
const passphrase = v.passphrase || (await prompt('Standing passphrase for this recipient: ', true));
|
|
442
|
+
if (passphrase.length < 4 || passphrase.length > 200) die('passphrase must be 4–200 characters');
|
|
443
|
+
await saveContact(cfg, v.to, passphrase, v.label);
|
|
444
|
+
return console.log(`Saved standing passphrase for ${v.to.trim().toLowerCase()}. Future sends reuse it automatically.`);
|
|
445
|
+
}
|
|
446
|
+
if (sub === 'rm' || sub === 'remove') {
|
|
447
|
+
const { values: v } = parseArgs({ args: rest, options: { to: { type: 'string' } } });
|
|
448
|
+
if (!v.to) die('usage: salamailer contacts rm --to <email>');
|
|
449
|
+
const { contacts } = await api(cfg, 'GET', '/api/contacts');
|
|
450
|
+
const c = contacts.find((x) => x.email === v.to.trim().toLowerCase());
|
|
451
|
+
if (!c) die(`no saved recipient for ${v.to}`);
|
|
452
|
+
await api(cfg, 'DELETE', `/api/contacts/${c.id}`);
|
|
453
|
+
return console.log(`Removed saved passphrase for ${c.email}.`);
|
|
454
|
+
}
|
|
455
|
+
die('usage: salamailer contacts [list | add --to <email> --passphrase <p> | rm --to <email>]');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function cmdStatus(args) {
|
|
459
|
+
const { values: v } = parseArgs({ args, options: { json: { type: 'boolean' } } });
|
|
460
|
+
const s = await api(loadConfig(), 'GET', '/api/billing/status');
|
|
461
|
+
console.log(v.json ? JSON.stringify(s, null, 2) : statusReport(s));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function cmdResend(id) {
|
|
465
|
+
if (!id) die('usage: salamailer resend <message-id>');
|
|
466
|
+
const cfg = loadConfig();
|
|
467
|
+
const openUrl = await reconstructOpenUrl(cfg, id);
|
|
468
|
+
await api(cfg, 'POST', `/api/messages/${id}/notify`, { openUrl });
|
|
469
|
+
console.log(`Notification email re-sent for ${id}.`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function cmdResendCode(id) {
|
|
473
|
+
if (!id) die('usage: salamailer resend-code <message-id>');
|
|
474
|
+
await api(loadConfig(), 'POST', `/api/messages/${id}/resend-code`);
|
|
475
|
+
console.log(`A fresh one-time SMS code was sent for ${id}.`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function cmdList() {
|
|
479
|
+
const { messages } = await api(loadConfig(), 'GET', '/api/messages');
|
|
480
|
+
if (!messages.length) return console.log('No messages.');
|
|
481
|
+
for (const m of messages) {
|
|
482
|
+
console.log(`${m.id} ${m.status.padEnd(8)} ${m.second_channel.padEnd(10)} → ${m.recipient_email} (expires ${new Date(m.expiry_at).toISOString().slice(0, 10)})`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function cmdReplies() {
|
|
487
|
+
const { replies } = await api(loadConfig(), 'GET', '/api/replies');
|
|
488
|
+
if (!replies.length) return console.log('No secure replies.');
|
|
489
|
+
for (const r of replies)
|
|
490
|
+
console.log(`${r.id} ${r.read_at ? 'read ' : 'NEW '} from ${r.recipient_email} ${new Date(r.created_at).toISOString()}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function cmdReply(args) {
|
|
494
|
+
const [id, ...rest] = args;
|
|
495
|
+
if (!id) die('usage: salamailer reply <reply-id> [--save-dir <dir>]');
|
|
496
|
+
const { values: v } = parseArgs({ args: rest, options: { 'save-dir': { type: 'string' }, json: { type: 'boolean' } } });
|
|
497
|
+
const r = await readReplyDecrypted(loadConfig(), id, v['save-dir']);
|
|
498
|
+
console.log(v.json ? JSON.stringify(r, null, 2) : replyReport(r));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function cmdReceipt(id) {
|
|
502
|
+
if (!id) die('usage: salamailer receipt <message-id>');
|
|
503
|
+
console.log(JSON.stringify(await api(loadConfig(), 'GET', `/api/messages/${id}/receipt`), null, 2));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function cmdRevoke(id) {
|
|
507
|
+
if (!id) die('usage: salamailer revoke <message-id>');
|
|
508
|
+
await api(loadConfig(), 'DELETE', `/api/messages/${id}`);
|
|
509
|
+
console.log(`Revoked and permanently deleted ${id}.`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
// MCP server (stdio, newline-delimited JSON-RPC 2.0)
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
const TOOLS = [
|
|
516
|
+
{
|
|
517
|
+
name: 'send_secure_message',
|
|
518
|
+
description:
|
|
519
|
+
'Send an end-to-end encrypted message (and optional file attachments) to any email address. ' +
|
|
520
|
+
'The recipient needs no account: they get an email with a secure link, gated by a passphrase or SMS code. ' +
|
|
521
|
+
'Encryption happens locally; the server never sees plaintext. Returns the link and (if applicable) the ' +
|
|
522
|
+
'gate passphrase — the passphrase must be relayed to the recipient via a DIFFERENT channel than the link.',
|
|
523
|
+
inputSchema: {
|
|
524
|
+
type: 'object',
|
|
525
|
+
properties: {
|
|
526
|
+
recipient_email: { type: 'string', description: 'Recipient email address (unavailable when the account hides recipient details from agents — use "contact" instead)' },
|
|
527
|
+
contact: { type: 'string', description: 'Saved-contact label to send to (e.g. "Matti"), an alternative to recipient_email. The label is resolved to the address locally, outside the model. Required when the account is configured with hideRecipients.' },
|
|
528
|
+
message: { type: 'string', description: 'The confidential message body' },
|
|
529
|
+
passphrase: { type: 'string', description: 'Optional gate passphrase (4–200 chars). Auto-generated if omitted and sms_phone is not set.' },
|
|
530
|
+
sms_phone: { type: 'string', description: 'Recipient phone (e.g. +358401234567) to gate with an auto-sent SMS one-time code instead of a passphrase' },
|
|
531
|
+
whatsapp_phone: { type: 'string', description: 'Recipient phone to gate with an auto-sent WhatsApp one-time code instead of SMS/passphrase (where the server has WhatsApp configured)' },
|
|
532
|
+
file_paths: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach (encrypted before upload)' },
|
|
533
|
+
expiry_days: { type: 'number', enum: [7, 14, 30], description: 'Auto-delete after N days (default 30)' },
|
|
534
|
+
burn_after_read: { type: 'boolean', description: 'Delete permanently after the first read (default false)' },
|
|
535
|
+
self_relay: { type: 'boolean', description: 'If true, send NO notification email and never hand the key to the server — the returned Secure link must be delivered to the recipient by you/the user over their own channel (maximal zero-knowledge guarantee).' },
|
|
536
|
+
save_passphrase: { type: 'boolean', description: 'If true, remember the passphrase as this recipient’s standing passphrase so future sends to them reuse it automatically (stored encrypted; the server can’t read it).' },
|
|
537
|
+
paranoid: { type: 'boolean', description: 'Passphrase gates only: mix the passphrase into the encryption key itself, so even a server breach combined with an intercepted link cannot decrypt the message. The recipient just enters the passphrase as usual.' },
|
|
538
|
+
lang: { type: 'string', enum: ['fi', 'en', 'sv'], description: 'Recipient language for the notification email, one-time code text and read page (default fi)' },
|
|
539
|
+
},
|
|
540
|
+
required: ['message'],
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
name: 'list_sent_messages',
|
|
545
|
+
description: 'List sent secure messages with status (sent/opened/expired/revoked), recipient, and expiry.',
|
|
546
|
+
inputSchema: { type: 'object', properties: {} },
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
name: 'get_delivery_receipt',
|
|
550
|
+
description: 'Get the exportable, metadata-only delivery receipt for a message (audit evidence of delivery and opening).',
|
|
551
|
+
inputSchema: { type: 'object', properties: { message_id: { type: 'string' } }, required: ['message_id'] },
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
name: 'revoke_message',
|
|
555
|
+
description: 'Revoke a sent message: permanently deletes the ciphertext so the link stops working. Irreversible.',
|
|
556
|
+
inputSchema: { type: 'object', properties: { message_id: { type: 'string' } }, required: ['message_id'] },
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
name: 'get_account_status',
|
|
560
|
+
description: 'Get the sending account status and quotas: plan, whether sending is allowed (and why not), trial sends left, and SMS remaining this period.',
|
|
561
|
+
inputSchema: { type: 'object', properties: {} },
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
name: 'resend_notification',
|
|
565
|
+
description: 'Re-send the notification email for a sent message. The open link is reconstructed locally from the sender key; the server never stores the key.',
|
|
566
|
+
inputSchema: { type: 'object', properties: { message_id: { type: 'string' } }, required: ['message_id'] },
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
name: 'resend_sms_code',
|
|
570
|
+
description: 'Issue and send a fresh one-time SMS gate code for an SMS-gated message (e.g. after the recipient locked themselves out). Counts against the SMS quota.',
|
|
571
|
+
inputSchema: { type: 'object', properties: { message_id: { type: 'string' } }, required: ['message_id'] },
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
name: 'save_contact',
|
|
575
|
+
description: 'Save a standing gate passphrase for a recipient so every future send to them reuses the same passphrase (they only learn it once). Stored encrypted under your vault key; the server can never read it. Ideal for a regular correspondent you message often.',
|
|
576
|
+
inputSchema: {
|
|
577
|
+
type: 'object',
|
|
578
|
+
properties: {
|
|
579
|
+
recipient_email: { type: 'string' },
|
|
580
|
+
passphrase: { type: 'string', description: 'The shared passphrase (4–200 chars) this recipient will use for all your messages.' },
|
|
581
|
+
label: { type: 'string', description: 'Optional display name for the contact.' },
|
|
582
|
+
},
|
|
583
|
+
required: ['recipient_email', 'passphrase'],
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
name: 'list_replies',
|
|
588
|
+
description: 'List inbound secure replies from recipients (id, from, read/unread, date). Replies are end-to-end encrypted; use read_reply to decrypt one locally.',
|
|
589
|
+
inputSchema: { type: 'object', properties: {} },
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
name: 'read_reply',
|
|
593
|
+
description: 'Decrypt an inbound secure reply locally (the server only ever held ciphertext). Returns the reply text and attachment names; pass save_dir to write the attachments to disk (must be inside the attachment root).',
|
|
594
|
+
inputSchema: {
|
|
595
|
+
type: 'object',
|
|
596
|
+
properties: {
|
|
597
|
+
reply_id: { type: 'string' },
|
|
598
|
+
save_dir: { type: 'string', description: 'Optional directory to save decrypted attachments into (contained in the attachment root)' },
|
|
599
|
+
},
|
|
600
|
+
required: ['reply_id'],
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
name: 'list_contacts',
|
|
605
|
+
description: 'List recipients that have a saved standing passphrase (emails and labels only; the passphrases themselves are never returned).',
|
|
606
|
+
inputSchema: { type: 'object', properties: {} },
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
name: 'delete_contact',
|
|
610
|
+
description: 'Forget a recipient’s saved standing passphrase. Future sends to them will auto-generate a fresh passphrase again.',
|
|
611
|
+
inputSchema: { type: 'object', properties: { recipient_email: { type: 'string' } }, required: ['recipient_email'] },
|
|
612
|
+
},
|
|
613
|
+
];
|
|
614
|
+
|
|
615
|
+
async function mcpToolCall(cfg, name, args) {
|
|
616
|
+
switch (name) {
|
|
617
|
+
case 'send_secure_message': {
|
|
618
|
+
// Containment: an injected agent must not exfiltrate files outside the root.
|
|
619
|
+
const root = cfg.attachmentRoot || process.cwd();
|
|
620
|
+
for (const p of args.file_paths || []) checkAttachmentAllowed(p, root);
|
|
621
|
+
let to = args.recipient_email;
|
|
622
|
+
if (cfg.hideRecipients) {
|
|
623
|
+
// Privacy mode: raw addresses neither accepted nor revealed. Phone-code
|
|
624
|
+
// gates are off here too — a phone number is exactly the PII being hidden.
|
|
625
|
+
if (args.recipient_email || args.sms_phone || args.whatsapp_phone)
|
|
626
|
+
throw new Error('this account hides recipient details from the agent channel: address the recipient ' +
|
|
627
|
+
'with the "contact" argument (a saved-contact label). Phone-gated sends are unavailable in this mode.');
|
|
628
|
+
if (!args.contact) throw new Error('missing "contact": in hideRecipients mode, sends are addressed by saved-contact label. Use list_contacts to see the labels.');
|
|
629
|
+
to = (await resolveContactByLabel(cfg, args.contact)).email;
|
|
630
|
+
} else if (args.contact && !args.recipient_email) {
|
|
631
|
+
to = (await resolveContactByLabel(cfg, args.contact)).email;
|
|
632
|
+
}
|
|
633
|
+
if (!to) throw new Error('recipient_email (or contact) is required');
|
|
634
|
+
const r = await sendSecure(cfg, {
|
|
635
|
+
to, message: args.message, files: args.file_paths || [],
|
|
636
|
+
passphrase: args.passphrase, smsPhone: args.sms_phone, waPhone: args.whatsapp_phone,
|
|
637
|
+
expiryDays: args.expiry_days || 30, burn: !!args.burn_after_read, selfRelay: !!args.self_relay,
|
|
638
|
+
savePassphrase: !!args.save_passphrase, paranoid: !!args.paranoid, lang: args.lang || 'fi',
|
|
639
|
+
});
|
|
640
|
+
return sendReport(r, !!cfg.hideRecipients);
|
|
641
|
+
}
|
|
642
|
+
case 'list_sent_messages': {
|
|
643
|
+
const { messages } = await api(cfg, 'GET', '/api/messages');
|
|
644
|
+
const show = await recipientDisplayer(cfg);
|
|
645
|
+
return messages.length
|
|
646
|
+
? messages.map((m) => `${m.id} · ${m.status} · ${m.second_channel} → ${show(m.recipient_email)} · expires ${new Date(m.expiry_at).toISOString()}`).join('\n')
|
|
647
|
+
: 'No messages sent yet.';
|
|
648
|
+
}
|
|
649
|
+
case 'get_delivery_receipt': {
|
|
650
|
+
const receipt = await api(cfg, 'GET', `/api/messages/${args.message_id}/receipt`);
|
|
651
|
+
if (cfg.hideRecipients) {
|
|
652
|
+
const show = await recipientDisplayer(cfg);
|
|
653
|
+
for (const k of ['recipientEmail', 'recipient_email'])
|
|
654
|
+
if (receipt[k]) receipt[k] = show(receipt[k]);
|
|
655
|
+
if (receipt.recipientPhone) receipt.recipientPhone = '***';
|
|
656
|
+
if (receipt.recipient_phone) receipt.recipient_phone = '***';
|
|
657
|
+
}
|
|
658
|
+
return JSON.stringify(receipt, null, 2);
|
|
659
|
+
}
|
|
660
|
+
case 'revoke_message':
|
|
661
|
+
await api(cfg, 'DELETE', `/api/messages/${args.message_id}`);
|
|
662
|
+
return `Message ${args.message_id} revoked and permanently deleted.`;
|
|
663
|
+
case 'get_account_status':
|
|
664
|
+
return statusReport(await api(cfg, 'GET', '/api/billing/status'));
|
|
665
|
+
case 'resend_notification': {
|
|
666
|
+
const openUrl = await reconstructOpenUrl(cfg, args.message_id);
|
|
667
|
+
await api(cfg, 'POST', `/api/messages/${args.message_id}/notify`, { openUrl });
|
|
668
|
+
return `Notification email re-sent for ${args.message_id}.`;
|
|
669
|
+
}
|
|
670
|
+
case 'resend_sms_code':
|
|
671
|
+
await api(cfg, 'POST', `/api/messages/${args.message_id}/resend-code`);
|
|
672
|
+
return `A fresh one-time SMS code was sent for ${args.message_id}.`;
|
|
673
|
+
case 'save_contact': {
|
|
674
|
+
// contactsOnly: the contact list IS the approval boundary, so the agent
|
|
675
|
+
// channel must not be able to grow it — else a prompt-injected agent
|
|
676
|
+
// simply approves its own recipient before sending.
|
|
677
|
+
if (cfg.contactsOnly || cfg.hideRecipients)
|
|
678
|
+
throw new Error('contactsOnly mode: contacts can only be approved by a human ' +
|
|
679
|
+
'(web Account page or "salamailer contacts add"), not via the agent channel.');
|
|
680
|
+
if (String(args.passphrase || '').length < 4 || String(args.passphrase).length > 200)
|
|
681
|
+
throw new Error('passphrase must be 4–200 characters');
|
|
682
|
+
await saveContact(cfg, args.recipient_email, args.passphrase, args.label);
|
|
683
|
+
return `Saved a standing passphrase for ${String(args.recipient_email).trim().toLowerCase()}. Future sends to them reuse it automatically.`;
|
|
684
|
+
}
|
|
685
|
+
case 'list_replies': {
|
|
686
|
+
const { replies } = await api(cfg, 'GET', '/api/replies');
|
|
687
|
+
const show = await recipientDisplayer(cfg);
|
|
688
|
+
return replies.length
|
|
689
|
+
? replies.map((r) => `${r.id} · ${r.read_at ? 'read' : 'NEW'} · from ${show(r.recipient_email)} · ${new Date(r.created_at).toISOString()}`).join('\n')
|
|
690
|
+
: 'No secure replies yet.';
|
|
691
|
+
}
|
|
692
|
+
case 'read_reply':
|
|
693
|
+
return replyReport(await readReplyDecrypted(cfg, args.reply_id, args.save_dir));
|
|
694
|
+
case 'list_contacts': {
|
|
695
|
+
const { contacts } = await api(cfg, 'GET', '/api/contacts');
|
|
696
|
+
if (cfg.hideRecipients)
|
|
697
|
+
return contacts.length
|
|
698
|
+
? contacts.map((c) => `${c.label || maskEmail(c.email)} · saved passphrase`).join('\n')
|
|
699
|
+
: 'No saved recipients yet.';
|
|
700
|
+
return contacts.length
|
|
701
|
+
? contacts.map((c) => `${c.email}${c.label ? ` (${c.label})` : ''} · saved passphrase`).join('\n')
|
|
702
|
+
: 'No saved recipients yet.';
|
|
703
|
+
}
|
|
704
|
+
case 'delete_contact': {
|
|
705
|
+
if (cfg.contactsOnly || cfg.hideRecipients)
|
|
706
|
+
throw new Error('contactsOnly mode: the contact list is managed by a human, not via the agent channel.');
|
|
707
|
+
const { contacts } = await api(cfg, 'GET', '/api/contacts');
|
|
708
|
+
const c = contacts.find((x) => x.email === String(args.recipient_email).trim().toLowerCase());
|
|
709
|
+
if (!c) throw new Error(`no saved passphrase for ${args.recipient_email}`);
|
|
710
|
+
await api(cfg, 'DELETE', `/api/contacts/${c.id}`);
|
|
711
|
+
return `Removed the saved passphrase for ${c.email}.`;
|
|
712
|
+
}
|
|
713
|
+
default:
|
|
714
|
+
throw new Error(`unknown tool: ${name}`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function cmdMcp() {
|
|
719
|
+
const cfg = loadConfig();
|
|
720
|
+
const out = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
|
721
|
+
const rl = createInterface({ input: process.stdin });
|
|
722
|
+
rl.on('line', async (line) => {
|
|
723
|
+
if (!line.trim()) return;
|
|
724
|
+
let msg;
|
|
725
|
+
try { msg = JSON.parse(line); }
|
|
726
|
+
catch { return out({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }); }
|
|
727
|
+
const { id, method, params } = msg;
|
|
728
|
+
try {
|
|
729
|
+
if (method === 'initialize') {
|
|
730
|
+
return out({ jsonrpc: '2.0', id, result: {
|
|
731
|
+
protocolVersion: params?.protocolVersion || '2025-06-18',
|
|
732
|
+
capabilities: { tools: {} },
|
|
733
|
+
serverInfo: { name: 'salamailer', version: VERSION },
|
|
734
|
+
} });
|
|
735
|
+
}
|
|
736
|
+
if (method === 'notifications/initialized' || method?.startsWith('notifications/')) return; // no response
|
|
737
|
+
if (method === 'ping') return out({ jsonrpc: '2.0', id, result: {} });
|
|
738
|
+
if (method === 'tools/list') return out({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
|
739
|
+
if (method === 'tools/call') {
|
|
740
|
+
try {
|
|
741
|
+
const text = await mcpToolCall(cfg, params.name, params.arguments || {});
|
|
742
|
+
return out({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }] } });
|
|
743
|
+
} catch (err) {
|
|
744
|
+
return out({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true } });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (id !== undefined) out({ jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${method}` } });
|
|
748
|
+
} catch (err) {
|
|
749
|
+
if (id !== undefined) out({ jsonrpc: '2.0', id, error: { code: -32603, message: err.message } });
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ---------------------------------------------------------------------------
|
|
755
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
756
|
+
const commands = {
|
|
757
|
+
init: () => cmdInit(rest),
|
|
758
|
+
send: () => cmdSend(rest),
|
|
759
|
+
list: () => cmdList(),
|
|
760
|
+
replies: () => cmdReplies(),
|
|
761
|
+
reply: () => cmdReply(rest),
|
|
762
|
+
status: () => cmdStatus(rest),
|
|
763
|
+
contacts: () => cmdContacts(rest),
|
|
764
|
+
receipt: () => cmdReceipt(rest[0]),
|
|
765
|
+
resend: () => cmdResend(rest[0]),
|
|
766
|
+
'resend-code': () => cmdResendCode(rest[0]),
|
|
767
|
+
revoke: () => cmdRevoke(rest[0]),
|
|
768
|
+
mcp: () => cmdMcp(),
|
|
769
|
+
};
|
|
770
|
+
if (!cmd || !commands[cmd]) {
|
|
771
|
+
console.log(`salamailer v${VERSION} — zero-knowledge secure messaging
|
|
772
|
+
|
|
773
|
+
Usage:
|
|
774
|
+
salamailer init --origin <url> --email <email> configure (password prompted)
|
|
775
|
+
[--contacts-only] agents may only send to human-saved contacts
|
|
776
|
+
[--allow-domain <d>]... restrict agent sends to these recipient domains
|
|
777
|
+
salamailer send --to <email> --message <text>
|
|
778
|
+
[--file <path>]... [--passphrase <p> | --sms <phone> | --whatsapp <phone>]
|
|
779
|
+
[--burn] [--expiry 7|14|30] [--lang fi|en|sv] [--self-relay] [--save-passphrase] [--paranoid] [--json]
|
|
780
|
+
salamailer list sent messages + status
|
|
781
|
+
salamailer replies inbound secure replies
|
|
782
|
+
salamailer reply <id> [--save-dir <dir>] decrypt a reply locally
|
|
783
|
+
salamailer status [--json] plan, sends left, SMS quota
|
|
784
|
+
salamailer contacts [list | add --to <email> --passphrase <p> | rm --to <email>]
|
|
785
|
+
standing per-recipient passphrases
|
|
786
|
+
salamailer receipt <id> delivery receipt (JSON)
|
|
787
|
+
salamailer resend <id> re-send the notification email
|
|
788
|
+
salamailer resend-code <id> re-send the SMS gate code
|
|
789
|
+
salamailer revoke <id> permanent delete
|
|
790
|
+
salamailer mcp run as an MCP server (stdio)
|
|
791
|
+
|
|
792
|
+
MCP (Claude Code): claude mcp add salamailer -- salamailer mcp`);
|
|
793
|
+
process.exit(cmd ? 1 : 0);
|
|
794
|
+
}
|
|
795
|
+
commands[cmd]().catch((err) => die(`Error: ${err.message}`));
|