v.2.5.1
This commit is contained in:
+417
-196
@@ -19,65 +19,96 @@ interface EmailConfig {
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface EmailAccount {
|
||||
id: string;
|
||||
config: EmailConfig;
|
||||
}
|
||||
|
||||
function resolveConfigPath(workspacePath?: string): string | null {
|
||||
const perUserPath = workspacePath
|
||||
? path.join(workspacePath, '.smallclaw', 'email-config.json')
|
||||
: null;
|
||||
const globalPath = path.join(os.homedir(), '.smallclaw', 'config.json');
|
||||
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
if (perUserPath && fs.existsSync(perUserPath)) return perUserPath;
|
||||
if (fs.existsSync(projectCfg)) return projectCfg;
|
||||
if (fs.existsSync(globalPath)) return globalPath;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAccount(raw: any, cfgPath: string): EmailConfig | null {
|
||||
if (!raw?.imap_host || !raw?.username) return null;
|
||||
let password = raw.password || '';
|
||||
if (password.startsWith('vault:')) {
|
||||
try {
|
||||
const vault = getVault(path.dirname(cfgPath));
|
||||
const secret = vault.get(password.slice(6), 'email:resolve');
|
||||
password = secret ? secret.expose() : '';
|
||||
} catch {
|
||||
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 {
|
||||
// Per-user config: look in the workspace directory first (e.g. <workspace>/.smallclaw/email-config.json)
|
||||
const perUserPath = workspacePath
|
||||
? path.join(workspacePath, '.smallclaw', 'email-config.json')
|
||||
: null;
|
||||
const globalPath = path.join(os.homedir(), '.smallclaw', 'config.json');
|
||||
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
const cfgPath = perUserPath && fs.existsSync(perUserPath)
|
||||
? perUserPath
|
||||
: fs.existsSync(projectCfg)
|
||||
? projectCfg
|
||||
: globalPath;
|
||||
if (!fs.existsSync(cfgPath)) return null;
|
||||
const cfgPath = resolveConfigPath(workspacePath);
|
||||
if (!cfgPath) return null;
|
||||
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||
const emailCfg = data.email;
|
||||
if (!emailCfg) return null;
|
||||
|
||||
let e: any;
|
||||
if (Array.isArray(emailCfg.accounts)) {
|
||||
const target = accountId || emailCfg.default;
|
||||
e = target
|
||||
? emailCfg.accounts.find((a: any) => a.id === target) ?? emailCfg.accounts[0]
|
||||
: emailCfg.accounts[0];
|
||||
if (accountId) {
|
||||
e = emailCfg.accounts.find((a: any) => a.id === accountId);
|
||||
}
|
||||
if (!e) e = emailCfg.accounts[0];
|
||||
} else {
|
||||
e = emailCfg;
|
||||
}
|
||||
|
||||
if (!e?.imap_host || !e?.username) return null;
|
||||
|
||||
// Resolve vault: references for password (vault is shared with the config dir)
|
||||
let password = e.password || '';
|
||||
if (password.startsWith('vault:')) {
|
||||
try {
|
||||
const vault = getVault(path.dirname(cfgPath));
|
||||
const secret = vault.get(password.slice(6), 'email:resolve');
|
||||
password = secret ? secret.expose() : '';
|
||||
} catch {
|
||||
password = '';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
imap_host: e.imap_host,
|
||||
imap_port: Number(e.imap_port ?? 993),
|
||||
imap_tls: e.imap_tls !== false,
|
||||
imap_reject_unauthorized: e.imap_reject_unauthorized !== false,
|
||||
smtp_host: e.smtp_host || e.imap_host.replace('imap.', 'smtp.'),
|
||||
smtp_port: Number(e.smtp_port ?? 587),
|
||||
smtp_secure: e.smtp_secure === true,
|
||||
smtp_reject_unauthorized: e.smtp_reject_unauthorized !== false,
|
||||
username: e.username,
|
||||
password,
|
||||
};
|
||||
return parseAccount(e, cfgPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getAllEmailConfigs(workspacePath?: string): EmailAccount[] {
|
||||
try {
|
||||
const cfgPath = resolveConfigPath(workspacePath);
|
||||
if (!cfgPath) return [];
|
||||
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||
const emailCfg = data.email;
|
||||
if (!emailCfg) return [];
|
||||
|
||||
if (Array.isArray(emailCfg.accounts)) {
|
||||
const accounts: EmailAccount[] = [];
|
||||
for (const a of emailCfg.accounts) {
|
||||
const config = parseAccount(a, cfgPath);
|
||||
if (config) accounts.push({ id: a.id, config });
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
const config = parseAccount(emailCfg, cfgPath);
|
||||
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> {
|
||||
@@ -124,18 +155,18 @@ function stripHtml(html: string): string {
|
||||
|
||||
export const emailListTool = {
|
||||
name: 'email_list',
|
||||
description: '받은편지함(또는 지정 폴더)의 최근 이메일 목록을 가져옵니다. uid, 제목, 발신자, 날짜, 읽음 여부를 반환합니다.',
|
||||
description: '받은편지함(또는 지정 폴더)의 최근 이메일 목록을 가져옵니다. account 미지정 시 모든 계정을 순회합니다.',
|
||||
schema: {
|
||||
folder: '조회할 폴더 (기본: INBOX)',
|
||||
limit: '가져올 메일 수 (기본: 20, 최대: 50)',
|
||||
account: '계정 ID (미지정 시 default)',
|
||||
account: '계정 ID (미지정 시 모든 계정 순회)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
|
||||
limit: { type: 'number', description: '가져올 수 (기본 20, 최대 50)' },
|
||||
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
|
||||
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정)' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
@@ -145,45 +176,115 @@ export const emailListTool = {
|
||||
const account = args?.account ? String(args.account) : undefined;
|
||||
const workspacePath = args?._workspacePath || args?._workspace || undefined;
|
||||
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 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'),
|
||||
});
|
||||
}
|
||||
return results.reverse();
|
||||
}, account, workspacePath);
|
||||
// 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: `${folder}: 메일이 없습니다.`, data: [] };
|
||||
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: [] };
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`📬 ${folder} — 최근 ${messages.length}개`,
|
||||
`📬 ${folder} — 전체 계정에서 ${trimmed.length}개`,
|
||||
'',
|
||||
...messages.map((m, i) =>
|
||||
`${i + 1}. [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`
|
||||
...trimmed.map((m: any, i: number) =>
|
||||
`${i + 1}. [${m.account}] [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`
|
||||
),
|
||||
];
|
||||
return { success: true, stdout: lines.join('\n'), data: messages };
|
||||
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 };
|
||||
}
|
||||
@@ -194,18 +295,18 @@ export const emailListTool = {
|
||||
|
||||
export const emailReadTool = {
|
||||
name: 'email_read',
|
||||
description: 'UID로 특정 이메일의 전문(발신자, 수신자, 제목, 본문)을 읽어옵니다. 첨부파일이 있으면 자동으로 workspace/attachments/uid-{uid}/ 에 저장하고 경로를 반환합니다.',
|
||||
description: 'UID로 특정 이메일의 전문(발신자, 수신자, 제목, 본문)을 읽어옵니다. account 미지정 시 모든 계정에서 UID를 찾습니다. 첨부파일이 있으면 자동으로 workspace/attachments/uid-{uid}/ 에 저장하고 경로를 반환합니다.',
|
||||
schema: {
|
||||
uid: '읽을 메일의 UID (email_list에서 확인)',
|
||||
folder: '폴더명 (기본: INBOX)',
|
||||
account: '계정 ID (미지정 시 default)',
|
||||
account: '계정 ID (미지정 시 모든 계정에서 검색)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
uid: { type: 'number', description: '메일 UID' },
|
||||
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
|
||||
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
|
||||
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정 검색)' },
|
||||
},
|
||||
required: ['uid'],
|
||||
additionalProperties: false,
|
||||
@@ -215,80 +316,121 @@ export const emailReadTool = {
|
||||
const folder = String(args?.folder || 'INBOX');
|
||||
const account = args?.account ? String(args.account) : undefined;
|
||||
const workspacePath = args?._workspacePath || args?._workspace || undefined;
|
||||
const saveAttachments = true; // always save attachments when present
|
||||
const saveAttachments = true;
|
||||
if (!uid) return { success: false, error: 'uid가 필요합니다.' };
|
||||
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) : '(본문 없음)');
|
||||
|
||||
// Save attachments if requested
|
||||
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 cfgPath = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||
resolvedWorkspacePath = path.resolve(cfg?.workspace?.path || path.join(process.cwd(), 'workspace'));
|
||||
}
|
||||
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, '_');
|
||||
const dest = path.join(saveDir, safeName);
|
||||
fs.writeFileSync(dest, att.content);
|
||||
savedPaths.push(dest);
|
||||
savedRelPaths.push(path.join(relDir, safeName));
|
||||
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 cfgPath = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||
resolvedWorkspacePath = path.resolve(cfg?.workspace?.path || path.join(process.cwd(), 'workspace'));
|
||||
}
|
||||
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, '_');
|
||||
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 {
|
||||
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;
|
||||
}, account, workspacePath);
|
||||
}
|
||||
};
|
||||
|
||||
if (!result) return { success: false, error: `UID ${uid} 메일을 찾을 수 없습니다.` };
|
||||
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/read 등에서 아래 경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` "${p}"`).join('\n')}`
|
||||
: '',
|
||||
'',
|
||||
'─── 본문 ───',
|
||||
r.body,
|
||||
].filter((l: string) => l !== '');
|
||||
return { success: true, stdout: lines.join('\n'), data: { ...r, account: found.account } };
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`📧 메일 상세 (UID: ${uid})`,
|
||||
`제목: ${result.subject}`,
|
||||
`발신: ${result.from}`,
|
||||
`수신: ${result.to}`,
|
||||
result.cc ? `참조: ${result.cc}` : '',
|
||||
`날짜: ${result.date}`,
|
||||
result.attachments.length ? `첨부파일: ${result.attachments.join(', ')}` : '',
|
||||
result.savedRelPaths?.length
|
||||
? `✅ 첨부파일 저장 완료 — pdf_read/read 등에서 아래 경로를 그대로 사용하세요:\n${result.savedRelPaths.map((p: string) => ` "${p}"`).join('\n')}`
|
||||
: '',
|
||||
'',
|
||||
'─── 본문 ───',
|
||||
result.body,
|
||||
].filter(l => l !== '');
|
||||
|
||||
return { success: true, stdout: lines.join('\n'), data: result };
|
||||
// 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/read 등에서 아래 경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` "${p}"`).join('\n')}`
|
||||
: '',
|
||||
'',
|
||||
'─── 본문 ───',
|
||||
r.body,
|
||||
].filter((l: string) => l !== '');
|
||||
return { success: true, stdout: lines.join('\n'), data: { ...r, account: found.account } };
|
||||
}
|
||||
errors.push(acct.id);
|
||||
}
|
||||
return { success: false, error: `UID ${uid} 메일을 찾을 수 없습니다. (검색한 계정: ${errors.join(', ')})` };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
@@ -299,13 +441,13 @@ export const emailReadTool = {
|
||||
|
||||
export const emailSendTool = {
|
||||
name: 'email_send',
|
||||
description: '이메일을 발송합니다. SMTP를 통해 to/subject/body를 지정해 발송하며 cc 추가도 가능합니다.',
|
||||
description: '이메일을 발송합니다. SMTP를 통해 to/subject/body를 지정해 발송하며 cc 추가도 가능합니다. account 미지정 시 첫 번째 등록 계정으로 발송합니다.',
|
||||
schema: {
|
||||
to: '수신자 이메일 주소 (여러 명은 쉼표로 구분)',
|
||||
subject: '제목',
|
||||
body: '본문 (Plain text)',
|
||||
cc: '참조 주소 (선택, 쉼표 구분)',
|
||||
account: '계정 ID (미지정 시 default)',
|
||||
account: '계정 ID (미지정 시 첫 번째 계정)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
@@ -314,7 +456,7 @@ export const emailSendTool = {
|
||||
subject: { type: 'string', description: '제목' },
|
||||
body: { type: 'string', description: '본문' },
|
||||
cc: { type: 'string', description: '참조 (선택)' },
|
||||
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
|
||||
account: { type: 'string', description: '계정 ID (미지정 시 첫 번째 계정)' },
|
||||
},
|
||||
required: ['to', 'subject', 'body'],
|
||||
additionalProperties: false,
|
||||
@@ -369,7 +511,7 @@ export const emailSendTool = {
|
||||
|
||||
export const emailSearchTool = {
|
||||
name: 'email_search',
|
||||
description: '이메일을 검색합니다. 키워드, 발신자, 제목, 날짜 범위로 검색 가능합니다.',
|
||||
description: '이메일을 검색합니다. 키워드, 발신자, 제목, 날짜 범위로 검색 가능합니다. account 미지정 시 모든 계정을 순회합니다.',
|
||||
schema: {
|
||||
query: '검색어 (제목+본문 전문 검색)',
|
||||
from: '발신자 이메일 주소로 필터',
|
||||
@@ -378,7 +520,7 @@ export const emailSearchTool = {
|
||||
before: '이 날짜 이전 (YYYY-MM-DD)',
|
||||
folder: '검색할 폴더 (기본: INBOX)',
|
||||
limit: '최대 결과 수 (기본: 20)',
|
||||
account: '계정 ID (미지정 시 default)',
|
||||
account: '계정 ID (미지정 시 모든 계정 순회)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
@@ -390,7 +532,7 @@ export const emailSearchTool = {
|
||||
before: { type: 'string', description: 'YYYY-MM-DD 이전' },
|
||||
folder: { type: 'string', description: '폴더 (기본 INBOX)' },
|
||||
limit: { type: 'number', description: '최대 결과 수 (기본 20)' },
|
||||
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
|
||||
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정)' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
@@ -404,20 +546,24 @@ export const emailSearchTool = {
|
||||
return { success: false, error: 'query, from, subject, since, before 중 하나 이상이 필요합니다.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = await withImap(async (client) => {
|
||||
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 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)) });
|
||||
|
||||
const searchCriteria = criteria.length === 1 ? criteria[0] : { and: criteria };
|
||||
const uids: number[] = await client.search(searchCriteria, { uid: true });
|
||||
const recentUids = uids.slice(-limit).reverse();
|
||||
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[] = [];
|
||||
@@ -435,23 +581,69 @@ export const emailSearchTool = {
|
||||
? new Date(msg.envelope.date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })
|
||||
: '',
|
||||
unread: !msg.flags?.has('\\Seen'),
|
||||
account: acctId,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}, account, workspacePath);
|
||||
}, acctId, workspacePath);
|
||||
};
|
||||
|
||||
if (messages.length === 0) {
|
||||
return { success: true, stdout: '검색 결과가 없습니다.', data: [] };
|
||||
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 = [
|
||||
`🔍 검색 결과 — ${messages.length}개`,
|
||||
`🔍 검색 결과 — 전체 계정에서 ${trimmed.length}개`,
|
||||
'',
|
||||
...messages.map((m, i) =>
|
||||
`${i + 1}. [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`
|
||||
...trimmed.map((m: any, i: number) =>
|
||||
`${i + 1}. [${m.account}] [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`
|
||||
),
|
||||
];
|
||||
return { success: true, stdout: lines.join('\n'), data: messages };
|
||||
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 };
|
||||
}
|
||||
@@ -462,18 +654,18 @@ export const emailSearchTool = {
|
||||
|
||||
export const emailDeleteTool = {
|
||||
name: 'email_delete',
|
||||
description: 'UID로 이메일을 삭제합니다. 가능한 경우 휴지통으로 이동하고, 불가능하면 \\Deleted 플래그를 설정합니다.',
|
||||
description: 'UID로 이메일을 삭제합니다. account 미지정 시 모든 계정에서 UID를 찾아 삭제합니다. 가능한 경우 휴지통으로 이동하고, 불가능하면 \\Deleted 플래그를 설정합니다.',
|
||||
schema: {
|
||||
uid: '삭제할 메일의 UID',
|
||||
folder: '폴더명 (기본: INBOX)',
|
||||
account: '계정 ID (미지정 시 default)',
|
||||
account: '계정 ID (미지정 시 모든 계정에서 검색)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
uid: { type: 'number', description: '삭제할 메일 UID' },
|
||||
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
|
||||
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
|
||||
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정 검색)' },
|
||||
},
|
||||
required: ['uid'],
|
||||
additionalProperties: false,
|
||||
@@ -485,29 +677,58 @@ export const emailDeleteTool = {
|
||||
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 {
|
||||
await withImap(async (client) => {
|
||||
await client.mailboxOpen(folder);
|
||||
// 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 },
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
// 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 },
|
||||
};
|
||||
}
|
||||
}, account, workspacePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout: `🗑️ UID ${uid} 메일이 삭제되었습니다.`,
|
||||
data: { uid, folder },
|
||||
};
|
||||
}
|
||||
return { success: false, error: `UID ${uid} 메일을 찾을 수 없어 삭제하지 못했습니다.` };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `삭제 실패: ${err.message}` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user