Skill: jundonggit/smart-email (version 1.2.0)

--- name: smart-email description: Email assistant skill — check emails, AI summaries, daily digests. Supports Gmail, Outlook/M365, Google Workspace. Users interact through their chat platform (Telegram, Feishu, WhatsApp, etc.). homepage: https://clawhub.ai/skills/smart-email metadata: {"clawdbot":{"emoji":"📧","requires":{"bins":["node"]},"install":[{"id":"npm","kind":"npm","cwd":"__skill__","label":"Install dependencies"}]}} --- # Smart Email — 邮件助手 让用户通过聊天（Telegram / 飞书 / WhatsApp）管理邮箱，查看邮件，获取 AI 摘要和每日日报。 ## 用户可以说什么（意图识别） 当用户发送以下类型的消息时，使用此 skill： | 用户说的话 | 执行的命令 | |---|---| | "查邮件" / "有新邮件吗" / "check email" | `check --summarize` | | "查看 [REDACTED_SECRET] 的邮件" | `check --summarize --account [REDACTED_SECRET]` | | "最近3天的邮件" | `check --summarize --since 4320` | | "邮件日报" / "今天邮件总结" / "email digest" | `digest` | | "读一下这封邮件" + uid | `read <uid>` | | "添加邮箱" / "设置邮箱" / "setup email" | → 引导 setup 流程 | | "删除邮箱 xxx" | `remove [REDACTED_SECRET]` | | "我有哪些邮箱" / "邮箱列表" | `accounts` | | "设置 API Key" / "配置 AI" | → 引导 config 流程 | ## CLI 所有命令通过此路径执行，输出 JSON： ```bash node <SKILL_DIR>/cli.js <command> [options] ``` `<SKILL_DIR>` 是此 skill 的安装目录。 ## 命令参考 ### check — 查看新邮件 ```bash node <SKILL_DIR>/cli.js check [--summarize] [--account EMAIL] [--since MINUTES] [--max N] ``` - `--summarize` — 包含 AI 摘要（推荐默认开启） - `--since M` — 回看 M 分钟（默认 60） - `--max N` — 最多返回 N 封（默认 10） - `--account EMAIL` — 只查指定邮箱 输出示例： ```json { "emails": [ { "account": "[REDACTED_SECRET]", "uid": "12345", "from": "Boss", "subject": "Q1 Report", "date": "2026-03-12T10:00:00Z", "bodyPreview": "Please review...", "summary": "🔴 老板要你审核 Q1 报告，需要尽快回复" } ], "total": 1 } ``` **格式化回复建议**：按邮件逐条展示，包含发件人、主题、AI摘要。标注重要程度。 ### read — 读取邮件全文 ```bash node <SKILL_DIR>/cli.js read <uid> [--account EMAIL] ``` 返回邮件全文 + AI 解读。 ### digest — 邮件日报 ```bash node <SKILL_DIR>/cli.js digest [--since MINUTES] [--account EMAIL] ``` - `--since M` — 回看时间（默认 1440 = 24小时） 返回 AI 生成的邮件总结，按紧急/关注/可忽略分类。 ### accounts — 查看已配置邮箱 ```bash node <SKILL_DIR>/cli.js accounts ``` ### setup — 添加邮箱 ```bash # Gmail / Google Workspace node <SKILL_DIR>/cli.js setup <email> --password <APP_PASSWORD> # Outlook / M365 (OAuth2) node <SKILL_DIR>/cli.js setup <email> --auth oauth # Outlook / M365 (App Password) node <SKILL_DIR>/cli.js setup <email> --auth password --password <APP_PASSWORD> ``` **引导用户的流程**： 1. 问用户邮箱地址 2. 自动检测类型（Gmail / Outlook / Workspace） 3. 如果是 Gmail/Workspace： - 告诉用户需要 App Password - 指导：Google 账户 → 安全性 → 两步验证 → 应用专用密码 - 用户提供后执行 setup 4. 如果是 Outlook/M365： - 推荐 OAuth2（更安全，不需要密码） - 执行 `setup <email> --auth oauth`，会返回一个链接和验证码 - 告诉用户在浏览器打开链接、输入验证码 - 等待授权完成 5. 其他邮箱：问用户密码/App Password ### remove — 删除邮箱 ```bash node <SKILL_DIR>/cli.js remove <email> ``` ### config — 配置 ```bash # 查看当前配置 node <SKILL_DIR>/cli.js config # 设置 AI API Key（必需，用于邮件摘要） node <SKILL_DIR>/cli.js config ai_api_key <KEY> # 设置 API 地址和模型（可选） node <SKILL_DIR>/cli.js config ai_api_base https://api.deepseek.com node <SKILL_DIR>/cli.js config ai_model deepseek-chat # Microsoft OAuth2 配置（Outlook 用户需要） node <SKILL_DIR>/cli.js config ms_client_id <CLIENT_ID> node <SKILL_DIR>/cli.js config ms_tenant_id <TENANT_ID> ``` ## Web UI（可选） 提供浏览器界面，适合不喜欢命令行的用户： ```bash node <SKILL_DIR>/server.js [--port 3900] ``` 启动后会显示带 token 的 URL，打开即可在浏览器中配置和使用。 ## 首次使用检查 当用户第一次使用邮件相关功能时，检查： 1. `accounts` 是否有已配置邮箱？没有 → 引导 setup 2. `config` 是否有 `ai_api_key`？没有 → 提示需要配置 AI Key（否则没有摘要功能） ## 数据存储 - 邮箱凭证：`<SKILL_DIR>/data/email.db`（SQLite，本地加密存储） - 配置：`<SKILL_DIR>/data/config.json` - 所有数据仅存在用户本地，不上传

path: _meta.json
content: slug: smart-email
version: 1.2.0
publishedAt: 0

path: ai.js
content: /**
 * ai.js — Email AI summarization (supports DeepSeek / OpenAI compatible APIs)
 */

const { get } = require('./config');

function getApiConfig() {
  return {
    key: get('ai_api_key', ''),
    base: get('ai_api_base', 'https://api.deepseek.com'),
    model: get('ai_model', 'deepseek-chat'),
  };
}

async function summarizeEmail(from, subject, body) {
  const prompt = `你是邮件助手。请用中文简洁解读以下邮件，包括：
1. 一句话概述
2. 是否需要用户处理/回复
3. 重要程度（🔴紧急/🟡一般/🟢无需关注）

发件人: ${from}
主题: ${subject}
正文:
${body || '(无法读取正文)'}`;

  const api = getApiConfig();
  if (!api.key) return fallback(from, subject);

  try {
    const res = await fetch(`${api.base}/chat/completions`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${api.key}`,
      },
      body: JSON.stringify({
        model: api.model,
        messages: [{ role: 'user', content: prompt }],
        max_tokens: 500,
        temperature: 0.3,
      }),
    });

    if (!res.ok) return fallback(from, subject);
    const data = await res.json();
    return data.choices?.[0]?.message?.content || fallback(from, subject);
  } catch {
    return fallback(from, subject);
  }
}

async function summarizeBatch(emails) {
  if (!emails.length) return '';

  const emailList = emails.map((e, i) =>
    `[${i + 1}] 发件人: ${e.from || e.fromAddr || '未知'}\n主题: ${e.subject || '(无主题)'}\n正文摘要: ${(e.body || '').substring(0, 200)}`
  ).join('\n\n');

  const prompt = `你是邮件助手。以下是用户收到的 ${emails.length} 封邮件。请生成简洁的邮件总结：

1. 总体概述（2-3句话）
2. 按重要程度分类：
   - 🔴 需要处理/回复的邮件（列出具体邮件和建议动作）
   - 🟡 值得关注的信息
   - 🟢 可忽略的（广告、通知等，提一下数量）

邮件列表：
${emailList}

请用中文输出，简洁明了。`;

  const api = getApiConfig();
  if (!api.key) return '';

  try {
    const res = await fetch(`${api.base}/chat/completions`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${api.key}`,
      },
      body: JSON.stringify({
        model: api.model,
        messages: [{ role: 'user', content: prompt }],
        max_tokens: 1500,
        temperature: 0.3,
      }),
    });

    if (!res.ok) return '';
    const data = await res.json();
    return data.choices?.[0]?.message?.content || '';
  } catch {
    return '';
  }
}

function fallback(from, subject) {
  return `来自 ${from} 的邮件\n主题: ${subject}\n(AI 解读暂时不可用)`;
}

module.exports = { summarizeEmail, summarizeBatch };


path: cli.js
content: #!/usr/bin/env node
/**
 * email-cli — OpenClaw Email Skill
 *
 * On-demand email checking with AI summarization.
 *
 * Commands:
 *   check [--max N] [--since M] [--summarize] [--account EMAIL]
 *   read <uid> [--account EMAIL]
 *   digest [--date YYYY-MM-DD] [--account EMAIL]
 *   accounts
 *   setup <email>
 *   remove <email>
 *   config [key] [value]
 */

const path = require('path');
const DATA_DIR = path.join(__dirname, 'data');
const fs = require('fs');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });

const { initDb, addAccount, removeAccount, getAccounts, getAccount,
        addOAuthAccount, updateTokens } = require('./store');
const { fetchNewEmails, detectEmailType, IMAP_SERVERS } = require('./imap');
const { refreshAccessToken, requestDeviceCode, pollForToken, fetchEmailsViaGraph } = require('./oauth');
const { summarizeEmail, summarizeBatch } = require('./ai');
const config = require('./config');

// ─── CLI argument parsing ────────────────────────────────────

const args = process.argv.slice(2);
const command = args[0];

