Files

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 수정

현재 getActionTargetmaestro_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 비밀번호 미포함