Skip to content

Commit 5f86ea1

Browse files
committed
feat: implemnt email verify logic
1 parent 065239a commit 5f86ea1

9 files changed

+331
-0
lines changed

rook/.sqlx/query-03a8230734da8b966adda4eadf99bd665be8d2cbba1b71c55677f83ec71969f5.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rook/.sqlx/query-37bd35a93ead8ace791a16f9fd49a011438edf41f2967985de1ec9b88518725d.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rook/.sqlx/query-5079ceb0a5dadbc9f948da81be241398b81e775b4bbb9c5014d7c6c6ce8ed3e7.json

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rook/.sqlx/query-bd09aa624890b7c599d0a89016d941d3d0b76ecd2d3c603c4814182c5e202be2.json

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
File renamed without changes.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
ALTER TABLE subscribers
2+
ADD COLUMN IF NOT EXISTS verification_code VARCHAR(255),
3+
ADD COLUMN IF NOT EXISTS verification_code_created_at TIMESTAMP WITH TIME ZONE,
4+
ADD COLUMN IF NOT EXISTS verification_code_expires_at TIMESTAMP WITH TIME ZONE,
5+
ADD COLUMN IF NOT EXISTS is_verified BOOLEAN NOT NULL DEFAULT FALSE;
6+
7+
CREATE INDEX IF NOT EXISTS idx_subscribers_verification_expires
8+
ON subscribers(verification_code_expires_at);
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
use chrono::{DateTime, Duration, Utc};
2+
use rand::{Rng, rngs::ThreadRng};
3+
use secrecy::{ExposeSecret, Secret};
4+
use serde::{Deserialize, Serialize};
5+
use sqlx::{PgPool, postgres::PgQueryResult};
6+
7+
#[derive(Debug, Clone, Serialize, Deserialize)]
8+
pub struct EmailVerification {
9+
pub email: String,
10+
#[serde(skip_serializing)]
11+
pub code: Secret<String>,
12+
pub created_at: DateTime<Utc>,
13+
pub expires_at: DateTime<Utc>,
14+
pub verified: bool,
15+
}
16+
17+
impl EmailVerification {
18+
pub fn new(email: String) -> Self {
19+
let code = generate_verification_code();
20+
let created_at = Utc::now();
21+
let expires_at = created_at + Duration::minutes(10); // 10분 후 만료
22+
23+
Self {
24+
email,
25+
code,
26+
created_at,
27+
expires_at,
28+
verified: false,
29+
}
30+
}
31+
32+
pub async fn save(&self, pool: &PgPool) -> Result<(), sqlx::Error> {
33+
sqlx::query!(
34+
r#"
35+
UPDATE subscribers
36+
SET verification_code = $1,
37+
verification_code_created_at = $2,
38+
verification_code_expires_at = $3,
39+
is_verified = $4
40+
WHERE email = $5
41+
"#,
42+
self.code.expose_secret(),
43+
self.created_at,
44+
self.expires_at,
45+
self.verified,
46+
self.email
47+
)
48+
.execute(pool)
49+
.await?;
50+
51+
Ok(())
52+
}
53+
54+
pub async fn verify(&mut self, code: &str, pool: &PgPool) -> Result<bool, sqlx::Error> {
55+
if self.verified || Utc::now() > self.expires_at {
56+
return Ok(false);
57+
}
58+
59+
if self.code.expose_secret() == code {
60+
self.verified = true;
61+
sqlx::query!(
62+
r#"
63+
UPDATE subscribers
64+
SET is_verified = true
65+
WHERE email = $1
66+
"#,
67+
self.email
68+
)
69+
.execute(pool)
70+
.await?;
71+
Ok(true)
72+
} else {
73+
Ok(false)
74+
}
75+
}
76+
}
77+
78+
fn generate_verification_code() -> Secret<String> {
79+
let mut rng = ThreadRng::default();
80+
let code: String = (0..6)
81+
.map(|_| rng.random_range(0..10).to_string())
82+
.collect();
83+
Secret::new(code)
84+
}
85+
86+
#[derive(Debug)]
87+
pub struct EmailVerificationStore {
88+
pool: PgPool,
89+
}
90+
91+
impl EmailVerificationStore {
92+
pub fn new(pool: PgPool) -> Self {
93+
Self { pool }
94+
}
95+
96+
pub async fn create_verification(
97+
&self,
98+
email: String,
99+
) -> Result<EmailVerification, sqlx::Error> {
100+
let verification = EmailVerification::new(email);
101+
verification.save(&self.pool).await?;
102+
Ok(verification)
103+
}
104+
105+
pub async fn get_verification(
106+
&self,
107+
email: &str,
108+
) -> Result<Option<EmailVerification>, sqlx::Error> {
109+
#[derive(sqlx::FromRow)]
110+
struct VerificationRecord {
111+
email: String,
112+
verification_code: Option<String>,
113+
verification_code_created_at: Option<DateTime<Utc>>,
114+
verification_code_expires_at: Option<DateTime<Utc>>,
115+
is_verified: bool,
116+
}
117+
118+
let record = sqlx::query_as::<_, VerificationRecord>(
119+
r#"
120+
SELECT email,
121+
verification_code,
122+
verification_code_created_at,
123+
verification_code_expires_at,
124+
is_verified
125+
FROM subscribers
126+
WHERE email = $1
127+
"#,
128+
)
129+
.bind(email)
130+
.fetch_optional(&self.pool)
131+
.await?;
132+
133+
Ok(record.map(|r| EmailVerification {
134+
email: r.email,
135+
code: Secret::new(r.verification_code.unwrap_or_default()),
136+
created_at: r.verification_code_created_at.unwrap_or_else(Utc::now),
137+
expires_at: r.verification_code_expires_at.unwrap_or_else(Utc::now),
138+
verified: r.is_verified,
139+
}))
140+
}
141+
142+
pub async fn verify_code(&self, email: &str, code: &str) -> Result<bool, sqlx::Error> {
143+
if let Some(mut verification) = self.get_verification(email).await? {
144+
verification.verify(code, &self.pool).await
145+
} else {
146+
Ok(false)
147+
}
148+
}
149+
150+
pub async fn is_verified(&self, email: &str) -> Result<bool, sqlx::Error> {
151+
let record = sqlx::query!(
152+
r#"
153+
SELECT is_verified
154+
FROM subscribers
155+
WHERE email = $1
156+
"#,
157+
email
158+
)
159+
.fetch_optional(&self.pool)
160+
.await?;
161+
162+
Ok(record.map(|r| r.is_verified).unwrap_or(false))
163+
}
164+
165+
pub async fn cleanup_expired(&self) -> Result<PgQueryResult, sqlx::Error> {
166+
sqlx::query!(
167+
r#"
168+
UPDATE subscribers
169+
SET verification_code = NULL,
170+
verification_code_created_at = NULL,
171+
verification_code_expires_at = NULL
172+
WHERE verification_code_expires_at < CURRENT_TIMESTAMP
173+
"#
174+
)
175+
.execute(&self.pool)
176+
.await
177+
}
178+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
use crate::domain::{SubscriberEmail, email_verification::EmailVerificationStore};
2+
use crate::email_client::EmailClient;
3+
use secrecy::ExposeSecret;
4+
use sqlx::PgPool;
5+
6+
pub struct EmailVerificationService {
7+
store: EmailVerificationStore,
8+
email_client: EmailClient,
9+
}
10+
11+
impl EmailVerificationService {
12+
pub fn new(pool: PgPool, email_client: EmailClient) -> Self {
13+
Self {
14+
store: EmailVerificationStore::new(pool),
15+
email_client,
16+
}
17+
}
18+
19+
pub async fn send_verification_email(&self, email: String) -> Result<(), String> {
20+
let verification = self
21+
.store
22+
.create_verification(email.clone())
23+
.await
24+
.map_err(|e| format!("Failed to create verification: {}", e))?;
25+
26+
let subscriber_email =
27+
SubscriberEmail::new(email.clone()).map_err(|_| "Invalid email format".to_string())?;
28+
29+
let subject = "이메일 인증 코드";
30+
let html_content = format!(
31+
r#"
32+
<h1>이메일 인증</h1>
33+
<p>귀하의 이메일 인증 코드는 다음과 같습니다:</p>
34+
<h2>{}</h2>
35+
<p>이 코드는 10분 후에 만료됩니다.</p>
36+
"#,
37+
verification.code.expose_secret()
38+
);
39+
let text_content = format!(
40+
"귀하의 이메일 인증 코드는 {} 입니다. 이 코드는 10분 후에 만료됩니다.",
41+
verification.code.expose_secret()
42+
);
43+
44+
self.email_client
45+
.send_email(
46+
subscriber_email,
47+
subject.to_string(),
48+
html_content,
49+
text_content,
50+
)
51+
.await
52+
}
53+
54+
pub async fn verify_code(&self, email: &str, code: &str) -> Result<bool, String> {
55+
self.store
56+
.verify_code(email, code)
57+
.await
58+
.map_err(|e| format!("Failed to verify code: {}", e))
59+
}
60+
61+
pub async fn is_verified(&self, email: &str) -> Result<bool, String> {
62+
self.store
63+
.is_verified(email)
64+
.await
65+
.map_err(|e| format!("Failed to check verification status: {}", e))
66+
}
67+
68+
pub async fn cleanup_expired(&self) -> Result<(), String> {
69+
self.store
70+
.cleanup_expired()
71+
.await
72+
.map_err(|e| format!("Failed to cleanup expired verifications: {}", e))?;
73+
Ok(())
74+
}
75+
}

rook/src/domain/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
mod email_verification;
2+
mod email_verification_service;
13
mod new_subscriber;
24
mod repository_url;
35
mod subscriber_email;
46

7+
pub use email_verification::*;
8+
pub use email_verification_service::*;
59
pub use new_subscriber::*;
610
pub use repository_url::*;
711
pub use subscriber_email::*;

0 commit comments

Comments
 (0)