# Phase 11. 이메일 발송 환경 및 자동 발송 ## 목표 연장 승인 / 업그레이드 승인 완료 시 마에스트로에게 안내 이메일을 자동 발송한다. 발송은 DB 커밋 성공 후 트랜잭션 바깥에서 수행하며, 발송 실패가 승인 결과에 영향을 주지 않는다. --- ## 1. 사전 조사 결과 (기존 mouse-typing 코드 확인) ### SMTP 설정 (`mail_setting.php`, `send_jinaju_mail.php`) | 항목 | 값 | |---|---| | SMTP host | `smtp.gmail.com` | | Port | `465` | | Secure | SMTPS (implicit TLS) | | Auth username | `support+chocomae@jinaju.com` | | From name | `초코마에` | | From address | `support+chocomae@jinaju.com` | | Reply-to | `support+chocomae@jinaju.com` | | BCC | `support+chocomae.automail@jinaju.com` | Gmail App Password는 환경변수 `MAIL_SMTP_PASSWORD`로 분리. 코드에 하드코딩하지 않는다. ### 연장 완료 이메일 (`extensionDone`, `mail_contents.php:165`) - **제목**: `[초코마에] {maestro_name} - 마에스트로 계정 유효기간 연장 완료` - **본문 변수**: `{maestro_name}`, `{new_available_date}` (YYYY-MM-DD 형식) - **본문 내용**: - `{maestro_name} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.` - `새로운 유효기간 : {new_available_date}` - 상위 요금제 업그레이드 안내, 6개월 후 자동 삭제 안내 ### 업그레이드 완료 이메일 (2가지 케이스, `mail_contents.php:209,255`) 업그레이드 전 상태(`RegisteredActivateStatus`)에 따라 본문이 다르다. **케이스 A — 체험 → 유료** (`trialMaestroUpgradeDone`, `RegisteredActivateStatus = 1`) - **제목**: `[초코마에] {maestro_name} - 마에스트로 계정의 요금제 업그레이드 완료 알림` - **본문 핵심**: `[ {prev_student_count}명 (0원 : 무료 체험 기간) ] 에서 [ {upgrade_student_count}명 ({upgrade_price}원) ] 으로 업그레이드` - `{prev_price}` 없음 — 체험 기간이므로 0원 고정 문자열 사용 **케이스 B — 유료 → 유료** (`upgradeDone`, `RegisteredActivateStatus = 2`) - **제목**: 케이스 A와 동일 - **본문 핵심**: `[ {prev_student_count}명 ({prev_price}원) ] 에서 [ {upgrade_student_count}명 ({upgrade_price}원) ] 으로 업그레이드` - `{prev_price}` 있음 두 케이스 모두 공통 마무리: `마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.` --- ## 2. 메일 발송 정책 | 항목 | 정책 | |---|---| | **발송 시점** | DB 커밋 완료 후, 트랜잭션 바깥에서 발송 | | **발송 실패 처리** | `logger.error`로 기록, 승인 응답은 200으로 그대로 반환 (롤백 없음) | | **중복 발송 방지** | 승인 API는 이미 처리된 건(`Status ≠ 1`)에 409를 반환하므로 발송 자체가 중복 호출되지 않음 | | **발송 비활성화** | `MAIL_SEND_ENABLED=false`이면 발송을 건너뛰고 info 로그만 남김 | | **민감정보 마스킹** | SMTP 비밀번호는 환경변수로 관리, 로그에는 수신자 주소와 제목만 출력 | --- ## 3. 구현 순서 ### 3-1. 패키지 설치 ```bash pnpm add nodemailer pnpm add -D @types/nodemailer ``` nodemailer는 Node.js 런타임에서만 동작하므로 Next.js API Route(서버 측)에서만 호출한다. ### 3-2. 환경변수 추가 (`.env.local`) ```dotenv MAIL_SMTP_HOST=smtp.gmail.com MAIL_SMTP_PORT=465 MAIL_SMTP_SECURE=true MAIL_SMTP_USER=support+chocomae@jinaju.com MAIL_SMTP_PASSWORD= MAIL_FROM_NAME=초코마에 MAIL_FROM_ADDRESS=support+chocomae@jinaju.com MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com MAIL_SEND_ENABLED=false # local/stage 발송 테스트 전까지 false 유지 ``` ### 3-3. `lib/mail.ts` 구현 (신규) ``` lib/mail.ts ``` **내부 구조** ``` createTransporter() — nodemailer transport 생성 (환경변수 기반) sendMail(options) — 공통 발송 함수 (MAIL_SEND_ENABLED 체크, 로그, BCC) sendExtensionDoneEmail(...) — 연장 완료 이메일 sendUpgradeDoneEmail(...) — 업그레이드 완료 이메일 (체험/유료 케이스 내부 분기) ``` **`sendExtensionDoneEmail` 시그니처** ```typescript sendExtensionDoneEmail( maestroName: string, maestroEmail: string, newAvailableDate: string // YYYY-MM-DD ): Promise ``` **`sendUpgradeDoneEmail` 시그니처** ```typescript sendUpgradeDoneEmail( maestroName: string, maestroEmail: string, registeredActivateStatus: number, // ACTIVATE_STATUSES.TRIAL(1) or ACTIVE(2) registeredAccountType: number, requestedAccountType: number, newAvailableDate: string // YYYY-MM-DD ): Promise ``` 내부에서 `registeredActivateStatus === ACTIVATE_STATUSES.TRIAL`이면 체험 케이스 본문을 사용한다. 계정별 학생 수와 가격은 `getAccountTypePlayerCount()`, `getAccountTypePrice()` (`lib/utils.ts`)를 호출한다. ### 3-4. `lib/extension-requests.ts` 수정 `approveExtensionRequest`의 반환 타입에 이메일 발송에 필요한 정보를 추가한다. `getActionTarget`이 이미 `include: { maestro: true }`를 사용하므로 추가 쿼리 없이 `request.maestro.Email`을 사용할 수 있다. **변경 전** ```typescript export async function approveExtensionRequest( maestroExtensionID: number ): Promise<{ availableActivateDateTime: string }> ``` **변경 후** ```typescript export async function approveExtensionRequest( maestroExtensionID: number ): Promise<{ availableActivateDateTime: string; maestroName: string; maestroEmail: string; }> ``` 트랜잭션 내부 마지막에 반환값에 `maestroName`, `maestroEmail` 추가: ```typescript return { availableActivateDateTime: availableActivateDateTime.toISOString(), maestroName: request.maestro.Name.trim(), maestroEmail: request.maestro.Email.trim(), }; ``` ### 3-5. `lib/upgrade-requests.ts` 수정 현재 `getActionTarget`이 `maestro_upgrade`만 조회하고 `maestro`를 포함하지 않는다. include를 추가해야 한다. **`getActionTarget` 변경** ```typescript const request = await tx.maestro_upgrade.findUnique({ where: { MaestroUpgradeID: maestroUpgradeID }, include: { maestro: { select: { Name: true, Email: true } } }, // 추가 }); ``` **`approveUpgradeRequest` 반환 타입 변경** ```typescript export async function approveUpgradeRequest( maestroUpgradeID: number ): Promise<{ availableActivateDateTime: string; maestroName: string; maestroEmail: string; registeredActivateStatus: number; registeredAccountType: number; requestedAccountType: number; }> ``` 트랜잭션 반환값에 추가: ```typescript return { availableActivateDateTime: availableActivateDateTime.toISOString(), maestroName: request.maestro.Name.trim(), maestroEmail: request.maestro.Email.trim(), registeredActivateStatus: request.RegisteredActivateStatus, registeredAccountType: request.RegisteredAccountType, requestedAccountType: request.RequestedAccountType, }; ``` ### 3-6. API route 수정 **`app/api/extension-requests/[id]/route.ts`** ```typescript if (body.data.action === "approve") { const result = await approveExtensionRequest(maestroExtensionID); logger.info(CTX, "approved", { id: maestroExtensionID, duration: Date.now() - t0 }); // 메일 발송 (실패해도 응답에 영향 없음) void sendExtensionDoneEmail( result.maestroName, result.maestroEmail, formatDate(result.availableActivateDateTime), ); return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime }); } ``` **`app/api/upgrade-requests/[id]/route.ts`** ```typescript if (body.data.action === "approve") { const result = await approveUpgradeRequest(maestroUpgradeID); logger.info(CTX, "approved", { id: maestroUpgradeID, duration: Date.now() - t0 }); void sendUpgradeDoneEmail( result.maestroName, result.maestroEmail, result.registeredActivateStatus, result.registeredAccountType, result.requestedAccountType, formatDate(result.availableActivateDateTime), ); return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime }); } ``` `void`를 붙여 메일 발송을 백그라운드로 실행하되, `sendMail` 내부에서 `await`로 실제 발송을 기다린다. 메일 발송 실패는 `logger.error`로만 처리한다. --- ## 4. 이메일 본문 (참조용) ### 연장 완료 **제목**: `[초코마에] {maestroName} - 마에스트로 계정 유효기간 연장 완료` ```html 안녕하세요.
[초코마에] 마에스트로 계정의 유효기간 연장을 신청해 주셔서 감사합니다.