function getFlag(name) {
  const idx = args.indexOf('--' + name);
  if (idx === -1) return undefined;
  return args[idx + 1];
}

function hasFlag(name) {
  return args.includes('--' + name);
}

// ─── Main ────────────────────────────────────────────────────

async function main() {
  initDb();

  switch (command) {
    case 'check': return await cmdCheck();
    case 'read': return await cmdRead();
    case 'digest': return await cmdDigest();
    case 'accounts': return cmdAccounts();
    case 'setup': return await cmdSetup();
    case 'remove': return cmdRemove();
    case 'config': return cmdConfig();
    default:
      console.log(JSON.stringify({ error: 'Unknown command. Use: check, read, digest, accounts, setup, remove' }));
      process.exit(1);
  }
}

// ─── check ───────────────────────────────────────────────────

async function cmdCheck() {
  const max = parseInt(getFlag('max')) || 10;
  const since = parseInt(getFlag('since')) || 60; // minutes
  const doSummarize = hasFlag('summarize');
  const accountFilter = getFlag('account');

  const accounts = getAccounts().filter(a => !accountFilter || a.email === accountFilter);
  if (!accounts.length) {
    console.log(JSON.stringify({ error: 'No accounts configured. Use: setup <email>' }));
    return;
  }

  const results = [];

  for (const acct of accounts) {
    try {
      const emails = await fetchAccountEmails(acct, since);
      const limited = emails.slice(0, max);

      for (const email of limited) {
        const entry = {
          account: acct.email,
          uid: email.uid,
          from: email.from,
          fromAddr: email.fromAddr,
          to: email.to,
          subject: email.subject,
          date: email.date,
          bodyPreview: (email.body || '').substring(0, 300),
        };

        if (doSummarize) {
          entry.summary = await summarizeEmail(email.from, email.subject, email.body);
        }

        results.push(entry);
      }
    } catch (err) {
      results.push({ account: acct.email, error: err.message });
    }
  }

  console.log(JSON.stringify({ emails: results, total: results.length }, null, 2));
}

// ─── read ────────────────────────────────────────────────────

async function cmdRead() {
  const uid = args[1];
  if (!uid) {
    console.log(JSON.stringify({ error: 'Usage: read <uid> [--account EMAIL]' }));
    return;
  }

  const accountFilter = getFlag('account');
  const accounts = getAccounts().filter(a => !accountFilter || a.email === accountFilter);
  if (!accounts.length) {
    console.log(JSON.stringify({ error: 'No accounts configured' }));
    return;
  }

  // Fetch recent emails and find the one with matching uid
  for (const acct of accounts) {
    try {
      const emails = await fetchAccountEmails(acct, 1440); // last 24h
      const match = emails.find(e => e.uid === uid || e.uid === String(uid));
      if (match) {
        const summary = await summarizeEmail(match.from, match.subject, match.body);
        console.log(JSON.stringify({
          account: acct.email,
          uid: match.uid,
          from: match.from,
          fromAddr: match.fromAddr,
          to: match.to,
          subject: match.subject,
          date: match.date,
          body: match.body,
          summary,
        }, null, 2));
        return;
      }
    } catch (err) {
      // try next account
    }
  }

  console.log(JSON.stringify({ error: `Email with uid ${uid} not found` }));
}

// ─── digest ──────────────────────────────────────────────────

async function cmdDigest() {
  const accountFilter = getFlag('account');
  const since = parseInt(getFlag('since')) || 1440; // default 24h
  const accounts = getAccounts().filter(a => !accountFilter || a.email === accountFilter);

  if (!accounts.length) {
    console.log(JSON.stringify({ error: 'No accounts configured' }));
    return;
  }

  const allEmails = [];

  for (const acct of accounts) {
    try {
      const emails = await fetchAccountEmails(acct, since);
      for (const e of emails) {
        allEmails.push({ ...e, account: acct.email });
      }
    } catch (err) {
      allEmails.push({ account: acct.email, error: err.message });
    }
  }

  if (!allEmails.length) {
    console.log(JSON.stringify({ digest: 'No emails found', total: 0 }));
    return;
  }

  const digest = await summarizeBatch(allEmails);
  console.log(JSON.stringify({ digest, total: allEmails.length, emails: allEmails.map(e => ({
    account: e.account,
    from: e.from,
    subject: e.subject,
    date: e.date,
  }))}, null, 2));
}

// ─── accounts ────────────────────────────────────────────────

function cmdAccounts() {
  const accounts = getAccounts();
  if (!accounts.length) {
    console.log(JSON.stringify({ accounts: [], message: 'No accounts configured. Use: setup <email>' }));
    return;
  }

  console.log(JSON.stringify({
    accounts: accounts.map(a => ({
      email: a.email,
      type: a.email_type,
      auth: a.auth_type,
    })),
  }, null, 2));
}

// ─── setup ───────────────────────────────────────────────────

async function cmdSetup() {
  const email = args[1];
  if (!email || !email.includes('@')) {
    console.log(JSON.stringify({ error: 'Usage: setup <email>' }));
    return;
  }

  const existing = getAccount(email);
  if (existing) {
    console.log(JSON.stringify({ error: `${email} already configured. Use remove first.` }));
    return;
  }

  // Detect type
  const detected = await detectEmailType(email);
  console.error(`Detected: ${detected.label} (${detected.type})`);

  if (detected.type === 'outlook' || detected.type === 'unknown') {
    // For Outlook/M365, try OAuth2
    console.log(JSON.stringify({
      action: 'setup_pending',
      email,
      detected: detected.label,
      auth_options: ['oauth', 'password'],
      message: `Detected ${detected.label}. Use --auth oauth or --auth password --password <APP_PASSWORD>`,
    }));

    const authMethod = getFlag('auth') || 'oauth';

    if (authMethod === 'oauth') {
      return await setupOAuth(email, detected.type === 'unknown' ? 'outlook' : detected.type);
    } else {
      const [REDACTED_SECRET]'password');
      if (!password) {
        console.log(JSON.stringify({ error: 'Password required: --password <APP_PASSWORD>' }));
        return;
      }
      return await setupPassword(email, password, detected);
    }
  }

  // Gmail/Workspace/others need app password
  const [REDACTED_SECRET]'password');
  if (!password) {
    console.log(JSON.stringify({
      action: 'setup_need_password',
      email,
      detected: detected.label,
      message: `Run again with: setup ${email} --password <APP_PASSWORD>`,
    }));
    return;
  }

  await setupPassword(email, password, detected);
}

async function setupPassword(email, password, detected) {
  const serverArg = detected.host
    ? { host: detected.host, port: detected.port || 993 }
    : detected.type;

  try {
    const emails = await fetchNewEmails(email, password, 60, serverArg);
    let storeType = detected.type;
    if (detected.host && !['gmail', 'outlook', 'workspace'].includes(detected.type)) {
      storeType = `custom:${detected.host}:${detected.port || 993}`;
    }
    addAccount(email, password, storeType);
    console.log(JSON.stringify({
      success: true,
      email,
      type: detected.label,
      unread: emails.length,
    }));
  } catch (err) {
    console.log(JSON.stringify({ error: `Connection failed: ${err.message}` }));
  }
}

async function setupOAuth(email, emailType) {
  try {
    const dc = await requestDeviceCode();
    if (dc.error) {
      console.log(JSON.stringify({ error: `OAuth error: ${dc.error_description || dc.error}` }));
      return;
    }

    console.log(JSON.stringify({
      action: 'oauth_pending',
      verification_uri: dc.verification_uri,
      user_code: dc.user_code,
      message: `Open ${dc.verification_uri} and enter code: ${dc.user_code}`,
      expires_in: dc.expires_in,
    }));

    // Poll for token
    const tokens = await pollForToken(dc.device_code, dc.interval || 5, 300);
    const emails = await fetchEmailsViaGraph(tokens.access_token, 60);

    addOAuthAccount(email, emailType, tokens.access_token, tokens.refresh_token, tokens.expires_at);

    console.log(JSON.stringify({
      success: true,
      email,
      type: emailType,
      auth: 'oauth',
      unread: emails.length,
    }));
  } catch (err) {
    console.log(JSON.stringify({ error: `OAuth failed: ${err.message}` }));
  }
}

// ─── remove ──────────────────────────────────────────────────

function cmdRemove() {
  const email = args[1];
  if (!email) {
    console.log(JSON.stringify({ error: 'Usage: remove <email>' }));
    return;
  }

  const removed = removeAccount(email);
  console.log(JSON.stringify({
    success: removed,
    message: removed ? `Removed ${email}` : `${email} not found`,
  }));
}

// ─── config ──────────────────────────────────────────────────

function cmdConfig() {
  const key = args[1];
  const value = args[2];

  if (!key) {
    // Show all config
    const all = config.getAll();
    // Mask sensitive values
    const masked = {};
    for (const [k, v] of Object.entries(all)) {
      masked[k] = (k.includes('key') || k.includes('password') || k.includes('secret'))
        ? String(v).substring(0, 8) + '****'
        : v;
    }
    console.log(JSON.stringify({
      config: masked,
      available_keys: [
        'ai_api_key     — AI API key (DeepSeek / OpenAI compatible)',
        'ai_api_base    — AI API base URL (default: https://api.deepseek.com)',
        'ai_model       — AI model name (default: deepseek-chat)',
        'ms_client_id   — Microsoft OAuth2 Client ID',
        'ms_tenant_id   — Microsoft OAuth2 Tenant ID (default: common)',
      ],
    }, null, 2));
    return;
  }

  if (!value) {
    // Get single value
    const v = config.get(key, null);
    console.log(JSON.stringify({ [key]: v }));
    return;
  }

  // Set value
  config.set(key, value);
  console.log(JSON.stringify({ success: true, message: `${key} updated` }));
}

