// Email account storage and CRUD for per-user email accounts. // Each user's accounts stored at ~/.clay/email/{userId}.json // Provides provider presets, connection testing, and account management. var fs = require("fs"); var path = require("path"); var crypto = require("crypto"); var nodemailer = require("nodemailer"); var { CONFIG_DIR, chmodSafe } = require("./config"); var EMAIL_DIR = path.join(CONFIG_DIR, "email"); // --- Encryption key (derived from machine-specific secret) --- var _encKey = null; function getEncKey() { if (_encKey) return _encKey; // Derive a stable encryption key from CONFIG_DIR path + a salt file. // If the salt file does not exist, create one. var saltPath = path.join(CONFIG_DIR, ".email-salt"); var salt; try { salt = fs.readFileSync(saltPath, "utf8").trim(); } catch (e) { salt = crypto.randomBytes(32).toString("hex"); fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(saltPath, salt); chmodSafe(saltPath, 0o600); } _encKey = crypto.scryptSync(salt, "clay-email-accounts", 32); return _encKey; } function encrypt(text) { var iv = crypto.randomBytes(16); var cipher = crypto.createCipheriv("aes-256-gcm", getEncKey(), iv); var encrypted = cipher.update(text, "utf8", "hex") + cipher.final("hex"); var tag = cipher.getAuthTag().toString("hex"); return iv.toString("hex") + ":" + tag + ":" + encrypted; } function decrypt(data) { var parts = data.split(":"); if (parts.length !== 3) return data; // not encrypted (legacy), return as-is var iv = Buffer.from(parts[0], "hex"); var tag = Buffer.from(parts[1], "hex"); var encrypted = parts[2]; var decipher = crypto.createDecipheriv("aes-256-gcm", getEncKey(), iv); decipher.setAuthTag(tag); return decipher.update(encrypted, "hex", "utf8") + decipher.final("utf8"); } // --- Provider presets --- var PROVIDER_PRESETS = { gmail: { label: "Gmail", imap: { host: "imap.gmail.com", port: 993, tls: true }, smtp: { host: "smtp.gmail.com", port: 587 }, helpUrl: "https://support.google.com/accounts/answer/185833", }, outlook: { label: "Outlook", imap: { host: "outlook.office365.com", port: 993, tls: true }, smtp: { host: "smtp.office365.com", port: 587 }, helpUrl: "https://support.microsoft.com/en-us/account-billing/using-app-passwords-with-apps-that-don-t-support-two-step-verification-5896ed9b-4263-e681-128a-a6f2979a7944", }, yahoo: { label: "Yahoo", imap: { host: "imap.mail.yahoo.com", port: 993, tls: true }, smtp: { host: "smtp.mail.yahoo.com", port: 587 }, helpUrl: "https://help.yahoo.com/kb/generate-manage-third-party-passwords-sln15241.html", }, }; // --- Storage helpers --- function ensureEmailDir() { fs.mkdirSync(EMAIL_DIR, { recursive: true }); chmodSafe(EMAIL_DIR, 0o700); } function userFilePath(userId) { return path.join(EMAIL_DIR, userId + ".json"); } function loadUserAccounts(userId) { try { var raw = fs.readFileSync(userFilePath(userId), "utf8"); var data = JSON.parse(raw); return data.accounts || []; } catch (e) { return []; } } function saveUserAccounts(userId, accounts) { ensureEmailDir(); var filePath = userFilePath(userId); var tmpPath = filePath + ".tmp"; fs.writeFileSync(tmpPath, JSON.stringify({ accounts: accounts }, null, 2)); chmodSafe(tmpPath, 0o600); fs.renameSync(tmpPath, filePath); chmodSafe(filePath, 0o600); } // --- Account CRUD --- function generateAccountId() { return "acc_" + crypto.randomBytes(8).toString("hex"); } function listAccounts(userId) { var accounts = loadUserAccounts(userId); // Return accounts without exposing app passwords return accounts.map(function (acc) { return { id: acc.id, email: acc.email, provider: acc.provider, label: acc.label || "", imap: acc.imap, smtp: acc.smtp, addedAt: acc.addedAt, }; }); } function getAccount(userId, accountId) { var accounts = loadUserAccounts(userId); for (var i = 0; i < accounts.length; i++) { if (accounts[i].id === accountId) return accounts[i]; } return null; } function getAccountByEmail(userId, email) { var accounts = loadUserAccounts(userId); for (var i = 0; i < accounts.length; i++) { if (accounts[i].email === email) return accounts[i]; } return null; } function getAccountDecrypted(userId, accountId) { var acc = getAccount(userId, accountId); if (!acc) return null; return Object.assign({}, acc, { appPassword: decrypt(acc.appPassword) }); } function getAccountByEmailDecrypted(userId, email) { var acc = getAccountByEmail(userId, email); if (!acc) return null; return Object.assign({}, acc, { appPassword: decrypt(acc.appPassword) }); } function addAccount(userId, opts) { var accounts = loadUserAccounts(userId); // Check for duplicate email for (var i = 0; i < accounts.length; i++) { if (accounts[i].email === opts.email) { return { error: "Account with this email already exists" }; } } var provider = opts.provider || "custom"; var preset = PROVIDER_PRESETS[provider]; var account = { id: generateAccountId(), email: opts.email, provider: provider, imap: opts.imap || (preset ? Object.assign({}, preset.imap) : { host: "", port: 993, tls: true }), smtp: opts.smtp || (preset ? Object.assign({}, preset.smtp) : { host: "", port: 587 }), appPassword: encrypt(opts.appPassword), addedAt: Date.now(), label: opts.label || (preset ? preset.label : "Custom"), }; accounts.push(account); saveUserAccounts(userId, accounts); return { ok: true, account: { id: account.id, email: account.email, provider: account.provider, label: account.label, imap: account.imap, smtp: account.smtp, addedAt: account.addedAt, }, }; } function removeAccount(userId, accountId) { var accounts = loadUserAccounts(userId); var filtered = accounts.filter(function (acc) { return acc.id !== accountId; }); if (filtered.length === accounts.length) { return { error: "Account not found" }; } saveUserAccounts(userId, filtered); return { ok: true }; } // --- Connection testing --- function testSmtpConnection(account) { var password = account.appPassword; // If encrypted, decrypt if (password.indexOf(":") !== -1 && password.length > 50) { try { password = decrypt(password); } catch (e) {} } var transport = nodemailer.createTransport({ host: account.smtp.host, port: account.smtp.port || 587, secure: false, auth: { user: account.email, pass: password }, connectionTimeout: 10000, greetingTimeout: 10000, }); return transport.verify().then(function () { try { transport.close(); } catch (e) {} return { ok: true }; }).catch(function (err) { try { transport.close(); } catch (e) {} return { ok: false, error: err.message || "SMTP connection failed" }; }); } function testImapConnection(account) { var password = account.appPassword; if (password.indexOf(":") !== -1 && password.length > 50) { try { password = decrypt(password); } catch (e) {} } var ImapFlow; try { ImapFlow = require("imapflow").ImapFlow; } catch (e) { return Promise.resolve({ ok: false, error: "imapflow module not installed" }); } var client = new ImapFlow({ host: account.imap.host, port: account.imap.port || 993, secure: account.imap.tls !== false, auth: { user: account.email, pass: password }, logger: false, }); return client.connect().then(function () { return client.logout().then(function () { return { ok: true }; }); }).catch(function (err) { try { client.close(); } catch (e) {} return { ok: false, error: err.message || "IMAP connection failed" }; }); } function testConnection(account) { return Promise.all([ testImapConnection(account), testSmtpConnection(account), ]).then(function (results) { var imap = results[0]; var smtp = results[1]; return { imap: imap, smtp: smtp, ok: imap.ok && smtp.ok, }; }); } // --- Exports --- module.exports = { PROVIDER_PRESETS: PROVIDER_PRESETS, listAccounts: listAccounts, getAccount: getAccount, getAccountByEmail: getAccountByEmail, getAccountDecrypted: getAccountDecrypted, getAccountByEmailDecrypted: getAccountByEmailDecrypted, addAccount: addAccount, removeAccount: removeAccount, testConnection: testConnection, testImapConnection: testImapConnection, testSmtpConnection: testSmtpConnection, encrypt: encrypt, decrypt: decrypt, };