/** * 간단한 메일 발송 서비스 (쿼리 제외) * 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; // 템플릿 변수 치환 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 { 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 { // 일반적인 메일 레이아웃 (전체 너비, 그림자 없음) let html = `
`; template.components.forEach((component: any) => { switch (component.type) { case 'text': let content = component.content || ''; if (variables) { content = this.replaceVariables(content, variables); } // 텍스트는 왼쪽 정렬, 적절한 줄간격 html += `
${content}
`; break; case 'button': let buttonText = component.text || 'Button'; if (variables) { buttonText = this.replaceVariables(buttonText, variables); } // 버튼은 왼쪽 정렬 (text-align 제거) html += ``; break; case 'image': // 이미지는 왼쪽 정렬 html += `
${component.alt || ''}
`; break; case 'spacer': html += `
`; break; } }); html += `
`; return html; } /** * 변수 치환 */ private replaceVariables( content: string, variables?: Record ): 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.replace(/\n/g, '
')}

`) .join(''); formattedCustomContent = `
${paragraphs}
`; } else { // 이미 HTML인 경우 구분선만 추가 formattedCustomContent = `
${customContent}
`; } // 또는 태그 앞에 삽입 if (templateHtml.includes('')) { return templateHtml.replace('', `${formattedCustomContent}`); } else if (templateHtml.includes('')) { // 마지막 앞에 삽입 const lastDivIndex = templateHtml.lastIndexOf(''); 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();