// ─── Helpers ─────────────────────────────────────────────────

async function fetchAccountEmails(acct, sinceMinutes) {
  if (acct.auth_type === 'oauth') {
    let accessToken = acct.access_token;
    const needsRefresh = !accessToken || (acct.token_expires && Date.now() > acct.token_expires - 300000);

    if (needsRefresh) {
      const newTokens = await refreshAccessToken(acct.refresh_token);
      accessToken = newTokens.access_token;
      updateTokens(acct.email, newTokens.access_token, newTokens.refresh_token, newTokens.expires_at);
    }

    return await fetchEmailsViaGraph(accessToken, sinceMinutes);
  }

  let serverArg = acct.email_type || 'gmail';
  if (serverArg.startsWith('custom:')) {
    const p = serverArg.split(':');
    serverArg = { host: p[1], port: parseInt(p[2]) || 993 };
  }
  return await fetchNewEmails(acct.email, acct.password, sinceMinutes, serverArg);
}

main().catch(err => {
  console.log(JSON.stringify({ error: err.message }));
  process.exit(1);
});


path: config.js
content: /**
 * config.js — Skill configuration loader
 *
 * Priority: environment variables > data/config.json > defaults
 */

const fs = require('fs');
const path = require('path');

const CONFIG_FILE = path.join(__dirname, 'data', 'config.json');

let _cache = null;

function loadFileConfig() {
  if (_cache) return _cache;
  try {
    _cache = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
  } catch {
    _cache = {};
  }
  return _cache;
}

function get(key, defaultValue) {
  // 1. Environment variable (e.g. EMAIL_SKILL_DEEPSEEK_API_KEY)
  const envKey = 'EMAIL_SKILL_' + key.toUpperCase().replace(/\./g, '_');
  if (process.env[envKey]) return process.env[envKey];

  // 2. Config file
  const file = loadFileConfig();
  if (file[key] !== undefined) return file[key];

  // 3. Default
  return defaultValue;
}

function set(key, value) {
  const file = loadFileConfig();
  file[key] = value;
  const dir = path.dirname(CONFIG_FILE);
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(file, null, 2));
  _cache = file;
}

function getAll() {
  return { ...loadFileConfig() };
}

module.exports = { get, set, getAll };


path: imap.js
content: /**
 * imap.js — IMAP email fetching (reused from email-bot)
 */

const { ImapFlow } = require('imapflow');
const dns = require('dns').promises;

const MAX_BODY_LEN = 6000;
const CONNECT_TIMEOUT = 20000;

const IMAP_SERVERS = {
  gmail:     { host: 'imap.gmail.com',         port: 993 },
  outlook:   { host: 'outlook.office365.com',  port: 993 },
  workspace: { host: 'imap.gmail.com',         port: 993 },
  yahoo:     { host: 'imap.mail.yahoo.com',    port: 993 },
};

async function detectEmailType(email) {
  const domain = (email.split('@')[1] || '').toLowerCase();

  if (domain === 'gmail.com') return { type: 'gmail', label: 'Gmail' };
  if (['outlook.com', 'hotmail.com', 'live.com', 'msn.com'].includes(domain)) return { type: 'outlook', label: 'Outlook' };
  if (domain.includes('yahoo')) return { type: 'yahoo', label: 'Yahoo' };

  try {
    const mxRecords = await dns.resolveMx(domain);
    const mxHosts = mxRecords.map(r => r.exchange.toLowerCase()).join(' ');

    if (mxHosts.includes('google') || mxHosts.includes('gmail'))
      return { type: 'workspace', label: 'Google Workspace', mx: mxHosts };
    if (mxHosts.includes('outlook') || mxHosts.includes('microsoft') || mxHosts.includes('protection.outlook'))
      return { type: 'outlook', label: 'Microsoft 365', mx: mxHosts };
    if (mxHosts.includes('zoho'))
      return { type: 'custom', label: 'Zoho Mail', host: 'imap.zoho.com', port: 993 };
    if (mxHosts.includes('qq.com') || mxHosts.includes('exmail'))
      return { type: 'custom', label: '腾讯企业邮箱', host: 'imap.exmail.qq.com', port: 993 };
    if (mxHosts.includes('alibaba') || mxHosts.includes('aliyun'))
      return { type: 'custom', label: '阿里企业邮箱', host: 'imap.qiye.aliyun.com', port: 993 };

    return { type: 'unknown', label: '其他', mx: mxHosts, domain };
  } catch {
    return { type: 'unknown', label: '未知', domain };
  }
}

async function fetchNewEmails(email, authConfig, sinceMinutes = 10, emailType) {
  let server;

  if (typeof emailType === 'object' && emailType.host) {
    server = emailType;
    emailType = 'custom';
  } else {
    server = IMAP_SERVERS[emailType] || IMAP_SERVERS.gmail;
  }

  let authObj;
  if (typeof authConfig === 'object' && authConfig.accessToken) {
    authObj = { user: email, accessToken: authConfig.accessToken };
  } else {
    authObj = { user: email, pass: authConfig };
  }

  const client = new ImapFlow({
    host: server.host,
    port: server.port,
    secure: true,
    auth: authObj,
    logger: false,
    tls: { rejectUnauthorized: emailType === 'custom' ? false : true },
  });

  const emails = [];

  try {
    await Promise.race([
      client.connect(),
      new Promise((_, rej) => setTimeout(() => rej(new Error('连接超时')), CONNECT_TIMEOUT)),
    ]);

    const lock = await client.getMailboxLock('INBOX');

    try {
      const since = new Date(Date.now() - sinceMinutes * 60 * 1000);
      const uids = await client.search({ seen: false, since }, { uid: true });

      if (!uids.length) { lock.release(); await client.logout(); return emails; }

      const fetchUids = uids.slice(-20);

      for await (const msg of client.fetch(fetchUids, {
        uid: true,
        envelope: true,
        bodyStructure: true,
        source: { maxBytes: MAX_BODY_LEN * 3 },
      }, { uid: true })) {
        const env = msg.envelope || {};
        const from = env.from?.[0];
        const fromStr = from ? (from.name || `${from.mailbox}@${from.host}`) : 'Unknown';
        const fromAddr = from ? `${from.mailbox}@${from.host}` : '';
        const to = env.to?.[0];
        const toStr = to ? (to.name || `${to.mailbox}@${to.host}`) : '';
        const subject = env.subject || '(No Subject)';
        const date = env.date || new Date();

        let body = '';
        if (msg.source) {
          body = extractTextBody(msg.source.toString('utf8'));
        }

        emails.push({
          uid: String(msg.uid),
          from: fromStr,
          fromAddr,
          to: toStr,
          subject,
          date: date instanceof Date ? date.toISOString() : String(date),
          body: body.substring(0, MAX_BODY_LEN),
        });
      }
    } finally {
      lock.release();
    }

    await client.logout();
  } catch (err) {
    try { await client.logout(); } catch {}
    throw err;
  }

  return emails;
}

function extractTextBody(rawEmail) {
  const headerEnd = rawEmail.indexOf('\r\n\r\n');
  if (headerEnd === -1) return stripHtml(rawEmail.substring(0, MAX_BODY_LEN));

  const headers = rawEmail.substring(0, headerEnd).toLowerCase();
  let body = rawEmail.substring(headerEnd + 4);

  const boundaryMatch = headers.match(/boundary="?([^";\r\n]+)"?/);
  if (boundaryMatch) body = extractFromMultipart(body, boundaryMatch[1]);

  const bodyHeaders = body.substring(0, Math.min(500, body.length)).toLowerCase();
  if (bodyHeaders.includes('content-transfer-encoding: base64') || headers.includes('content-transfer-encoding: base64')) {
    body = decodeBase64Part(body);
  } else if (bodyHeaders.includes('quoted-printable') || headers.includes('quoted-printable')) {
    body = decodeQuotedPrintable(body);
  }

  return stripHtml(body);
}

function extractFromMultipart(body, boundary) {
  const parts = body.split('--' + boundary);
  let textPlain = '', textHtml = '';

  for (const part of parts) {
    const partHeaderEnd = part.indexOf('\r\n\r\n');
    if (partHeaderEnd === -1) continue;

    const partHeaders = part.substring(0, partHeaderEnd).toLowerCase();
    let partBody = part.substring(partHeaderEnd + 4);

    const nestedBoundary = partHeaders.match(/boundary="?([^";\r\n]+)"?/);
    if (nestedBoundary) {
      const nested = extractFromMultipart(partBody, nestedBoundary[1]);
      if (nested) return nested;
      continue;
    }

    if (partHeaders.includes('content-transfer-encoding: base64')) partBody = decodeBase64Part(partBody);
    else if (partHeaders.includes('quoted-printable')) partBody = decodeQuotedPrintable(partBody);

    if (partHeaders.includes('text/plain')) textPlain = partBody;
    else if (partHeaders.includes('text/html')) textHtml = partBody;
  }

  if (textPlain.trim()) return textPlain;
  if (textHtml.trim()) return stripHtml(textHtml);
  return '';
}

