diff --git a/backend-node/src/services/mailAccountFileService.ts b/backend-node/src/services/mailAccountFileService.ts index d81d2c37..7b07b531 100644 --- a/backend-node/src/services/mailAccountFileService.ts +++ b/backend-node/src/services/mailAccountFileService.ts @@ -1,6 +1,6 @@ -import fs from 'fs/promises'; -import path from 'path'; -import { encryptionService } from './encryptionService'; +import fs from "fs/promises"; +import path from "path"; +import { encryptionService } from "./encryptionService"; export interface MailAccount { id: string; @@ -12,7 +12,7 @@ export interface MailAccount { smtpUsername: string; smtpPassword: string; // 암호화된 비밀번호 dailyLimit: number; - status: 'active' | 'inactive' | 'suspended'; + status: "active" | "inactive" | "suspended"; createdAt: string; updatedAt: string; } @@ -21,7 +21,11 @@ class MailAccountFileService { private accountsDir: string; 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(); } @@ -29,7 +33,11 @@ class MailAccountFileService { try { await fs.access(this.accountsDir); } catch { - await fs.mkdir(this.accountsDir, { recursive: true }); + try { + await fs.mkdir(this.accountsDir, { recursive: true }); + } catch (error) { + console.error("메일 계정 디렉토리 생성 실패:", error); + } } } @@ -39,23 +47,24 @@ class MailAccountFileService { async getAllAccounts(): Promise { await this.ensureDirectoryExists(); - + try { 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( jsonFiles.map(async (file) => { const content = await fs.readFile( path.join(this.accountsDir, file), - 'utf-8' + "utf-8" ); return JSON.parse(content) as MailAccount; }) ); - return accounts.sort((a, b) => - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + return accounts.sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); } catch { return []; @@ -64,7 +73,7 @@ class MailAccountFileService { async getAccountById(id: string): Promise { 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); } catch { return null; @@ -72,7 +81,7 @@ class MailAccountFileService { } async createAccount( - data: Omit + data: Omit ): Promise { const id = `account-${Date.now()}`; const now = new Date().toISOString(); @@ -91,7 +100,7 @@ class MailAccountFileService { await fs.writeFile( this.getAccountPath(id), JSON.stringify(account, null, 2), - 'utf-8' + "utf-8" ); return account; @@ -99,7 +108,7 @@ class MailAccountFileService { async updateAccount( id: string, - data: Partial> + data: Partial> ): Promise { const existing = await this.getAccountById(id); if (!existing) { @@ -122,7 +131,7 @@ class MailAccountFileService { await fs.writeFile( this.getAccountPath(id), JSON.stringify(updated, null, 2), - 'utf-8' + "utf-8" ); return updated; @@ -139,12 +148,12 @@ class MailAccountFileService { async getAccountByEmail(email: string): Promise { const accounts = await this.getAllAccounts(); - return accounts.find(a => a.email === email) || null; + return accounts.find((a) => a.email === email) || null; } async getActiveAccounts(): Promise { 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(); - diff --git a/backend-node/src/services/mailReceiveBasicService.ts b/backend-node/src/services/mailReceiveBasicService.ts index 2c1112a1..741353fa 100644 --- a/backend-node/src/services/mailReceiveBasicService.ts +++ b/backend-node/src/services/mailReceiveBasicService.ts @@ -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 { + async fetchMailList( + accountId: string, + limit: number = 50 + ): Promise { 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 { + async getMailDetail( + accountId: string, + seqno: number + ): Promise { 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 { 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); // 최대 길이 제한 } } - diff --git a/backend-node/src/services/mailTemplateFileService.ts b/backend-node/src/services/mailTemplateFileService.ts index 8b53014a..7a8d4300 100644 --- a/backend-node/src/services/mailTemplateFileService.ts +++ b/backend-node/src/services/mailTemplateFileService.ts @@ -1,5 +1,5 @@ -import fs from 'fs/promises'; -import path from 'path'; +import fs from "fs/promises"; +import path from "path"; // MailComponent 인터페이스 정의 export interface MailComponent { @@ -30,7 +30,7 @@ export interface MailTemplate { queries: QueryConfig[]; }; recipientConfig?: { - type: 'query' | 'manual'; + type: "query" | "manual"; emailField?: string; nameField?: string; queryId?: string; @@ -45,19 +45,26 @@ class MailTemplateFileService { private templatesDir: string; constructor() { - // uploads/mail-templates 디렉토리 사용 - this.templatesDir = path.join(process.cwd(), 'uploads', 'mail-templates'); + // 운영 환경에서는 /app/uploads/mail-templates, 개발 환경에서는 프로젝트 루트 + this.templatesDir = + process.env.NODE_ENV === "production" + ? "/app/uploads/mail-templates" + : path.join(process.cwd(), "uploads", "mail-templates"); this.ensureDirectoryExists(); } /** - * 템플릿 디렉토리 생성 (없으면) + * 템플릿 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지 */ private async ensureDirectoryExists() { try { await fs.access(this.templatesDir); } catch { - await fs.mkdir(this.templatesDir, { recursive: true }); + try { + await fs.mkdir(this.templatesDir, { recursive: true }); + } catch (error) { + console.error("메일 템플릿 디렉토리 생성 실패:", error); + } } } @@ -73,24 +80,25 @@ class MailTemplateFileService { */ async getAllTemplates(): Promise { await this.ensureDirectoryExists(); - + try { 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( jsonFiles.map(async (file) => { const content = await fs.readFile( path.join(this.templatesDir, file), - 'utf-8' + "utf-8" ); return JSON.parse(content) as MailTemplate; }) ); // 최신순 정렬 - return templates.sort((a, b) => - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + return templates.sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); } catch (error) { return []; @@ -102,7 +110,7 @@ class MailTemplateFileService { */ async getTemplateById(id: string): Promise { 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); } catch { return null; @@ -113,7 +121,7 @@ class MailTemplateFileService { * 템플릿 생성 */ async createTemplate( - data: Omit + data: Omit ): Promise { const id = `template-${Date.now()}`; const now = new Date().toISOString(); @@ -128,7 +136,7 @@ class MailTemplateFileService { await fs.writeFile( this.getTemplatePath(id), JSON.stringify(template, null, 2), - 'utf-8' + "utf-8" ); return template; @@ -139,7 +147,7 @@ class MailTemplateFileService { */ async updateTemplate( id: string, - data: Partial> + data: Partial> ): Promise { try { const existing = await this.getTemplateById(id); @@ -161,7 +169,7 @@ class MailTemplateFileService { await fs.writeFile( this.getTemplatePath(id), JSON.stringify(updated, null, 2), - 'utf-8' + "utf-8" ); // console.log(`✅ 템플릿 저장 성공: ${id}`); @@ -188,40 +196,41 @@ class MailTemplateFileService { * 템플릿을 HTML로 렌더링 */ renderTemplateToHtml(components: MailComponent[]): string { - let html = '
'; + let html = + '
'; - components.forEach(comp => { + components.forEach((comp) => { const styles = Object.entries(comp.styles || {}) .map(([key, value]) => `${this.camelToKebab(key)}: ${value}`) - .join('; '); + .join("; "); switch (comp.type) { - case 'text': - html += `
${comp.content || ''}
`; + case "text": + html += `
${comp.content || ""}
`; break; - case 'button': + case "button": html += ``; break; - case 'image': + case "image": html += `
- +
`; break; - case 'spacer': + case "spacer": html += `
`; break; } }); - html += '
'; + html += "
"; return html; } @@ -229,7 +238,7 @@ class MailTemplateFileService { * camelCase를 kebab-case로 변환 */ 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 { const allTemplates = await this.getAllTemplates(); - return allTemplates.filter(t => t.category === category); + return allTemplates.filter((t) => t.category === category); } /** @@ -246,14 +255,14 @@ class MailTemplateFileService { async searchTemplates(keyword: string): Promise { const allTemplates = await this.getAllTemplates(); const lowerKeyword = keyword.toLowerCase(); - - return allTemplates.filter(t => - t.name.toLowerCase().includes(lowerKeyword) || - t.subject.toLowerCase().includes(lowerKeyword) || - t.category?.toLowerCase().includes(lowerKeyword) + + return allTemplates.filter( + (t) => + t.name.toLowerCase().includes(lowerKeyword) || + t.subject.toLowerCase().includes(lowerKeyword) || + t.category?.toLowerCase().includes(lowerKeyword) ); } } export const mailTemplateFileService = new MailTemplateFileService(); - diff --git a/docker/deploy/backend.Dockerfile b/docker/deploy/backend.Dockerfile index 08ab6f5a..bbfd3438 100644 --- a/docker/deploy/backend.Dockerfile +++ b/docker/deploy/backend.Dockerfile @@ -35,7 +35,11 @@ COPY --from=build /app/dist ./dist COPY package*.json ./ # 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 && \ chmod -R 755 logs uploads data diff --git a/scripts/prod/deploy.sh b/scripts/prod/deploy.sh index 5ef1d5dc..b5388d54 100755 --- a/scripts/prod/deploy.sh +++ b/scripts/prod/deploy.sh @@ -25,10 +25,12 @@ echo "" echo "[2/6] 호스트 디렉토리 준비..." 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-templates +mkdir -p /home/vexplor/backend_data/uploads/mail-accounts mkdir -p /home/vexplor/frontend_data chmod -R 755 /home/vexplor/backend_data chmod -R 755 /home/vexplor/frontend_data -echo "디렉토리 생성 완료 (data, uploads, frontend)" +echo "디렉토리 생성 완료 (mail-sent, mail-attachments, mail-templates, mail-accounts, frontend)" # 기존 컨테이너 중지 및 제거 echo ""