/** * 간단한 메일 발송 서비스 (쿼리 제외) * 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; 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 생성 (템플릿 또는 커스텀) htmlContent = request.customHtml || ''; 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('메일 내용이 없습니다.'); } // 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); } html += `
${buttonText}
`; 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; } /** * 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();