{maestroName} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.
* 새로운 유효기간 : {newAvailableDate}

* 상위 요금제로 업그레이드 하시면, 유효기간이 업그레이드한 날짜로부터 1년으로 갱신됩니다.
* 유효기간이 끝나면, 비활성화된 계정과 그 안에 있는 모든 정보가 6개월 후에 자동 삭제됩니다.

감사합니다. ``` ### 업그레이드 완료 — 체험 → 유료 **제목**: `[초코마에] {maestroName} - 마에스트로 계정의 요금제 업그레이드 완료 알림` ```html 안녕하세요.
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.

{maestroName} - 마에스트로 계정의 요금제가 [ {prevStudentCount}명 (0원 : 무료 체험 기간) ] 에서 [ {upgradeStudentCount}명 ({upgradePrice}원) ] 으로 업그레이드 되었습니다.

* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.


등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)

감사합니다. ``` ### 업그레이드 완료 — 유료 → 유료 **제목**: 위와 동일 ```html 안녕하세요.
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.

{maestroName} - 마에스트로 계정의 요금제가 [ {prevStudentCount}명 ({prevPrice}원) ] 에서 [ {upgradeStudentCount}명 ({upgradePrice}원) ] 으로 업그레이드 되었습니다.

* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.


등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)

감사합니다. ``` --- ## 5. 검증 절차 | 항목 | 방법 | 기대 결과 | |---|---|---| | `MAIL_SEND_ENABLED=false` 상태에서 승인 | 연장/업그레이드 승인 API 호출 | 승인 성공, 메일 발송 없음, `info: send skipped` 로그 출력 | | `MAIL_SEND_ENABLED=true` — 연장 승인 이메일 | 연장 승인 후 수신 메일함 확인 | 제목/본문/수신자/BCC 형식 일치 | | `MAIL_SEND_ENABLED=true` — 업그레이드 승인 이메일 (체험) | 체험 마에스트로 업그레이드 승인 | `0원 : 무료 체험 기간` 문구 포함, 제목/본문 일치 | | `MAIL_SEND_ENABLED=true` — 업그레이드 승인 이메일 (유료) | 유료 마에스트로 업그레이드 승인 | `{prev_price}원` 포함, 제목/본문 일치 | | SMTP 오류 시 응답 | SMTP 연결 실패 환경에서 승인 | 승인 API 200 응답, `error: send failed` 로그 | | 로그 민감정보 확인 | 발송 로그 출력 | 수신자 주소, 제목만 출력. SMTP 비밀번호 미포함 |