Files
vexplor_dev/backend-node/src/services/userMailImapService.ts
2026-04-01 15:29:00 +09:00

420 lines
15 KiB
TypeScript

import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
import { encryptionService } from './encryptionService';
import { UserMailAccount } from './userMailAccountService';
import { imapConnectionPool } from './imapConnectionPool';
import { mailCache } from './mailCache';
export interface ReceivedMail {
id: string;
messageId: string;
from: string;
to: string;
subject: string;
date: Date;
preview: string;
isRead: boolean;
hasAttachments: boolean;
}
export interface MailDetail extends ReceivedMail {
htmlBody: string;
textBody: string;
cc?: string;
bcc?: string;
attachments: Array<{
filename: string;
contentType: string;
size: number;
}>;
}
class UserMailImapService {
async fetchMailList(account: UserMailAccount, limit: number = 50): Promise<ReceivedMail[]> {
const cacheKey = `mailList:${account.id}:INBOX:${limit}`;
const cached = mailCache.get<ReceivedMail[]>(cacheKey);
if (cached) return cached;
const mails = await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock('INBOX');
try {
const status = await client.status('INBOX', { messages: true });
const total = status.messages || 0;
if (total === 0) return [];
const start = Math.max(1, total - limit + 1);
const range = `${start}:${total}`;
const result: ReceivedMail[] = [];
for await (const msg of client.fetch(range, {
uid: true,
flags: true,
envelope: true,
bodyStructure: true,
})) {
const hasAttachments = msg.bodyStructure
? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"')
: false;
result.push({
id: `${account.id}-imap-${msg.seq}`,
messageId: msg.envelope?.messageId || `${msg.seq}`,
from: msg.envelope?.from?.[0]
? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim()
: 'Unknown',
to: msg.envelope?.to?.[0]?.address || '',
subject: msg.envelope?.subject || '(제목 없음)',
date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(),
preview: '',
isRead: msg.flags?.has('\\Seen') || false,
hasAttachments,
});
}
result.sort((a, b) => b.date.getTime() - a.date.getTime());
return result;
} finally {
mailbox.release();
}
});
mailCache.set(cacheKey, mails, 60_000);
return mails;
}
async fetchMailListStream(
account: UserMailAccount,
limit: number = 20,
beforeSeqno: number | null = null,
onMail: (mail: ReceivedMail) => void,
onDone: () => void,
onError: (err: Error) => void
): Promise<void> {
try {
await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock('INBOX');
try {
const status = await client.status('INBOX', { messages: true });
const total = status.messages || 0;
if (total === 0) { onDone(); return; }
let start: number, end: number;
if (beforeSeqno !== null) {
end = beforeSeqno - 1;
start = Math.max(1, beforeSeqno - limit);
} else {
start = Math.max(1, total - limit + 1);
end = total;
}
if (end < 1 || start > end) { onDone(); return; }
for await (const msg of client.fetch(`${start}:${end}`, {
uid: true,
flags: true,
envelope: true,
bodyStructure: true,
})) {
const hasAttachments = msg.bodyStructure
? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"')
: false;
onMail({
id: `${account.id}-imap-${msg.seq}`,
messageId: msg.envelope?.messageId || `${msg.seq}`,
from: msg.envelope?.from?.[0]
? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim()
: 'Unknown',
to: msg.envelope?.to?.[0]?.address || '',
subject: msg.envelope?.subject || '(제목 없음)',
date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(),
preview: '',
isRead: msg.flags?.has('\\Seen') || false,
hasAttachments,
});
}
onDone();
} finally {
mailbox.release();
}
});
} catch (err) {
onError(err instanceof Error ? err : new Error(String(err)));
}
}
async getMailDetail(account: UserMailAccount, seqno: number): Promise<MailDetail | null> {
const cacheKey = `mailDetail:${account.id}:${seqno}`;
const cached = mailCache.get<MailDetail>(cacheKey);
if (cached) return cached;
const detail = await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock('INBOX');
try {
const msg = await client.fetchOne(`${seqno}`, {
uid: true,
flags: true,
envelope: true,
bodyStructure: true,
source: true,
});
if (!msg) return null;
const parsed = await simpleParser(msg.source as Buffer);
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
const ccAddress = Array.isArray(parsed.cc) ? parsed.cc[0] : parsed.cc;
return {
id: `${account.id}-imap-${seqno}`,
messageId: parsed.messageId || `${seqno}`,
from: fromAddress?.text || 'Unknown',
to: toAddress?.text || '',
cc: ccAddress?.text,
subject: parsed.subject || '(제목 없음)',
date: parsed.date || new Date(),
htmlBody: parsed.html || '',
textBody: parsed.text || '',
preview: '',
isRead: msg.flags?.has('\\Seen') || false,
hasAttachments: (parsed.attachments?.length || 0) > 0,
attachments: (parsed.attachments || []).map((att: any) => ({
filename: att.filename || 'unnamed',
contentType: att.contentType || 'application/octet-stream',
size: att.size || 0,
})),
} as MailDetail;
} finally {
mailbox.release();
}
});
if (detail) mailCache.set(cacheKey, detail, 300_000);
return detail;
}
async markAsRead(account: UserMailAccount, seqno: number): Promise<{ success: boolean; message: string }> {
try {
await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock('INBOX');
try {
await client.messageFlagsAdd(`${seqno}`, ['\\Seen']);
} finally {
mailbox.release();
}
});
mailCache.invalidateByPrefix(`mailList:${account.id}:`);
return { success: true, message: '읽음 처리 완료' };
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : '오류' };
}
}
async deleteMail(account: UserMailAccount, seqno: number): Promise<{ success: boolean; message: string }> {
try {
await imapConnectionPool.execute(account, async (client) => {
// \Trash 특수 폴더 탐색 (Gmail: [Gmail]/휴지통 등)
const folders = await client.list();
const trashFolder = folders.find(f => f.specialUse === '\\Trash');
const mailbox = await client.getMailboxLock('INBOX');
try {
if (trashFolder) {
await client.messageMove(`${seqno}`, trashFolder.path);
} else {
await client.messageDelete(`${seqno}`);
}
} finally {
mailbox.release();
}
});
mailCache.invalidateByPrefix(`mailList:${account.id}:`);
mailCache.invalidateByPrefix(`mailDetail:${account.id}:${seqno}`);
return { success: true, message: '휴지통으로 이동 완료' };
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : '오류' };
}
}
async listFolders(account: UserMailAccount): Promise<Array<{ path: string; name: string; unseen: number; }>> {
return imapConnectionPool.execute(account, async (client) => {
const folders = await client.list({ statusQuery: { unseen: true } });
return folders
.filter(f => f.listed)
.map(f => ({
path: f.path,
name: f.name,
unseen: (f as any).status?.unseen ?? 0,
}));
});
}
async streamMailsByFolder(
account: UserMailAccount,
folder: string,
limit: number = 20,
beforeSeqno: number | null = null,
onMail: (mail: ReceivedMail) => void,
onDone: () => void,
onError: (err: Error) => void
): Promise<void> {
try {
await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock(folder);
try {
const status = await client.status(folder, { messages: true });
const total = status.messages || 0;
if (total === 0) { onDone(); return; }
let start: number, end: number;
if (beforeSeqno !== null) {
end = beforeSeqno - 1;
start = Math.max(1, beforeSeqno - limit);
} else {
start = Math.max(1, total - limit + 1);
end = total;
}
if (end < 1 || start > end) { onDone(); return; }
for await (const msg of client.fetch(`${start}:${end}`, {
uid: true, flags: true, envelope: true, bodyStructure: true,
})) {
const hasAttachments = msg.bodyStructure
? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"')
: false;
onMail({
id: `${account.id}-imap-${msg.seq}`,
messageId: msg.envelope?.messageId || `${msg.seq}`,
from: msg.envelope?.from?.[0]
? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim()
: 'Unknown',
to: msg.envelope?.to?.[0]?.address || '',
subject: msg.envelope?.subject || '(제목 없음)',
date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(),
preview: '',
isRead: msg.flags?.has('\\Seen') || false,
hasAttachments,
});
}
onDone();
} finally {
mailbox.release();
}
});
} catch (err) {
onError(err instanceof Error ? err : new Error(String(err)));
}
}
async moveMail(account: UserMailAccount, seqno: number, targetFolder: string): Promise<{ success: boolean; message: string }> {
try {
await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock('INBOX');
try {
await client.messageMove(`${seqno}`, targetFolder);
} finally {
mailbox.release();
}
});
mailCache.invalidateByPrefix(`mailList:${account.id}:`);
return { success: true, message: '이동 완료' };
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : '오류' };
}
}
async downloadAttachment(
account: UserMailAccount,
seqno: number,
partId: string,
res: import('express').Response,
folder: string = 'INBOX',
filenameHint?: string
): Promise<void> {
await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock(folder);
try {
const { meta, content } = await client.download(`${seqno}`, partId);
const rawFilename = filenameHint || (meta as any).filename || 'attachment';
const encodedFilename = encodeURIComponent(rawFilename);
res.setHeader('Content-Disposition', `attachment; filename="${rawFilename}"; filename*=UTF-8''${encodedFilename}`);
res.setHeader('Content-Type', (meta as any).contentType || 'application/octet-stream');
if ((meta as any).size) res.setHeader('Content-Length', String((meta as any).size));
await require('stream/promises').pipeline(content, res);
} finally {
mailbox.release();
}
});
}
async getAttachmentList(account: UserMailAccount, seqno: number, folder: string = 'INBOX'): Promise<Array<{ partId: string; filename: string; contentType: string; size: number }>> {
return imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock(folder);
try {
const msg = await client.fetchOne(`${seqno}`, { bodyStructure: true });
if (!msg || !msg.bodyStructure) return [];
const result: Array<{ partId: string; filename: string; contentType: string; size: number }> = [];
function walk(node: any, part: string) {
const filename = node.parameters?.name || node.dispositionParameters?.filename;
if (filename && node.type !== 'text' && node.type !== 'multipart') {
result.push({
partId: node.part || part,
filename,
contentType: `${node.type}/${node.subtype}`,
size: node.size || 0,
});
}
if (node.childNodes) node.childNodes.forEach((c: any, i: number) => walk(c, `${part}.${i + 1}`));
}
walk(msg.bodyStructure, '1');
return result;
} finally {
mailbox.release();
}
});
}
async testConnection(account: UserMailAccount): Promise<{ success: boolean; message: string }> {
const decryptedPassword = encryptionService.decrypt(account.password);
const client = new ImapFlow({
host: account.host,
port: account.port,
secure: account.useTls,
auth: { user: account.username, pass: decryptedPassword },
logger: false as any,
tls: { rejectUnauthorized: false },
});
try {
await client.connect();
await client.logout();
return { success: true, message: 'IMAP 연결 성공' };
} catch (err) {
let message = '연결 실패';
if (err instanceof Error) {
const imapErr = err as any;
const raw = imapErr.response || imapErr.responseCode || imapErr.cause?.message || err.message;
const r = String(raw).toLowerCase();
if (r.includes('authentication') || r.includes('invalid credentials') || r.includes('authenticationfailed') || r.includes('login failed')) {
message = '인증 실패: 이메일 주소 또는 비밀번호가 올바르지 않습니다.';
} else if (r.includes('econnrefused') || r.includes('connection refused')) {
message = '연결 거부: 호스트 또는 포트를 확인하세요.';
} else if (r.includes('enotfound') || r.includes('getaddrinfo')) {
message = '호스트를 찾을 수 없습니다. IMAP 주소를 확인하세요.';
} else if (r.includes('timeout') || r.includes('etimedout')) {
message = '연결 시간 초과: 서버가 응답하지 않습니다.';
} else if (r.includes('self signed') || r.includes('certificate')) {
message = 'SSL 인증서 오류가 발생했습니다.';
} else if (r.includes('econnreset')) {
message = '연결이 강제로 끊겼습니다. TLS/SSL 설정을 확인하세요.';
} else {
message = raw;
}
}
return { success: false, message };
}
}
}
export const userMailImapService = new UserMailImapService();