mail-templates도 수정
This commit is contained in:
parent
b6eaaed85e
commit
fbb42dd83c
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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); // 최대 길이 제한
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue