// Project-level email module. // Handles email context injection and unread polling. // Follows the attachXxx(ctx) pattern. var fs = require("fs"); var path = require("path"); var emailAccounts = require("./email-accounts"); var smtp = require("./smtp"); var { CONFIG_DIR } = require("./config"); var AUDIT_LOG_PATH = path.join(CONFIG_DIR, "email-audit.jsonl"); // --- Audit log (Server SMTP only) --- function appendAuditLog(entry) { try { fs.mkdirSync(path.dirname(AUDIT_LOG_PATH), { recursive: true }); fs.appendFileSync(AUDIT_LOG_PATH, JSON.stringify(entry) + "\n"); } catch (e) { console.error("[email] Failed to write audit log:", e.message); } } // --- Unread count fetching --- function fetchUnreadCount(account) { var ImapFlow; try { ImapFlow = require("imapflow").ImapFlow; } catch (e) { return Promise.resolve(0); } var client = new ImapFlow({ host: account.imap.host, port: account.imap.port || 993, secure: account.imap.tls !== false, auth: { user: account.email, pass: account.appPassword }, logger: false, }); return client.connect().then(function () { return client.status("INBOX", { unseen: true }); }).then(function (status) { var count = status.unseen || 0; return client.logout().then(function () { return count; }); }).catch(function () { try { client.close(); } catch (e) {} return 0; }); } // --- Email context builder (for injecting into user messages) --- function buildEmailContext(accounts, limit) { if (!accounts || accounts.length === 0) return Promise.resolve(""); var perAccount = Math.max(Math.floor((limit || 10) / accounts.length), 3); var ImapFlow; try { ImapFlow = require("imapflow").ImapFlow; } catch (e) { return Promise.resolve(""); } var promises = accounts.map(function (account) { var client = new ImapFlow({ host: account.imap.host, port: account.imap.port || 993, secure: account.imap.tls !== false, auth: { user: account.email, pass: account.appPassword }, logger: false, }); return client.connect().then(function () { return client.getMailboxLock("INBOX"); }).then(function (lock) { return client.search({ seen: false }, { uid: true }).then(function (uids) { if (!uids || uids.length === 0) { lock.release(); return client.logout().then(function () { return { email: account.email, unread: 0, messages: [] }; }); } var recentUids = uids.slice(-perAccount).reverse(); var fetchIter = client.fetch(recentUids, { uid: true, envelope: true, flags: true, }, { uid: true }); var messages = []; function collect() { return fetchIter.next().then(function (result) { if (result.done) return; var msg = result.value; var env = msg.envelope || {}; var fromAddr = env.from && env.from[0] ? (env.from[0].address || "") : ""; var date = env.date || new Date(); var ago = formatTimeAgo(date); messages.push({ from: fromAddr, subject: env.subject || "(no subject)", ago: ago, }); return collect(); }); } return collect().then(function () { lock.release(); return client.logout().then(function () { return { email: account.email, unread: uids.length, messages: messages }; }); }); }); }).catch(function () { try { client.close(); } catch (e) {} return { email: account.email, unread: 0, messages: [], error: true }; }); }); return Promise.all(promises).then(function (results) { var parts = []; for (var i = 0; i < results.length; i++) { var r = results[i]; if (r.messages.length === 0 && !r.error) continue; var header = "--- Email Context: " + r.email + " (" + r.unread + " unread) ---"; var lines = []; for (var j = 0; j < r.messages.length; j++) { var m = r.messages[j]; lines.push((j + 1) + ". From: " + m.from + " | Subject: " + m.subject + " | " + m.ago); } if (lines.length > 0) { parts.push(header + "\n" + lines.join("\n")); } else { parts.push(header + "\n(unable to fetch messages)"); } } return parts.join("\n\n"); }); } function formatTimeAgo(date) { var now = Date.now(); var d = date instanceof Date ? date : new Date(date); var diff = now - d.getTime(); if (diff < 0) diff = 0; var minutes = Math.floor(diff / 60000); if (minutes < 1) return "just now"; if (minutes < 60) return minutes + "m ago"; var hours = Math.floor(minutes / 60); if (hours < 24) return hours + "h ago"; var days = Math.floor(hours / 24); return days + "d ago"; } // --- Module attachment --- function attachEmail(ctx) { var slug = ctx.slug; var send = ctx.send; var clients = ctx.clients; var loadContextSources = ctx.loadContextSources; var getUserIdForWs = ctx.getUserIdForWs; // Unread count cache: { accountId: { count, fetchedAt } } var unreadCache = {}; var POLL_INTERVAL_ACTIVE = 2 * 60 * 1000; // 2 minutes for checked accounts var POLL_INTERVAL_IDLE = 10 * 60 * 1000; // 10 minutes for unchecked var pollTimer = null; function getCheckedEmailAccounts(userId, sessionId) { var sources = loadContextSources(slug, sessionId); var accounts = []; for (var i = 0; i < sources.length; i++) { if (sources[i].startsWith("email:")) { var accountId = sources[i].split(":")[1]; var acc = emailAccounts.getAccountDecrypted(userId, accountId); if (acc) accounts.push(acc); } } return accounts; } function getCheckedEmailAccountsByEmail(userId, sessionId) { var sources = loadContextSources(slug, sessionId); var emailIds = []; for (var i = 0; i < sources.length; i++) { if (sources[i].startsWith("email:")) { emailIds.push(sources[i].split(":")[1]); } } if (emailIds.length === 0) return []; var allAccounts = emailAccounts.listAccounts(userId); var checked = []; for (var j = 0; j < allAccounts.length; j++) { if (emailIds.indexOf(allAccounts[j].id) !== -1) { var dec = emailAccounts.getAccountDecrypted(userId, allAccounts[j].id); if (dec) checked.push(dec); } } return checked; } // Poll unread counts and push updates function pollUnreadCounts() { // Find first connected client to get userId var userId = null; for (var ws of clients) { if (ws.readyState === 1) { userId = (ws._clayUser && ws._clayUser.id) || "default"; break; } } if (!userId) return; var allAccounts = emailAccounts.listAccounts(userId); if (allAccounts.length === 0) return; // Collect checked email IDs across all connected clients' sessions var checkedIds = {}; for (var ws2 of clients) { if (ws2.readyState !== 1) continue; var sid = ws2._clayActiveSession || null; var sources = loadContextSources(slug, sid); for (var i = 0; i < sources.length; i++) { if (sources[i].startsWith("email:")) { checkedIds[sources[i].split(":")[1]] = true; } } } var toFetch = []; for (var j = 0; j < allAccounts.length; j++) { var acc = allAccounts[j]; var isChecked = !!checkedIds[acc.id]; var interval = isChecked ? POLL_INTERVAL_ACTIVE : POLL_INTERVAL_IDLE; var cached = unreadCache[acc.id]; if (!cached || (Date.now() - cached.fetchedAt) > interval) { toFetch.push(acc); } } if (toFetch.length === 0) return; var fetchPromises = toFetch.map(function (acc) { var decrypted = emailAccounts.getAccountDecrypted(userId, acc.id); if (!decrypted) return Promise.resolve(null); return fetchUnreadCount(decrypted).then(function (count) { return { id: acc.id, count: count }; }); }); Promise.all(fetchPromises).then(function (results) { var changed = false; for (var k = 0; k < results.length; k++) { if (!results[k]) continue; var prev = unreadCache[results[k].id]; unreadCache[results[k].id] = { count: results[k].count, fetchedAt: Date.now() }; if (!prev || prev.count !== results[k].count) changed = true; } if (changed) { var updates = {}; var ukeys = Object.keys(unreadCache); for (var m = 0; m < ukeys.length; m++) { updates[ukeys[m]] = unreadCache[ukeys[m]].count; } var msg = JSON.stringify({ type: "email_unread_update", unread: updates }); for (var ws of clients) { if (ws.readyState === 1) ws.send(msg); } } }).catch(function () {}); } // Start polling pollTimer = setInterval(pollUnreadCounts, 60000); // Check every minute // Initial poll after 5 seconds setTimeout(pollUnreadCounts, 5000); // Get email context for message injection function getEmailContext(userId, sessionId) { var checked = getCheckedEmailAccountsByEmail(userId, sessionId); if (checked.length === 0) return Promise.resolve(""); return buildEmailContext(checked, 10); } // Collect checked email accounts across all connected clients' active sessions function getAllCheckedAccounts(userId) { var seen = {}; var result = []; for (var ws of clients) { if (ws.readyState !== 1) continue; var sid = ws._clayActiveSession || null; var checked = getCheckedEmailAccountsByEmail(userId, sid); for (var ci = 0; ci < checked.length; ci++) { if (!seen[checked[ci].id]) { seen[checked[ci].id] = true; result.push(checked[ci]); } } } return result; } // Resolve the active userId from connected clients at call time function getActiveUserId() { for (var ws of clients) { if (ws.readyState === 1 && ws._clayUser && ws._clayUser.id) { return ws._clayUser.id; } } return "default"; } // Create MCP server dependencies (userId resolved dynamically per call) function createMcpDeps() { return { getAccountForTool: function (email) { return emailAccounts.getAccountByEmailDecrypted(getActiveUserId(), email); }, getCheckedAccounts: function () { return getAllCheckedAccounts(getActiveUserId()); }, getServerSmtp: function () { if (!smtp.isSmtpConfigured()) return null; return { sendMail: function (to, subject, body) { return smtp.sendMail(to, subject, '
' + body.replace(//g, ">") + '
'); }, }; }, appendAuditLog: function (entry) { entry.userId = getActiveUserId(); entry.projectSlug = slug; appendAuditLog(entry); }, }; } function destroy() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } // Whether any email capability is available for the active user. // Used to gate the clay-email MCP so its tools are hidden from the model // when the user hasn't registered any accounts and the server SMTP isn't // configured (see issue #325 — we don't expose tools the user can't use). function hasEmailCapability() { try { var accounts = emailAccounts.listAccounts(getActiveUserId()); if (accounts && accounts.length > 0) return true; } catch (e) { /* fall through to SMTP check */ } try { if (smtp.isSmtpConfigured()) return true; } catch (e) { /* ignore */ } return false; } return { getEmailContext: getEmailContext, getCheckedEmailAccounts: getCheckedEmailAccounts, createMcpDeps: createMcpDeps, hasEmailCapability: hasEmailCapability, destroy: destroy, }; } module.exports = { attachEmail: attachEmail, appendAuditLog: appendAuditLog };