898 lines
29 KiB
TypeScript
898 lines
29 KiB
TypeScript
/**
|
|
* 메일 수신 서비스 (Step 2 - 기본 구현)
|
|
* IMAP 연결 및 메일 목록 조회
|
|
*/
|
|
|
|
// CommonJS 모듈이므로 require 사용
|
|
const Imap = require("imap");
|
|
import { simpleParser } from "mailparser";
|
|
import { mailAccountFileService } from "./mailAccountFileService";
|
|
import { encryptionService } from "./encryptionService";
|
|
import fs from "fs/promises";
|
|
import path from "path";
|
|
|
|
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; // HTML 본문
|
|
textBody: string; // 텍스트 본문
|
|
cc?: string;
|
|
bcc?: string;
|
|
attachments: Array<{
|
|
filename: string;
|
|
contentType: string;
|
|
size: number;
|
|
}>;
|
|
}
|
|
|
|
export interface ImapConfig {
|
|
user: string;
|
|
password: string;
|
|
host: string;
|
|
port: number;
|
|
tls: boolean;
|
|
}
|
|
|
|
export class MailReceiveBasicService {
|
|
private attachmentsDir: string;
|
|
|
|
constructor() {
|
|
// 운영 환경에서는 /app/uploads/mail-attachments, 개발 환경에서는 프로젝트 루트
|
|
this.attachmentsDir =
|
|
process.env.NODE_ENV === "production"
|
|
? "/app/uploads/mail-attachments"
|
|
: path.join(process.cwd(), "uploads", "mail-attachments");
|
|
this.ensureDirectoryExists();
|
|
}
|
|
|
|
private async ensureDirectoryExists() {
|
|
try {
|
|
await fs.access(this.attachmentsDir);
|
|
} catch {
|
|
await fs.mkdir(this.attachmentsDir, { recursive: true, mode: 0o755 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SMTP 포트에서 IMAP 포트 추론
|
|
*/
|
|
private inferImapPort(smtpPort: number, imapPort?: number): number {
|
|
if (imapPort) return imapPort;
|
|
|
|
if (smtpPort === 465 || smtpPort === 587) {
|
|
return 993; // IMAPS (SSL/TLS)
|
|
} else if (smtpPort === 25) {
|
|
return 143; // IMAP (no encryption)
|
|
}
|
|
return 993; // 기본값: IMAPS
|
|
}
|
|
|
|
/**
|
|
* IMAP 연결 생성
|
|
*/
|
|
private createImapConnection(config: ImapConfig): any {
|
|
return new (Imap as any)({
|
|
user: config.user,
|
|
password: config.password,
|
|
host: config.host,
|
|
port: config.port,
|
|
tls: config.tls,
|
|
tlsOptions: { rejectUnauthorized: false },
|
|
authTimeout: 30000, // 인증 타임아웃 30초
|
|
connTimeout: 30000, // 연결 타임아웃 30초
|
|
keepalive: true,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 메일 계정으로 받은 메일 목록 조회
|
|
*/
|
|
async fetchMailList(
|
|
accountId: string,
|
|
limit: number = 50
|
|
): Promise<ReceivedMail[]> {
|
|
const account = await mailAccountFileService.getAccountById(accountId);
|
|
if (!account) {
|
|
throw new Error("메일 계정을 찾을 수 없습니다.");
|
|
}
|
|
|
|
// 비밀번호 복호화
|
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
|
|
|
// IMAP 설정
|
|
const accountAny = account as any;
|
|
const imapConfig: ImapConfig = {
|
|
user: account.email,
|
|
password: decryptedPassword,
|
|
host: accountAny.imapHost || account.smtpHost,
|
|
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
|
tls: true,
|
|
};
|
|
|
|
// // console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const imap = this.createImapConnection(imapConfig);
|
|
const mails: ReceivedMail[] = [];
|
|
|
|
// 30초 타임아웃 설정
|
|
const timeout = setTimeout(() => {
|
|
// console.error('❌ IMAP 연결 타임아웃 (30초)');
|
|
imap.end();
|
|
reject(new Error("IMAP 연결 타임아웃"));
|
|
}, 30000);
|
|
|
|
imap.once("ready", () => {
|
|
// // console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
|
|
clearTimeout(timeout);
|
|
|
|
imap.openBox("INBOX", true, (err: any, box: any) => {
|
|
if (err) {
|
|
// console.error('❌ INBOX 열기 실패:', err);
|
|
imap.end();
|
|
return reject(err);
|
|
}
|
|
|
|
// // console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
|
|
const totalMessages = box.messages.total;
|
|
if (totalMessages === 0) {
|
|
// // console.log('📭 메일함이 비어있습니다');
|
|
imap.end();
|
|
return resolve([]);
|
|
}
|
|
|
|
// 최근 메일부터 가져오기
|
|
const start = Math.max(1, totalMessages - limit + 1);
|
|
const end = totalMessages;
|
|
|
|
// // console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
|
|
const fetch = imap.seq.fetch(`${start}:${end}`, {
|
|
bodies: ["HEADER", "TEXT"],
|
|
struct: true,
|
|
});
|
|
|
|
// // console.log(`📦 fetch 객체 생성 완료`);
|
|
|
|
let processedCount = 0;
|
|
const totalToProcess = end - start + 1;
|
|
|
|
fetch.on("message", (msg: any, seqno: any) => {
|
|
// // console.log(`📬 메일 #${seqno} 처리 시작`);
|
|
let header: string = "";
|
|
let body: string = "";
|
|
let attributes: any = null;
|
|
let bodiesReceived = 0;
|
|
|
|
msg.on("body", (stream: any, info: any) => {
|
|
let buffer = "";
|
|
stream.on("data", (chunk: any) => {
|
|
buffer += chunk.toString("utf8");
|
|
});
|
|
stream.once("end", () => {
|
|
if (info.which === "HEADER") {
|
|
header = buffer;
|
|
} else {
|
|
body = buffer;
|
|
}
|
|
bodiesReceived++;
|
|
});
|
|
});
|
|
|
|
msg.once("attributes", (attrs: any) => {
|
|
attributes = attrs;
|
|
});
|
|
|
|
msg.once("end", () => {
|
|
// body 데이터를 모두 받을 때까지 대기
|
|
const waitForBodies = setInterval(async () => {
|
|
if (bodiesReceived >= 2 || (header && body)) {
|
|
clearInterval(waitForBodies);
|
|
|
|
try {
|
|
const parsed = await simpleParser(
|
|
header + "\r\n\r\n" + body
|
|
);
|
|
|
|
const fromAddress = Array.isArray(parsed.from)
|
|
? parsed.from[0]
|
|
: parsed.from;
|
|
const toAddress = Array.isArray(parsed.to)
|
|
? parsed.to[0]
|
|
: parsed.to;
|
|
|
|
const mail: ReceivedMail = {
|
|
id: `${accountId}-${seqno}`,
|
|
messageId: parsed.messageId || `${seqno}`,
|
|
from: fromAddress?.text || "Unknown",
|
|
to: toAddress?.text || "",
|
|
subject: parsed.subject || "(제목 없음)",
|
|
date: parsed.date || new Date(),
|
|
preview: this.extractPreview(
|
|
parsed.text || parsed.html || ""
|
|
),
|
|
isRead: attributes?.flags?.includes("\\Seen") || false,
|
|
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
|
};
|
|
|
|
mails.push(mail);
|
|
// // console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
|
|
processedCount++;
|
|
} catch (parseError) {
|
|
// console.error(`메일 #${seqno} 파싱 오류:`, parseError);
|
|
processedCount++;
|
|
}
|
|
}
|
|
}, 50);
|
|
});
|
|
});
|
|
|
|
fetch.once("error", (fetchErr: any) => {
|
|
// console.error('❌ 메일 fetch 에러:', fetchErr);
|
|
imap.end();
|
|
reject(fetchErr);
|
|
});
|
|
|
|
fetch.once("end", () => {
|
|
// // console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
|
|
|
|
// 모든 메일 처리가 완료될 때까지 대기
|
|
const checkComplete = setInterval(() => {
|
|
// // console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
|
|
if (processedCount >= totalToProcess) {
|
|
clearInterval(checkComplete);
|
|
// // console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
|
|
imap.end();
|
|
// 최신 메일이 위로 오도록 정렬
|
|
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
|
// // console.log(`📤 메일 목록 반환: ${mails.length}개`);
|
|
resolve(mails);
|
|
}
|
|
}, 100);
|
|
|
|
// 최대 10초 대기
|
|
setTimeout(() => {
|
|
clearInterval(checkComplete);
|
|
// // console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
|
|
imap.end();
|
|
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
|
resolve(mails);
|
|
}, 10000);
|
|
});
|
|
});
|
|
});
|
|
|
|
imap.once("error", (imapErr: any) => {
|
|
// console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr);
|
|
clearTimeout(timeout);
|
|
reject(imapErr);
|
|
});
|
|
|
|
imap.once("end", () => {
|
|
// // console.log('🔌 IMAP 연결 종료');
|
|
});
|
|
|
|
// // console.log('🔗 IMAP.connect() 호출...');
|
|
imap.connect();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 텍스트 미리보기 추출 (최대 150자)
|
|
*/
|
|
private extractPreview(text: string): string {
|
|
// HTML 태그 제거
|
|
const plainText = text.replace(/<[^>]*>/g, "");
|
|
// 공백 정리
|
|
const cleaned = plainText.replace(/\s+/g, " ").trim();
|
|
// 최대 150자
|
|
return cleaned.length > 150 ? cleaned.substring(0, 150) + "..." : cleaned;
|
|
}
|
|
|
|
/**
|
|
* 메일 상세 조회
|
|
*/
|
|
async getMailDetail(
|
|
accountId: string,
|
|
seqno: number
|
|
): Promise<MailDetail | null> {
|
|
const account = await mailAccountFileService.getAccountById(accountId);
|
|
if (!account) {
|
|
throw new Error("메일 계정을 찾을 수 없습니다.");
|
|
}
|
|
|
|
// 비밀번호 복호화
|
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
|
|
|
const accountAny = account as any;
|
|
const imapConfig: ImapConfig = {
|
|
user: account.email,
|
|
password: decryptedPassword,
|
|
host: accountAny.imapHost || account.smtpHost,
|
|
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
|
tls: true,
|
|
};
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const imap = this.createImapConnection(imapConfig);
|
|
|
|
imap.once("ready", () => {
|
|
imap.openBox("INBOX", false, (err: any, box: any) => {
|
|
if (err) {
|
|
imap.end();
|
|
return reject(err);
|
|
}
|
|
|
|
// console.log(
|
|
// `📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`
|
|
// );
|
|
|
|
if (seqno > box.messages.total || seqno < 1) {
|
|
console.error(
|
|
`❌ 유효하지 않은 seqno: ${seqno} (메일 총 개수: ${box.messages.total})`
|
|
);
|
|
imap.end();
|
|
return resolve(null);
|
|
}
|
|
|
|
const fetch = imap.seq.fetch(`${seqno}:${seqno}`, {
|
|
bodies: "",
|
|
struct: true,
|
|
});
|
|
|
|
let mailDetail: MailDetail | null = null;
|
|
let parsingComplete = false;
|
|
|
|
fetch.on("message", (msg: any, seqnum: any) => {
|
|
// console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
|
|
|
msg.on("body", (stream: any, info: any) => {
|
|
// console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
|
|
let buffer = "";
|
|
stream.on("data", (chunk: any) => {
|
|
buffer += chunk.toString("utf8");
|
|
});
|
|
stream.once("end", async () => {
|
|
// console.log(
|
|
// `✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
|
|
// );
|
|
try {
|
|
const parsed = await simpleParser(buffer);
|
|
// console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`);
|
|
|
|
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;
|
|
const bccAddress = Array.isArray(parsed.bcc)
|
|
? parsed.bcc[0]
|
|
: parsed.bcc;
|
|
|
|
mailDetail = {
|
|
id: `${accountId}-${seqnum}`,
|
|
messageId: parsed.messageId || `${seqnum}`,
|
|
from: fromAddress?.text || "Unknown",
|
|
to: toAddress?.text || "",
|
|
cc: ccAddress?.text,
|
|
bcc: bccAddress?.text,
|
|
subject: parsed.subject || "(제목 없음)",
|
|
date: parsed.date || new Date(),
|
|
htmlBody: parsed.html || "",
|
|
textBody: parsed.text || "",
|
|
preview: this.extractPreview(
|
|
parsed.text || parsed.html || ""
|
|
),
|
|
isRead: true, // 조회 시 읽음으로 표시
|
|
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,
|
|
})),
|
|
};
|
|
parsingComplete = true;
|
|
} catch (parseError) {
|
|
console.error("메일 파싱 오류:", parseError);
|
|
parsingComplete = true;
|
|
}
|
|
});
|
|
});
|
|
|
|
// msg 전체가 처리되었을 때 이벤트
|
|
msg.once("end", () => {
|
|
// console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
|
|
});
|
|
});
|
|
|
|
fetch.once("error", (fetchErr: any) => {
|
|
console.error(`❌ Fetch 에러:`, fetchErr);
|
|
imap.end();
|
|
reject(fetchErr);
|
|
});
|
|
|
|
fetch.once("end", () => {
|
|
// console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
|
|
|
|
// 비동기 파싱이 완료될 때까지 대기
|
|
const waitForParsing = setInterval(() => {
|
|
if (parsingComplete) {
|
|
clearInterval(waitForParsing);
|
|
// console.log(
|
|
// `✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}`
|
|
// );
|
|
imap.end();
|
|
resolve(mailDetail);
|
|
}
|
|
}, 10); // 10ms마다 체크
|
|
|
|
// 타임아웃 설정 (10초)
|
|
setTimeout(() => {
|
|
if (!parsingComplete) {
|
|
clearInterval(waitForParsing);
|
|
console.error("❌ 파싱 타임아웃");
|
|
imap.end();
|
|
resolve(mailDetail); // 타임아웃 시에도 현재 상태 반환
|
|
}
|
|
}, 10000);
|
|
});
|
|
});
|
|
});
|
|
|
|
imap.once("error", (imapErr: any) => {
|
|
reject(imapErr);
|
|
});
|
|
|
|
imap.connect();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 메일을 읽음으로 표시
|
|
*/
|
|
async markAsRead(
|
|
accountId: string,
|
|
seqno: number
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const account = await mailAccountFileService.getAccountById(accountId);
|
|
if (!account) {
|
|
throw new Error("메일 계정을 찾을 수 없습니다.");
|
|
}
|
|
|
|
// 비밀번호 복호화
|
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
|
|
|
const accountAny = account as any;
|
|
const imapPort = accountAny.imapPort || this.inferImapPort(account.smtpPort);
|
|
|
|
const imapConfig: ImapConfig = {
|
|
user: account.email,
|
|
password: decryptedPassword,
|
|
host: accountAny.imapHost || account.smtpHost,
|
|
port: imapPort,
|
|
tls: imapPort === 993, // 993 포트면 TLS 사용
|
|
};
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const imap = this.createImapConnection(imapConfig);
|
|
|
|
// 타임아웃 설정
|
|
const timeout = setTimeout(() => {
|
|
console.error('❌ IMAP 읽음 표시 타임아웃 (30초)');
|
|
imap.end();
|
|
reject(new Error("IMAP 연결 타임아웃"));
|
|
}, 30000);
|
|
|
|
imap.once("ready", () => {
|
|
clearTimeout(timeout);
|
|
// console.log(`🔗 IMAP 연결 성공 - 읽음 표시 시작 (seqno=${seqno})`);
|
|
|
|
// false로 변경: 쓰기 가능 모드로 INBOX 열기
|
|
imap.openBox("INBOX", false, (err: any, box: any) => {
|
|
if (err) {
|
|
console.error('❌ INBOX 열기 실패:', err);
|
|
imap.end();
|
|
return reject(err);
|
|
}
|
|
|
|
// console.log(`📬 INBOX 열림 (쓰기 가능 모드)`);
|
|
|
|
imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => {
|
|
imap.end();
|
|
if (flagErr) {
|
|
console.error("❌ 읽음 플래그 설정 실패:", flagErr);
|
|
reject(flagErr);
|
|
} else {
|
|
// console.log("✅ 읽음 플래그 설정 성공 - seqno:", seqno);
|
|
resolve({
|
|
success: true,
|
|
message: "메일을 읽음으로 표시했습니다.",
|
|
});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
imap.once("error", (imapErr: any) => {
|
|
clearTimeout(timeout);
|
|
console.error('❌ IMAP 에러:', imapErr);
|
|
reject(imapErr);
|
|
});
|
|
|
|
imap.once("end", () => {
|
|
clearTimeout(timeout);
|
|
});
|
|
|
|
// console.log(`🔌 IMAP 연결 시도 중... (host=${imapConfig.host}, port=${imapConfig.port})`);
|
|
imap.connect();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* IMAP 연결 테스트
|
|
*/
|
|
async testImapConnection(
|
|
accountId: string
|
|
): Promise<{ success: boolean; message: string }> {
|
|
try {
|
|
const account = await mailAccountFileService.getAccountById(accountId);
|
|
if (!account) {
|
|
throw new Error("메일 계정을 찾을 수 없습니다.");
|
|
}
|
|
|
|
// 비밀번호 복호화
|
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
|
// // console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`);
|
|
|
|
const accountAny = account as any;
|
|
const imapConfig: ImapConfig = {
|
|
user: account.email,
|
|
password: decryptedPassword,
|
|
host: accountAny.imapHost || account.smtpHost,
|
|
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
|
tls: true,
|
|
};
|
|
// // console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const imap = this.createImapConnection(imapConfig);
|
|
|
|
imap.once("ready", () => {
|
|
imap.end();
|
|
resolve({
|
|
success: true,
|
|
message: "IMAP 연결 성공",
|
|
});
|
|
});
|
|
|
|
imap.once("error", (err: any) => {
|
|
reject(err);
|
|
});
|
|
|
|
// 타임아웃 설정 (10초)
|
|
const timeout = setTimeout(() => {
|
|
imap.end();
|
|
reject(new Error("연결 시간 초과"));
|
|
}, 10000);
|
|
|
|
imap.once("ready", () => {
|
|
clearTimeout(timeout);
|
|
});
|
|
|
|
imap.connect();
|
|
});
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
message: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 오늘 수신한 메일 수 조회 (통계용)
|
|
*/
|
|
async getTodayReceivedCount(accountId?: string): Promise<number> {
|
|
try {
|
|
const accounts = accountId
|
|
? [await mailAccountFileService.getAccountById(accountId)]
|
|
: await mailAccountFileService.getAllAccounts();
|
|
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
let totalCount = 0;
|
|
|
|
for (const account of accounts) {
|
|
if (!account) continue;
|
|
|
|
try {
|
|
const mails = await this.fetchMailList(account.id, 100);
|
|
const todayMails = mails.filter((mail) => {
|
|
const mailDate = new Date(mail.date);
|
|
return mailDate >= today;
|
|
});
|
|
totalCount += todayMails.length;
|
|
} catch (error) {
|
|
// 개별 계정 오류는 무시하고 계속 진행
|
|
console.error(`계정 ${account.id} 메일 조회 실패:`, error);
|
|
}
|
|
}
|
|
|
|
return totalCount;
|
|
} catch (error) {
|
|
console.error("오늘 수신 메일 수 조회 실패:", error);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 첨부파일 다운로드
|
|
*/
|
|
async downloadAttachment(
|
|
accountId: string,
|
|
seqno: number,
|
|
attachmentIndex: number
|
|
): Promise<{
|
|
filePath: string;
|
|
filename: string;
|
|
contentType: string;
|
|
} | null> {
|
|
const account = await mailAccountFileService.getAccountById(accountId);
|
|
if (!account) {
|
|
throw new Error("메일 계정을 찾을 수 없습니다.");
|
|
}
|
|
|
|
// 비밀번호 복호화
|
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
|
|
|
const accountAny = account as any;
|
|
const imapConfig: ImapConfig = {
|
|
user: account.email,
|
|
password: decryptedPassword,
|
|
host: accountAny.imapHost || account.smtpHost,
|
|
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
|
tls: true,
|
|
};
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const imap = this.createImapConnection(imapConfig);
|
|
|
|
imap.once("ready", () => {
|
|
imap.openBox("INBOX", true, (err: any, box: any) => {
|
|
if (err) {
|
|
imap.end();
|
|
return reject(err);
|
|
}
|
|
|
|
const fetch = imap.seq.fetch(`${seqno}:${seqno}`, {
|
|
bodies: "",
|
|
struct: true,
|
|
});
|
|
|
|
let attachmentResult: {
|
|
filePath: string;
|
|
filename: string;
|
|
contentType: string;
|
|
} | null = null;
|
|
let parsingComplete = false;
|
|
|
|
fetch.on("message", (msg: any, seqnum: any) => {
|
|
// console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
|
|
|
msg.on("body", (stream: any, info: any) => {
|
|
// console.log(`📎 메일 본문 스트림 시작`);
|
|
let buffer = "";
|
|
stream.on("data", (chunk: any) => {
|
|
buffer += chunk.toString("utf8");
|
|
});
|
|
stream.once("end", async () => {
|
|
// console.log(
|
|
// `📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
|
|
// );
|
|
try {
|
|
const parsed = await simpleParser(buffer);
|
|
// console.log(
|
|
// `📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`
|
|
// );
|
|
|
|
if (
|
|
parsed.attachments &&
|
|
parsed.attachments[attachmentIndex]
|
|
) {
|
|
const attachment = parsed.attachments[attachmentIndex];
|
|
// console.log(
|
|
// `📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`
|
|
// );
|
|
|
|
// 안전한 파일명 생성
|
|
const safeFilename = this.sanitizeFilename(
|
|
attachment.filename || `attachment-${Date.now()}`
|
|
);
|
|
const timestamp = Date.now();
|
|
const filename = `${accountId}-${seqno}-${timestamp}-${safeFilename}`;
|
|
const filePath = path.join(this.attachmentsDir, filename);
|
|
|
|
// 파일 저장
|
|
await fs.writeFile(filePath, attachment.content);
|
|
// console.log(`📎 파일 저장 완료: ${filePath}`);
|
|
|
|
attachmentResult = {
|
|
filePath,
|
|
filename: attachment.filename || "unnamed",
|
|
contentType:
|
|
attachment.contentType || "application/octet-stream",
|
|
};
|
|
parsingComplete = true;
|
|
} else {
|
|
// console.log(
|
|
// `❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`
|
|
// );
|
|
parsingComplete = true;
|
|
}
|
|
} catch (parseError) {
|
|
console.error("첨부파일 파싱 오류:", parseError);
|
|
parsingComplete = true;
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
fetch.once("error", (fetchErr: any) => {
|
|
console.error("❌ fetch 오류:", fetchErr);
|
|
imap.end();
|
|
reject(fetchErr);
|
|
});
|
|
|
|
fetch.once("end", () => {
|
|
// console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
|
|
|
|
// 파싱 완료를 기다림 (최대 5초)
|
|
const checkComplete = setInterval(() => {
|
|
if (parsingComplete) {
|
|
// console.log(
|
|
// `✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
|
|
// );
|
|
clearInterval(checkComplete);
|
|
imap.end();
|
|
resolve(attachmentResult);
|
|
}
|
|
}, 100);
|
|
|
|
setTimeout(() => {
|
|
clearInterval(checkComplete);
|
|
// console.log(
|
|
// `⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
|
|
// );
|
|
imap.end();
|
|
resolve(attachmentResult);
|
|
}, 5000);
|
|
});
|
|
});
|
|
});
|
|
|
|
imap.once("error", (imapErr: any) => {
|
|
reject(imapErr);
|
|
});
|
|
|
|
imap.connect();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 파일명 정제 (안전한 파일명 생성)
|
|
*/
|
|
private sanitizeFilename(filename: string): string {
|
|
return filename
|
|
.replace(/[^a-zA-Z0-9가-힣.\-_]/g, "_")
|
|
.replace(/_{2,}/g, "_")
|
|
.substring(0, 200); // 최대 길이 제한
|
|
}
|
|
|
|
/**
|
|
* IMAP 서버에서 메일 삭제 (휴지통으로 이동)
|
|
*/
|
|
async deleteMail(accountId: string, seqno: number): Promise<{ success: boolean; message: string }> {
|
|
const account = await mailAccountFileService.getAccountById(accountId);
|
|
|
|
if (!account) {
|
|
throw new Error("메일 계정을 찾을 수 없습니다.");
|
|
}
|
|
|
|
// 비밀번호 복호화
|
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
|
|
|
// IMAP 설정 (타입 캐스팅)
|
|
const accountAny = account as any;
|
|
const imapPort = accountAny.imapPort || this.inferImapPort(account.smtpPort);
|
|
|
|
const config: ImapConfig = {
|
|
user: account.smtpUsername || account.email,
|
|
password: decryptedPassword,
|
|
host: accountAny.imapHost || account.smtpHost,
|
|
port: imapPort,
|
|
tls: imapPort === 993, // 993 포트면 TLS 사용, 143이면 사용 안함
|
|
};
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const imap = this.createImapConnection(config);
|
|
|
|
// 30초 타임아웃 설정
|
|
const timeout = setTimeout(() => {
|
|
console.error('❌ IMAP 메일 삭제 타임아웃 (30초)');
|
|
imap.end();
|
|
reject(new Error("IMAP 연결 타임아웃"));
|
|
}, 30000);
|
|
|
|
imap.once("ready", () => {
|
|
clearTimeout(timeout);
|
|
// console.log(`🔗 IMAP 연결 성공 - 메일 삭제 시작 (seqno=${seqno})`);
|
|
|
|
imap.openBox("INBOX", false, (err: any) => {
|
|
if (err) {
|
|
console.error('❌ INBOX 열기 실패:', err);
|
|
imap.end();
|
|
return reject(err);
|
|
}
|
|
|
|
// 메일을 삭제 플래그로 표시 (seq.addFlags 사용)
|
|
imap.seq.addFlags(seqno, ["\\Deleted"], (flagErr: any) => {
|
|
if (flagErr) {
|
|
console.error('❌ 삭제 플래그 추가 실패:', flagErr);
|
|
imap.end();
|
|
return reject(flagErr);
|
|
}
|
|
|
|
// console.log(`✓ 삭제 플래그 추가 완료 (seqno=${seqno})`);
|
|
|
|
// 삭제 플래그가 표시된 메일을 영구 삭제 (실제로는 휴지통으로 이동)
|
|
imap.expunge((expungeErr: any) => {
|
|
imap.end();
|
|
|
|
if (expungeErr) {
|
|
console.error('❌ expunge 실패:', expungeErr);
|
|
return reject(expungeErr);
|
|
}
|
|
|
|
// console.log(`🗑️ 메일 삭제 완료: seqno=${seqno}`);
|
|
resolve({
|
|
success: true,
|
|
message: "메일이 삭제되었습니다.",
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
imap.once("error", (imapErr: any) => {
|
|
clearTimeout(timeout);
|
|
console.error('❌ IMAP 에러:', imapErr);
|
|
reject(imapErr);
|
|
});
|
|
|
|
imap.once("end", () => {
|
|
clearTimeout(timeout);
|
|
});
|
|
|
|
// console.log(`🔌 IMAP 연결 시도 중... (host=${config.host}, port=${config.port})`);
|
|
imap.connect();
|
|
});
|
|
}
|
|
}
|
|
|
|
export const mailReceiveBasicService = new MailReceiveBasicService();
|