From d9bd126c8807c396ffecf70a5e15f9d7c3f150c4 Mon Sep 17 00:00:00 2001 From: sjw4371 Date: Thu, 4 Dec 2025 17:45:52 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20resend=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/package.json b/package.json index 10f11a0..7d905aa 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "react-dom": "^18.3.1", "react-icons": "^5.5.0", "react-lottie-player": "^2.1.0", + "resend": "^6.5.2", "sharp": "^0.33.5", "zustand": "^5.0.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 628975f..3ae0005 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: react-lottie-player: specifier: ^2.1.0 version: 2.1.0(react@18.3.1) + resend: + specifier: ^6.5.2 + version: 6.5.2 sharp: specifier: ^0.33.5 version: 0.33.5 @@ -680,6 +683,9 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -791,6 +797,9 @@ packages: '@types/node@20.17.30': resolution: {integrity: sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==} + '@types/node@22.19.1': + resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + '@types/prismjs@1.26.5': resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} @@ -1541,6 +1550,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1719,6 +1731,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -3203,6 +3218,15 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resend@6.5.2: + resolution: {integrity: sha512-Yl83UvS8sYsjgmF8dVbNPzlfpmb3DkLUk3VwsAbkaEFo9UMswpNuPGryHBXGk+Ta4uYMv5HmjVk3j9jmNkcEDg==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -3485,6 +3509,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.76.1: + resolution: {integrity: sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -3620,6 +3647,9 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@5.29.0: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} @@ -3667,6 +3697,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -4484,6 +4518,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@stablelib/base64@1.0.1': {} + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.5': @@ -4617,6 +4653,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/node@22.19.1': + dependencies: + undici-types: 6.21.0 + '@types/prismjs@1.26.5': {} '@types/prop-types@15.7.14': {} @@ -5519,6 +5559,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es6-promise@4.2.8: {} + escalade@3.2.0: {} escape-string-regexp@2.0.0: {} @@ -5781,6 +5823,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -7829,6 +7873,10 @@ snapshots: requires-port@1.0.0: {} + resend@6.5.2: + dependencies: + svix: 1.76.1 + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -8161,6 +8209,15 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.76.1: + dependencies: + '@stablelib/base64': 1.0.1 + '@types/node': 22.19.1 + es6-promise: 4.2.8 + fast-sha256: 1.3.0 + url-parse: 1.5.10 + uuid: 10.0.0 + symbol-tree@3.2.4: {} tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.30)(typescript@5.8.3)): @@ -8329,6 +8386,8 @@ snapshots: undici-types@6.19.8: {} + undici-types@6.21.0: {} + undici@5.29.0: dependencies: '@fastify/busboy': 2.1.1 @@ -8410,6 +8469,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@10.0.0: {} + uuid@8.3.2: {} v8-compile-cache-lib@3.0.1: {} From cce7bb4bcbaecf17496cae2c9fca37bb9267287d Mon Sep 17 00:00:00 2001 From: sjw4371 Date: Thu, 4 Dec 2025 17:47:02 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/posts/route.ts | 15 +++ app/api/subscribe/route.ts | 178 +++++++++++++++++++++++++ app/api/subscribe/unsubscribe/route.ts | 42 ++++++ app/api/subscribe/verify/route.ts | 43 ++++++ app/entities/common/Footer.tsx | 130 ++++++++++++++---- app/lib/email/notifications.ts | 86 ++++++++++++ app/lib/email/resend.ts | 128 ++++++++++++++++++ app/lib/email/templates.ts | 163 ++++++++++++++++++++++ app/lib/rateLimit.ts | 72 ++++++++++ app/models/Subscriber.ts | 51 +++++++ app/subscribe/error/page.tsx | 59 ++++++++ app/subscribe/unsubscribed/page.tsx | 64 +++++++++ app/subscribe/verified/page.tsx | 58 ++++++++ 13 files changed, 1062 insertions(+), 27 deletions(-) create mode 100644 app/api/subscribe/route.ts create mode 100644 app/api/subscribe/unsubscribe/route.ts create mode 100644 app/api/subscribe/verify/route.ts create mode 100644 app/lib/email/notifications.ts create mode 100644 app/lib/email/resend.ts create mode 100644 app/lib/email/templates.ts create mode 100644 app/lib/rateLimit.ts create mode 100644 app/models/Subscriber.ts create mode 100644 app/subscribe/error/page.tsx create mode 100644 app/subscribe/unsubscribed/page.tsx create mode 100644 app/subscribe/verified/page.tsx diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index fd92f21..d4917b2 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -176,6 +176,21 @@ export async function POST(req: Request) { }); } + // 새 글이 공개 글인 경우 구독자들에게 이메일 발송 + if (!post.isPrivate) { + const { sendNewPostNotifications } = await import( + '@/app/lib/email/notifications' + ); + sendNewPostNotifications({ + title: newPost.title, + subTitle: newPost.subTitle, + slug: newPost.slug, + thumbnailImage: newPost.thumbnailImage, + }).catch((error) => { + console.error('Failed to send post notifications:', error); + }); + } + return Response.json( { success: true, diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts new file mode 100644 index 0000000..589300d --- /dev/null +++ b/app/api/subscribe/route.ts @@ -0,0 +1,178 @@ +import { randomUUID } from 'crypto'; +import { NextRequest } from 'next/server'; +import dbConnect from '@/app/lib/dbConnect'; +import { sendVerificationEmail } from '@/app/lib/email/resend'; +import { checkRateLimit, getClientIP } from '@/app/lib/rateLimit'; +import Subscriber from '@/app/models/Subscriber'; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export async function POST(req: NextRequest) { + try { + const clientIP = getClientIP(req); + const rateLimit = checkRateLimit(clientIP); + + if (!rateLimit.allowed) { + return Response.json( + { + success: false, + error: '너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.', + }, + { status: 429 } + ); + } + + const { email, nickname } = await req.json(); + + if (!email || !nickname) { + return Response.json( + { + success: false, + error: '이메일과 닉네임은 필수 항목입니다.', + }, + { status: 400 } + ); + } + + if (!EMAIL_REGEX.test(email)) { + return Response.json( + { + success: false, + error: '유효한 이메일 주소를 입력해주세요.', + }, + { status: 400 } + ); + } + + if (nickname.trim().length < 2) { + return Response.json( + { + success: false, + error: '닉네임은 최소 2자 이상이어야 합니다.', + }, + { status: 400 } + ); + } + + await dbConnect(); + + const existingSubscriber = await Subscriber.findOne({ email }); + + if (existingSubscriber) { + if (existingSubscriber.isActive && existingSubscriber.isVerified) { + return Response.json( + { + success: false, + error: '이미 구독 중인 이메일입니다.', + }, + { status: 409 } + ); + } + + if (!existingSubscriber.isVerified) { + const verificationAge = Date.now() - existingSubscriber.createdAt; + const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; + + if (verificationAge < TWENTY_FOUR_HOURS) { + return Response.json( + { + success: false, + error: + '이미 인증 이메일이 발송되었습니다. 이메일을 확인해주세요.', + }, + { status: 409 } + ); + } + + const newVerificationToken = randomUUID(); + existingSubscriber.verificationToken = newVerificationToken; + existingSubscriber.nickname = nickname.trim(); + await existingSubscriber.save(); + + const emailResult = await sendVerificationEmail( + email, + nickname.trim(), + newVerificationToken + ); + + if (!emailResult.success) { + return Response.json( + { + success: false, + error: + '인증 이메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요.', + }, + { status: 500 } + ); + } + + return Response.json( + { + success: true, + message: '인증 이메일이 재발송되었습니다. 이메일을 확인해주세요.', + }, + { status: 200 } + ); + } + + existingSubscriber.isActive = true; + await existingSubscriber.save(); + + return Response.json( + { + success: true, + message: '구독이 재활성화되었습니다.', + }, + { status: 200 } + ); + } + + const verificationToken = randomUUID(); + const unsubscribeToken = randomUUID(); + + const newSubscriber = await Subscriber.create({ + email: email.toLowerCase().trim(), + nickname: nickname.trim(), + verificationToken, + unsubscribeToken, + isActive: false, + isVerified: false, + }); + + const emailResult = await sendVerificationEmail( + email, + nickname.trim(), + verificationToken + ); + + if (!emailResult.success) { + await Subscriber.deleteOne({ _id: newSubscriber._id }); + + return Response.json( + { + success: false, + error: '인증 이메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요.', + }, + { status: 500 } + ); + } + + return Response.json( + { + success: true, + message: '인증 이메일이 발송되었습니다. 이메일을 확인해주세요.', + }, + { status: 201 } + ); + } catch (error) { + console.error('Subscribe API error:', error); + return Response.json( + { + success: false, + error: '구독 처리 중 오류가 발생했습니다.', + detail: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/app/api/subscribe/unsubscribe/route.ts b/app/api/subscribe/unsubscribe/route.ts new file mode 100644 index 0000000..4cc6e5f --- /dev/null +++ b/app/api/subscribe/unsubscribe/route.ts @@ -0,0 +1,42 @@ +import { redirect } from 'next/navigation'; +import { NextRequest } from 'next/server'; +import dbConnect from '@/app/lib/dbConnect'; +import { sendUnsubscribeConfirmation } from '@/app/lib/email/resend'; +import Subscriber from '@/app/models/Subscriber'; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const token = searchParams.get('token'); + + if (!token) { + redirect('/subscribe/error?message=invalid_token'); + } + + await dbConnect(); + + const subscriber = await Subscriber.findOne({ unsubscribeToken: token }); + + if (!subscriber) { + redirect('/subscribe/error?message=subscriber_not_found'); + } + + if (!subscriber.isActive) { + redirect('/subscribe/unsubscribed?message=already_unsubscribed'); + } + + subscriber.isActive = false; + await subscriber.save(); + + sendUnsubscribeConfirmation(subscriber.email, subscriber.nickname).catch( + (error) => { + console.error('Failed to send unsubscribe confirmation:', error); + } + ); + + redirect('/subscribe/unsubscribed'); + } catch (error) { + console.error('Unsubscribe API error:', error); + redirect('/subscribe/error?message=server_error'); + } +} diff --git a/app/api/subscribe/verify/route.ts b/app/api/subscribe/verify/route.ts new file mode 100644 index 0000000..b4469ef --- /dev/null +++ b/app/api/subscribe/verify/route.ts @@ -0,0 +1,43 @@ +import { redirect } from 'next/navigation'; +import { NextRequest } from 'next/server'; +import dbConnect from '@/app/lib/dbConnect'; +import Subscriber from '@/app/models/Subscriber'; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const token = searchParams.get('token'); + + if (!token) { + redirect('/subscribe/error?message=invalid_token'); + } + + await dbConnect(); + + const subscriber = await Subscriber.findOne({ verificationToken: token }); + + if (!subscriber) { + redirect('/subscribe/error?message=token_not_found'); + } + + const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; + const tokenAge = Date.now() - subscriber.createdAt; + + if (tokenAge > TWENTY_FOUR_HOURS) { + redirect('/subscribe/error?message=token_expired'); + } + + if (subscriber.isVerified && subscriber.isActive) { + redirect('/subscribe/verified?message=already_verified'); + } + + subscriber.isVerified = true; + subscriber.isActive = true; + await subscriber.save(); + + redirect('/subscribe/verified'); + } catch (error) { + console.error('Verify API error:', error); + redirect('/subscribe/error?message=server_error'); + } +} diff --git a/app/entities/common/Footer.tsx b/app/entities/common/Footer.tsx index 55230a8..3afed5b 100644 --- a/app/entities/common/Footer.tsx +++ b/app/entities/common/Footer.tsx @@ -1,9 +1,63 @@ 'use client'; +import axios from 'axios'; import Link from 'next/link'; +import { useState, FormEvent } from 'react'; import useToast from '@/app/hooks/useToast'; const Footer = () => { const toast = useToast(); + const [nickname, setNickname] = useState(''); + const [email, setEmail] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!nickname.trim() || !email.trim()) { + toast.error('닉네임과 이메일을 모두 입력해주세요.'); + return; + } + + if (nickname.trim().length < 2) { + toast.error('닉네임은 최소 2자 이상이어야 합니다.'); + return; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + toast.error('유효한 이메일 주소를 입력해주세요.'); + return; + } + + setIsLoading(true); + + try { + const response = await axios.post('/api/subscribe', { + email: email.trim(), + nickname: nickname.trim(), + }); + + if (response.data.success) { + toast.success( + response.data.message || '인증 이메일이 발송되었습니다.' + ); + setIsSubmitted(true); + setNickname(''); + setEmail(''); + } + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + toast.error( + error.response.data.error || '구독 신청에 실패했습니다.' + ); + } else { + toast.error('구독 신청 중 오류가 발생했습니다.'); + } + } finally { + setIsLoading(false); + } + }; return (