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

@ -1,6 +1,6 @@
import fs from 'fs/promises'; import fs from "fs/promises";
import path from 'path'; import path from "path";
import { encryptionService } from './encryptionService'; import { encryptionService } from "./encryptionService";
export interface MailAccount { export interface MailAccount {
id: string; id: string;
@ -12,7 +12,7 @@ export interface MailAccount {
smtpUsername: string; smtpUsername: string;
smtpPassword: string; // 암호화된 비밀번호 smtpPassword: string; // 암호화된 비밀번호
dailyLimit: number; dailyLimit: number;
status: 'active' | 'inactive' | 'suspended'; status: "active" | "inactive" | "suspended";
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@ -21,7 +21,11 @@ class MailAccountFileService {
private accountsDir: string; private accountsDir: string;
constructor() { constructor() {
this.accountsDir = path.join(process.cwd(), 'uploads', 'mail-accounts'); // 운영 환경에서는 /app/uploads/mail-accounts, 개발 환경에서는 프로젝트 루트
this.accountsDir =
process.env.NODE_ENV === "production"
? "/app/uploads/mail-accounts"
: path.join(process.cwd(), "uploads", "mail-accounts");
this.ensureDirectoryExists(); this.ensureDirectoryExists();
} }
@ -29,7 +33,11 @@ class MailAccountFileService {
try { try {
await fs.access(this.accountsDir); await fs.access(this.accountsDir);
} catch { } catch {
try {
await fs.mkdir(this.accountsDir, { recursive: true }); await fs.mkdir(this.accountsDir, { recursive: true });
} catch (error) {
console.error("메일 계정 디렉토리 생성 실패:", error);
}
} }
} }
@ -42,19 +50,20 @@ class MailAccountFileService {
try { try {
const files = await fs.readdir(this.accountsDir); const files = await fs.readdir(this.accountsDir);
const jsonFiles = files.filter(f => f.endsWith('.json')); const jsonFiles = files.filter((f) => f.endsWith(".json"));
const accounts = await Promise.all( const accounts = await Promise.all(
jsonFiles.map(async (file) => { jsonFiles.map(async (file) => {
const content = await fs.readFile( const content = await fs.readFile(
path.join(this.accountsDir, file), path.join(this.accountsDir, file),
'utf-8' "utf-8"
); );
return JSON.parse(content) as MailAccount; return JSON.parse(content) as MailAccount;
}) })
); );
return accounts.sort((a, b) => return accounts.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
); );
} catch { } catch {
@ -64,7 +73,7 @@ class MailAccountFileService {
async getAccountById(id: string): Promise<MailAccount | null> { async getAccountById(id: string): Promise<MailAccount | null> {
try { try {
const content = await fs.readFile(this.getAccountPath(id), 'utf-8'); const content = await fs.readFile(this.getAccountPath(id), "utf-8");
return JSON.parse(content); return JSON.parse(content);
} catch { } catch {
return null; return null;
@ -72,7 +81,7 @@ class MailAccountFileService {
} }
async createAccount( async createAccount(
data: Omit<MailAccount, 'id' | 'createdAt' | 'updatedAt'> data: Omit<MailAccount, "id" | "createdAt" | "updatedAt">
): Promise<MailAccount> { ): Promise<MailAccount> {
const id = `account-${Date.now()}`; const id = `account-${Date.now()}`;
const now = new Date().toISOString(); const now = new Date().toISOString();
@ -91,7 +100,7 @@ class MailAccountFileService {
await fs.writeFile( await fs.writeFile(
this.getAccountPath(id), this.getAccountPath(id),
JSON.stringify(account, null, 2), JSON.stringify(account, null, 2),
'utf-8' "utf-8"
); );
return account; return account;
@ -99,7 +108,7 @@ class MailAccountFileService {
async updateAccount( async updateAccount(
id: string, id: string,
data: Partial<Omit<MailAccount, 'id' | 'createdAt'>> data: Partial<Omit<MailAccount, "id" | "createdAt">>
): Promise<MailAccount | null> { ): Promise<MailAccount | null> {
const existing = await this.getAccountById(id); const existing = await this.getAccountById(id);
if (!existing) { if (!existing) {
@ -122,7 +131,7 @@ class MailAccountFileService {
await fs.writeFile( await fs.writeFile(
this.getAccountPath(id), this.getAccountPath(id),
JSON.stringify(updated, null, 2), JSON.stringify(updated, null, 2),
'utf-8' "utf-8"
); );
return updated; return updated;
@ -139,12 +148,12 @@ class MailAccountFileService {
async getAccountByEmail(email: string): Promise<MailAccount | null> { async getAccountByEmail(email: string): Promise<MailAccount | null> {
const accounts = await this.getAllAccounts(); const accounts = await this.getAllAccounts();
return accounts.find(a => a.email === email) || null; return accounts.find((a) => a.email === email) || null;
} }
async getActiveAccounts(): Promise<MailAccount[]> { async getActiveAccounts(): Promise<MailAccount[]> {
const accounts = await this.getAllAccounts(); const accounts = await this.getAllAccounts();
return accounts.filter(a => a.status === 'active'); return accounts.filter((a) => a.status === "active");
} }
/** /**
@ -156,4 +165,3 @@ class MailAccountFileService {
} }
export const mailAccountFileService = new MailAccountFileService(); export const mailAccountFileService = new MailAccountFileService();

View File

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

View File

@ -1,5 +1,5 @@
import fs from 'fs/promises'; import fs from "fs/promises";
import path from 'path'; import path from "path";
// MailComponent 인터페이스 정의 // MailComponent 인터페이스 정의
export interface MailComponent { export interface MailComponent {
@ -30,7 +30,7 @@ export interface MailTemplate {
queries: QueryConfig[]; queries: QueryConfig[];
}; };
recipientConfig?: { recipientConfig?: {
type: 'query' | 'manual'; type: "query" | "manual";
emailField?: string; emailField?: string;
nameField?: string; nameField?: string;
queryId?: string; queryId?: string;
@ -45,19 +45,26 @@ class MailTemplateFileService {
private templatesDir: string; private templatesDir: string;
constructor() { constructor() {
// uploads/mail-templates 디렉토리 사용 // 운영 환경에서는 /app/uploads/mail-templates, 개발 환경에서는 프로젝트 루트
this.templatesDir = path.join(process.cwd(), 'uploads', 'mail-templates'); this.templatesDir =
process.env.NODE_ENV === "production"
? "/app/uploads/mail-templates"
: path.join(process.cwd(), "uploads", "mail-templates");
this.ensureDirectoryExists(); this.ensureDirectoryExists();
} }
/** /**
* 릿 () * 릿 () - try-catch로
*/ */
private async ensureDirectoryExists() { private async ensureDirectoryExists() {
try { try {
await fs.access(this.templatesDir); await fs.access(this.templatesDir);
} catch { } catch {
try {
await fs.mkdir(this.templatesDir, { recursive: true }); await fs.mkdir(this.templatesDir, { recursive: true });
} catch (error) {
console.error("메일 템플릿 디렉토리 생성 실패:", error);
}
} }
} }
@ -76,20 +83,21 @@ class MailTemplateFileService {
try { try {
const files = await fs.readdir(this.templatesDir); const files = await fs.readdir(this.templatesDir);
const jsonFiles = files.filter(f => f.endsWith('.json')); const jsonFiles = files.filter((f) => f.endsWith(".json"));
const templates = await Promise.all( const templates = await Promise.all(
jsonFiles.map(async (file) => { jsonFiles.map(async (file) => {
const content = await fs.readFile( const content = await fs.readFile(
path.join(this.templatesDir, file), path.join(this.templatesDir, file),
'utf-8' "utf-8"
); );
return JSON.parse(content) as MailTemplate; return JSON.parse(content) as MailTemplate;
}) })
); );
// 최신순 정렬 // 최신순 정렬
return templates.sort((a, b) => return templates.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
); );
} catch (error) { } catch (error) {
@ -102,7 +110,7 @@ class MailTemplateFileService {
*/ */
async getTemplateById(id: string): Promise<MailTemplate | null> { async getTemplateById(id: string): Promise<MailTemplate | null> {
try { try {
const content = await fs.readFile(this.getTemplatePath(id), 'utf-8'); const content = await fs.readFile(this.getTemplatePath(id), "utf-8");
return JSON.parse(content); return JSON.parse(content);
} catch { } catch {
return null; return null;
@ -113,7 +121,7 @@ class MailTemplateFileService {
* 릿 * 릿
*/ */
async createTemplate( async createTemplate(
data: Omit<MailTemplate, 'id' | 'createdAt' | 'updatedAt'> data: Omit<MailTemplate, "id" | "createdAt" | "updatedAt">
): Promise<MailTemplate> { ): Promise<MailTemplate> {
const id = `template-${Date.now()}`; const id = `template-${Date.now()}`;
const now = new Date().toISOString(); const now = new Date().toISOString();
@ -128,7 +136,7 @@ class MailTemplateFileService {
await fs.writeFile( await fs.writeFile(
this.getTemplatePath(id), this.getTemplatePath(id),
JSON.stringify(template, null, 2), JSON.stringify(template, null, 2),
'utf-8' "utf-8"
); );
return template; return template;
@ -139,7 +147,7 @@ class MailTemplateFileService {
*/ */
async updateTemplate( async updateTemplate(
id: string, id: string,
data: Partial<Omit<MailTemplate, 'id' | 'createdAt'>> data: Partial<Omit<MailTemplate, "id" | "createdAt">>
): Promise<MailTemplate | null> { ): Promise<MailTemplate | null> {
try { try {
const existing = await this.getTemplateById(id); const existing = await this.getTemplateById(id);
@ -161,7 +169,7 @@ class MailTemplateFileService {
await fs.writeFile( await fs.writeFile(
this.getTemplatePath(id), this.getTemplatePath(id),
JSON.stringify(updated, null, 2), JSON.stringify(updated, null, 2),
'utf-8' "utf-8"
); );
// console.log(`✅ 템플릿 저장 성공: ${id}`); // console.log(`✅ 템플릿 저장 성공: ${id}`);
@ -188,40 +196,41 @@ class MailTemplateFileService {
* 릿 HTML로 * 릿 HTML로
*/ */
renderTemplateToHtml(components: MailComponent[]): string { renderTemplateToHtml(components: MailComponent[]): string {
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">'; let html =
'<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
components.forEach(comp => { components.forEach((comp) => {
const styles = Object.entries(comp.styles || {}) const styles = Object.entries(comp.styles || {})
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`) .map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
.join('; '); .join("; ");
switch (comp.type) { switch (comp.type) {
case 'text': case "text":
html += `<div style="${styles}">${comp.content || ''}</div>`; html += `<div style="${styles}">${comp.content || ""}</div>`;
break; break;
case 'button': case "button":
html += `<div style="text-align: center; ${styles}"> html += `<div style="text-align: center; ${styles}">
<a href="${comp.url || '#'}" <a href="${comp.url || "#"}"
style="display: inline-block; padding: 12px 24px; text-decoration: none; style="display: inline-block; padding: 12px 24px; text-decoration: none;
background-color: ${comp.styles?.backgroundColor || '#007bff'}; background-color: ${comp.styles?.backgroundColor || "#007bff"};
color: ${comp.styles?.color || '#fff'}; color: ${comp.styles?.color || "#fff"};
border-radius: 4px;"> border-radius: 4px;">
${comp.text || 'Button'} ${comp.text || "Button"}
</a> </a>
</div>`; </div>`;
break; break;
case 'image': case "image":
html += `<div style="${styles}"> html += `<div style="${styles}">
<img src="${comp.src || ''}" alt="" style="max-width: 100%; height: auto;" /> <img src="${comp.src || ""}" alt="" style="max-width: 100%; height: auto;" />
</div>`; </div>`;
break; break;
case 'spacer': case "spacer":
html += `<div style="height: ${comp.height || 20}px;"></div>`; html += `<div style="height: ${comp.height || 20}px;"></div>`;
break; break;
} }
}); });
html += '</div>'; html += "</div>";
return html; return html;
} }
@ -229,7 +238,7 @@ class MailTemplateFileService {
* camelCase를 kebab-case로 * camelCase를 kebab-case로
*/ */
private camelToKebab(str: string): string { private camelToKebab(str: string): string {
return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`); return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
} }
/** /**
@ -237,7 +246,7 @@ class MailTemplateFileService {
*/ */
async getTemplatesByCategory(category: string): Promise<MailTemplate[]> { async getTemplatesByCategory(category: string): Promise<MailTemplate[]> {
const allTemplates = await this.getAllTemplates(); const allTemplates = await this.getAllTemplates();
return allTemplates.filter(t => t.category === category); return allTemplates.filter((t) => t.category === category);
} }
/** /**
@ -247,7 +256,8 @@ class MailTemplateFileService {
const allTemplates = await this.getAllTemplates(); const allTemplates = await this.getAllTemplates();
const lowerKeyword = keyword.toLowerCase(); const lowerKeyword = keyword.toLowerCase();
return allTemplates.filter(t => return allTemplates.filter(
(t) =>
t.name.toLowerCase().includes(lowerKeyword) || t.name.toLowerCase().includes(lowerKeyword) ||
t.subject.toLowerCase().includes(lowerKeyword) || t.subject.toLowerCase().includes(lowerKeyword) ||
t.category?.toLowerCase().includes(lowerKeyword) t.category?.toLowerCase().includes(lowerKeyword)
@ -256,4 +266,3 @@ class MailTemplateFileService {
} }
export const mailTemplateFileService = new MailTemplateFileService(); export const mailTemplateFileService = new MailTemplateFileService();

View File

@ -35,7 +35,11 @@ COPY --from=build /app/dist ./dist
COPY package*.json ./ COPY package*.json ./
# Create logs, uploads, and data directories and set permissions (use existing node user with UID 1000) # Create logs, uploads, and data directories and set permissions (use existing node user with UID 1000)
RUN mkdir -p logs uploads/mail-attachments data/mail-sent && \ RUN mkdir -p logs \
uploads/mail-attachments \
uploads/mail-templates \
uploads/mail-accounts \
data/mail-sent && \
chown -R node:node logs uploads data && \ chown -R node:node logs uploads data && \
chmod -R 755 logs uploads data chmod -R 755 logs uploads data

View File

@ -25,10 +25,12 @@ echo ""
echo "[2/6] 호스트 디렉토리 준비..." echo "[2/6] 호스트 디렉토리 준비..."
mkdir -p /home/vexplor/backend_data/data/mail-sent mkdir -p /home/vexplor/backend_data/data/mail-sent
mkdir -p /home/vexplor/backend_data/uploads/mail-attachments mkdir -p /home/vexplor/backend_data/uploads/mail-attachments
mkdir -p /home/vexplor/backend_data/uploads/mail-templates
mkdir -p /home/vexplor/backend_data/uploads/mail-accounts
mkdir -p /home/vexplor/frontend_data mkdir -p /home/vexplor/frontend_data
chmod -R 755 /home/vexplor/backend_data chmod -R 755 /home/vexplor/backend_data
chmod -R 755 /home/vexplor/frontend_data chmod -R 755 /home/vexplor/frontend_data
echo "디렉토리 생성 완료 (data, uploads, frontend)" echo "디렉토리 생성 완료 (mail-sent, mail-attachments, mail-templates, mail-accounts, frontend)"
# 기존 컨테이너 중지 및 제거 # 기존 컨테이너 중지 및 제거
echo "" echo ""