420 lines
15 KiB
TypeScript
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();
|