12 KiB
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. 패키지 설치
pnpm add nodemailer
pnpm add -D @types/nodemailer
nodemailer는 Node.js 런타임에서만 동작하므로 Next.js API Route(서버 측)에서만 호출한다.
3-2. 환경변수 추가 (.env.local)
MAIL_SMTP_HOST=smtp.gmail.com
MAIL_SMTP_PORT=465
MAIL_SMTP_SECURE=true
MAIL_SMTP_USER=support+chocomae@jinaju.com
MAIL_SMTP_PASSWORD=<gmail_app_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 시그니처
sendExtensionDoneEmail(
maestroName: string,
maestroEmail: string,
newAvailableDate: string // YYYY-MM-DD
): Promise<boolean>
sendUpgradeDoneEmail 시그니처
sendUpgradeDoneEmail(
maestroName: string,
maestroEmail: string,
registeredActivateStatus: number, // ACTIVATE_STATUSES.TRIAL(1) or ACTIVE(2)
registeredAccountType: number,
requestedAccountType: number,
newAvailableDate: string // YYYY-MM-DD
): Promise<boolean>
내부에서 registeredActivateStatus === ACTIVATE_STATUSES.TRIAL이면 체험 케이스 본문을 사용한다.
계정별 학생 수와 가격은 getAccountTypePlayerCount(), getAccountTypePrice() (lib/utils.ts)를 호출한다.
3-4. lib/extension-requests.ts 수정
approveExtensionRequest의 반환 타입에 이메일 발송에 필요한 정보를 추가한다.
getActionTarget이 이미 include: { maestro: true }를 사용하므로 추가 쿼리 없이 request.maestro.Email을 사용할 수 있다.
변경 전
export async function approveExtensionRequest(
maestroExtensionID: number
): Promise<{ availableActivateDateTime: string }>
변경 후
export async function approveExtensionRequest(
maestroExtensionID: number
): Promise<{
availableActivateDateTime: string;
maestroName: string;
maestroEmail: string;
}>
트랜잭션 내부 마지막에 반환값에 maestroName, maestroEmail 추가:
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 변경
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
include: { maestro: { select: { Name: true, Email: true } } }, // 추가
});
approveUpgradeRequest 반환 타입 변경
export async function approveUpgradeRequest(
maestroUpgradeID: number
): Promise<{
availableActivateDateTime: string;
maestroName: string;
maestroEmail: string;
registeredActivateStatus: number;
registeredAccountType: number;
requestedAccountType: number;
}>
트랜잭션 반환값에 추가:
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
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
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} - 마에스트로 계정 유효기간 연장 완료
안녕하세요.<br/>
[초코마에] 마에스트로 계정의 유효기간 연장을 신청해 주셔서 감사합니다.<br/>
<br/>
{maestroName} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.<br/>
* 새로운 유효기간 : {newAvailableDate}<br/>
<br/>
* 상위 요금제로 업그레이드 하시면, 유효기간이 업그레이드한 날짜로부터 1년으로 갱신됩니다.<br/>
* 유효기간이 끝나면, 비활성화된 계정과 그 안에 있는 모든 정보가 6개월 후에 자동 삭제됩니다.<br/>
<br/>
감사합니다.
업그레이드 완료 — 체험 → 유료
제목: [초코마에] {maestroName} - 마에스트로 계정의 요금제 업그레이드 완료 알림
안녕하세요.<br/>
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
<br/>
{maestroName} - 마에스트로 계정의 요금제가 [ {prevStudentCount}명 (0원 : 무료 체험 기간) ] 에서 [ {upgradeStudentCount}명 ({upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
<br/>
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
<br/>
<br/>
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
<br/>
감사합니다.
업그레이드 완료 — 유료 → 유료
제목: 위와 동일
안녕하세요.<br/>
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
<br/>
{maestroName} - 마에스트로 계정의 요금제가 [ {prevStudentCount}명 ({prevPrice}원) ] 에서 [ {upgradeStudentCount}명 ({upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
<br/>
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
<br/>
<br/>
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
<br/>
감사합니다.
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 비밀번호 미포함 |