Ctrl K
ComunidadGuías…

lib/project-email.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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, '<pre style="font-family:system-ui,sans-serif;white-space:pre-wrap">' + body.replace(/</g, "&lt;").replace(/>/g, "&gt;") + '</pre>');
          },
        };
      },
      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 };