function decodeBase64Part(text) {
  const lines = text.split('\r\n');
  const base64Lines = [];
  let started = false;
  for (const line of lines) {
    if (!started && line.trim() === '') { started = true; continue; }
    if (started || /^[A-Za-z0-9+/=]+$/.test(line.trim())) {
      started = true;
      if (line.startsWith('--')) break;
      base64Lines.push(line.trim());
    }
  }
  if (!base64Lines.length) {
    const cleaned = text.replace(/^[\s\S]*?\r\n\r\n/, '').replace(/[\r\n\s]/g, '').replace(/--.*/s, '');
    try { return Buffer.from(cleaned, 'base64').toString('utf8'); } catch { return text; }
  }
  try { return Buffer.from(base64Lines.join(''), 'base64').toString('utf8'); } catch { return text; }
}

function decodeQuotedPrintable(str) {
  return str.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
}

function stripHtml(text) {
  return text
    .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
    .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
    .replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>/gi, '\n').replace(/<\/div>/gi, '\n')
    .replace(/<\/tr>/gi, '\n').replace(/<\/li>/gi, '\n')
    .replace(/<[^>]+>/g, '')
    .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"').replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
    .replace(/\n{3,}/g, '\n\n').replace(/[ \t]+/g, ' ')
    .trim();
}

module.exports = { fetchNewEmails, detectEmailType, IMAP_SERVERS };


path: oauth.js
content: /**
 * oauth.js — Microsoft OAuth2 Device Code Flow + Graph API
 */

const https = require('https');
const { get } = require('./config');

function getOAuthConfig() {
  return {
    clientId: get('ms_client_id', ''),
    tenantId: get('ms_tenant_id', 'common'),
    scopes: get('ms_scopes', 'https://graph.microsoft.com/Mail.Read offline_access'),
  };
}

function httpsRequest(method, url, body, headers = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(url);
    const data = body ? (typeof body === 'string' ? body : new URLSearchParams(body).toString()) : '';
    const reqHeaders = { ...headers };
    if (method === 'POST' && !headers['Content-Type']) {
      reqHeaders['Content-Type'] = 'application/x-www-form-urlencoded';
      reqHeaders['Content-Length'] = Buffer.byteLength(data);
    }
    const req = https.request({
      hostname: u.hostname,
      path: u.pathname + u.search,
      method,
      headers: reqHeaders,
    }, (res) => {
      let buf = '';
      res.on('data', d => buf += d);
      res.on('end', () => {
        try { resolve(JSON.parse(buf)); } catch { resolve({ raw: buf }); }
      });
    });
    req.on('error', reject);
    if (data) req.write(data);
    req.end();
  });
}

async function requestDeviceCode() {
  const { clientId, tenantId, scopes } = getOAuthConfig();
  if (!clientId) throw new Error('ms_client_id not configured. Run: node cli.js config ms_client_id <YOUR_CLIENT_ID>');
  return await httpsRequest('POST', `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/devicecode`, {
    client_id: clientId,
    scope: scopes,
  });
}

async function pollForToken(deviceCode, interval = 5, timeout = 300) {
  const { clientId, tenantId } = getOAuthConfig();
  const url = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
  const start = Date.now();

  while (Date.now() - start < timeout * 1000) {
    await new Promise(r => setTimeout(r, interval * 1000));

    const result = await httpsRequest('POST', url, {
      grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
      client_id: clientId,
      device_code: deviceCode,
    });

    if (result.access_token) {
      return {
        access_token: result.access_token,
        refresh_token: result.refresh_token,
        expires_at: Date.now() + (result.expires_in || 3600) * 1000,
      };
    }

    if (result.error === 'authorization_pending') continue;
    if (result.error === 'slow_down') { interval += 5; continue; }
    if (result.error === 'expired_token') throw new Error('Authorization timeout');
    if (result.error) throw new Error(result.error_description || result.error);
  }

  throw new Error('Authorization timeout');
}

async function refreshAccessToken(refreshToken) {
  const { clientId, tenantId, scopes } = getOAuthConfig();
  const result = await httpsRequest('POST', `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
    grant_type: 'refresh_token',
    client_id: clientId,
    refresh_token: refreshToken,
    scope: scopes,
  });

  if (result.error) throw new Error(result.error_description || result.error);

  return {
    access_token: result.access_token,
    refresh_token: result.refresh_token || refreshToken,
    expires_at: Date.now() + (result.expires_in || 3600) * 1000,
  };
}

async function fetchEmailsViaGraph(accessToken, sinceMinutes = 10) {
  const since = new Date(Date.now() - sinceMinutes * 60 * 1000).toISOString();
  const filter = encodeURIComponent(`isRead eq false and receivedDateTime ge ${since}`);
  const select = 'id,subject,from,toRecipients,receivedDateTime,body,bodyPreview,isRead';
  const url = `https://graph.microsoft.com/v1.0/me/messages?$filter=${filter}&$top=20&$orderby=receivedDateTime desc&$select=${select}`;

  const result = await httpsRequest('GET', url, null, {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  });

  if (result.error) throw new Error(result.error.message || result.error.code || 'Graph API error');

  const emails = [];
  for (const msg of (result.value || [])) {
    const from = msg.from?.emailAddress;
    const to = msg.toRecipients?.[0]?.emailAddress;

    let body = '';
    if (msg.body) {
      body = msg.body.contentType === 'text' ? (msg.body.content || '') : stripHtml(msg.body.content || '');
    }
    if (!body && msg.bodyPreview) body = msg.bodyPreview;

    emails.push({
      uid: msg.id,
      from: from?.name || from?.address || 'Unknown',
      fromAddr: from?.address || '',
      to: to?.name || to?.address || '',
      subject: msg.subject || '(No Subject)',
      date: msg.receivedDateTime || new Date().toISOString(),
      body: body.substring(0, 6000),
    });
  }

  return emails;
}

function stripHtml(text) {
  return text
    .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
    .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
    .replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>/gi, '\n').replace(/<\/div>/gi, '\n')
    .replace(/<[^>]+>/g, '')
    .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
    .replace(/\n{3,}/g, '\n\n').replace(/[ \t]+/g, ' ')
    .trim();
}

module.exports = { requestDeviceCode, pollForToken, refreshAccessToken, fetchEmailsViaGraph };


