456 lines
16 KiB
TypeScript
456 lines
16 KiB
TypeScript
/**
|
|
* 간단한 메일 발송 서비스 (쿼리 제외)
|
|
* Nodemailer를 사용한 직접 발송
|
|
*/
|
|
|
|
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;
|
|
modifiedTemplateComponents?: any[]; // 🎯 프론트엔드에서 수정된 템플릿 컴포넌트
|
|
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 {
|
|
success: boolean;
|
|
messageId?: string;
|
|
accepted?: string[];
|
|
rejected?: string[];
|
|
error?: string;
|
|
}
|
|
|
|
class MailSendSimpleService {
|
|
/**
|
|
* 단일 메일 발송 또는 소규모 발송
|
|
*/
|
|
async sendMail(request: SendMailRequest): Promise<SendMailResult> {
|
|
let htmlContent = ''; // 상위 스코프로 이동
|
|
|
|
try {
|
|
// 1. 계정 조회
|
|
const account = await mailAccountFileService.getAccountById(request.accountId);
|
|
if (!account) {
|
|
throw new Error('메일 계정을 찾을 수 없습니다.');
|
|
}
|
|
|
|
// 2. 계정 활성화 확인
|
|
if (account.status !== 'active') {
|
|
throw new Error('비활성 상태의 계정입니다.');
|
|
}
|
|
|
|
// 3. HTML 생성 (템플릿 + 추가 메시지 병합)
|
|
if (request.templateId) {
|
|
// 템플릿 사용
|
|
const template = await mailTemplateFileService.getTemplateById(request.templateId);
|
|
if (!template) {
|
|
throw new Error('템플릿을 찾을 수 없습니다.');
|
|
}
|
|
|
|
// 🎯 수정된 컴포넌트가 있으면 덮어쓰기
|
|
if (request.modifiedTemplateComponents && request.modifiedTemplateComponents.length > 0) {
|
|
console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length);
|
|
template.components = request.modifiedTemplateComponents;
|
|
}
|
|
|
|
htmlContent = this.renderTemplate(template, request.variables);
|
|
|
|
// 템플릿 + 추가 메시지 병합
|
|
if (request.customHtml && request.customHtml.trim()) {
|
|
htmlContent = this.mergeTemplateAndCustomContent(htmlContent, request.customHtml);
|
|
}
|
|
} else {
|
|
// 직접 작성
|
|
htmlContent = request.customHtml || '';
|
|
}
|
|
|
|
if (!htmlContent) {
|
|
throw new Error('메일 내용이 없습니다.');
|
|
}
|
|
|
|
// 4. 비밀번호 복호화
|
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
|
// 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,
|
|
// });
|
|
|
|
const transporter = nodemailer.createTransport({
|
|
host: account.smtpHost,
|
|
port: account.smtpPort,
|
|
secure: isSecure, // SSL/TLS (포트 465는 자동으로 true)
|
|
auth: {
|
|
user: account.smtpUsername,
|
|
pass: decryptedPassword, // 복호화된 비밀번호 사용
|
|
},
|
|
// 타임아웃 설정 (30초)
|
|
connectionTimeout: 30000,
|
|
greetingTimeout: 30000,
|
|
});
|
|
|
|
console.log('📧 메일 발송 시도 중...');
|
|
|
|
// 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,
|
|
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);
|
|
// 이력 저장 실패는 메일 발송 성공에 영향 주지 않음
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
messageId: info.messageId,
|
|
accepted: info.accepted as string[],
|
|
rejected: info.rejected as string[],
|
|
};
|
|
} catch (error) {
|
|
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,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 템플릿 렌더링 (일반 메일 양식)
|
|
*/
|
|
private renderTemplate(
|
|
template: any,
|
|
variables?: Record<string, string>
|
|
): string {
|
|
// 일반적인 메일 레이아웃 (전체 너비, 그림자 없음)
|
|
let html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
</head>
|
|
<body style="margin: 0; padding: 0; background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #ffffff;">
|
|
<tr>
|
|
<td style="padding: 20px;">
|
|
`;
|
|
|
|
template.components.forEach((component: any) => {
|
|
switch (component.type) {
|
|
case 'text':
|
|
let content = component.content || '';
|
|
if (variables) {
|
|
content = this.replaceVariables(content, variables);
|
|
}
|
|
// 텍스트는 왼쪽 정렬, 적절한 줄간격
|
|
html += `<div style="margin: 0 0 20px 0; color: ${component.color || '#333'}; font-size: ${component.fontSize || '15px'}; line-height: 1.6; text-align: left;">${content}</div>`;
|
|
break;
|
|
case 'button':
|
|
let buttonText = component.text || 'Button';
|
|
if (variables) {
|
|
buttonText = this.replaceVariables(buttonText, variables);
|
|
}
|
|
// 버튼은 왼쪽 정렬 (text-align 제거)
|
|
html += `<div style="margin: 30px 0; text-align: left;">
|
|
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${component.backgroundColor || '#007bff'}; color: ${component.textColor || '#fff'}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
|
</div>`;
|
|
break;
|
|
case 'image':
|
|
// 이미지는 왼쪽 정렬
|
|
html += `<div style="margin: 20px 0; text-align: left;">
|
|
<img src="${component.src}" alt="${component.alt || ''}" style="max-width: 100%; height: auto; display: block; border-radius: 4px;" />
|
|
</div>`;
|
|
break;
|
|
case 'spacer':
|
|
html += `<div style="height: ${component.height || '20px'};"></div>`;
|
|
break;
|
|
}
|
|
});
|
|
|
|
html += `
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
`;
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* 변수 치환
|
|
*/
|
|
private replaceVariables(
|
|
content: string,
|
|
variables?: Record<string, string>
|
|
): string {
|
|
if (!variables) return content;
|
|
|
|
let result = content;
|
|
Object.entries(variables).forEach(([key, value]) => {
|
|
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
|
result = result.replace(regex, value);
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 템플릿과 추가 메시지 병합
|
|
* 템플릿 HTML의 body 태그 끝 부분에 추가 메시지를 삽입
|
|
*/
|
|
private mergeTemplateAndCustomContent(templateHtml: string, customContent: string): string {
|
|
// customContent에 HTML 태그가 없으면 기본 스타일 적용
|
|
let formattedCustomContent = customContent;
|
|
if (!customContent.includes('<')) {
|
|
// 일반 텍스트인 경우 단락으로 변환
|
|
const paragraphs = customContent
|
|
.split('\n\n')
|
|
.filter((p) => p.trim())
|
|
.map((p) => `<p style="margin: 16px 0; line-height: 1.6;">${p.replace(/\n/g, '<br>')}</p>`)
|
|
.join('');
|
|
|
|
formattedCustomContent = `
|
|
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;">
|
|
${paragraphs}
|
|
</div>
|
|
`;
|
|
} else {
|
|
// 이미 HTML인 경우 구분선만 추가
|
|
formattedCustomContent = `
|
|
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;">
|
|
${customContent}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// </body> 또는 </div> 태그 앞에 삽입
|
|
if (templateHtml.includes('</body>')) {
|
|
return templateHtml.replace('</body>', `${formattedCustomContent}</body>`);
|
|
} else if (templateHtml.includes('</div>')) {
|
|
// 마지막 </div> 앞에 삽입
|
|
const lastDivIndex = templateHtml.lastIndexOf('</div>');
|
|
return (
|
|
templateHtml.substring(0, lastDivIndex) +
|
|
formattedCustomContent +
|
|
templateHtml.substring(lastDivIndex)
|
|
);
|
|
} else {
|
|
// 태그가 없으면 단순 결합
|
|
return templateHtml + formattedCustomContent;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SMTP 연결 테스트
|
|
*/
|
|
async testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
|
|
try {
|
|
const account = await mailAccountFileService.getAccountById(accountId);
|
|
if (!account) {
|
|
return { success: false, message: '메일 계정을 찾을 수 없습니다.' };
|
|
}
|
|
|
|
// 비밀번호 복호화
|
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
|
// 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,
|
|
// });
|
|
|
|
const transporter = nodemailer.createTransport({
|
|
host: account.smtpHost,
|
|
port: account.smtpPort,
|
|
secure: isSecure,
|
|
auth: {
|
|
user: account.smtpUsername,
|
|
pass: decryptedPassword, // 복호화된 비밀번호 사용
|
|
},
|
|
// 테스트용 타임아웃 (10초)
|
|
connectionTimeout: 10000,
|
|
greetingTimeout: 10000,
|
|
});
|
|
|
|
// 연결 테스트
|
|
await transporter.verify();
|
|
|
|
console.log('✅ SMTP 연결 테스트 성공');
|
|
return { success: true, message: 'SMTP 연결이 성공했습니다.' };
|
|
} catch (error) {
|
|
const err = error as Error;
|
|
console.error('❌ SMTP 연결 테스트 실패:', err.message);
|
|
return { success: false, message: `SMTP 연결 실패: ${err.message}` };
|
|
}
|
|
}
|
|
}
|
|
|
|
export const mailSendSimpleService = new MailSendSimpleService(); |