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
+${description}
+ +버튼이 열리지 않으면 아래 링크를 복사해서 브라우저에 붙여넣어 주세요.
+${linkUrl}
+이 링크는 30분 동안 사용할 수 있습니다.
+