ERP-node/backend-node/src/services/mailSendSimpleService.ts

374 lines
13 KiB
TypeScript
Raw Normal View History

2025-10-01 16:15:53 +09:00
/**
* ( )
* Nodemailer를
*/
import nodemailer from 'nodemailer';
import { mailAccountFileService } from './mailAccountFileService';
import { mailTemplateFileService } from './mailTemplateFileService';
2025-10-02 15:46:23 +09:00
import { encryptionService } from './encryptionService';
import { mailSentHistoryService } from './mailSentHistoryService';
2025-10-01 16:15:53 +09:00
export interface SendMailRequest {
accountId: string;
templateId?: string;
to: string[]; // 받는 사람
cc?: string[]; // 참조 (Carbon Copy)
bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
2025-10-01 16:15:53 +09:00
subject: string;
variables?: Record<string, string>; // 템플릿 변수 치환
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
attachments?: Array<{ // 첨부파일
filename: string;
path: string;
contentType?: string;
}>;
2025-10-01 16:15:53 +09:00
}
export interface SendMailResult {
success: boolean;
messageId?: string;
accepted?: string[];
rejected?: string[];
error?: string;
}
class MailSendSimpleService {
/**
*
*/
async sendMail(request: SendMailRequest): Promise<SendMailResult> {
let htmlContent = ''; // 상위 스코프로 이동
2025-10-01 16:15:53 +09:00
try {
// 1. 계정 조회
const account = await mailAccountFileService.getAccountById(request.accountId);
if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
// 2. 계정 활성화 확인
if (account.status !== 'active') {
throw new Error('비활성 상태의 계정입니다.');
}
// 3. HTML 생성 (템플릿 또는 커스텀)
htmlContent = request.customHtml || '';
2025-10-01 16:15:53 +09:00
if (!htmlContent && request.templateId) {
const template = await mailTemplateFileService.getTemplateById(request.templateId);
if (!template) {
throw new Error('템플릿을 찾을 수 없습니다.');
}
htmlContent = this.renderTemplate(template, request.variables);
}
if (!htmlContent) {
throw new Error('메일 내용이 없습니다.');
}
2025-10-02 15:46:23 +09:00
// 4. 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
// console.log('🔐 비밀번호 복호화 완료');
// console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
2025-10-02 15:46:23 +09:00
// 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,
// });
2025-10-02 15:46:23 +09:00
2025-10-01 16:15:53 +09:00
const transporter = nodemailer.createTransport({
host: account.smtpHost,
port: account.smtpPort,
2025-10-02 15:46:23 +09:00
secure: isSecure, // SSL/TLS (포트 465는 자동으로 true)
2025-10-01 16:15:53 +09:00
auth: {
user: account.smtpUsername,
2025-10-02 15:46:23 +09:00
pass: decryptedPassword, // 복호화된 비밀번호 사용
2025-10-01 16:15:53 +09:00
},
2025-10-02 15:46:23 +09:00
// 타임아웃 설정 (30초)
connectionTimeout: 30000,
greetingTimeout: 30000,
2025-10-01 16:15:53 +09:00
});
2025-10-02 15:46:23 +09:00
console.log('📧 메일 발송 시도 중...');
// 6. 메일 발송 (CC, BCC, 첨부파일 지원)
const mailOptions: any = {
2025-10-01 16:15:53 +09:00
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);
2025-10-01 16:15:53 +09:00
2025-10-02 15:46:23 +09:00
console.log('✅ 메일 발송 성공:', {
messageId: info.messageId,
accepted: info.accepted,
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);
// 이력 저장 실패는 메일 발송 성공에 영향 주지 않음
}
2025-10-01 16:15:53 +09:00
return {
success: true,
messageId: info.messageId,
accepted: info.accepted as string[],
rejected: info.rejected as string[],
};
} catch (error) {
const err = error as Error;
2025-10-02 15:46:23 +09:00
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);
}
2025-10-01 16:15:53 +09:00
return {
success: false,
error: err.message,
};
}
}
/**
* 릿 ( )
*/
private renderTemplate(
template: any,
variables?: Record<string, string>
): string {
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
template.components.forEach((component: any) => {
switch (component.type) {
case 'text':
let content = component.content || '';
if (variables) {
content = this.replaceVariables(content, variables);
}
html += `<p style="margin: 16px 0; color: ${component.color || '#333'}; font-size: ${component.fontSize || '14px'};">${content}</p>`;
2025-10-01 16:15:53 +09:00
break;
case 'button':
let buttonText = component.text || 'Button';
if (variables) {
buttonText = this.replaceVariables(buttonText, variables);
}
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>`;
2025-10-01 16:15:53 +09:00
break;
case 'image':
html += `<div style="text-align: center; margin: 16px 0;">
<img src="${component.src}" alt="${component.alt || ''}" style="max-width: 100%; height: auto;" />
</div>`;
2025-10-01 16:15:53 +09:00
break;
case 'spacer':
html += `<div style="height: ${component.height || '20px'};"></div>`;
2025-10-01 16:15:53 +09:00
break;
}
});
html += '</div>';
return html;
}
/**
*
*/
private replaceVariables(
content: string,
variables?: Record<string, string>
): string {
if (!variables) return content;
2025-10-01 16:15:53 +09:00
let result = content;
2025-10-01 16:15:53 +09:00
Object.entries(variables).forEach(([key, value]) => {
const regex = new RegExp(`\\{${key}\\}`, 'g');
result = result.replace(regex, value);
});
return result;
}
/**
* SMTP
*/
async testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
try {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
return { success: false, message: '메일 계정을 찾을 수 없습니다.' };
2025-10-01 16:15:53 +09:00
}
2025-10-02 15:46:23 +09:00
// 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
// console.log('🔐 테스트용 비밀번호 복호화 완료');
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
2025-10-02 15:46:23 +09:00
// 포트 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,
// });
2025-10-02 15:46:23 +09:00
2025-10-01 16:15:53 +09:00
const transporter = nodemailer.createTransport({
host: account.smtpHost,
port: account.smtpPort,
2025-10-02 15:46:23 +09:00
secure: isSecure,
2025-10-01 16:15:53 +09:00
auth: {
user: account.smtpUsername,
2025-10-02 15:46:23 +09:00
pass: decryptedPassword, // 복호화된 비밀번호 사용
2025-10-01 16:15:53 +09:00
},
// 테스트용 타임아웃 (10초)
connectionTimeout: 10000,
2025-10-02 15:46:23 +09:00
greetingTimeout: 10000,
2025-10-01 16:15:53 +09:00
});
// 연결 테스트
2025-10-01 16:15:53 +09:00
await transporter.verify();
console.log('✅ SMTP 연결 테스트 성공');
return { success: true, message: 'SMTP 연결이 성공했습니다.' };
2025-10-01 16:15:53 +09:00
} catch (error) {
const err = error as Error;
console.error('❌ SMTP 연결 테스트 실패:', err.message);
return { success: false, message: `SMTP 연결 실패: ${err.message}` };
2025-10-01 16:15:53 +09:00
}
}
}
export const mailSendSimpleService = new MailSendSimpleService();