Skip to content

Commit ce2303f

Browse files
Merge pull request #66 from ShipFriend0516/feature/email
[Feature] 이메일 구독 기능
2 parents a7f1ae6 + 81ddbec commit ce2303f

15 files changed

Lines changed: 1124 additions & 27 deletions

File tree

app/api/posts/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,21 @@ export async function POST(req: Request) {
176176
});
177177
}
178178

179+
// 새 글이 공개 글인 경우 구독자들에게 이메일 발송
180+
if (!post.isPrivate) {
181+
const { sendNewPostNotifications } = await import(
182+
'@/app/lib/email/notifications'
183+
);
184+
sendNewPostNotifications({
185+
title: newPost.title,
186+
subTitle: newPost.subTitle,
187+
slug: newPost.slug,
188+
thumbnailImage: newPost.thumbnailImage,
189+
}).catch((error) => {
190+
console.error('Failed to send post notifications:', error);
191+
});
192+
}
193+
179194
return Response.json(
180195
{
181196
success: true,

app/api/subscribe/route.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { randomUUID } from 'crypto';
2+
import { NextRequest } from 'next/server';
3+
import dbConnect from '@/app/lib/dbConnect';
4+
import { sendVerificationEmail } from '@/app/lib/email/resend';
5+
import { checkRateLimit, getClientIP } from '@/app/lib/rateLimit';
6+
import Subscriber from '@/app/models/Subscriber';
7+
8+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
9+
10+
export async function POST(req: NextRequest) {
11+
try {
12+
const clientIP = getClientIP(req);
13+
const rateLimit = checkRateLimit(clientIP);
14+
15+
if (!rateLimit.allowed) {
16+
return Response.json(
17+
{
18+
success: false,
19+
error: '너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.',
20+
},
21+
{ status: 429 }
22+
);
23+
}
24+
25+
const { email, nickname } = await req.json();
26+
27+
if (!email || !nickname) {
28+
return Response.json(
29+
{
30+
success: false,
31+
error: '이메일과 닉네임은 필수 항목입니다.',
32+
},
33+
{ status: 400 }
34+
);
35+
}
36+
37+
if (!EMAIL_REGEX.test(email)) {
38+
return Response.json(
39+
{
40+
success: false,
41+
error: '유효한 이메일 주소를 입력해주세요.',
42+
},
43+
{ status: 400 }
44+
);
45+
}
46+
47+
if (nickname.trim().length < 2) {
48+
return Response.json(
49+
{
50+
success: false,
51+
error: '닉네임은 최소 2자 이상이어야 합니다.',
52+
},
53+
{ status: 400 }
54+
);
55+
}
56+
57+
await dbConnect();
58+
59+
const existingSubscriber = await Subscriber.findOne({ email });
60+
61+
if (existingSubscriber) {
62+
if (existingSubscriber.isActive && existingSubscriber.isVerified) {
63+
return Response.json(
64+
{
65+
success: false,
66+
error: '이미 구독 중인 이메일입니다.',
67+
},
68+
{ status: 409 }
69+
);
70+
}
71+
72+
if (!existingSubscriber.isVerified) {
73+
const verificationAge = Date.now() - existingSubscriber.createdAt;
74+
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
75+
76+
if (verificationAge < TWENTY_FOUR_HOURS) {
77+
return Response.json(
78+
{
79+
success: false,
80+
error:
81+
'이미 인증 이메일이 발송되었습니다. 이메일을 확인해주세요.',
82+
},
83+
{ status: 409 }
84+
);
85+
}
86+
87+
const newVerificationToken = randomUUID();
88+
existingSubscriber.verificationToken = newVerificationToken;
89+
existingSubscriber.nickname = nickname.trim();
90+
await existingSubscriber.save();
91+
92+
const emailResult = await sendVerificationEmail(
93+
email,
94+
nickname.trim(),
95+
newVerificationToken
96+
);
97+
98+
if (!emailResult.success) {
99+
return Response.json(
100+
{
101+
success: false,
102+
error:
103+
'인증 이메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요.',
104+
},
105+
{ status: 500 }
106+
);
107+
}
108+
109+
return Response.json(
110+
{
111+
success: true,
112+
message: '인증 이메일이 재발송되었습니다. 이메일을 확인해주세요.',
113+
},
114+
{ status: 200 }
115+
);
116+
}
117+
118+
existingSubscriber.isActive = true;
119+
await existingSubscriber.save();
120+
121+
return Response.json(
122+
{
123+
success: true,
124+
message: '구독이 재활성화되었습니다.',
125+
},
126+
{ status: 200 }
127+
);
128+
}
129+
130+
const verificationToken = randomUUID();
131+
const unsubscribeToken = randomUUID();
132+
133+
const newSubscriber = await Subscriber.create({
134+
email: email.toLowerCase().trim(),
135+
nickname: nickname.trim(),
136+
verificationToken,
137+
unsubscribeToken,
138+
isActive: false,
139+
isVerified: false,
140+
});
141+
142+
const emailResult = await sendVerificationEmail(
143+
email,
144+
nickname.trim(),
145+
verificationToken
146+
);
147+
148+
if (!emailResult.success) {
149+
await Subscriber.deleteOne({ _id: newSubscriber._id });
150+
151+
return Response.json(
152+
{
153+
success: false,
154+
error: '인증 이메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요.',
155+
},
156+
{ status: 500 }
157+
);
158+
}
159+
160+
return Response.json(
161+
{
162+
success: true,
163+
message: '인증 이메일이 발송되었습니다. 이메일을 확인해주세요.',
164+
},
165+
{ status: 201 }
166+
);
167+
} catch (error) {
168+
console.error('Subscribe API error:', error);
169+
return Response.json(
170+
{
171+
success: false,
172+
error: '구독 처리 중 오류가 발생했습니다.',
173+
detail: error instanceof Error ? error.message : 'Unknown error',
174+
},
175+
{ status: 500 }
176+
);
177+
}
178+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { redirect } from 'next/navigation';
2+
import { NextRequest } from 'next/server';
3+
import dbConnect from '@/app/lib/dbConnect';
4+
import { sendUnsubscribeConfirmation } from '@/app/lib/email/resend';
5+
import Subscriber from '@/app/models/Subscriber';
6+
7+
export async function GET(req: NextRequest) {
8+
try {
9+
const { searchParams } = new URL(req.url);
10+
const token = searchParams.get('token');
11+
12+
if (!token) {
13+
redirect('/subscribe/error?message=invalid_token');
14+
}
15+
16+
await dbConnect();
17+
18+
const subscriber = await Subscriber.findOne({ unsubscribeToken: token });
19+
20+
if (!subscriber) {
21+
redirect('/subscribe/error?message=subscriber_not_found');
22+
}
23+
24+
if (!subscriber.isActive) {
25+
redirect('/subscribe/unsubscribed?message=already_unsubscribed');
26+
}
27+
28+
subscriber.isActive = false;
29+
await subscriber.save();
30+
31+
sendUnsubscribeConfirmation(subscriber.email, subscriber.nickname).catch(
32+
(error) => {
33+
console.error('Failed to send unsubscribe confirmation:', error);
34+
}
35+
);
36+
37+
redirect('/subscribe/unsubscribed');
38+
} catch (error) {
39+
console.error('Unsubscribe API error:', error);
40+
redirect('/subscribe/error?message=server_error');
41+
}
42+
}

app/api/subscribe/verify/route.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { redirect } from 'next/navigation';
2+
import { NextRequest } from 'next/server';
3+
import dbConnect from '@/app/lib/dbConnect';
4+
import Subscriber from '@/app/models/Subscriber';
5+
6+
export async function GET(req: NextRequest) {
7+
try {
8+
const { searchParams } = new URL(req.url);
9+
const token = searchParams.get('token');
10+
11+
if (!token) {
12+
redirect('/subscribe/error?message=invalid_token');
13+
}
14+
15+
await dbConnect();
16+
17+
const subscriber = await Subscriber.findOne({ verificationToken: token });
18+
19+
if (!subscriber) {
20+
redirect('/subscribe/error?message=token_not_found');
21+
}
22+
23+
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
24+
const tokenAge = Date.now() - subscriber.createdAt;
25+
26+
if (tokenAge > TWENTY_FOUR_HOURS) {
27+
redirect('/subscribe/error?message=token_expired');
28+
}
29+
30+
if (subscriber.isVerified && subscriber.isActive) {
31+
redirect('/subscribe/verified?message=already_verified');
32+
}
33+
34+
subscriber.isVerified = true;
35+
subscriber.isActive = true;
36+
await subscriber.save();
37+
38+
redirect('/subscribe/verified');
39+
} catch (error) {
40+
console.error('Verify API error:', error);
41+
redirect('/subscribe/error?message=server_error');
42+
}
43+
}

0 commit comments

Comments
 (0)