메일관리 기능 구현 완료
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "1e492bb1-d069-4242-8cbf-9829b8f6c7e6",
|
||||
"sentAt": "2025-10-13T01:08:34.764Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "제목 없음",
|
||||
"htmlContent": "\n<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"margin: 0; padding: 0; background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\">\n <table role=\"presentation\" style=\"width: 100%; border-collapse: collapse; background-color: #ffffff;\">\n <tr>\n <td style=\"padding: 20px;\">\n<div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹㄴㅇㄹ</p></div><div style=\"margin: 30px 0; text-align: left;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 14px 28px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;\">ㄴㅇㄹ버튼</a>\n </div><div style=\"margin: 20px 0; text-align: left;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto; display: block; border-radius: 4px;\" />\n </div><div style=\"height: 20;\"></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div>\n </td>\n </tr>\n </table>\n\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹ</p>\r\n </div>\r\n \n </div>\n </body>\n</html>\n",
|
||||
"templateId": "template-1760315158387",
|
||||
"templateName": "테스트2",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "스크린샷 2025-10-13 오전 10.00.06.png",
|
||||
"originalName": "스크린샷 2025-10-13 오전 10.00.06.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1760317712416-622369845.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<f03bea59-9a77-b454-845e-7ad2a070bade@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "9eab902e-f77b-424f-ada4-0ea8709b36bf",
|
||||
"sentAt": "2025-10-13T00:53:55.193Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "제목 없음",
|
||||
"htmlContent": "<div style=\"max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;\"><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><div style=\"text-align: center; margin: 24px 0;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 12px 24px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 4px;\">버튼</a>\n </div><div style=\"text-align: center; margin: 16px 0;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto;\" />\n </div><div style=\"height: 20;\"></div><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p>\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어덯게 나오는지 봅시다 추가메시지 영역이빈다.</p>\r\n </div>\r\n \n </div>\n </div>",
|
||||
"templateId": "template-1760315158387",
|
||||
"templateName": "테스트2",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "한글.txt",
|
||||
"originalName": "한글.txt",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1760316833254-789302611.txt",
|
||||
"mimetype": "text/plain"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<3d0bef10-2e58-fd63-b175-c1f499af0102@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "fc26aba3-6b6e-47ba-91e8-609ae25e0e7d",
|
||||
"sentAt": "2025-10-13T00:21:51.799Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "test용입니다.",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"templateId": "template-1759302346758",
|
||||
"templateName": "test",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "웨이스-임직원-프로파일-이희진.key",
|
||||
"originalName": "웨이스-임직원-프로파일-이희진.key",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1760314910154-84512253.key",
|
||||
"mimetype": "application/x-iwork-keynote-sffkey"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<c84bcecc-2e8f-4a32-1b7f-44a91b195b2d@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
15
backend-node/nodemon.json
Normal file
15
backend-node/nodemon.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"watch": ["src"],
|
||||
"ignore": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"data/**",
|
||||
"uploads/**",
|
||||
"logs/**",
|
||||
"*.log"
|
||||
],
|
||||
"ext": "ts,json",
|
||||
"exec": "ts-node src/app.ts",
|
||||
"delay": 2000
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ app.use(
|
||||
})
|
||||
);
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
|
||||
|
||||
// 정적 파일 서빙 (업로드된 파일들)
|
||||
app.use(
|
||||
@@ -165,6 +165,17 @@ app.use("/api/layouts", layoutRoutes);
|
||||
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
||||
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
||||
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
||||
// 메일 수신 라우트 디버깅 - 모든 요청 로깅
|
||||
app.use("/api/mail/receive", (req, res, next) => {
|
||||
console.log(`\n🔍 [MAIL RECEIVE REQUEST]`);
|
||||
console.log(` Method: ${req.method}`);
|
||||
console.log(` URL: ${req.originalUrl}`);
|
||||
console.log(` Path: ${req.path}`);
|
||||
console.log(` Base URL: ${req.baseUrl}`);
|
||||
console.log(` Params: ${JSON.stringify(req.params)}`);
|
||||
console.log(` Query: ${JSON.stringify(req.query)}`);
|
||||
next();
|
||||
});
|
||||
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
||||
app.use("/api/mail/sent", mailSentHistoryRoutes); // 발송 이력
|
||||
app.use("/api/screen", screenStandardRoutes);
|
||||
|
||||
@@ -18,6 +18,12 @@ export class MailReceiveBasicController {
|
||||
*/
|
||||
async getMailList(req: Request, res: Response) {
|
||||
try {
|
||||
console.log('📬 메일 목록 조회 요청:', {
|
||||
params: req.params,
|
||||
path: req.path,
|
||||
originalUrl: req.originalUrl
|
||||
});
|
||||
|
||||
const { accountId } = req.params;
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
|
||||
@@ -43,6 +49,12 @@ export class MailReceiveBasicController {
|
||||
*/
|
||||
async getMailDetail(req: Request, res: Response) {
|
||||
try {
|
||||
console.log('🔍 메일 상세 조회 요청:', {
|
||||
params: req.params,
|
||||
path: req.path,
|
||||
originalUrl: req.originalUrl
|
||||
});
|
||||
|
||||
const { accountId, seqno } = req.params;
|
||||
const seqnoNumber = parseInt(seqno, 10);
|
||||
|
||||
@@ -109,29 +121,39 @@ export class MailReceiveBasicController {
|
||||
*/
|
||||
async downloadAttachment(req: Request, res: Response) {
|
||||
try {
|
||||
console.log('📎🎯 컨트롤러 downloadAttachment 진입');
|
||||
const { accountId, seqno, index } = req.params;
|
||||
console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`);
|
||||
|
||||
const seqnoNumber = parseInt(seqno, 10);
|
||||
const indexNumber = parseInt(index, 10);
|
||||
|
||||
if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
|
||||
console.log('❌ 유효하지 않은 파라미터');
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 파라미터입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
console.log('📎 서비스 호출 시작...');
|
||||
const result = await this.mailReceiveService.downloadAttachment(
|
||||
accountId,
|
||||
seqnoNumber,
|
||||
indexNumber
|
||||
);
|
||||
console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`);
|
||||
|
||||
if (!result) {
|
||||
console.log('❌ 첨부파일을 찾을 수 없음');
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '첨부파일을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📎 파일 다운로드 시작: ${result.filename}`);
|
||||
console.log(`📎 파일 경로: ${result.filePath}`);
|
||||
|
||||
// 파일 다운로드
|
||||
res.download(result.filePath, result.filename, (err) => {
|
||||
@@ -173,5 +195,27 @@ export class MailReceiveBasicController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mail/receive/today-count
|
||||
* 오늘 수신 메일 수 조회
|
||||
*/
|
||||
async getTodayReceivedCount(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId } = req.query;
|
||||
const count = await this.mailReceiveService.getTodayReceivedCount(accountId as string);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { count }
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('오늘 수신 메일 수 조회 실패:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '오늘 수신 메일 수 조회에 실패했습니다.'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ export class MailSendSimpleController {
|
||||
// FormData에서 JSON 문자열 파싱
|
||||
const accountId = req.body.accountId;
|
||||
const templateId = req.body.templateId;
|
||||
const modifiedTemplateComponents = req.body.modifiedTemplateComponents
|
||||
? JSON.parse(req.body.modifiedTemplateComponents)
|
||||
: undefined; // 🎯 수정된 템플릿 컴포넌트
|
||||
const to = req.body.to ? JSON.parse(req.body.to) : [];
|
||||
const cc = req.body.cc ? JSON.parse(req.body.cc) : undefined;
|
||||
const bcc = req.body.bcc ? JSON.parse(req.body.bcc) : undefined;
|
||||
@@ -90,6 +93,7 @@ export class MailSendSimpleController {
|
||||
const result = await mailSendSimpleService.sendMail({
|
||||
accountId,
|
||||
templateId,
|
||||
modifiedTemplateComponents, // 🎯 수정된 템플릿 컴포넌트 전달
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
|
||||
@@ -12,20 +12,29 @@ const router = express.Router();
|
||||
router.use(authenticateToken);
|
||||
const controller = new MailReceiveBasicController();
|
||||
|
||||
// 메일 목록 조회
|
||||
router.get('/:accountId', (req, res) => controller.getMailList(req, res));
|
||||
// 오늘 수신 메일 수 조회 (통계) - 가장 먼저 정의 (가장 구체적)
|
||||
router.get('/today-count', (req, res) => controller.getTodayReceivedCount(req, res));
|
||||
|
||||
// 메일 상세 조회
|
||||
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
|
||||
// 첨부파일 다운로드 - 매우 구체적인 경로
|
||||
router.get('/:accountId/:seqno/attachment/:index', (req, res) => {
|
||||
console.log(`📎 첨부파일 라우트 핸들러 진입!`);
|
||||
console.log(` accountId: ${req.params.accountId}`);
|
||||
console.log(` seqno: ${req.params.seqno}`);
|
||||
console.log(` index: ${req.params.index}`);
|
||||
controller.downloadAttachment(req, res);
|
||||
});
|
||||
|
||||
// 첨부파일 다운로드 (상세 조회보다 먼저 정의해야 함)
|
||||
router.get('/:accountId/:seqno/attachment/:index', (req, res) => controller.downloadAttachment(req, res));
|
||||
|
||||
// 메일 읽음 표시
|
||||
// 메일 읽음 표시 - 구체적인 경로
|
||||
router.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res));
|
||||
|
||||
// IMAP 연결 테스트
|
||||
// 메일 상세 조회 - /:accountId보다 먼저 정의해야 함
|
||||
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
|
||||
|
||||
// IMAP 연결 테스트 - /:accountId보다 먼저 정의해야 함
|
||||
router.post('/:accountId/test-imap', (req, res) => controller.testImapConnection(req, res));
|
||||
|
||||
// 메일 목록 조회 - 가장 마지막에 정의 (가장 일반적)
|
||||
router.get('/:accountId', (req, res) => controller.getMailList(req, res));
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ export class MailReceiveBasicService {
|
||||
tls: true,
|
||||
};
|
||||
|
||||
console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
|
||||
// console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
@@ -117,26 +117,26 @@ export class MailReceiveBasicService {
|
||||
|
||||
// 30초 타임아웃 설정
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('❌ IMAP 연결 타임아웃 (30초)');
|
||||
// console.error('❌ IMAP 연결 타임아웃 (30초)');
|
||||
imap.end();
|
||||
reject(new Error('IMAP 연결 타임아웃'));
|
||||
}, 30000);
|
||||
|
||||
imap.once('ready', () => {
|
||||
console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
|
||||
// console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
|
||||
clearTimeout(timeout);
|
||||
|
||||
imap.openBox('INBOX', true, (err: any, box: any) => {
|
||||
if (err) {
|
||||
console.error('❌ INBOX 열기 실패:', err);
|
||||
// console.error('❌ INBOX 열기 실패:', err);
|
||||
imap.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
|
||||
// console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
|
||||
const totalMessages = box.messages.total;
|
||||
if (totalMessages === 0) {
|
||||
console.log('📭 메일함이 비어있습니다');
|
||||
// console.log('📭 메일함이 비어있습니다');
|
||||
imap.end();
|
||||
return resolve([]);
|
||||
}
|
||||
@@ -145,19 +145,19 @@ export class MailReceiveBasicService {
|
||||
const start = Math.max(1, totalMessages - limit + 1);
|
||||
const end = totalMessages;
|
||||
|
||||
console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
|
||||
// console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
|
||||
const fetch = imap.seq.fetch(`${start}:${end}`, {
|
||||
bodies: ['HEADER', 'TEXT'],
|
||||
struct: true,
|
||||
});
|
||||
|
||||
console.log(`📦 fetch 객체 생성 완료`);
|
||||
// console.log(`📦 fetch 객체 생성 완료`);
|
||||
|
||||
let processedCount = 0;
|
||||
const totalToProcess = end - start + 1;
|
||||
|
||||
fetch.on('message', (msg: any, seqno: any) => {
|
||||
console.log(`📬 메일 #${seqno} 처리 시작`);
|
||||
// console.log(`📬 메일 #${seqno} 처리 시작`);
|
||||
let header: string = '';
|
||||
let body: string = '';
|
||||
let attributes: any = null;
|
||||
@@ -207,10 +207,10 @@ export class MailReceiveBasicService {
|
||||
};
|
||||
|
||||
mails.push(mail);
|
||||
console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
|
||||
// console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
|
||||
processedCount++;
|
||||
} catch (parseError) {
|
||||
console.error(`메일 #${seqno} 파싱 오류:`, parseError);
|
||||
// console.error(`메일 #${seqno} 파싱 오류:`, parseError);
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
@@ -219,24 +219,24 @@ export class MailReceiveBasicService {
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
console.error('❌ 메일 fetch 에러:', fetchErr);
|
||||
// console.error('❌ 메일 fetch 에러:', fetchErr);
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
|
||||
// console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
|
||||
|
||||
// 모든 메일 처리가 완료될 때까지 대기
|
||||
const checkComplete = setInterval(() => {
|
||||
console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
|
||||
// console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
|
||||
if (processedCount >= totalToProcess) {
|
||||
clearInterval(checkComplete);
|
||||
console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
|
||||
// console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
|
||||
imap.end();
|
||||
// 최신 메일이 위로 오도록 정렬
|
||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
console.log(`📤 메일 목록 반환: ${mails.length}개`);
|
||||
// console.log(`📤 메일 목록 반환: ${mails.length}개`);
|
||||
resolve(mails);
|
||||
}
|
||||
}, 100);
|
||||
@@ -244,7 +244,7 @@ export class MailReceiveBasicService {
|
||||
// 최대 10초 대기
|
||||
setTimeout(() => {
|
||||
clearInterval(checkComplete);
|
||||
console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
|
||||
// console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
|
||||
imap.end();
|
||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
resolve(mails);
|
||||
@@ -254,16 +254,16 @@ export class MailReceiveBasicService {
|
||||
});
|
||||
|
||||
imap.once('error', (imapErr: any) => {
|
||||
console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr);
|
||||
// console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr);
|
||||
clearTimeout(timeout);
|
||||
reject(imapErr);
|
||||
});
|
||||
|
||||
imap.once('end', () => {
|
||||
console.log('🔌 IMAP 연결 종료');
|
||||
// console.log('🔌 IMAP 연결 종료');
|
||||
});
|
||||
|
||||
console.log('🔗 IMAP.connect() 호출...');
|
||||
// console.log('🔗 IMAP.connect() 호출...');
|
||||
imap.connect();
|
||||
});
|
||||
}
|
||||
@@ -311,22 +311,36 @@ export class MailReceiveBasicService {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
console.log(`📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`);
|
||||
|
||||
if (seqno > box.messages.total || seqno < 1) {
|
||||
console.error(`❌ 유효하지 않은 seqno: ${seqno} (메일 총 개수: ${box.messages.total})`);
|
||||
imap.end();
|
||||
return resolve(null);
|
||||
}
|
||||
|
||||
const fetch = imap.seq.fetch(`${seqno}:${seqno}`, {
|
||||
bodies: '',
|
||||
struct: true,
|
||||
});
|
||||
|
||||
let mailDetail: MailDetail | null = null;
|
||||
let parsingComplete = false;
|
||||
|
||||
fetch.on('message', (msg: any, seqnum: any) => {
|
||||
console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
||||
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk: any) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
});
|
||||
stream.once('end', async () => {
|
||||
console.log(`✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`);
|
||||
try {
|
||||
const parsed = await simpleParser(buffer);
|
||||
console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`);
|
||||
|
||||
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
|
||||
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
|
||||
@@ -353,21 +367,48 @@ export class MailReceiveBasicService {
|
||||
size: att.size || 0,
|
||||
})),
|
||||
};
|
||||
parsingComplete = true;
|
||||
} catch (parseError) {
|
||||
console.error('메일 파싱 오류:', parseError);
|
||||
parsingComplete = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// msg 전체가 처리되었을 때 이벤트
|
||||
msg.once('end', () => {
|
||||
console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
console.error(`❌ Fetch 에러:`, fetchErr);
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
imap.end();
|
||||
resolve(mailDetail);
|
||||
console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
|
||||
|
||||
// 비동기 파싱이 완료될 때까지 대기
|
||||
const waitForParsing = setInterval(() => {
|
||||
if (parsingComplete) {
|
||||
clearInterval(waitForParsing);
|
||||
console.log(`✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? '존재함' : 'null'}`);
|
||||
imap.end();
|
||||
resolve(mailDetail);
|
||||
}
|
||||
}, 10); // 10ms마다 체크
|
||||
|
||||
// 타임아웃 설정 (10초)
|
||||
setTimeout(() => {
|
||||
if (!parsingComplete) {
|
||||
clearInterval(waitForParsing);
|
||||
console.error('❌ 파싱 타임아웃');
|
||||
imap.end();
|
||||
resolve(mailDetail); // 타임아웃 시에도 현재 상태 반환
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -492,6 +533,43 @@ export class MailReceiveBasicService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 수신한 메일 수 조회 (통계용)
|
||||
*/
|
||||
async getTodayReceivedCount(accountId?: string): Promise<number> {
|
||||
try {
|
||||
const accounts = accountId
|
||||
? [await mailAccountFileService.getAccountById(accountId)]
|
||||
: await mailAccountFileService.getAllAccounts();
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let totalCount = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
if (!account) continue;
|
||||
|
||||
try {
|
||||
const mails = await this.fetchMailList(account.id, 100);
|
||||
const todayMails = mails.filter(mail => {
|
||||
const mailDate = new Date(mail.date);
|
||||
return mailDate >= today;
|
||||
});
|
||||
totalCount += todayMails.length;
|
||||
} catch (error) {
|
||||
// 개별 계정 오류는 무시하고 계속 진행
|
||||
console.error(`계정 ${account.id} 메일 조회 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return totalCount;
|
||||
} catch (error) {
|
||||
console.error('오늘 수신 메일 수 조회 실패:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 다운로드
|
||||
*/
|
||||
@@ -533,19 +611,26 @@ export class MailReceiveBasicService {
|
||||
});
|
||||
|
||||
let attachmentResult: { filePath: string; filename: string; contentType: string } | null = null;
|
||||
let parsingComplete = false;
|
||||
|
||||
fetch.on('message', (msg: any, seqnum: any) => {
|
||||
console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
||||
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
console.log(`📎 메일 본문 스트림 시작`);
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk: any) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
});
|
||||
stream.once('end', async () => {
|
||||
console.log(`📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`);
|
||||
try {
|
||||
const parsed = await simpleParser(buffer);
|
||||
console.log(`📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`);
|
||||
|
||||
if (parsed.attachments && parsed.attachments[attachmentIndex]) {
|
||||
const attachment = parsed.attachments[attachmentIndex];
|
||||
console.log(`📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`);
|
||||
|
||||
// 안전한 파일명 생성
|
||||
const safeFilename = this.sanitizeFilename(
|
||||
@@ -557,28 +642,51 @@ export class MailReceiveBasicService {
|
||||
|
||||
// 파일 저장
|
||||
await fs.writeFile(filePath, attachment.content);
|
||||
console.log(`📎 파일 저장 완료: ${filePath}`);
|
||||
|
||||
attachmentResult = {
|
||||
filePath,
|
||||
filename: attachment.filename || 'unnamed',
|
||||
contentType: attachment.contentType || 'application/octet-stream',
|
||||
};
|
||||
parsingComplete = true;
|
||||
} else {
|
||||
console.log(`❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`);
|
||||
parsingComplete = true;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('첨부파일 파싱 오류:', parseError);
|
||||
parsingComplete = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
console.error('❌ fetch 오류:', fetchErr);
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
imap.end();
|
||||
resolve(attachmentResult);
|
||||
console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
|
||||
|
||||
// 파싱 완료를 기다림 (최대 5초)
|
||||
const checkComplete = setInterval(() => {
|
||||
if (parsingComplete) {
|
||||
console.log(`✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? '있음' : '없음'}`);
|
||||
clearInterval(checkComplete);
|
||||
imap.end();
|
||||
resolve(attachmentResult);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(checkComplete);
|
||||
console.log(`⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? '있음' : '없음'}`);
|
||||
imap.end();
|
||||
resolve(attachmentResult);
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { mailSentHistoryService } from './mailSentHistoryService';
|
||||
export interface SendMailRequest {
|
||||
accountId: string;
|
||||
templateId?: string;
|
||||
modifiedTemplateComponents?: any[]; // 🎯 프론트엔드에서 수정된 템플릿 컴포넌트
|
||||
to: string[]; // 받는 사람
|
||||
cc?: string[]; // 참조 (Carbon Copy)
|
||||
bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
|
||||
@@ -52,15 +53,29 @@ class MailSendSimpleService {
|
||||
throw new Error('비활성 상태의 계정입니다.');
|
||||
}
|
||||
|
||||
// 3. HTML 생성 (템플릿 또는 커스텀)
|
||||
htmlContent = request.customHtml || '';
|
||||
|
||||
if (!htmlContent && request.templateId) {
|
||||
// 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) {
|
||||
@@ -261,13 +276,25 @@ class MailSendSimpleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 렌더링 (간단 버전)
|
||||
* 템플릿 렌더링 (일반 메일 양식)
|
||||
*/
|
||||
private renderTemplate(
|
||||
template: any,
|
||||
variables?: Record<string, string>
|
||||
): string {
|
||||
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
|
||||
// 일반적인 메일 레이아웃 (전체 너비, 그림자 없음)
|
||||
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) {
|
||||
@@ -276,20 +303,23 @@ class MailSendSimpleService {
|
||||
if (variables) {
|
||||
content = this.replaceVariables(content, variables);
|
||||
}
|
||||
html += `<p style="margin: 16px 0; color: ${component.color || '#333'}; font-size: ${component.fontSize || '14px'};">${content}</p>`;
|
||||
// 텍스트는 왼쪽 정렬, 적절한 줄간격
|
||||
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);
|
||||
}
|
||||
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>
|
||||
// 버튼은 왼쪽 정렬 (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="text-align: center; margin: 16px 0;">
|
||||
<img src="${component.src}" alt="${component.alt || ''}" style="max-width: 100%; height: auto;" />
|
||||
// 이미지는 왼쪽 정렬
|
||||
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':
|
||||
@@ -298,7 +328,13 @@ class MailSendSimpleService {
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
html += `
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -320,6 +356,52 @@ class MailSendSimpleService {
|
||||
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 연결 테스트
|
||||
*/
|
||||
|
||||
@@ -141,26 +141,35 @@ class MailTemplateFileService {
|
||||
id: string,
|
||||
data: Partial<Omit<MailTemplate, 'id' | 'createdAt'>>
|
||||
): Promise<MailTemplate | null> {
|
||||
const existing = await this.getTemplateById(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
try {
|
||||
const existing = await this.getTemplateById(id);
|
||||
if (!existing) {
|
||||
// console.error(`❌ 템플릿을 찾을 수 없음: ${id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated: MailTemplate = {
|
||||
...existing,
|
||||
...data,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`);
|
||||
|
||||
await fs.writeFile(
|
||||
this.getTemplatePath(id),
|
||||
JSON.stringify(updated, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// console.log(`✅ 템플릿 저장 성공: ${id}`);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
// console.error(`❌ 템플릿 저장 실패: ${id}`, error);
|
||||
throw error; // 에러를 컨트롤러로 전달
|
||||
}
|
||||
|
||||
const updated: MailTemplate = {
|
||||
...existing,
|
||||
...data,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
this.getTemplatePath(id),
|
||||
JSON.stringify(updated, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user