diff --git a/.env.example b/.env.example index 69d2536..a46e457 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,7 @@ ADMIN_ACCOUNT_ID=replace-with-private-admin-id ADMIN_ACCOUNT_PASSWORD=replace-with-private-admin-password ADMIN_ACCOUNT_EMAIL=admin@example.com ADMIN_ACCOUNT_NICKNAME=Planner Admin +APP_BASE_URL=https://planner.sori.studio +RESEND_API_KEY=replace-with-private-resend-api-key +MAIL_FROM_EMAIL=planner@sori.studio +MAIL_FROM_NAME=10 Minute Planner diff --git a/HANDOFF.md b/HANDOFF.md index 1378d9e..ca97e32 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -221,6 +221,7 @@ - STATS의 `RANGE FLOW`는 항목 수에 따라 막대 폭과 간격을 조정한다. 1주일 내외는 넓은 막대로 카드 폭을 채우고, 1달 내외는 좁은 막대와 요약 라벨로 한 화면에서 흐름을 보며, 세부 날짜와 집중 시간은 막대 hover 팝업으로 확인한다. - 왼쪽 사이드바의 인쇄 영역은 `PRINT` 버튼 하나로 줄이고, 클릭 시 모달에서 시작일/종료일과 `1페이지씩` 또는 `2페이지씩` 출력 방식을 선택한다. 인쇄 전용 렌더링은 선택 기간의 날짜를 순서대로 여러 장 생성하며, 2페이지씩 출력에서 홀수 날짜가 남으면 오른쪽은 빈 페이지 프레임으로 둔다. - 회원가입과 프로필 수정 시 이메일뿐 아니라 닉네임 중복도 서버에서 409로 막는다. 비밀번호 재설정 API는 로그인 모달의 `비밀번호 찾기` 흐름과 연결되어 있고, `/reset-password?token=...` URL로 들어오면 새 비밀번호 설정 모드가 열린다. 실제 메일 발송 전까지는 백엔드 응답의 `resetPreviewUrl`을 개발용 링크로 표시한다. +- Resend 메일러가 추가되었다. `RESEND_API_KEY`, `MAIL_FROM_EMAIL`, `MAIL_FROM_NAME`, `APP_BASE_URL` 환경변수를 설정하면 이메일 인증과 비밀번호 재설정 메일을 실제로 발송한다. API 키는 저장소에 커밋하지 말고 루트 `.env`/`.env.dev`에만 넣는다. - `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다. - 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. diff --git a/backend/src/config.js b/backend/src/config.js index 1c51c87..09867d5 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -14,6 +14,9 @@ const envSchema = z.object({ CORS_ORIGIN: z.string().default('http://localhost:5173'), SESSION_TTL_DAYS: z.coerce.number().default(30), APP_BASE_URL: z.string().default('http://localhost:5173'), + RESEND_API_KEY: z.string().optional(), + MAIL_FROM_EMAIL: z.string().email().default('planner@sori.studio'), + MAIL_FROM_NAME: z.string().default('10 Minute Planner'), ADMIN_ACCOUNT_ID: z.string().min(1), ADMIN_ACCOUNT_PASSWORD: z.string().min(12), ADMIN_ACCOUNT_EMAIL: z.string().email(), diff --git a/backend/src/lib/mailer.js b/backend/src/lib/mailer.js new file mode 100644 index 0000000..8cab752 --- /dev/null +++ b/backend/src/lib/mailer.js @@ -0,0 +1,89 @@ +import { env } from '../config.js' + +const RESEND_API_URL = 'https://api.resend.com/emails' + +function getFromAddress() { + return `${env.MAIL_FROM_NAME} <${env.MAIL_FROM_EMAIL}>` +} + +async function sendWithResend({ to, subject, html, text }) { + if (!env.RESEND_API_KEY) { + return { + skipped: true, + reason: 'RESEND_API_KEY is not configured.', + } + } + + const response = await fetch(RESEND_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${env.RESEND_API_KEY}`, + 'Content-Type': 'application/json', + 'User-Agent': 'ten-minute-planner/1.0', + }, + body: JSON.stringify({ + from: getFromAddress(), + to, + subject, + html, + text, + }), + }) + + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + throw new Error(data.message || data.error?.message || 'Resend 메일 발송에 실패했습니다.') + } + + return { + skipped: false, + id: data.id, + } +} + +function buildLinkHtml({ title, description, linkUrl, buttonLabel }) { + return ` +
+

10 Minute Planner

+

${title}

+

${description}

+

+ + ${buttonLabel} + +

+

버튼이 열리지 않으면 아래 링크를 복사해서 브라우저에 붙여넣어 주세요.

+

${linkUrl}

+

이 링크는 30분 동안 사용할 수 있습니다.

+
+ ` +} + +export function sendVerificationEmail({ to, linkUrl }) { + return sendWithResend({ + to, + subject: '[10 Minute Planner] 이메일 인증', + html: buildLinkHtml({ + title: '이메일 인증을 완료해 주세요.', + description: '아래 버튼을 눌러 10 Minute Planner 계정의 이메일 인증을 완료할 수 있습니다.', + linkUrl, + buttonLabel: '이메일 인증하기', + }), + text: `10 Minute Planner 이메일 인증 링크입니다.\n${linkUrl}\n이 링크는 30분 동안 사용할 수 있습니다.`, + }) +} + +export function sendPasswordResetEmail({ to, linkUrl }) { + return sendWithResend({ + to, + subject: '[10 Minute Planner] 비밀번호 재설정', + html: buildLinkHtml({ + title: '비밀번호를 재설정해 주세요.', + description: '아래 버튼을 눌러 새 비밀번호를 설정할 수 있습니다. 요청하지 않았다면 이 메일은 무시해 주세요.', + linkUrl, + buttonLabel: '비밀번호 재설정', + }), + text: `10 Minute Planner 비밀번호 재설정 링크입니다.\n${linkUrl}\n요청하지 않았다면 이 메일은 무시해 주세요.`, + }) +} diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 6b50fe3..1d2cabc 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -4,6 +4,7 @@ import { db } from '../db/client.js' import { authSessions, emailVerificationTokens, passwordResetTokens, users } from '../db/schema.js' import { createSessionToken, hashSessionToken, hashPassword, verifyPassword } from '../lib/password.js' import { createSession, findAuthenticatedUser } from '../lib/authSession.js' +import { sendPasswordResetEmail, sendVerificationEmail } from '../lib/mailer.js' import { env } from '../config.js' const signupSchema = z.object({ @@ -171,6 +172,10 @@ export async function registerAuthRoutes(app) { const { token } = await createSession(user.id) const verification = await createEmailVerificationToken(user.id) + await sendVerificationEmail({ + to: user.email, + linkUrl: verification.previewUrl, + }) return reply.code(201).send({ message: '회원가입이 완료되었습니다.', @@ -387,6 +392,10 @@ export async function registerAuthRoutes(app) { } const verification = await createEmailVerificationToken(user.id) + await sendVerificationEmail({ + to: user.email, + linkUrl: verification.previewUrl, + }) return { message: '이메일 인증 링크를 준비했습니다.', @@ -471,6 +480,10 @@ export async function registerAuthRoutes(app) { } const reset = await createPasswordResetToken(user.id) + await sendPasswordResetEmail({ + to: user.email, + linkUrl: reset.previewUrl, + }) return { message: '비밀번호 재설정 링크를 준비했습니다.',