메일관리 콘솔로그 주석처리 세이브
This commit is contained in:
@@ -3,9 +3,11 @@
|
||||
* IMAP 연결 및 메일 목록 조회
|
||||
*/
|
||||
|
||||
import * as Imap from 'imap';
|
||||
// CommonJS 모듈이므로 require 사용
|
||||
const Imap = require('imap');
|
||||
import { simpleParser } from 'mailparser';
|
||||
import { mailAccountFileService } from './mailAccountFileService';
|
||||
import { encryptionService } from './encryptionService';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
@@ -57,6 +59,20 @@ export class MailReceiveBasicService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP 포트에서 IMAP 포트 추론
|
||||
*/
|
||||
private inferImapPort(smtpPort: number, imapPort?: number): number {
|
||||
if (imapPort) return imapPort;
|
||||
|
||||
if (smtpPort === 465 || smtpPort === 587) {
|
||||
return 993; // IMAPS (SSL/TLS)
|
||||
} else if (smtpPort === 25) {
|
||||
return 143; // IMAP (no encryption)
|
||||
}
|
||||
return 993; // 기본값: IMAPS
|
||||
}
|
||||
|
||||
/**
|
||||
* IMAP 연결 생성
|
||||
*/
|
||||
@@ -80,27 +96,47 @@ export class MailReceiveBasicService {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
|
||||
// IMAP 설정
|
||||
const accountAny = account as any;
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword, // 이미 복호화됨
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort, // SMTP 587 -> IMAP 993
|
||||
password: decryptedPassword,
|
||||
host: accountAny.imapHost || account.smtpHost,
|
||||
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||
tls: true,
|
||||
};
|
||||
|
||||
console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
const mails: ReceivedMail[] = [];
|
||||
|
||||
// 30초 타임아웃 설정
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('❌ IMAP 연결 타임아웃 (30초)');
|
||||
imap.end();
|
||||
reject(new Error('IMAP 연결 타임아웃'));
|
||||
}, 30000);
|
||||
|
||||
imap.once('ready', () => {
|
||||
console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
|
||||
clearTimeout(timeout);
|
||||
|
||||
imap.openBox('INBOX', true, (err: any, box: any) => {
|
||||
if (err) {
|
||||
console.error('❌ INBOX 열기 실패:', err);
|
||||
imap.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
|
||||
const totalMessages = box.messages.total;
|
||||
if (totalMessages === 0) {
|
||||
console.log('📭 메일함이 비어있습니다');
|
||||
imap.end();
|
||||
return resolve([]);
|
||||
}
|
||||
@@ -109,15 +145,23 @@ export class MailReceiveBasicService {
|
||||
const start = Math.max(1, totalMessages - limit + 1);
|
||||
const end = totalMessages;
|
||||
|
||||
console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
|
||||
const fetch = imap.seq.fetch(`${start}:${end}`, {
|
||||
bodies: ['HEADER', 'TEXT'],
|
||||
struct: true,
|
||||
});
|
||||
|
||||
console.log(`📦 fetch 객체 생성 완료`);
|
||||
|
||||
let processedCount = 0;
|
||||
const totalToProcess = end - start + 1;
|
||||
|
||||
fetch.on('message', (msg: any, seqno: any) => {
|
||||
console.log(`📬 메일 #${seqno} 처리 시작`);
|
||||
let header: string = '';
|
||||
let body: string = '';
|
||||
let attributes: any = null;
|
||||
let bodiesReceived = 0;
|
||||
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
let buffer = '';
|
||||
@@ -130,6 +174,7 @@ export class MailReceiveBasicService {
|
||||
} else {
|
||||
body = buffer;
|
||||
}
|
||||
bodiesReceived++;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,50 +182,88 @@ export class MailReceiveBasicService {
|
||||
attributes = attrs;
|
||||
});
|
||||
|
||||
msg.once('end', async () => {
|
||||
try {
|
||||
const parsed = await simpleParser(header + '\r\n\r\n' + body);
|
||||
msg.once('end', () => {
|
||||
// body 데이터를 모두 받을 때까지 대기
|
||||
const waitForBodies = setInterval(async () => {
|
||||
if (bodiesReceived >= 2 || (header && body)) {
|
||||
clearInterval(waitForBodies);
|
||||
|
||||
try {
|
||||
const parsed = await simpleParser(header + '\r\n\r\n' + body);
|
||||
|
||||
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
|
||||
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
|
||||
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
|
||||
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
|
||||
|
||||
const mail: ReceivedMail = {
|
||||
id: `${accountId}-${seqno}`,
|
||||
messageId: parsed.messageId || `${seqno}`,
|
||||
from: fromAddress?.text || 'Unknown',
|
||||
to: toAddress?.text || '',
|
||||
subject: parsed.subject || '(제목 없음)',
|
||||
date: parsed.date || new Date(),
|
||||
preview: this.extractPreview(parsed.text || parsed.html || ''),
|
||||
isRead: attributes?.flags?.includes('\\Seen') || false,
|
||||
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
||||
};
|
||||
const mail: ReceivedMail = {
|
||||
id: `${accountId}-${seqno}`,
|
||||
messageId: parsed.messageId || `${seqno}`,
|
||||
from: fromAddress?.text || 'Unknown',
|
||||
to: toAddress?.text || '',
|
||||
subject: parsed.subject || '(제목 없음)',
|
||||
date: parsed.date || new Date(),
|
||||
preview: this.extractPreview(parsed.text || parsed.html || ''),
|
||||
isRead: attributes?.flags?.includes('\\Seen') || false,
|
||||
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
||||
};
|
||||
|
||||
mails.push(mail);
|
||||
} catch (parseError) {
|
||||
console.error('메일 파싱 오류:', parseError);
|
||||
}
|
||||
mails.push(mail);
|
||||
console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
|
||||
processedCount++;
|
||||
} catch (parseError) {
|
||||
console.error(`메일 #${seqno} 파싱 오류:`, parseError);
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
console.error('❌ 메일 fetch 에러:', fetchErr);
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
imap.end();
|
||||
// 최신 메일이 위로 오도록 정렬
|
||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
resolve(mails);
|
||||
console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
|
||||
|
||||
// 모든 메일 처리가 완료될 때까지 대기
|
||||
const checkComplete = setInterval(() => {
|
||||
console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
|
||||
if (processedCount >= totalToProcess) {
|
||||
clearInterval(checkComplete);
|
||||
console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
|
||||
imap.end();
|
||||
// 최신 메일이 위로 오도록 정렬
|
||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
console.log(`📤 메일 목록 반환: ${mails.length}개`);
|
||||
resolve(mails);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// 최대 10초 대기
|
||||
setTimeout(() => {
|
||||
clearInterval(checkComplete);
|
||||
console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
|
||||
imap.end();
|
||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
resolve(mails);
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.once('error', (imapErr: any) => {
|
||||
console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr);
|
||||
clearTimeout(timeout);
|
||||
reject(imapErr);
|
||||
});
|
||||
|
||||
imap.once('end', () => {
|
||||
console.log('🔌 IMAP 연결 종료');
|
||||
});
|
||||
|
||||
console.log('🔗 IMAP.connect() 호출...');
|
||||
imap.connect();
|
||||
});
|
||||
}
|
||||
@@ -206,11 +289,15 @@ export class MailReceiveBasicService {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
|
||||
const accountAny = account as any;
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
password: decryptedPassword,
|
||||
host: accountAny.imapHost || account.smtpHost,
|
||||
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||
tls: true,
|
||||
};
|
||||
|
||||
@@ -302,11 +389,15 @@ export class MailReceiveBasicService {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
|
||||
const accountAny = account as any;
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
password: decryptedPassword,
|
||||
host: accountAny.imapHost || account.smtpHost,
|
||||
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||
tls: true,
|
||||
};
|
||||
|
||||
@@ -352,13 +443,19 @@ export class MailReceiveBasicService {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
// console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`);
|
||||
|
||||
const accountAny = account as any;
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
password: decryptedPassword,
|
||||
host: accountAny.imapHost || account.smtpHost,
|
||||
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||
tls: true,
|
||||
};
|
||||
// console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
@@ -408,11 +505,15 @@ export class MailReceiveBasicService {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
|
||||
const accountAny = account as any;
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
password: decryptedPassword,
|
||||
host: accountAny.imapHost || account.smtpHost,
|
||||
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||
tls: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,14 +7,22 @@ import nodemailer from 'nodemailer';
|
||||
import { mailAccountFileService } from './mailAccountFileService';
|
||||
import { mailTemplateFileService } from './mailTemplateFileService';
|
||||
import { encryptionService } from './encryptionService';
|
||||
import { mailSentHistoryService } from './mailSentHistoryService';
|
||||
|
||||
export interface SendMailRequest {
|
||||
accountId: string;
|
||||
templateId?: string;
|
||||
to: string[]; // 수신자 이메일 배열
|
||||
to: string[]; // 받는 사람
|
||||
cc?: string[]; // 참조 (Carbon Copy)
|
||||
bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
|
||||
subject: string;
|
||||
variables?: Record<string, string>; // 템플릿 변수 치환
|
||||
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
|
||||
attachments?: Array<{ // 첨부파일
|
||||
filename: string;
|
||||
path: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SendMailResult {
|
||||
@@ -30,6 +38,8 @@ class MailSendSimpleService {
|
||||
* 단일 메일 발송 또는 소규모 발송
|
||||
*/
|
||||
async sendMail(request: SendMailRequest): Promise<SendMailResult> {
|
||||
let htmlContent = ''; // 상위 스코프로 이동
|
||||
|
||||
try {
|
||||
// 1. 계정 조회
|
||||
const account = await mailAccountFileService.getAccountById(request.accountId);
|
||||
@@ -43,7 +53,7 @@ class MailSendSimpleService {
|
||||
}
|
||||
|
||||
// 3. HTML 생성 (템플릿 또는 커스텀)
|
||||
let htmlContent = request.customHtml || '';
|
||||
htmlContent = request.customHtml || '';
|
||||
|
||||
if (!htmlContent && request.templateId) {
|
||||
const template = await mailTemplateFileService.getTemplateById(request.templateId);
|
||||
@@ -59,20 +69,20 @@ class MailSendSimpleService {
|
||||
|
||||
// 4. 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
console.log('🔐 비밀번호 복호화 완료');
|
||||
console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
|
||||
console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
||||
// console.log('🔐 비밀번호 복호화 완료');
|
||||
// console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
|
||||
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
||||
|
||||
// 5. SMTP 연결 생성
|
||||
// 포트 465는 SSL/TLS를 사용해야 함
|
||||
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
||||
|
||||
console.log('📧 SMTP 연결 설정:', {
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort,
|
||||
secure: isSecure,
|
||||
user: account.smtpUsername,
|
||||
});
|
||||
// console.log('📧 SMTP 연결 설정:', {
|
||||
// host: account.smtpHost,
|
||||
// port: account.smtpPort,
|
||||
// secure: isSecure,
|
||||
// user: account.smtpUsername,
|
||||
// });
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: account.smtpHost,
|
||||
@@ -89,13 +99,60 @@ class MailSendSimpleService {
|
||||
|
||||
console.log('📧 메일 발송 시도 중...');
|
||||
|
||||
// 6. 메일 발송
|
||||
const info = await transporter.sendMail({
|
||||
// 6. 메일 발송 (CC, BCC, 첨부파일 지원)
|
||||
const mailOptions: any = {
|
||||
from: `"${account.name}" <${account.email}>`,
|
||||
to: request.to.join(', '),
|
||||
subject: this.replaceVariables(request.subject, request.variables),
|
||||
html: htmlContent,
|
||||
});
|
||||
};
|
||||
|
||||
// 참조(CC) 추가
|
||||
if (request.cc && request.cc.length > 0) {
|
||||
mailOptions.cc = request.cc.join(', ');
|
||||
// console.log('📧 참조(CC):', request.cc);
|
||||
}
|
||||
|
||||
// 숨은참조(BCC) 추가
|
||||
if (request.bcc && request.bcc.length > 0) {
|
||||
mailOptions.bcc = request.bcc.join(', ');
|
||||
// console.log('🔒 숨은참조(BCC):', request.bcc);
|
||||
}
|
||||
|
||||
// 첨부파일 추가 (한글 파일명 인코딩 처리)
|
||||
if (request.attachments && request.attachments.length > 0) {
|
||||
mailOptions.attachments = request.attachments.map(att => {
|
||||
// 파일명에서 타임스탬프_랜덤숫자_ 부분 제거하여 원본 파일명 복원
|
||||
let filename = att.filename.replace(/^\d+-\d+_/, '');
|
||||
|
||||
// NFC 정규화 (한글 조합 문자 정규화)
|
||||
filename = filename.normalize('NFC');
|
||||
|
||||
// ISO-8859-1 호환을 위한 안전한 파일명 생성
|
||||
// 한글이 포함된 경우 RFC 2047 MIME 인코딩 사용
|
||||
const hasKorean = /[\uAC00-\uD7AF]/.test(filename);
|
||||
let safeFilename = filename;
|
||||
|
||||
if (hasKorean) {
|
||||
// 한글이 포함된 경우: RFC 2047 MIME 인코딩 사용
|
||||
safeFilename = `=?UTF-8?B?${Buffer.from(filename, 'utf8').toString('base64')}?=`;
|
||||
}
|
||||
|
||||
return {
|
||||
filename: safeFilename,
|
||||
path: att.path,
|
||||
contentType: att.contentType,
|
||||
// 다중 호환성을 위한 헤더 설정
|
||||
headers: {
|
||||
'Content-Disposition': `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodeURIComponent(filename)}`
|
||||
}
|
||||
};
|
||||
});
|
||||
console.log('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, '')));
|
||||
console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename));
|
||||
}
|
||||
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
|
||||
console.log('✅ 메일 발송 성공:', {
|
||||
messageId: info.messageId,
|
||||
@@ -103,6 +160,43 @@ class MailSendSimpleService {
|
||||
rejected: info.rejected,
|
||||
});
|
||||
|
||||
// 발송 이력 저장 (성공)
|
||||
try {
|
||||
const template = request.templateId
|
||||
? await mailTemplateFileService.getTemplateById(request.templateId)
|
||||
: undefined;
|
||||
|
||||
// AttachmentInfo 형식으로 변환
|
||||
const attachmentInfos = request.attachments?.map(att => ({
|
||||
filename: att.filename,
|
||||
originalName: att.filename,
|
||||
size: 0, // multer에서 제공하지 않으므로 0으로 설정
|
||||
path: att.path,
|
||||
mimetype: att.contentType || 'application/octet-stream',
|
||||
}));
|
||||
|
||||
await mailSentHistoryService.saveSentMail({
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
accountEmail: account.email,
|
||||
to: request.to,
|
||||
cc: request.cc,
|
||||
bcc: request.bcc,
|
||||
subject: this.replaceVariables(request.subject, request.variables),
|
||||
htmlContent,
|
||||
templateId: request.templateId,
|
||||
templateName: template?.name,
|
||||
attachments: attachmentInfos,
|
||||
status: 'success',
|
||||
messageId: info.messageId,
|
||||
accepted: info.accepted as string[],
|
||||
rejected: info.rejected as string[],
|
||||
});
|
||||
} catch (historyError) {
|
||||
console.error('발송 이력 저장 실패:', historyError);
|
||||
// 이력 저장 실패는 메일 발송 성공에 영향 주지 않음
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
@@ -113,6 +207,52 @@ class MailSendSimpleService {
|
||||
const err = error as Error;
|
||||
console.error('❌ 메일 발송 실패:', err.message);
|
||||
console.error('❌ 에러 상세:', err);
|
||||
|
||||
// 발송 이력 저장 (실패)
|
||||
try {
|
||||
// 계정 정보 가져오기 (실패 시에도 필요)
|
||||
let accountInfo = { name: 'Unknown', email: 'unknown@example.com' };
|
||||
try {
|
||||
const acc = await mailAccountFileService.getAccountById(request.accountId);
|
||||
if (acc) {
|
||||
accountInfo = { name: acc.name, email: acc.email };
|
||||
}
|
||||
} catch (accError) {
|
||||
// 계정 조회 실패는 무시
|
||||
}
|
||||
|
||||
const template = request.templateId
|
||||
? await mailTemplateFileService.getTemplateById(request.templateId)
|
||||
: undefined;
|
||||
|
||||
// AttachmentInfo 형식으로 변환
|
||||
const attachmentInfos = request.attachments?.map(att => ({
|
||||
filename: att.filename,
|
||||
originalName: att.filename,
|
||||
size: 0,
|
||||
path: att.path,
|
||||
mimetype: att.contentType || 'application/octet-stream',
|
||||
}));
|
||||
|
||||
await mailSentHistoryService.saveSentMail({
|
||||
accountId: request.accountId,
|
||||
accountName: accountInfo.name,
|
||||
accountEmail: accountInfo.email,
|
||||
to: request.to,
|
||||
cc: request.cc,
|
||||
bcc: request.bcc,
|
||||
subject: request.subject,
|
||||
htmlContent: htmlContent || '',
|
||||
templateId: request.templateId,
|
||||
templateName: template?.name,
|
||||
attachments: attachmentInfos,
|
||||
status: 'failed',
|
||||
errorMessage: err.message,
|
||||
});
|
||||
} catch (historyError) {
|
||||
console.error('발송 이력 저장 실패:', historyError);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
@@ -136,33 +276,24 @@ class MailSendSimpleService {
|
||||
if (variables) {
|
||||
content = this.replaceVariables(content, variables);
|
||||
}
|
||||
html += `<div style="${this.styleObjectToString(component.styles)}">${content}</div>`;
|
||||
html += `<p style="margin: 16px 0; color: ${component.color || '#333'}; font-size: ${component.fontSize || '14px'};">${content}</p>`;
|
||||
break;
|
||||
|
||||
case 'button':
|
||||
let buttonText = component.text || 'Button';
|
||||
if (variables) {
|
||||
buttonText = this.replaceVariables(buttonText, variables);
|
||||
}
|
||||
html += `
|
||||
<a href="${component.url || '#'}" style="
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: ${component.styles?.backgroundColor || '#007bff'};
|
||||
color: ${component.styles?.color || 'white'};
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
${this.styleObjectToString(component.styles)}
|
||||
">${buttonText}</a>
|
||||
`;
|
||||
html += `<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="${component.url || '#'}" style="display: inline-block; padding: 12px 24px; background-color: ${component.backgroundColor || '#007bff'}; color: ${component.textColor || '#fff'}; text-decoration: none; border-radius: 4px;">${buttonText}</a>
|
||||
</div>`;
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
html += `<img src="${component.src || ''}" style="max-width: 100%; ${this.styleObjectToString(component.styles)}" />`;
|
||||
html += `<div style="text-align: center; margin: 16px 0;">
|
||||
<img src="${component.src}" alt="${component.alt || ''}" style="max-width: 100%; height: auto;" />
|
||||
</div>`;
|
||||
break;
|
||||
|
||||
case 'spacer':
|
||||
html += `<div style="height: ${component.height || 20}px;"></div>`;
|
||||
html += `<div style="height: ${component.height || '20px'};"></div>`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -174,10 +305,13 @@ class MailSendSimpleService {
|
||||
/**
|
||||
* 변수 치환
|
||||
*/
|
||||
private replaceVariables(text: string, variables?: Record<string, string>): string {
|
||||
if (!variables) return text;
|
||||
private replaceVariables(
|
||||
content: string,
|
||||
variables?: Record<string, string>
|
||||
): string {
|
||||
if (!variables) return content;
|
||||
|
||||
let result = text;
|
||||
let result = content;
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||
result = result.replace(regex, value);
|
||||
@@ -186,48 +320,30 @@ class MailSendSimpleService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스타일 객체를 CSS 문자열로 변환
|
||||
*/
|
||||
private styleObjectToString(styles?: Record<string, string>): string {
|
||||
if (!styles) return '';
|
||||
return Object.entries(styles)
|
||||
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* camelCase를 kebab-case로 변환
|
||||
*/
|
||||
private camelToKebab(str: string): string {
|
||||
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP 연결 테스트
|
||||
*/
|
||||
async testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
console.log('🔌 SMTP 연결 테스트 시작:', accountId);
|
||||
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('계정을 찾을 수 없습니다.');
|
||||
return { success: false, message: '메일 계정을 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
console.log('🔐 비밀번호 복호화 완료');
|
||||
// console.log('🔐 테스트용 비밀번호 복호화 완료');
|
||||
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
||||
|
||||
// 포트 465는 SSL/TLS를 사용해야 함
|
||||
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
||||
|
||||
console.log('🔌 SMTP 연결 설정:', {
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort,
|
||||
secure: isSecure,
|
||||
user: account.smtpUsername,
|
||||
});
|
||||
// console.log('🧪 SMTP 연결 테스트 시작:', {
|
||||
// host: account.smtpHost,
|
||||
// port: account.smtpPort,
|
||||
// secure: isSecure,
|
||||
// user: account.smtpUsername,
|
||||
// });
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: account.smtpHost,
|
||||
@@ -237,28 +353,22 @@ class MailSendSimpleService {
|
||||
user: account.smtpUsername,
|
||||
pass: decryptedPassword, // 복호화된 비밀번호 사용
|
||||
},
|
||||
connectionTimeout: 10000, // 10초 타임아웃
|
||||
// 테스트용 타임아웃 (10초)
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
});
|
||||
|
||||
console.log('🔌 SMTP 연결 검증 중...');
|
||||
// 연결 테스트
|
||||
await transporter.verify();
|
||||
console.log('✅ SMTP 연결 검증 성공!');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'SMTP 연결 성공!',
|
||||
};
|
||||
|
||||
console.log('✅ SMTP 연결 테스트 성공');
|
||||
return { success: true, message: 'SMTP 연결이 성공했습니다.' };
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('❌ SMTP 연결 실패:', err.message);
|
||||
return {
|
||||
success: false,
|
||||
message: `연결 실패: ${err.message}`,
|
||||
};
|
||||
console.error('❌ SMTP 연결 테스트 실패:', err.message);
|
||||
return { success: false, message: `SMTP 연결 실패: ${err.message}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailSendSimpleService = new MailSendSimpleService();
|
||||
|
||||
export const mailSendSimpleService = new MailSendSimpleService();
|
||||
232
backend-node/src/services/mailSentHistoryService.ts
Normal file
232
backend-node/src/services/mailSentHistoryService.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 메일 발송 이력 관리 서비스 (파일 기반)
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
SentMailHistory,
|
||||
SentMailListQuery,
|
||||
SentMailListResponse,
|
||||
AttachmentInfo,
|
||||
} from '../types/mailSentHistory';
|
||||
|
||||
const SENT_MAIL_DIR = path.join(__dirname, '../../data/mail-sent');
|
||||
|
||||
class MailSentHistoryService {
|
||||
constructor() {
|
||||
// 디렉토리 생성 (없으면)
|
||||
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
||||
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 저장
|
||||
*/
|
||||
async saveSentMail(data: Omit<SentMailHistory, 'id' | 'sentAt'>): Promise<SentMailHistory> {
|
||||
const history: SentMailHistory = {
|
||||
id: uuidv4(),
|
||||
sentAt: new Date().toISOString(),
|
||||
...data,
|
||||
};
|
||||
|
||||
const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(history, null, 2), 'utf-8');
|
||||
|
||||
console.log('💾 발송 이력 저장:', history.id);
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 목록 조회 (필터링, 페이징)
|
||||
*/
|
||||
async getSentMailList(query: SentMailListQuery): Promise<SentMailListResponse> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
searchTerm = '',
|
||||
status = 'all',
|
||||
accountId,
|
||||
startDate,
|
||||
endDate,
|
||||
sortBy = 'sentAt',
|
||||
sortOrder = 'desc',
|
||||
} = query;
|
||||
|
||||
// 모든 발송 이력 파일 읽기
|
||||
const files = fs.readdirSync(SENT_MAIL_DIR).filter((f) => f.endsWith('.json'));
|
||||
let allHistory: SentMailHistory[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(SENT_MAIL_DIR, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const history: SentMailHistory = JSON.parse(content);
|
||||
allHistory.push(history);
|
||||
} catch (error) {
|
||||
console.error(`발송 이력 파일 읽기 실패: ${file}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 필터링
|
||||
let filtered = allHistory;
|
||||
|
||||
// 상태 필터
|
||||
if (status !== 'all') {
|
||||
filtered = filtered.filter((h) => h.status === status);
|
||||
}
|
||||
|
||||
// 계정 필터
|
||||
if (accountId) {
|
||||
filtered = filtered.filter((h) => h.accountId === accountId);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (startDate) {
|
||||
filtered = filtered.filter((h) => h.sentAt >= startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
filtered = filtered.filter((h) => h.sentAt <= endDate);
|
||||
}
|
||||
|
||||
// 검색어 필터 (제목, 받는사람)
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(h) =>
|
||||
h.subject.toLowerCase().includes(term) ||
|
||||
h.to.some((email) => email.toLowerCase().includes(term)) ||
|
||||
(h.cc && h.cc.some((email) => email.toLowerCase().includes(term)))
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aVal: any = a[sortBy];
|
||||
let bVal: any = b[sortBy];
|
||||
|
||||
if (sortBy === 'sentAt') {
|
||||
aVal = new Date(aVal).getTime();
|
||||
bVal = new Date(bVal).getTime();
|
||||
} else {
|
||||
aVal = aVal ? aVal.toLowerCase() : '';
|
||||
bVal = bVal ? bVal.toLowerCase() : '';
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aVal > bVal ? 1 : -1;
|
||||
} else {
|
||||
return aVal < bVal ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
// 페이징
|
||||
const total = filtered.length;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const items = filtered.slice(start, end);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 발송 이력 조회
|
||||
*/
|
||||
async getSentMailById(id: string): Promise<SentMailHistory | null> {
|
||||
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content) as SentMailHistory;
|
||||
} catch (error) {
|
||||
console.error('발송 이력 읽기 실패:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 삭제
|
||||
*/
|
||||
async deleteSentMail(id: string): Promise<boolean> {
|
||||
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log('🗑️ 발송 이력 삭제:', id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('발송 이력 삭제 실패:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회
|
||||
*/
|
||||
async getStatistics(accountId?: string): Promise<{
|
||||
totalSent: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
todayCount: number;
|
||||
thisMonthCount: number;
|
||||
successRate: number;
|
||||
}> {
|
||||
const files = fs.readdirSync(SENT_MAIL_DIR).filter((f) => f.endsWith('.json'));
|
||||
let allHistory: SentMailHistory[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(SENT_MAIL_DIR, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const history: SentMailHistory = JSON.parse(content);
|
||||
|
||||
// 계정 필터
|
||||
if (!accountId || history.accountId === accountId) {
|
||||
allHistory.push(history);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`발송 이력 파일 읽기 실패: ${file}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
||||
|
||||
const totalSent = allHistory.length;
|
||||
const successCount = allHistory.filter((h) => h.status === 'success').length;
|
||||
const failedCount = allHistory.filter((h) => h.status === 'failed').length;
|
||||
const todayCount = allHistory.filter((h) => h.sentAt >= todayStart).length;
|
||||
const thisMonthCount = allHistory.filter((h) => h.sentAt >= monthStart).length;
|
||||
const successRate = totalSent > 0 ? Math.round((successCount / totalSent) * 100) : 0;
|
||||
|
||||
return {
|
||||
totalSent,
|
||||
successCount,
|
||||
failedCount,
|
||||
todayCount,
|
||||
thisMonthCount,
|
||||
successRate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const mailSentHistoryService = new MailSentHistoryService();
|
||||
|
||||
Reference in New Issue
Block a user