Files
homeclaw/src/tools/email.ts
T
kimandClaude Sonnet 4.6 2d3e33b6b3 Centralize all path management via src/config/paths.ts
- Add getPaths() with workspace, scripts, ppt skin/template paths
- Export getWorkspacePath(args?) helper — removes duplicate local functions
  and stale process.cwd()/workspace fallbacks from sqlite, pdf, excel,
  image, pubmed, email, audio-transcribe (7 files)
- weather.ts: replace __dirname-relative scripts path with getPaths().scripts
- Move ppt/ to .smallclaw/skills/presenter/ppt/; update server-v2.ts,
  pptx.ts, pptx_gen.py (argparse --skin-dir/--template-dir)
- Fix PPT skin thumbnail serving: res.sendFile → fs.createReadStream
  (bypasses send module dotfiles restriction for .smallclaw/ paths)
- Fix workspace default: .smallclaw/workspace (was project root workspace/)
- Fix subagent paths to use SMALLCLAW_DATA_DIR env var
- Fix Monaco marked module load failure: restore define.amd on
  DOMContentLoaded after deferred UMD libs (tone, magenta) have loaded
- Add AudioContext resume on first user click (Chrome autoplay policy)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:55:14 +09:00

734 lines
30 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import { ToolResult } from '../types.js';
import { getConfig } from '../config/config.js';
import { getWorkspacePath } from '../config/paths.js';
// ── Config ────────────────────────────────────────────────────────────────────
interface EmailConfig {
imap_host: string;
imap_port: number;
imap_tls: boolean;
imap_reject_unauthorized: boolean;
smtp_host: string;
smtp_port: number;
smtp_secure: boolean;
smtp_reject_unauthorized: boolean;
username: string;
password: string;
}
interface EmailAccount {
id: string;
config: EmailConfig;
}
function parseAccount(raw: any): EmailConfig | null {
if (!raw?.imap_host || !raw?.username) return null;
const cm = getConfig();
let password = cm.resolveSecret(raw.password || '') || '';
return {
imap_host: raw.imap_host,
imap_port: Number(raw.imap_port ?? 993),
imap_tls: raw.imap_tls !== false,
imap_reject_unauthorized: raw.imap_reject_unauthorized !== false,
smtp_host: raw.smtp_host || raw.imap_host.replace('imap.', 'smtp.'),
smtp_port: Number(raw.smtp_port ?? 587),
smtp_secure: raw.smtp_secure === true,
smtp_reject_unauthorized: raw.smtp_reject_unauthorized !== false,
username: raw.username,
password,
};
}
function getEmailConfig(accountId?: string, workspacePath?: string): EmailConfig | null {
try {
const cm = getConfig();
const cfg = cm.getConfig();
const emailCfg = cfg.email;
if (!emailCfg) return null;
let e: any;
if (Array.isArray(emailCfg.accounts)) {
if (accountId) {
e = emailCfg.accounts.find((a: any) => a.id === accountId);
}
if (!e) e = emailCfg.accounts[0];
} else {
e = emailCfg;
}
return parseAccount(e);
} catch {
return null;
}
}
function getAllEmailConfigs(workspacePath?: string): EmailAccount[] {
try {
const cm = getConfig();
const cfg = cm.getConfig();
const emailCfg = cfg.email;
if (!emailCfg) return [];
if (Array.isArray(emailCfg.accounts)) {
const accounts: EmailAccount[] = [];
for (const a of emailCfg.accounts) {
const config = parseAccount(a);
if (config) accounts.push({ id: a.id, config });
}
return accounts;
}
const config = parseAccount(emailCfg);
const id = emailCfg.username ? emailCfg.username.split('@')[0] : 'default';
return config ? [{ id, config }] : [];
} catch {
return [];
}
}
// ── IMAP helper ───────────────────────────────────────────────────────────────
async function withImap<T>(fn: (client: any) => Promise<T>, accountId?: string, workspacePath?: string): Promise<T> {
const cfg = getEmailConfig(accountId, workspacePath);
if (!cfg) throw new Error('이메일 설정이 없습니다. config.json의 email 섹션을 확인하세요.');
const { ImapFlow } = await import('imapflow');
const client = new ImapFlow({
host: cfg.imap_host,
port: cfg.imap_port,
secure: cfg.imap_tls,
auth: { user: cfg.username, pass: cfg.password },
logger: false,
connectionTimeout: 15000,
greetingTimeout: 10000,
socketTimeout: 20000,
tls: { rejectUnauthorized: cfg.imap_reject_unauthorized },
});
client.on('error', () => {});
await client.connect();
try {
return await fn(client);
} finally {
client.logout().catch(() => {});
}
}
function stripHtml(html: string): string {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
// ── email_list ────────────────────────────────────────────────────────────────
export const emailListTool = {
name: 'email_list',
description: '받은편지함(또는 지정 폴더)의 최근 이메일 목록을 가져옵니다. account 미지정 시 모든 계정을 순회합니다.',
schema: {
folder: '조회할 폴더 (기본: INBOX)',
limit: '가져올 메일 수 (기본: 20, 최대: 50)',
account: '계정 ID (미지정 시 모든 계정 순회)',
},
jsonSchema: {
type: 'object',
properties: {
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
limit: { type: 'number', description: '가져올 수 (기본 20, 최대 50)' },
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정)' },
},
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const folder = String(args?.folder || 'INBOX');
const limit = Math.min(Number(args?.limit) || 50, 200);
const account = args?.account ? String(args.account) : undefined;
const workspacePath = args?._workspacePath || args?._workspace || undefined;
try {
// Single account mode
if (account) {
const messages = await withImap(async (client) => {
await client.mailboxOpen(folder, { readOnly: true });
const status = await client.status(folder, { messages: true });
const total = status.messages || 0;
if (total === 0) return [];
const seqFrom = Math.max(1, total - limit + 1);
const results: any[] = [];
for await (const msg of client.fetch(`${seqFrom}:*`, {
uid: true,
flags: true,
envelope: true,
})) {
const from = msg.envelope?.from?.[0];
results.push({
uid: msg.uid,
seq: msg.seq,
subject: msg.envelope?.subject || '(제목 없음)',
from: from ? `${from.name || ''} <${from.address}>`.trim() : '',
date: msg.envelope?.date
? new Date(msg.envelope.date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })
: '',
unread: !msg.flags?.has('\\Seen'),
account,
});
}
return results.reverse();
}, account, workspacePath);
if (messages.length === 0) {
return { success: true, stdout: `[${account}] ${folder}: 메일이 없습니다.`, data: [] };
}
const lines = [
`📬 [${account}] ${folder} — 최근 ${messages.length}개`,
'',
...messages.map((m: any, i: number) =>
`${i + 1}. [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`
),
];
return { success: true, stdout: lines.join('\n'), data: messages };
}
// All accounts mode
const allAccounts = getAllEmailConfigs(workspacePath);
if (allAccounts.length === 0) {
return { success: false, error: '등록된 이메일 계정이 없습니다.' };
}
const allMessages: any[] = [];
const errors: string[] = [];
for (const acct of allAccounts) {
try {
const messages = await withImap(async (client) => {
await client.mailboxOpen(folder, { readOnly: true });
const status = await client.status(folder, { messages: true });
const total = status.messages || 0;
if (total === 0) return [];
const perAcctLimit = Math.max(Math.ceil(limit / allAccounts.length), 5);
const seqFrom = Math.max(1, total - perAcctLimit + 1);
const results: any[] = [];
for await (const msg of client.fetch(`${seqFrom}:*`, {
uid: true,
flags: true,
envelope: true,
})) {
const from = msg.envelope?.from?.[0];
results.push({
uid: msg.uid,
seq: msg.seq,
subject: msg.envelope?.subject || '(제목 없음)',
from: from ? `${from.name || ''} <${from.address}>`.trim() : '',
date: msg.envelope?.date
? new Date(msg.envelope.date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })
: '',
unread: !msg.flags?.has('\\Seen'),
account: acct.id,
});
}
return results.reverse();
}, acct.id, workspacePath);
allMessages.push(...messages);
} catch (err: any) {
errors.push(`${acct.id}: ${err.message}`);
}
}
// Sort all messages by date descending
allMessages.sort((a, b) => {
const da = a.date ? new Date(a.date).getTime() : 0;
const db = b.date ? new Date(b.date).getTime() : 0;
return db - da;
});
const trimmed = allMessages.slice(0, limit);
if (trimmed.length === 0) {
const errInfo = errors.length ? `\n⚠️ 오류: ${errors.join('; ')}` : '';
return { success: true, stdout: `${folder}: 메일이 없습니다.${errInfo}`, data: [] };
}
// Group by account, preserving the account order from config
const grouped = new Map<string, any[]>();
for (const acct of allAccounts) {
grouped.set(acct.id, []);
}
for (const m of trimmed) {
if (!grouped.has(m.account)) grouped.set(m.account, []);
grouped.get(m.account)!.push(m);
}
const lines: string[] = [];
let totalShown = 0;
for (const [acctId, msgs] of grouped) {
if (msgs.length === 0) {
lines.push(`📭 [${acctId}] ${folder} — 메일 없음`, '');
continue;
}
lines.push(`📬 [${acctId}] ${folder} — ${msgs.length}개`, '');
for (let i = 0; i < msgs.length; i++) {
const m = msgs[i];
lines.push(`${i + 1}. [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`);
totalShown++;
}
lines.push('');
}
lines.unshift(`📬 ${folder} — 전체 계정에서 ${totalShown}개`, '');
if (errors.length) {
lines.push(`⚠️ 일부 계정 오류: ${errors.join('; ')}`);
}
return { success: true, stdout: lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd(), data: trimmed };
} catch (err: any) {
return { success: false, error: err.message };
}
},
};
// ── email_read ────────────────────────────────────────────────────────────────
export const emailReadTool = {
name: 'email_read',
description: 'UID로 특정 이메일의 전문(발신자, 수신자, 제목, 본문)을 읽어옵니다. account 미지정 시 모든 계정에서 UID를 찾습니다. 첨부파일이 있으면 자동으로 workspace/attachments/uid-{uid}/ 에 저장하고 경로를 반환합니다.',
schema: {
uid: '읽을 메일의 UID (email_list에서 확인)',
folder: '폴더명 (기본: INBOX)',
account: '계정 ID (미지정 시 모든 계정에서 검색)',
},
jsonSchema: {
type: 'object',
properties: {
uid: { type: 'number', description: '메일 UID' },
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정 검색)' },
},
required: ['uid'],
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const uid = Number(args?.uid);
const folder = String(args?.folder || 'INBOX');
const account = args?.account ? String(args.account) : undefined;
const workspacePath = args?._workspacePath || args?._workspace || undefined;
const saveAttachments = true;
if (!uid) return { success: false, error: 'uid가 필요합니다.' };
const readMail = async (acctId: string): Promise<{ result: any; account: string } | null> => {
try {
const result = await withImap(async (client) => {
await client.mailboxOpen(folder, { readOnly: false });
await client.messageFlagsAdd(uid.toString(), ['\\Seen'], { uid: true }).catch(() => {});
const { simpleParser } = await import('mailparser');
for await (const msg of client.fetch(uid.toString(), { uid: true, envelope: true, source: true }, { uid: true })) {
const parsed = await simpleParser(msg.source);
const body = parsed.text || (parsed.html ? stripHtml(parsed.html) : '(본문 없음)');
const savedPaths: string[] = [];
const savedRelPaths: string[] = [];
if (saveAttachments && parsed.attachments?.length) {
const injectedWorkspace = workspacePath || args?._workspacePath || args?._workspace;
let resolvedWorkspacePath: string;
if (injectedWorkspace) {
resolvedWorkspacePath = path.resolve(String(injectedWorkspace));
} else {
const cfg = getConfig().getConfig();
resolvedWorkspacePath = path.resolve(getWorkspacePath(args));
}
const relDir = path.join('attachments', `uid-${uid}`);
const saveDir = path.join(resolvedWorkspacePath, relDir);
fs.mkdirSync(saveDir, { recursive: true });
for (const att of parsed.attachments) {
const filename = att.filename || `attachment-${savedPaths.length + 1}`;
const safeName = filename.replace(/[/\\:*?"<>|]/g, '_').replace(/\s+/g, '_');
const dest = path.join(saveDir, safeName);
fs.writeFileSync(dest, att.content);
savedPaths.push(dest);
savedRelPaths.push(path.join(relDir, safeName));
}
}
return {
uid,
subject: parsed.subject || '(제목 없음)',
from: (Array.isArray(parsed.from) ? parsed.from[0] : parsed.from)?.text || '',
to: (Array.isArray(parsed.to) ? parsed.to[0] : parsed.to )?.text || '',
cc: (Array.isArray(parsed.cc) ? parsed.cc[0] : parsed.cc )?.text || '',
date: parsed.date
? new Date(parsed.date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })
: '',
body: body.slice(0, 8000),
attachments: (parsed.attachments || []).map((a: any) => `${a.filename} (${a.size} bytes)`),
savedPaths,
savedRelPaths,
};
}
return null;
}, acctId, workspacePath);
return result ? { result, account: acctId } : null;
} catch {
return null;
}
};
try {
// If account specified, try only that account
if (account) {
const found = await readMail(account);
if (!found) return { success: false, error: `[${account}] UID ${uid} 메일을 찾을 수 없습니다.` };
const r = found.result;
const lines = [
`📧 메일 상세 (UID: ${uid}, 계정: ${account})`,
`제목: ${r.subject}`,
`발신: ${r.from}`,
`수신: ${r.to}`,
r.cc ? `참조: ${r.cc}` : '',
`날짜: ${r.date}`,
r.attachments.length ? `첨부파일: ${r.attachments.join(', ')}` : '',
r.savedRelPaths?.length
? `✅ 첨부파일 저장 완료 — pdf_read 등에서 아래 상대경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` - [${path.basename(p)}](/api/files/${p.replace(/\\/g, '/')})`).join('\n')}`
: '',
'',
'─── 본문 ───',
r.body,
].filter((l: string) => l !== '');
const { savedPaths: _sp, ...safeData } = { ...r, account: found.account };
return { success: true, stdout: lines.join('\n'), data: safeData };
}
// No account specified — try all accounts
const allAccounts = getAllEmailConfigs(workspacePath);
if (allAccounts.length === 0) {
return { success: false, error: '등록된 이메일 계정이 없습니다.' };
}
const errors: string[] = [];
for (const acct of allAccounts) {
const found = await readMail(acct.id);
if (found) {
const r = found.result;
const lines = [
`📧 메일 상세 (UID: ${uid}, 계정: ${found.account})`,
`제목: ${r.subject}`,
`발신: ${r.from}`,
`수신: ${r.to}`,
r.cc ? `참조: ${r.cc}` : '',
`날짜: ${r.date}`,
r.attachments.length ? `첨부파일: ${r.attachments.join(', ')}` : '',
r.savedRelPaths?.length
? `✅ 첨부파일 저장 완료 — pdf_read 등에서 아래 상대경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` - [${path.basename(p)}](/api/files/${p.replace(/\\/g, '/')})`).join('\n')}`
: '',
'',
'─── 본문 ───',
r.body,
].filter((l: string) => l !== '');
const { savedPaths: _sp, ...safeData } = { ...r, account: found.account };
return { success: true, stdout: lines.join('\n'), data: safeData };
}
errors.push(acct.id);
}
return { success: false, error: `UID ${uid} 메일을 찾을 수 없습니다. (검색한 계정: ${errors.join(', ')})` };
} catch (err: any) {
return { success: false, error: err.message };
}
},
};
// ── email_send ────────────────────────────────────────────────────────────────
export const emailSendTool = {
name: 'email_send',
description: '이메일을 발송합니다. SMTP를 통해 to/subject/body를 지정해 발송하며 cc 추가도 가능합니다. account 미지정 시 첫 번째 등록 계정으로 발송합니다.',
schema: {
to: '수신자 이메일 주소 (여러 명은 쉼표로 구분)',
subject: '제목',
body: '본문 (Plain text)',
cc: '참조 주소 (선택, 쉼표 구분)',
account: '계정 ID (미지정 시 첫 번째 계정)',
},
jsonSchema: {
type: 'object',
properties: {
to: { type: 'string', description: '수신자 이메일' },
subject: { type: 'string', description: '제목' },
body: { type: 'string', description: '본문' },
cc: { type: 'string', description: '참조 (선택)' },
account: { type: 'string', description: '계정 ID (미지정 시 첫 번째 계정)' },
},
required: ['to', 'subject', 'body'],
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const to = String(args?.to || '').trim();
const subject = String(args?.subject || '').trim();
const body = String(args?.body || '').trim();
const cc = args?.cc ? String(args.cc).trim() : undefined;
const account = args?.account ? String(args.account) : undefined;
if (!to) return { success: false, error: 'to(수신자)가 필요합니다.' };
if (!subject) return { success: false, error: 'subject(제목)가 필요합니다.' };
if (!body) return { success: false, error: 'body(본문)가 필요합니다.' };
const workspacePath = args?._workspacePath || args?._workspace || undefined;
const cfg = getEmailConfig(account, workspacePath);
if (!cfg) return { success: false, error: '이메일 설정이 없습니다. config.json의 email 섹션을 확인하세요.' };
try {
const nodemailer = await import('nodemailer');
const transporter = nodemailer.createTransport({
host: cfg.smtp_host,
port: cfg.smtp_port,
secure: cfg.smtp_secure,
auth: { user: cfg.username, pass: cfg.password },
connectionTimeout: 15000,
greetingTimeout: 10000,
tls: { rejectUnauthorized: cfg.smtp_reject_unauthorized },
});
const info = await transporter.sendMail({
from: cfg.username,
to,
cc,
subject,
text: body,
});
return {
success: true,
stdout: `✅ 메일 발송 완료\n발신: ${cfg.username}\n수신: ${to}\n제목: ${subject}\nMessage-ID: ${info.messageId}`,
data: { messageId: info.messageId, from: cfg.username, to, subject },
};
} catch (err: any) {
return { success: false, error: `발송 실패: ${err.message}` };
}
},
};
// ── email_search ──────────────────────────────────────────────────────────────
export const emailSearchTool = {
name: 'email_search',
description: '이메일을 검색합니다. 키워드, 발신자, 제목, 날짜 범위로 검색 가능합니다. account 미지정 시 모든 계정을 순회합니다.',
schema: {
query: '검색어 (제목+본문 전문 검색)',
from: '발신자 이메일 주소로 필터',
subject: '제목으로 필터',
since: '이 날짜 이후 (YYYY-MM-DD)',
before: '이 날짜 이전 (YYYY-MM-DD)',
folder: '검색할 폴더 (기본: INBOX)',
limit: '최대 결과 수 (기본: 20)',
account: '계정 ID (미지정 시 모든 계정 순회)',
},
jsonSchema: {
type: 'object',
properties: {
query: { type: 'string', description: '전문 검색어' },
from: { type: 'string', description: '발신자 필터' },
subject: { type: 'string', description: '제목 필터' },
since: { type: 'string', description: 'YYYY-MM-DD 이후' },
before: { type: 'string', description: 'YYYY-MM-DD 이전' },
folder: { type: 'string', description: '폴더 (기본 INBOX)' },
limit: { type: 'number', description: '최대 결과 수 (기본 20)' },
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정)' },
},
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const folder = String(args?.folder || 'INBOX');
const limit = Math.min(Number(args?.limit) || 50, 200);
const account = args?.account ? String(args.account) : undefined;
const workspacePath = args?._workspacePath || args?._workspace || undefined;
if (!args?.query && !args?.from && !args?.subject && !args?.since && !args?.before) {
return { success: false, error: 'query, from, subject, since, before 중 하나 이상이 필요합니다.' };
}
const buildCriteria = () => {
const criteria: any[] = [];
if (args?.from) criteria.push({ from: String(args.from) });
if (args?.subject) criteria.push({ subject: String(args.subject) });
if (args?.query) criteria.push({ body: String(args.query) });
if (args?.since) criteria.push({ since: new Date(String(args.since)) });
if (args?.before) criteria.push({ before: new Date(String(args.before)) });
return criteria.length === 1 ? criteria[0] : { and: criteria };
};
const searchCriteria = buildCriteria();
const searchAccount = async (acctId: string): Promise<any[]> => {
return await withImap(async (client) => {
await client.mailboxOpen(folder, { readOnly: true });
const uids: number[] = await client.search(searchCriteria, { uid: true });
const perAcctLimit = account ? limit : Math.max(Math.ceil(limit / (getAllEmailConfigs(workspacePath).length || 1)), 5);
const recentUids = uids.slice(-perAcctLimit).reverse();
if (recentUids.length === 0) return [];
const results: any[] = [];
for await (const msg of client.fetch(recentUids.join(','), {
uid: true,
flags: true,
envelope: true,
}, { uid: true })) {
const from = msg.envelope?.from?.[0];
results.push({
uid: msg.uid,
subject: msg.envelope?.subject || '(제목 없음)',
from: from ? `${from.name || ''} <${from.address}>`.trim() : '',
date: msg.envelope?.date
? new Date(msg.envelope.date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })
: '',
unread: !msg.flags?.has('\\Seen'),
account: acctId,
});
}
return results;
}, acctId, workspacePath);
};
try {
// Single account mode
if (account) {
const messages = await searchAccount(account);
if (messages.length === 0) {
return { success: true, stdout: '검색 결과가 없습니다.', data: [] };
}
const lines = [
`🔍 [${account}] 검색 결과 — ${messages.length}개`,
'',
...messages.map((m: any, i: number) =>
`${i + 1}. [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`
),
];
return { success: true, stdout: lines.join('\n'), data: messages };
}
// All accounts mode
const allAccounts = getAllEmailConfigs(workspacePath);
if (allAccounts.length === 0) {
return { success: false, error: '등록된 이메일 계정이 없습니다.' };
}
const allMessages: any[] = [];
const errors: string[] = [];
for (const acct of allAccounts) {
try {
const messages = await searchAccount(acct.id);
allMessages.push(...messages);
} catch (err: any) {
errors.push(`${acct.id}: ${err.message}`);
}
}
// Sort by date descending
allMessages.sort((a, b) => {
const da = a.date ? new Date(a.date).getTime() : 0;
const db = b.date ? new Date(b.date).getTime() : 0;
return db - da;
});
const trimmed = allMessages.slice(0, limit);
if (trimmed.length === 0) {
const errInfo = errors.length ? `\n⚠️ 오류: ${errors.join('; ')}` : '';
return { success: true, stdout: `검색 결과가 없습니다.${errInfo}`, data: [] };
}
const lines = [
`🔍 검색 결과 — 전체 계정에서 ${trimmed.length}개`,
'',
...trimmed.map((m: any, i: number) =>
`${i + 1}. [${m.account}] [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`
),
];
if (errors.length) {
lines.push('', `⚠️ 일부 계정 오류: ${errors.join('; ')}`);
}
return { success: true, stdout: lines.join('\n'), data: trimmed };
} catch (err: any) {
return { success: false, error: err.message };
}
},
};
// ── email_delete ──────────────────────────────────────────────────────────────
export const emailDeleteTool = {
name: 'email_delete',
description: 'UID로 이메일을 삭제합니다. account 미지정 시 모든 계정에서 UID를 찾아 삭제합니다. 가능한 경우 휴지통으로 이동하고, 불가능하면 \\Deleted 플래그를 설정합니다.',
schema: {
uid: '삭제할 메일의 UID',
folder: '폴더명 (기본: INBOX)',
account: '계정 ID (미지정 시 모든 계정에서 검색)',
},
jsonSchema: {
type: 'object',
properties: {
uid: { type: 'number', description: '삭제할 메일 UID' },
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정 검색)' },
},
required: ['uid'],
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const uid = Number(args?.uid);
const folder = String(args?.folder || 'INBOX');
const account = args?.account ? String(args.account) : undefined;
const workspacePath = args?._workspacePath || args?._workspace || undefined;
if (!uid) return { success: false, error: 'uid가 필요합니다.' };
const deleteFromAccount = async (acctId: string): Promise<boolean> => {
try {
await withImap(async (client) => {
await client.mailboxOpen(folder);
const mailboxes = await client.list();
const trash = mailboxes.find((m: any) =>
m.specialUse === '\\Trash' ||
/^(Trash|휴지통|Deleted Items|Deleted Messages)$/i.test(m.name)
);
if (trash) {
await client.messageMove(uid.toString(), trash.path, { uid: true });
} else {
await client.messageFlagsAdd(uid.toString(), ['\\Deleted'], { uid: true });
await client.mailboxClose();
}
}, acctId, workspacePath);
return true;
} catch {
return false;
}
};
try {
// Single account mode
if (account) {
const ok = await deleteFromAccount(account);
if (!ok) return { success: false, error: `[${account}] UID ${uid} 삭제 실패` };
return {
success: true,
stdout: `🗑️ [${account}] UID ${uid} 메일이 삭제되었습니다.`,
data: { uid, folder, account },
};
}
// All accounts mode — try each account
const allAccounts = getAllEmailConfigs(workspacePath);
if (allAccounts.length === 0) {
return { success: false, error: '등록된 이메일 계정이 없습니다.' };
}
for (const acct of allAccounts) {
const ok = await deleteFromAccount(acct.id);
if (ok) {
return {
success: true,
stdout: `🗑️ [${acct.id}] UID ${uid} 메일이 삭제되었습니다.`,
data: { uid, folder, account: acct.id },
};
}
}
return { success: false, error: `UID ${uid} 메일을 찾을 수 없어 삭제하지 못했습니다.` };
} catch (err: any) {
return { success: false, error: `삭제 실패: ${err.message}` };
}
},
};