path: package-lock.json
content: {
  "name": "email-skill",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "email-skill",
      "version": "1.0.0",
      "dependencies": {
        "better-sqlite3": "^12.6.2",
        "imapflow": "^1.2.10"
      }
    },
    "node_modules/@pinojs/redact": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/@zone-eu/mailsplit": {
      "version": "5.4.8",
      "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "(MIT OR EUPL-1.1+)",
      "dependencies": {
        "libbase64": "1.3.0",
        "libmime": "5.3.7",
        "libqp": "2.1.1"
      }
    },
    "node_modules/atomic-sleep": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=8.0.0"
      }
    },
    "node_modules/base64-js": {
      "version": "1.5.1",
      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT"
    },
    "node_modules/better-sqlite3": {
      "version": "12.6.2",
      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
      "integrity": [REDACTED_SECRET],
      "hasInstallScript": true,
      "license": "MIT",
      "dependencies": {
        "bindings": "^1.5.0",
        "prebuild-install": "^7.1.1"
      },
      "engines": {
        "node": "20.x || 22.x || 23.x || 24.x || 25.x"
      }
    },
    "node_modules/bindings": {
      "version": "1.5.0",
      "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "file-uri-to-path": "1.0.0"
      }
    },
    "node_modules/bl": {
      "version": "4.1.0",
      "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "buffer": "^5.5.0",
        "inherits": "^2.0.4",
        "readable-stream": "^3.4.0"
      }
    },
    "node_modules/buffer": {
      "version": "5.7.1",
      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "base64-js": "^1.3.1",
        "ieee754": "^1.1.13"
      }
    },
    "node_modules/chownr": {
      "version": "1.1.4",
      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC"
    },
    "node_modules/decompress-response": {
      "version": "6.0.0",
      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "mimic-response": "^3.1.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/deep-extend": {
      "version": "0.6.0",
      "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=4.0.0"
      }
    },
    "node_modules/detect-libc": {
      "version": "2.1.2",
      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/encoding-japanese": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=8.10.0"
      }
    },
    "node_modules/end-of-stream": {
      "version": "1.4.5",
      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "once": "^1.4.0"
      }
    },
    "node_modules/expand-template": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "(MIT OR WTFPL)",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/file-uri-to-path": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/fs-constants": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/github-from-package": {
      "version": "0.0.0",
      "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/iconv-lite": {
      "version": "0.7.2",
      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "safer-buffer": ">= 2.1.2 < 3.0.0"
      },
      "engines": {
        "node": ">=0.10.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/express"
      }
    },
    "node_modules/ieee754": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "BSD-3-Clause"
    },
    "node_modules/imapflow": {
      "version": "1.2.13",
      "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.13.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@zone-eu/mailsplit": "5.4.8",
        "encoding-japanese": "2.2.0",
        "iconv-lite": "0.7.2",
        "libbase64": "1.3.0",
        "libmime": "5.3.7",
        "libqp": "2.1.1",
        "nodemailer": "8.0.2",
        "pino": "10.3.1",
        "socks": "2.8.7"
      }
    },
    "node_modules/inherits": {
      "version": "2.0.4",
      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC"
    },
    "node_modules/ini": {
      "version": "1.3.8",
      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC"
    },
    "node_modules/ip-address": {
      "version": "10.1.0",
      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 12"
      }
    },
    "node_modules/libbase64": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/libmime": {
      "version": "5.3.7",
      "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "encoding-japanese": "2.2.0",
        "iconv-lite": "0.6.3",
        "libbase64": "1.3.0",
        "libqp": "2.1.1"
      }
    },
    "node_modules/libmime/node_modules/iconv-lite": {
      "version": "0.6.3",
      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "safer-buffer": ">= 2.1.2 < 3.0.0"
      },
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/libqp": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/mimic-response": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/minimist": {
      "version": "1.2.8",
      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/mkdirp-classic": {
      "version": "0.5.3",
      "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/napi-build-utils": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/node-abi": {
      "version": "3.88.0",
      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "semver": "^7.3.5"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/nodemailer": {
      "version": "8.0.2",
      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT-0",
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/on-exit-leak-free": {
      "version": "2.1.2",
      "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/once": {
      "version": "1.4.0",
      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "wrappy": "1"
      }
    },
    "node_modules/pino": {
      "version": "10.3.1",
      "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@pinojs/redact": "^0.4.0",
        "atomic-sleep": "^1.0.0",
        "on-exit-leak-free": "^2.1.0",
        "pino-abstract-transport": "^3.0.0",
        "pino-std-serializers": "^7.0.0",
        "process-warning": "^5.0.0",
        "quick-format-unescaped": "^4.0.3",
        "real-require": "^0.2.0",
        "safe-stable-stringify": "^2.3.1",
        "sonic-boom": "^4.0.1",
        "thread-stream": "^4.0.0"
      },
      "bin": {
        "pino": "bin.js"
      }
    },
    "node_modules/pino-abstract-transport": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "split2": "^4.0.0"
      }
    },
    "node_modules/pino-std-serializers": {
      "version": "7.1.0",
      "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/prebuild-install": {
      "version": "7.1.3",
      "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
      "integrity": [REDACTED_SECRET],
      "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
      "license": "MIT",
      "dependencies": {
        "detect-libc": "^2.0.0",
        "expand-template": "^2.0.3",
        "github-from-package": "0.0.0",
        "minimist": "^1.2.3",
        "mkdirp-classic": "^0.5.3",
        "napi-build-utils": "^2.0.0",
        "node-abi": "^3.3.0",
        "pump": "^3.0.0",
        "rc": "^1.2.7",
        "simple-get": "^4.0.0",
        "tar-fs": "^2.0.0",
        "tunnel-agent": "^0.6.0"
      },
      "bin": {
        "prebuild-install": "bin.js"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/process-warning": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT"
    },
    "node_modules/pump": {
      "version": "3.0.4",
      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "end-of-stream": "^1.1.0",
        "once": "^1.3.1"
      }
    },
    "node_modules/quick-format-unescaped": {
      "version": "4.0.4",
      "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/rc": {
      "version": "1.2.8",
      "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
      "dependencies": {
        "deep-extend": "^0.6.0",
        "ini": "~1.3.0",
        "minimist": "^1.2.0",
        "strip-json-comments": "~2.0.1"
      },
      "bin": {
        "rc": "cli.js"
      }
    },
    "node_modules/readable-stream": {
      "version": "3.6.2",
      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "inherits": "^2.0.3",
        "string_decoder": "^1.1.1",
        "util-deprecate": "^1.0.1"
      },
      "engines": {
        "node": ">= 6"
      }
    },
    "node_modules/real-require": {
      "version": "0.2.0",
      "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 12.13.0"
      }
    },
    "node_modules/safe-buffer": {
      "version": "5.2.1",
      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT"
    },
    "node_modules/safe-stable-stringify": {
      "version": "2.5.0",
      "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/safer-buffer": {
      "version": "2.1.2",
      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/semver": {
      "version": "7.7.4",
      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "bin": {
        "semver": "bin/semver.js"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/simple-concat": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT"
    },
    "node_modules/simple-get": {
      "version": "4.0.1",
      "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "decompress-response": "^6.0.0",
        "once": "^1.3.1",
        "simple-concat": "^1.0.0"
      }
    },
    "node_modules/smart-buffer": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 6.0.0",
        "npm": ">= 3.0.0"
      }
    },
    "node_modules/socks": {
      "version": "2.8.7",
      "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "ip-address": "^10.0.1",
        "smart-buffer": "^4.2.0"
      },
      "engines": {
        "node": ">= 10.0.0",
        "npm": ">= 3.0.0"
      }
    },
    "node_modules/sonic-boom": {
      "version": "4.2.1",
      "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "atomic-sleep": "^1.0.0"
      }
    },
    "node_modules/split2": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">= 10.x"
      }
    },
    "node_modules/string_decoder": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "safe-buffer": "~5.2.0"
      }
    },
    "node_modules/strip-json-comments": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/tar-fs": {
      "version": "2.1.4",
      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "chownr": "^1.1.1",
        "mkdirp-classic": "^0.5.2",
        "pump": "^3.0.0",
        "tar-stream": "^2.1.4"
      }
    },
    "node_modules/tar-stream": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "bl": "^4.0.3",
        "end-of-stream": "^1.4.1",
        "fs-constants": "^1.0.0",
        "inherits": "^2.0.3",
        "readable-stream": "^3.1.1"
      },
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/thread-stream": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "real-require": "^0.2.0"
      },
      "engines": {
        "node": ">=20"
      }
    },
    "node_modules/tunnel-agent": {
      "version": "0.6.0",
      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "safe-buffer": "^5.0.1"
      },
      "engines": {
        "node": "*"
      }
    },
    "node_modules/util-deprecate": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/wrappy": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC"
    }
  }
}


path: package.json
content: {
  "name": "email-skill",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "better-sqlite3": "^12.6.2",
    "imapflow": "^1.2.10"
  }
}


path: server.js
content: #!/usr/bin/env node
/**
 * server.js — Email Skill Web UI
 *
 * Usage: node server.js [--port 3900]
 */

const http = require('http');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');

const DATA_DIR = path.join(__dirname, 'data');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });

const { initDb, addAccount, removeAccount, getAccounts, getAccount,
        addOAuthAccount, updateTokens } = require('./store');
const { fetchNewEmails, detectEmailType, IMAP_SERVERS } = require('./imap');
const { refreshAccessToken, requestDeviceCode, pollForToken, fetchEmailsViaGraph } = require('./oauth');
const { summarizeEmail, summarizeBatch } = require('./ai');
const config = require('./config');

// ─── Args ────────────────────────────────────────────────────

const args = process.argv.slice(2);
function getArg(name, def) {
  const idx = args.indexOf('--' + name);
  return idx !== -1 && args[idx + 1] ? args[idx + 1] : def;
}

const PORT = parseInt(getArg('port', '3900'));

// ─── Auth ────────────────────────────────────────────────────

function getOrCreateToken() {
  let [REDACTED_SECRET]'web_token', '');
  if (!token) {
    [REDACTED_SECRET]).toString('hex');
    config.set('web_token', token);
  }
  return token;
}

function checkAuth(req) {
  const url = new URL(req.url, `http://localhost:${PORT}`);
  const cookieToken = (req.headers.cookie || '').split(';')
    .map(c => c.trim().split('='))
    .find(([k]) => k === 'token')?.[1];
  const queryToken = url.searchParams.get('token');
  const expected = getOrCreateToken();
  return cookieToken === expected || queryToken === expected;
}

// ─── API Routes ──────────────────────────────────────────────

