mail-templates도 수정

This commit is contained in:
dohyeons
2025-10-13 16:18:54 +09:00
parent b6eaaed85e
commit fbb42dd83c
5 changed files with 289 additions and 197 deletions

View File

@@ -4,12 +4,12 @@
*/
// 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';
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;
@@ -47,7 +47,11 @@ export class MailReceiveBasicService {
private attachmentsDir: string;
constructor() {
this.attachmentsDir = path.join(process.cwd(), 'uploads', 'mail-attachments');
// 운영 환경에서는 /app/uploads/mail-attachments, 개발 환경에서는 프로젝트 루트
this.attachmentsDir =
process.env.NODE_ENV === "production"
? "/app/uploads/mail-attachments"
: path.join(process.cwd(), "uploads", "mail-attachments");
this.ensureDirectoryExists();
}
@@ -55,7 +59,11 @@ export class MailReceiveBasicService {
try {
await fs.access(this.attachmentsDir);
} catch {
await fs.mkdir(this.attachmentsDir, { recursive: true });
try {
await fs.mkdir(this.attachmentsDir, { recursive: true });
} catch (error) {
console.error("메일 첨부파일 디렉토리 생성 실패:", error);
}
}
}
@@ -90,10 +98,13 @@ export class MailReceiveBasicService {
/**
* 메일 계정으로 받은 메일 목록 조회
*/
async fetchMailList(accountId: string, limit: number = 50): Promise<ReceivedMail[]> {
async fetchMailList(
accountId: string,
limit: number = 50
): Promise<ReceivedMail[]> {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.');
throw new Error("메일 계정을 찾을 수 없습니다.");
}
// 비밀번호 복호화
@@ -119,14 +130,14 @@ export class MailReceiveBasicService {
const timeout = setTimeout(() => {
// console.error('❌ IMAP 연결 타임아웃 (30초)');
imap.end();
reject(new Error('IMAP 연결 타임아웃'));
reject(new Error("IMAP 연결 타임아웃"));
}, 30000);
imap.once('ready', () => {
imap.once("ready", () => {
// console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
clearTimeout(timeout);
imap.openBox('INBOX', true, (err: any, box: any) => {
imap.openBox("INBOX", true, (err: any, box: any) => {
if (err) {
// console.error('❌ INBOX 열기 실패:', err);
imap.end();
@@ -147,29 +158,29 @@ export class MailReceiveBasicService {
// console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
const fetch = imap.seq.fetch(`${start}:${end}`, {
bodies: ['HEADER', 'TEXT'],
bodies: ["HEADER", "TEXT"],
struct: true,
});
// console.log(`📦 fetch 객체 생성 완료`);
let processedCount = 0;
const totalToProcess = end - start + 1;
fetch.on('message', (msg: any, seqno: any) => {
fetch.on("message", (msg: any, seqno: any) => {
// console.log(`📬 메일 #${seqno} 처리 시작`);
let header: string = '';
let body: string = '';
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');
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') {
stream.once("end", () => {
if (info.which === "HEADER") {
header = buffer;
} else {
body = buffer;
@@ -178,31 +189,39 @@ export class MailReceiveBasicService {
});
});
msg.once('attributes', (attrs: any) => {
msg.once("attributes", (attrs: any) => {
attributes = attrs;
});
msg.once('end', () => {
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;
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 || '(제목 없음)',
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,
preview: this.extractPreview(
parsed.text || parsed.html || ""
),
isRead: attributes?.flags?.includes("\\Seen") || false,
hasAttachments: (parsed.attachments?.length || 0) > 0,
};
@@ -218,15 +237,15 @@ export class MailReceiveBasicService {
});
});
fetch.once('error', (fetchErr: any) => {
fetch.once("error", (fetchErr: any) => {
// console.error('❌ 메일 fetch 에러:', fetchErr);
imap.end();
reject(fetchErr);
});
fetch.once('end', () => {
fetch.once("end", () => {
// console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
// 모든 메일 처리가 완료될 때까지 대기
const checkComplete = setInterval(() => {
// console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
@@ -240,7 +259,7 @@ export class MailReceiveBasicService {
resolve(mails);
}
}, 100);
// 최대 10초 대기
setTimeout(() => {
clearInterval(checkComplete);
@@ -253,13 +272,13 @@ export class MailReceiveBasicService {
});
});
imap.once('error', (imapErr: any) => {
imap.once("error", (imapErr: any) => {
// console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr);
clearTimeout(timeout);
reject(imapErr);
});
imap.once('end', () => {
imap.once("end", () => {
// console.log('🔌 IMAP 연결 종료');
});
@@ -273,20 +292,23 @@ export class MailReceiveBasicService {
*/
private extractPreview(text: string): string {
// HTML 태그 제거
const plainText = text.replace(/<[^>]*>/g, '');
const plainText = text.replace(/<[^>]*>/g, "");
// 공백 정리
const cleaned = plainText.replace(/\s+/g, ' ').trim();
const cleaned = plainText.replace(/\s+/g, " ").trim();
// 최대 150자
return cleaned.length > 150 ? cleaned.substring(0, 150) + '...' : cleaned;
return cleaned.length > 150 ? cleaned.substring(0, 150) + "..." : cleaned;
}
/**
* 메일 상세 조회
*/
async getMailDetail(accountId: string, seqno: number): Promise<MailDetail | null> {
async getMailDetail(
accountId: string,
seqno: number
): Promise<MailDetail | null> {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.');
throw new Error("메일 계정을 찾을 수 없습니다.");
}
// 비밀번호 복호화
@@ -304,97 +326,116 @@ export class MailReceiveBasicService {
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
imap.once('ready', () => {
imap.openBox('INBOX', false, (err: any, box: any) => {
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}`);
console.log(
`📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`
);
if (seqno > box.messages.total || seqno < 1) {
console.error(`❌ 유효하지 않은 seqno: ${seqno} (메일 총 개수: ${box.messages.total})`);
console.error(
`❌ 유효하지 않은 seqno: ${seqno} (메일 총 개수: ${box.messages.total})`
);
imap.end();
return resolve(null);
}
const fetch = imap.seq.fetch(`${seqno}:${seqno}`, {
bodies: '',
bodies: "",
struct: true,
});
let mailDetail: MailDetail | null = null;
let parsingComplete = false;
fetch.on('message', (msg: any, seqnum: any) => {
fetch.on("message", (msg: any, seqnum: any) => {
console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
msg.on('body', (stream: any, info: any) => {
msg.on("body", (stream: any, info: any) => {
console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
let buffer = '';
stream.on('data', (chunk: any) => {
buffer += chunk.toString('utf8');
let buffer = "";
stream.on("data", (chunk: any) => {
buffer += chunk.toString("utf8");
});
stream.once('end', async () => {
console.log(`✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`);
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;
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 || '',
from: fromAddress?.text || "Unknown",
to: toAddress?.text || "",
cc: ccAddress?.text,
bcc: bccAddress?.text,
subject: parsed.subject || '(제목 없음)',
subject: parsed.subject || "(제목 없음)",
date: parsed.date || new Date(),
htmlBody: parsed.html || '',
textBody: parsed.text || '',
preview: this.extractPreview(parsed.text || parsed.html || ''),
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',
filename: att.filename || "unnamed",
contentType:
att.contentType || "application/octet-stream",
size: att.size || 0,
})),
};
parsingComplete = true;
} catch (parseError) {
console.error('메일 파싱 오류:', parseError);
console.error("메일 파싱 오류:", parseError);
parsingComplete = true;
}
});
});
// msg 전체가 처리되었을 때 이벤트
msg.once('end', () => {
msg.once("end", () => {
console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
});
});
fetch.once('error', (fetchErr: any) => {
fetch.once("error", (fetchErr: any) => {
console.error(`❌ Fetch 에러:`, fetchErr);
imap.end();
reject(fetchErr);
});
fetch.once('end', () => {
fetch.once("end", () => {
console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
// 비동기 파싱이 완료될 때까지 대기
const waitForParsing = setInterval(() => {
if (parsingComplete) {
clearInterval(waitForParsing);
console.log(`✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? '존재함' : 'null'}`);
console.log(
`✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}`
);
imap.end();
resolve(mailDetail);
}
@@ -404,7 +445,7 @@ export class MailReceiveBasicService {
setTimeout(() => {
if (!parsingComplete) {
clearInterval(waitForParsing);
console.error('❌ 파싱 타임아웃');
console.error("❌ 파싱 타임아웃");
imap.end();
resolve(mailDetail); // 타임아웃 시에도 현재 상태 반환
}
@@ -413,7 +454,7 @@ export class MailReceiveBasicService {
});
});
imap.once('error', (imapErr: any) => {
imap.once("error", (imapErr: any) => {
reject(imapErr);
});
@@ -424,10 +465,13 @@ export class MailReceiveBasicService {
/**
* 메일을 읽음으로 표시
*/
async markAsRead(accountId: string, seqno: number): Promise<{ success: boolean; message: string }> {
async markAsRead(
accountId: string,
seqno: number
): Promise<{ success: boolean; message: string }> {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.');
throw new Error("메일 계정을 찾을 수 없습니다.");
}
// 비밀번호 복호화
@@ -445,28 +489,28 @@ export class MailReceiveBasicService {
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
imap.once('ready', () => {
imap.openBox('INBOX', false, (err: any, box: any) => {
imap.once("ready", () => {
imap.openBox("INBOX", false, (err: any, box: any) => {
if (err) {
imap.end();
return reject(err);
}
imap.seq.addFlags(seqno, ['\\Seen'], (flagErr: any) => {
imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => {
imap.end();
if (flagErr) {
reject(flagErr);
} else {
resolve({
success: true,
message: '메일을 읽음으로 표시했습니다.',
message: "메일을 읽음으로 표시했습니다.",
});
}
});
});
});
imap.once('error', (imapErr: any) => {
imap.once("error", (imapErr: any) => {
reject(imapErr);
});
@@ -477,11 +521,13 @@ export class MailReceiveBasicService {
/**
* IMAP 연결 테스트
*/
async testImapConnection(accountId: string): Promise<{ success: boolean; message: string }> {
async testImapConnection(
accountId: string
): Promise<{ success: boolean; message: string }> {
try {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.');
throw new Error("메일 계정을 찾을 수 없습니다.");
}
// 비밀번호 복호화
@@ -501,25 +547,25 @@ export class MailReceiveBasicService {
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
imap.once('ready', () => {
imap.once("ready", () => {
imap.end();
resolve({
success: true,
message: 'IMAP 연결 성공',
message: "IMAP 연결 성공",
});
});
imap.once('error', (err: any) => {
imap.once("error", (err: any) => {
reject(err);
});
// 타임아웃 설정 (10초)
const timeout = setTimeout(() => {
imap.end();
reject(new Error('연결 시간 초과'));
reject(new Error("연결 시간 초과"));
}, 10000);
imap.once('ready', () => {
imap.once("ready", () => {
clearTimeout(timeout);
});
@@ -528,7 +574,7 @@ export class MailReceiveBasicService {
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : '알 수 없는 오류',
message: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
}
@@ -538,21 +584,21 @@ export class MailReceiveBasicService {
*/
async getTodayReceivedCount(accountId?: string): Promise<number> {
try {
const accounts = accountId
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 todayMails = mails.filter((mail) => {
const mailDate = new Date(mail.date);
return mailDate >= today;
});
@@ -562,10 +608,10 @@ export class MailReceiveBasicService {
console.error(`계정 ${account.id} 메일 조회 실패:`, error);
}
}
return totalCount;
} catch (error) {
console.error('오늘 수신 메일 수 조회 실패:', error);
console.error("오늘 수신 메일 수 조회 실패:", error);
return 0;
}
}
@@ -577,10 +623,14 @@ export class MailReceiveBasicService {
accountId: string,
seqno: number,
attachmentIndex: number
): Promise<{ filePath: string; filename: string; contentType: string } | null> {
): Promise<{
filePath: string;
filename: string;
contentType: string;
} | null> {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.');
throw new Error("메일 계정을 찾을 수 없습니다.");
}
// 비밀번호 복호화
@@ -598,40 +648,53 @@ export class MailReceiveBasicService {
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
imap.once('ready', () => {
imap.openBox('INBOX', true, (err: any, box: any) => {
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: '',
bodies: "",
struct: true,
});
let attachmentResult: { filePath: string; filename: string; contentType: string } | null = null;
let attachmentResult: {
filePath: string;
filename: string;
contentType: string;
} | null = null;
let parsingComplete = false;
fetch.on('message', (msg: any, seqnum: any) => {
fetch.on("message", (msg: any, seqnum: any) => {
console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
msg.on('body', (stream: any, info: any) => {
msg.on("body", (stream: any, info: any) => {
console.log(`📎 메일 본문 스트림 시작`);
let buffer = '';
stream.on('data', (chunk: any) => {
buffer += chunk.toString('utf8');
let buffer = "";
stream.on("data", (chunk: any) => {
buffer += chunk.toString("utf8");
});
stream.once('end', async () => {
console.log(`📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`);
stream.once("end", async () => {
console.log(
`📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
);
try {
const parsed = await simpleParser(buffer);
console.log(`📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`);
console.log(
`📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`
);
if (parsed.attachments && parsed.attachments[attachmentIndex]) {
if (
parsed.attachments &&
parsed.attachments[attachmentIndex]
) {
const attachment = parsed.attachments[attachmentIndex];
console.log(`📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`);
console.log(
`📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`
);
// 안전한 파일명 생성
const safeFilename = this.sanitizeFilename(
attachment.filename || `attachment-${Date.now()}`
@@ -646,44 +709,51 @@ export class MailReceiveBasicService {
attachmentResult = {
filePath,
filename: attachment.filename || 'unnamed',
contentType: attachment.contentType || 'application/octet-stream',
filename: attachment.filename || "unnamed",
contentType:
attachment.contentType || "application/octet-stream",
};
parsingComplete = true;
} else {
console.log(`❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`);
console.log(
`❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`
);
parsingComplete = true;
}
} catch (parseError) {
console.error('첨부파일 파싱 오류:', parseError);
console.error("첨부파일 파싱 오류:", parseError);
parsingComplete = true;
}
});
});
});
fetch.once('error', (fetchErr: any) => {
console.error('❌ fetch 오류:', fetchErr);
fetch.once("error", (fetchErr: any) => {
console.error("❌ fetch 오류:", fetchErr);
imap.end();
reject(fetchErr);
});
fetch.once('end', () => {
fetch.once("end", () => {
console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
// 파싱 완료를 기다림 (최대 5초)
const checkComplete = setInterval(() => {
if (parsingComplete) {
console.log(`✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? '있음' : '없음'}`);
console.log(
`✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
);
clearInterval(checkComplete);
imap.end();
resolve(attachmentResult);
}
}, 100);
setTimeout(() => {
clearInterval(checkComplete);
console.log(`⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? '있음' : '없음'}`);
console.log(
`⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
);
imap.end();
resolve(attachmentResult);
}, 5000);
@@ -691,7 +761,7 @@ export class MailReceiveBasicService {
});
});
imap.once('error', (imapErr: any) => {
imap.once("error", (imapErr: any) => {
reject(imapErr);
});
@@ -704,9 +774,8 @@ export class MailReceiveBasicService {
*/
private sanitizeFilename(filename: string): string {
return filename
.replace(/[^a-zA-Z0-9가-힣.\-_]/g, '_')
.replace(/_{2,}/g, '_')
.replace(/[^a-zA-Z0-9가-힣.\-_]/g, "_")
.replace(/_{2,}/g, "_")
.substring(0, 200); // 최대 길이 제한
}
}