async function handleApi(req, res, pathname, body) {
  const json = (data, status = 200) => {
    res.writeHead(status, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(data));
  };

  // Auth check
  if (pathname !== '/api/login') {
    if (!checkAuth(req)) return json({ error: 'Unauthorized' }, 401);
  }

  try {
    // POST /api/login
    if (pathname === '/api/login' && req.method === 'POST') {
      const { token } = JSON.parse(body);
      if (token === getOrCreateToken()) {
        res.writeHead(200, {
          'Content-Type': 'application/json',
          'Set-Cookie': `[REDACTED_SECRET]; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`,
        });
        res.end(JSON.stringify({ success: true }));
      } else {
        json({ error: 'Invalid token' }, 401);
      }
      return;
    }

    // GET /api/config
    if (pathname === '/api/config' && req.method === 'GET') {
      const all = config.getAll();
      const masked = {};
      for (const [k, v] of Object.entries(all)) {
        if (k === 'web_token') continue;
        masked[k] = (k.includes('key') || k.includes('password') || k.includes('secret'))
          ? (String(v).substring(0, 8) + '****') : v;
      }
      return json({ config: masked });
    }

    // POST /api/config
    if (pathname === '/api/config' && req.method === 'POST') {
      const { key, value } = JSON.parse(body);
      if (!key) return json({ error: 'key required' }, 400);
      config.set(key, value);
      return json({ success: true });
    }

    // GET /api/accounts
    if (pathname === '/api/accounts') {
      const accounts = getAccounts();
      return json({
        accounts: accounts.map(a => ({
          email: a.email,
          type: a.email_type,
          auth: a.auth_type,
        })),
      });
    }

    // POST /api/accounts/add
    if (pathname === '/api/accounts/add' && req.method === 'POST') {
      const { email, password, auth } = JSON.parse(body);
      if (!email) return json({ error: 'email required' }, 400);

      if (getAccount(email)) return json({ error: `${email} already exists` }, 400);

      const detected = await detectEmailType(email);

      if (auth === 'oauth') {
        const dc = await requestDeviceCode();
        if (dc.error) return json({ error: dc.error_description || dc.error }, 500);
        // Return device code for user to authorize
        return json({
          action: 'oauth_pending',
          email,
          detected: detected.label,
          verification_uri: dc.verification_uri,
          user_code: dc.user_code,
          device_code: dc.device_code,
          interval: dc.interval || 5,
        });
      }

      if (!password) return json({ error: 'password required', detected: detected.label }, 400);

      const serverArg = detected.host
        ? { host: detected.host, port: detected.port || 993 }
        : detected.type;

      const emails = await fetchNewEmails(email, password, 60, serverArg);
      let storeType = detected.type;
      if (detected.host && !['gmail', 'outlook', 'workspace'].includes(detected.type)) {
        storeType = `custom:${detected.host}:${detected.port || 993}`;
      }
      addAccount(email, password, storeType);
      return json({ success: true, email, type: detected.label, unread: emails.length });
    }

    // POST /api/accounts/oauth-poll
    if (pathname === '/api/accounts/oauth-poll' && req.method === 'POST') {
      const { email, device_code, interval } = JSON.parse(body);
      const tokens = await pollForToken(device_code, interval || 5, 300);
      const emails = await fetchEmailsViaGraph(tokens.access_token, 60);
      const detected = await detectEmailType(email);
      addOAuthAccount(email, detected.type || 'outlook', tokens.access_token, tokens.refresh_token, tokens.expires_at);
      return json({ success: true, email, unread: emails.length });
    }

    // POST /api/accounts/remove
    if (pathname === '/api/accounts/remove' && req.method === 'POST') {
      const { email } = JSON.parse(body);
      const removed = removeAccount(email);
      return json({ success: removed });
    }

    // POST /api/check
    if (pathname === '/api/check' && req.method === 'POST') {
      const { account, max = 10, since = 60, summarize = false } = JSON.parse(body || '{}');
      const accounts = getAccounts().filter(a => !account || a.email === account);
      if (!accounts.length) return json({ error: 'No accounts configured' }, 400);

      const results = [];
      for (const acct of accounts) {
        try {
          const emails = await fetchAccountEmails(acct, since);
          for (const email of emails.slice(0, max)) {
            const entry = {
              account: acct.email,
              uid: email.uid,
              from: email.from,
              fromAddr: email.fromAddr,
              subject: email.subject,
              date: email.date,
              bodyPreview: (email.body || '').substring(0, 300),
            };
            if (summarize) {
              entry.summary = await summarizeEmail(email.from, email.subject, email.body);
            }
            results.push(entry);
          }
        } catch (err) {
          results.push({ account: acct.email, error: err.message });
        }
      }
      return json({ emails: results, total: results.length });
    }

    // POST /api/read
    if (pathname === '/api/read' && req.method === 'POST') {
      const { uid, account } = JSON.parse(body);
      const accounts = getAccounts().filter(a => !account || a.email === account);

      for (const acct of accounts) {
        try {
          const emails = await fetchAccountEmails(acct, 1440);
          const match = emails.find(e => e.uid === uid || e.uid === String(uid));
          if (match) {
            const summary = await summarizeEmail(match.from, match.subject, match.body);
            return json({ ...match, account: acct.email, summary });
          }
        } catch {}
      }
      return json({ error: 'Email not found' }, 404);
    }

    // POST /api/digest
    if (pathname === '/api/digest' && req.method === 'POST') {
      const { account, since = 1440 } = JSON.parse(body || '{}');
      const accounts = getAccounts().filter(a => !account || a.email === account);
      const allEmails = [];

      for (const acct of accounts) {
        try {
          const emails = await fetchAccountEmails(acct, since);
          for (const e of emails) allEmails.push({ ...e, account: acct.email });
        } catch {}
      }

      if (!allEmails.length) return json({ digest: '没有新邮件', total: 0 });
      const digest = await summarizeBatch(allEmails);
      return json({ digest, total: allEmails.length });
    }

    json({ error: 'Not found' }, 404);
  } catch (err) {
    json({ error: err.message }, 500);
  }
}

// ─── Helpers ─────────────────────────────────────────────────

async function fetchAccountEmails(acct, sinceMinutes) {
  if (acct.auth_type === 'oauth') {
    let accessToken = acct.access_token;
    const needsRefresh = !accessToken || (acct.token_expires && Date.now() > acct.token_expires - 300000);
    if (needsRefresh) {
      const newTokens = await refreshAccessToken(acct.refresh_token);
      accessToken = newTokens.access_token;
      updateTokens(acct.email, newTokens.access_token, newTokens.refresh_token, newTokens.expires_at);
    }
    return await fetchEmailsViaGraph(accessToken, sinceMinutes);
  }

  let serverArg = acct.email_type || 'gmail';
  if (serverArg.startsWith('custom:')) {
    const p = serverArg.split(':');
    serverArg = { host: p[1], port: parseInt(p[2]) || 993 };
  }
  return await fetchNewEmails(acct.email, acct.password, sinceMinutes, serverArg);
}

// ─── HTML UI ─────────────────────────────────────────────────

function getHtml() {
  return fs.readFileSync(path.join(__dirname, 'ui.html'), 'utf-8');
}

// ─── Server ──────────────────────────────────────────────────

initDb();

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://localhost:${PORT}`);
  const pathname = url.pathname;

  // Collect body
  let body = '';
  if (req.method === 'POST') {
    for await (const chunk of req) body += chunk;
  }

  // API
  if (pathname.startsWith('/api/')) {
    return handleApi(req, res, pathname, body);
  }

  // UI
  if (pathname === '/' || pathname === '/index.html') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(getHtml());
    return;
  }

  res.writeHead(404);
  res.end('Not Found');
});

server.listen(PORT, () => {
  const [REDACTED_SECRET]);
  console.log(`\n📧 Email Skill Web UI`);
  console.log(`   http://localhost:${PORT}?[REDACTED_SECRET]`);
  console.log(`   Share this URL to give access (token included).\n`);
});


path: store.js
content: /**
 * store.js — Local SQLite storage for email accounts
 */

const Database = require('better-sqlite3');
const path = require('path');

const DB_PATH = path.join(__dirname, 'data', 'email.db');
let db;

function initDb() {
  db = new Database(DB_PATH);
  db.pragma('journal_mode = WAL');

  db.exec(`
    CREATE TABLE IF NOT EXISTS accounts (
      email TEXT PRIMARY KEY,
      password TEXT,
      email_type TEXT DEFAULT 'gmail',
      auth_type TEXT DEFAULT 'password',
      access_token TEXT,
      refresh_token TEXT,
      token_expires INTEGER DEFAULT 0,
      created_at INTEGER
    );
  `);

  return db;
}

function addAccount(email, password, emailType) {
  db.prepare(`
    INSERT INTO accounts (email, password, email_type, auth_type, created_at)
    VALUES (?, ?, ?, 'password', ?)
    ON CONFLICT(email) DO UPDATE SET
      [REDACTED_SECRET],
      email_type = excluded.email_type,
      auth_type = 'password',
      access_token = NULL,
      refresh_token = NULL,
      token_expires = 0
  `).run(email, password, emailType || 'gmail', Date.now());
}

function addOAuthAccount(email, emailType, accessToken, refreshToken, tokenExpires) {
  db.prepare(`
    INSERT INTO accounts (email, password, email_type, auth_type, access_token, refresh_token, token_expires, created_at)
    VALUES (?, '', ?, 'oauth', ?, ?, ?, ?)
    ON CONFLICT(email) DO UPDATE SET
      email_type = excluded.email_type,
      auth_type = 'oauth',
      access_token = excluded.access_token,
      refresh_token = excluded.refresh_token,
      token_expires = excluded.token_expires
  `).run(email, emailType || 'outlook', accessToken, refreshToken, tokenExpires, Date.now());
}

function updateTokens(email, accessToken, refreshToken, tokenExpires) {
  db.prepare(`
    UPDATE accounts SET access_token = ?, refresh_token = ?, token_expires = ? WHERE email = ?
  `).run(accessToken, refreshToken, tokenExpires, email);
}

function getAccounts() {
  return db.prepare('SELECT * FROM accounts').all();
}

function getAccount(email) {
  return db.prepare('SELECT * FROM accounts WHERE email = ?').get(email);
}

function removeAccount(email) {
  return db.prepare('DELETE FROM accounts WHERE email = ?').run(email).changes > 0;
}

module.exports = { initDb, addAccount, addOAuthAccount, updateTokens, getAccounts, getAccount, removeAccount };


path: ui.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📧 Email Assistant</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
:root {
  --bg: #0f1117; --surface: #1a1d27; --surface2: #242836;
  --border: #2e3345; --text: #e4e6ef; --text2: #9499b3;
  --primary: #6c8aff; --primary-hover: #8aa3ff;
  --success: #4ade80; --danger: #f87171; --warn: #fbbf24;
  --radius: 12px;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
.container { max-width: 900px; margin: 0 auto; padding: 20px; }

/* Header */
.header { display: flex; align-items: center; justify-content: space-between; padding: 20px 0; border-bottom: 1px solid var(--border); margin-bottom: 24px; }
.header h1 { font-size: 24px; font-weight: 600; }
.header h1 span { font-size: 28px; margin-right: 8px; }

/* Login */
.login-box { max-width: 400px; margin: 100px auto; text-align: center; }
.login-box h2 { margin-bottom: 20px; }
.login-box input { width: 100%; padding: 12px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); font-size: 15px; margin-bottom: 12px; }
.login-box input:focus { outline: none; border-color: var(--primary); }

/* Tabs */
.tabs { display: flex; gap: 4px; margin-bottom: 24px; background: var(--surface); border-radius: var(--radius); padding: 4px; }
.tab { flex: 1; padding: 10px; text-align: center; border-radius: 8px; cursor: pointer; font-size: 14px; color: var(--text2); transition: all .2s; border: none; background: none; }
.tab:hover { color: var(--text); }
.tab.active { background: var(--primary); color: #fff; }

/* Cards */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 16px; }
.card h3 { font-size: 16px; margin-bottom: 12px; color: var(--text); }
.card-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--border); }
.card-row:last-child { border-bottom: none; }

/* Buttons */
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; transition: all .2s; font-weight: 500; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); }
.btn-danger:hover { background: var(--danger); color: #fff; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn:disabled { opacity: .5; cursor: not-allowed; }

/* Inputs */
input, select { padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface2); color: var(--text); font-size: 14px; width: 100%; }
input:focus, select:focus { outline: none; border-color: var(--primary); }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 13px; color: var(--text2); margin-bottom: 6px; }
.form-row { display: flex; gap: 10px; align-items: flex-end; }
.form-row .form-group { flex: 1; margin-bottom: 0; }

/* Email list */
.email-item { padding: 14px; border-bottom: 1px solid var(--border); cursor: pointer; transition: background .2s; }
.email-item:hover { background: var(--surface2); }
.email-item:last-child { border-bottom: none; }
.email-from { font-weight: 500; font-size: 14px; }
.email-subject { font-size: 15px; margin: 4px 0; }
.email-meta { font-size: 12px; color: var(--text2); display: flex; gap: 12px; }
.email-preview { font-size: 13px; color: var(--text2); margin-top: 6px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.email-summary { background: var(--surface2); border-radius: 8px; padding: 12px; margin-top: 10px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; }

/* Badge */
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.badge-green { background: rgba(74,222,128,.15); color: var(--success); }
.badge-blue { background: rgba(108,138,255,.15); color: var(--primary); }

/* Modal */
.modal-overlay { position: fixed; top:0; left:0; right:0; bottom:0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; width: 90%; max-width: 500px; max-height: 80vh; overflow-y: auto; }
.modal h3 { margin-bottom: 16px; }

/* Loading */
.spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin .6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text { display: flex; align-items: center; gap: 10px; color: var(--text2); padding: 20px; justify-content: center; }

/* Digest */
.digest-content { line-height: 1.8; white-space: pre-wrap; font-size: 14px; }

/* Toast */
.toast { position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 8px; color: #fff; font-size: 14px; z-index: 200; animation: slideIn .3s; }
.toast-success { background: #16a34a; }
.toast-error { background: #dc2626; }
@keyframes slideIn { from { transform: translateX(100px); opacity: 0; } }

/* Empty */
.empty { text-align: center; padding: 40px 20px; color: var(--text2); }
.empty p { margin-bottom: 16px; }

/* Responsive */
@media (max-width: 600px) {
  .form-row { flex-direction: column; }
  .tabs { flex-wrap: wrap; }
}
</style>
</head>
<body>

<div class="container" id="app">
  <!-- Login -->
  <div id="login-view" class="login-box" style="display:none">
    <h2>📧 Email Assistant</h2>
    <p style="color:var(--text2);margin-bottom:20px">输入访问令牌登录</p>
    <input type="password" id="login-token" placeholder="Access Token" onkeydown="if(event.key==='Enter')doLogin()">
    <button class="btn btn-primary" style="width:100%" onclick="doLogin()">登录</button>
  </div>

  <!-- Main -->
  <div id="main-view" style="display:none">
    <div class="header">
      <h1><span>📧</span>Email Assistant</h1>
    </div>

    <div class="tabs">
      <button class="tab active" data-tab="emails" onclick="switchTab('emails')">📬 邮件</button>
      <button class="tab" data-tab="accounts" onclick="switchTab('accounts')">👤 账户</button>
      <button class="tab" data-tab="config" onclick="switchTab('config')">⚙️ 配置</button>
    </div>

    <!-- Emails Tab -->
    <div id="tab-emails">
      <div class="card" style="padding:14px 20px">
        <div class="form-row">
          <div class="form-group">
            <select id="check-account">
              <option value="">所有账户</option>
            </select>
          </div>
          <div class="form-group" style="max-width:120px">
            <select id="check-since">
              <option value="60">最近1小时</option>
              <option value="240">最近4小时</option>
              <option value="720">最近12小时</option>
              <option value="1440" selected>最近24小时</option>
              <option value="4320">最近3天</option>
            </select>
          </div>
          <button class="btn btn-primary" onclick="checkEmails(false)" id="btn-check">查看邮件</button>
          <button class="btn btn-primary" onclick="checkEmails(true)" id="btn-check-ai" style="background:#8b5cf6">AI 摘要</button>
          <button class="btn btn-primary" onclick="getDigest()" id="btn-digest" style="background:#0ea5e9">日报</button>
        </div>
      </div>

      <div id="email-list"></div>
      <div id="digest-box" style="display:none"></div>
    </div>

    <!-- Accounts Tab -->
    <div id="tab-accounts" style="display:none">
      <div class="card">
        <h3>已配置的邮箱</h3>
        <div id="account-list"></div>
      </div>

      <div class="card">
        <h3>添加邮箱</h3>
        <div class="form-group">
          <label>邮箱地址</label>
          <input type="email" id="add-email" placeholder="[REDACTED_SECRET]">
        </div>
        <div class="form-group">
          <label>认证方式</label>
          <select id="add-auth" onchange="togglePasswordField()">
            <option value="password">App Password</option>
            <option value="oauth">OAuth2 (Outlook/M365)</option>
          </select>
        </div>
        <div class="form-group" id="password-group">
          <label>App Password</label>
          <input type="password" id="add-password" placeholder="应用专用密码">
        </div>
        <button class="btn btn-primary" onclick="addAccount()" id="btn-add">添加</button>
      </div>
    </div>

    <!-- Config Tab -->
    <div id="tab-config" style="display:none">
      <div class="card">
        <h3>AI 配置</h3>
        <div class="form-group">
          <label>API Key（DeepSeek / OpenAI）</label>
          <input type="password" id="cfg-ai-key" placeholder="sk-...">
        </div>
        <div class="form-group">
          <label>API Base URL</label>
          <input id="cfg-ai-base" placeholder="https://api.deepseek.com">
        </div>
        <div class="form-group">
          <label>Model</label>
          <input id="cfg-ai-model" placeholder="deepseek-chat">
        </div>
        <button class="btn btn-primary" onclick="saveAiConfig()">保存 AI 配置</button>
      </div>

      <div class="card">
        <h3>Microsoft OAuth2（Outlook/M365 用户需要）</h3>
        <div class="form-group">
          <label>Client ID</label>
          <input id="cfg-ms-client" placeholder="Azure AD Application Client ID">
        </div>
        <div class="form-group">
          <label>Tenant ID</label>
          <input id="cfg-ms-tenant" placeholder="common">
        </div>
        <button class="btn btn-primary" onclick="saveMsConfig()">保存 OAuth 配置</button>
      </div>
    </div>
  </div>

  <!-- Email Detail Modal -->
  <div id="email-modal" class="modal-overlay" style="display:none" onclick="if(event.target===this)closeModal()">
    <div class="modal">
      <div id="modal-content"></div>
      <div style="text-align:right;margin-top:16px">
        <button class="btn btn-primary btn-sm" onclick="closeModal()">关闭</button>
      </div>
    </div>
  </div>
</div>

<script>
const API = '';

// ─── Auth ─────────────────────────────────────────────

function getToken() {
  const params = new URLSearchParams(location.search);
  return localStorage.getItem('email_token') || params.get('token') || '';
}

async function api(path, data) {
  const opts = { headers: {} };
  if (data !== undefined) {
    opts.method = 'POST';
    opts.headers['Content-Type'] = 'application/json';
    opts.body = JSON.stringify(data);
  }
  const res = await fetch(API + path, opts);
  const json = await res.json();
  if (json.error && res.status === 401) {
    showLogin();
    throw new Error('Unauthorized');
  }
  if (json.error) throw new Error(json.error);
  return json;
}

function showLogin() {
  document.getElementById('login-view').style.display = '';
  document.getElementById('main-view').style.display = 'none';
}

async function doLogin() {
  const [REDACTED_SECRET]'login-token').value.trim();
  if (!token) return;
  try {
    await api('/api/login', { token });
    localStorage.setItem('email_token', token);
    init();
  } catch (e) {
    toast('令牌无效', 'error');
  }
}

async function init() {
  // Try auto-login via URL token
  const params = new URLSearchParams(location.search);
  const urlToken = params.get('token');
  if (urlToken) {
    try {
      await api('/api/login', { [REDACTED_SECRET] });
      localStorage.setItem('email_token', urlToken);
      // Clean URL
      history.replaceState(null, '', location.pathname);
    } catch {}
  }

  try {
    await api('/api/config');
    document.getElementById('login-view').style.display = 'none';
    document.getElementById('main-view').style.display = '';
    loadAccounts();
    loadConfig();
  } catch {
    showLogin();
  }
}

// ─── Tabs ─────────────────────────────────────────────

function switchTab(name) {
  document.querySelectorAll('.tab').forEach(t => {
    t.classList.toggle('active', t.dataset.tab === name);
  });
  ['emails','accounts','config'].forEach(n => {
    document.getElementById('tab-' + n).style.display = n === name ? '' : 'none';
  });
  if (name === 'accounts') loadAccounts();
  if (name === 'config') loadConfig();
}

// ─── Accounts ─────────────────────────────────────────

async function loadAccounts() {
  try {
    const data = await api('/api/accounts');
    const list = document.getElementById('account-list');
    const select = document.getElementById('check-account');

    if (!data.accounts.length) {
      list.innerHTML = '<div class="empty"><p>还没有配置邮箱</p></div>';
      select.innerHTML = '<option value="">所有账户</option>';
      return;
    }

    list.innerHTML = data.accounts.map(a => `
      <div class="card-row">
        <div>
          <strong>${esc(a.email)}</strong>
          <span class="badge badge-blue" style="margin-left:8px">${esc(a.type)}</span>
          <span class="badge badge-green" style="margin-left:4px">${a.auth === 'oauth' ? 'OAuth2' : 'Password'}</span>
        </div>
        <button class="btn btn-danger btn-sm" onclick="removeAccount('${esc(a.email)}')">删除</button>
      </div>
    `).join('');

    select.innerHTML = '<option value="">所有账户</option>' +
      data.accounts.map(a => `<option value="${esc(a.email)}">${esc(a.email)}</option>`).join('');
  } catch (e) {
    toast(e.message, 'error');
  }
}

function togglePasswordField() {
  const auth = document.getElementById('add-auth').value;
  document.getElementById('password-group').style.display = auth === 'oauth' ? 'none' : '';
}

async function addAccount() {
  const email = document.getElementById('add-email').value.trim();
  const auth = document.getElementById('add-auth').value;
  const [REDACTED_SECRET]'add-password').value.trim();

  if (!email) return toast('请输入邮箱', 'error');
  if (auth !== 'oauth' && !password) return toast('请输入密码', 'error');

  const btn = document.getElementById('btn-add');
  btn.disabled = true;
  btn.innerHTML = '<span class="spinner"></span> 连接中...';

  try {
    const result = await api('/api/accounts/add', { email, password, auth });

    if (result.action === 'oauth_pending') {
      btn.innerHTML = '添加';
      btn.disabled = false;
      showOAuthModal(result);
      return;
    }

    toast(`添加成功！${result.unread} 封未读`, 'success');
    document.getElementById('add-email').value = '';
    document.getElementById('add-password').value = '';
    loadAccounts();
  } catch (e) {
    toast(e.message, 'error');
  } finally {
    btn.innerHTML = '添加';
    btn.disabled = false;
  }
}

function showOAuthModal(data) {
  const modal = document.getElementById('email-modal');
  document.getElementById('modal-content').innerHTML = `
    <h3>🔐 Microsoft OAuth2 授权</h3>
    <p style="margin:12px 0">请在浏览器中打开以下链接：</p>
    <p><a href="${data.verification_uri}" target="_blank" style="color:var(--primary);font-size:16px">${data.verification_uri}</a></p>
    <p style="margin:12px 0">输入验证码：</p>
    <p style="font-size:28px;font-weight:700;letter-spacing:4px;color:var(--primary);text-align:center;margin:16px 0">${data.user_code}</p>
    <div class="loading-text" id="oauth-status"><span class="spinner"></span> 等待授权...</div>
  `;
  modal.style.display = '';

  // Poll for completion
  pollOAuth(data.email, data.device_code, data.interval);
}

async function pollOAuth(email, device_code, interval) {
  try {
    const result = await api('/api/accounts/oauth-poll', { email, device_code, interval });
    document.getElementById('oauth-status').innerHTML = '✅ 授权成功！';
    toast(`添加成功！${result.unread} 封未读`, 'success');
    loadAccounts();
    setTimeout(closeModal, 2000);
  } catch (e) {
    document.getElementById('oauth-status').innerHTML = '❌ ' + e.message;
  }
}

async function removeAccount(email) {
  if (!confirm(`确定删除 ${email}？`)) return;
  try {
    await api('/api/accounts/remove', { email });
    toast('已删除', 'success');
    loadAccounts();
  } catch (e) {
    toast(e.message, 'error');
  }
}

// ─── Emails ───────────────────────────────────────────

async function checkEmails(summarize) {
  const account = document.getElementById('check-account').value;
  const since = parseInt(document.getElementById('check-since').value);
  const btnId = summarize ? 'btn-check-ai' : 'btn-check';
  const btn = document.getElementById(btnId);
  const list = document.getElementById('email-list');

  document.getElementById('digest-box').style.display = 'none';
  btn.disabled = true;
  list.innerHTML = '<div class="loading-text"><span class="spinner"></span> ' +
    (summarize ? '正在获取邮件并生成 AI 摘要...' : '正在获取邮件...') + '</div>';

  try {
    const data = await api('/api/check', { account, since, max: 30, summarize });

    if (!data.emails.length) {
      list.innerHTML = '<div class="empty"><p>没有新邮件</p></div>';
      return;
    }

    list.innerHTML = data.emails.map((e, i) => {
      if (e.error) return `<div class="email-item" style="color:var(--danger)">${esc(e.account)}: ${esc(e.error)}</div>`;
      return `
        <div class="email-item" onclick="readEmail('${esc(e.uid)}', '${esc(e.account)}')">
          <div class="email-meta">
            <span>${esc(e.account)}</span>
            <span>${formatDate(e.date)}</span>
          </div>
          <div class="email-from">${esc(e.from)}</div>
          <div class="email-subject">${esc(e.subject)}</div>
          ${e.bodyPreview ? `<div class="email-preview">${esc(e.bodyPreview)}</div>` : ''}
          ${e.summary ? `<div class="email-summary">${esc(e.summary)}</div>` : ''}
        </div>`;
    }).join('');
  } catch (e) {
    list.innerHTML = `<div class="empty" style="color:var(--danger)">${esc(e.message)}</div>`;
  } finally {
    btn.disabled = false;
  }
}

async function readEmail(uid, account) {
  const modal = document.getElementById('email-modal');
  document.getElementById('modal-content').innerHTML = '<div class="loading-text"><span class="spinner"></span> 读取中...</div>';
  modal.style.display = '';

  try {
    const e = await api('/api/read', { uid, account });
    document.getElementById('modal-content').innerHTML = `
      <div class="email-meta" style="margin-bottom:8px"><span>${esc(e.account)}</span><span>${formatDate(e.date)}</span></div>
      <div class="email-from" style="font-size:16px">${esc(e.from)}</div>
      <h3 style="margin:8px 0">${esc(e.subject)}</h3>
      ${e.summary ? `<div class="email-summary" style="margin-bottom:12px">🤖 <strong>AI 解读</strong>\n${esc(e.summary)}</div>` : ''}
      <div style="white-space:pre-wrap;font-size:13px;color:var(--text2);max-height:400px;overflow-y:auto">${esc(e.body || '(无内容)')}</div>
    `;
  } catch (e) {
    document.getElementById('modal-content').innerHTML = `<p style="color:var(--danger)">${esc(e.message)}</p>`;
  }
}

async function getDigest() {
  const account = document.getElementById('check-account').value;
  const since = parseInt(document.getElementById('check-since').value);
  const btn = document.getElementById('btn-digest');
  const box = document.getElementById('digest-box');
  const list = document.getElementById('email-list');

  list.innerHTML = '';
  btn.disabled = true;
  box.style.display = '';
  box.innerHTML = '<div class="card"><div class="loading-text"><span class="spinner"></span> 正在生成 AI 日报...</div></div>';

  try {
    const data = await api('/api/digest', { account, since });
    box.innerHTML = `
      <div class="card">
        <h3>📊 邮件日报 (${data.total} 封)</h3>
        <div class="digest-content" style="margin-top:12px">${esc(data.digest)}</div>
      </div>`;
  } catch (e) {
    box.innerHTML = `<div class="card"><p style="color:var(--danger)">${esc(e.message)}</p></div>`;
  } finally {
    btn.disabled = false;
  }
}

// ─── Config ───────────────────────────────────────────

async function loadConfig() {
  try {
    const data = await api('/api/config');
    const c = data.config;
    if (c.ai_api_key) document.getElementById('cfg-ai-key').placeholder = c.ai_api_key;
    if (c.ai_api_base) document.getElementById('cfg-ai-base').value = c.ai_api_base;
    if (c.ai_model) document.getElementById('cfg-ai-model').value = c.ai_model;
    if (c.ms_client_id) document.getElementById('cfg-ms-client').value = c.ms_client_id;
    if (c.ms_tenant_id) document.getElementById('cfg-ms-tenant').value = c.ms_tenant_id;
  } catch {}
}

async function saveAiConfig() {
  try {
    const key = document.getElementById('cfg-ai-key').value.trim();
    const base = document.getElementById('cfg-ai-base').value.trim();
    const model = document.getElementById('cfg-ai-model').value.trim();
    if (key) await api('/api/config', { key: 'ai_api_key', value: key });
    if (base) await api('/api/config', { key: 'ai_api_base', value: base });
    if (model) await api('/api/config', { key: 'ai_model', value: model });
    toast('AI 配置已保存', 'success');
    loadConfig();
  } catch (e) {
    toast(e.message, 'error');
  }
}

async function saveMsConfig() {
  try {
    const client = document.getElementById('cfg-ms-client').value.trim();
    const tenant = document.getElementById('cfg-ms-tenant').value.trim();
    if (client) await api('/api/config', { key: 'ms_client_id', value: client });
    if (tenant) await api('/api/config', { key: 'ms_tenant_id', value: tenant });
    toast('OAuth 配置已保存', 'success');
    loadConfig();
  } catch (e) {
    toast(e.message, 'error');
  }
}

// ─── Helpers ──────────────────────────────────────────

function closeModal() { document.getElementById('email-modal').style.display = 'none'; }

function esc(s) {
  if (!s) return '';
  const d = document.createElement('div');
  d.textContent = String(s);
  return d.innerHTML;
}

function formatDate(d) {
  if (!d) return '';
  try {
    const dt = new Date(d);
    return dt.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
  } catch { return d; }
}

function toast(msg, type = 'success') {
  const el = document.createElement('div');
  el.className = `toast toast-${type}`;
  el.textContent = msg;
  document.body.appendChild(el);
  setTimeout(() => el.remove(), 3000);
}

// ─── Init ─────────────────────────────────────────────
init();
</script>
</body>
</html>


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)