Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions rook/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion rook/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
shuttle-axum = "0.55.0"
shuttle-runtime = { version = "0.55.0", default-features = false } # see https://docs.shuttle.dev/docs/logs#default-tracing-subscriber
validator = "0.20"
secrecy = { version = "0.8", features = ["serde"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] }
dotenv = "0.15"
chrono = { version = "0.4.41", features = ["serde"] }
rand = { version = "0.9", features = ["std_rng"] }

[dev-dependencies]
serial_test = "3.0.0"
fake = "4.3"
quickcheck = "1.0.3"
quickcheck_macros = "1.1.0"
rand = { version = "0.9", features = ["std_rng"] }
8 changes: 8 additions & 0 deletions rook/migrations/20250527160932_alter_verify_code.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ALTER TABLE subscribers
ADD COLUMN IF NOT EXISTS verification_code VARCHAR(255),
ADD COLUMN IF NOT EXISTS verification_code_created_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS verification_code_expires_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS is_verified BOOLEAN NOT NULL DEFAULT FALSE;

CREATE INDEX IF NOT EXISTS idx_subscribers_verification_expires
ON subscribers(verification_code_expires_at);
178 changes: 178 additions & 0 deletions rook/src/domain/email_verification.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
use chrono::{DateTime, Duration, Utc};
use rand::{Rng, rngs::ThreadRng};
use secrecy::{ExposeSecret, Secret};
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, postgres::PgQueryResult};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailVerification {
pub email: String,
#[serde(skip_serializing)]
pub code: Secret<String>,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub verified: bool,
}

impl EmailVerification {
pub fn new(email: String) -> Self {
let code = generate_verification_code();
let created_at = Utc::now();
let expires_at = created_at + Duration::minutes(10); // 10분 후 만료
Copy link

Copilot AI May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The expiration duration (10 minutes) is hardcoded in multiple places. Extract this value into a named constant to ensure consistency and ease future changes.

Suggested change
let expires_at = created_at + Duration::minutes(10); // 10분 후 만료
let expires_at = created_at + Duration::minutes(EMAIL_VERIFICATION_EXPIRATION_MINUTES); // 10분 후 만료

Copilot uses AI. Check for mistakes.

Self {
email,
code,
created_at,
expires_at,
verified: false,
}
}

pub async fn save(&self, pool: &PgPool) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
UPDATE subscribers
SET verification_code = $1,
verification_code_created_at = $2,
verification_code_expires_at = $3,
is_verified = $4
WHERE email = $5
"#,
self.code.expose_secret(),
self.created_at,
self.expires_at,
self.verified,
self.email
)
.execute(pool)
.await?;

Ok(())
}

pub async fn verify(&mut self, code: &str, pool: &PgPool) -> Result<bool, sqlx::Error> {
if self.verified || Utc::now() > self.expires_at {
return Ok(false);
}

if self.code.expose_secret() == code {
self.verified = true;
sqlx::query!(
r#"
UPDATE subscribers
SET is_verified = true
WHERE email = $1
"#,
self.email
)
.execute(pool)
.await?;
Ok(true)
} else {
Ok(false)
}
}
}

fn generate_verification_code() -> Secret<String> {
let mut rng = ThreadRng::default();
let code: String = (0..6)
.map(|_| rng.random_range(0..10).to_string())
.collect();
Secret::new(code)
}

#[derive(Debug)]
pub struct EmailVerificationStore {
pool: PgPool,
}

impl EmailVerificationStore {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}

pub async fn create_verification(
&self,
email: String,
) -> Result<EmailVerification, sqlx::Error> {
let verification = EmailVerification::new(email);
verification.save(&self.pool).await?;
Ok(verification)
}

pub async fn get_verification(
&self,
email: &str,
) -> Result<Option<EmailVerification>, sqlx::Error> {
#[derive(sqlx::FromRow)]
struct VerificationRecord {
email: String,
verification_code: Option<String>,
verification_code_created_at: Option<DateTime<Utc>>,
verification_code_expires_at: Option<DateTime<Utc>>,
is_verified: bool,
}

let record = sqlx::query_as::<_, VerificationRecord>(
r#"
SELECT email,
verification_code,
verification_code_created_at,
verification_code_expires_at,
is_verified
FROM subscribers
WHERE email = $1
"#,
)
.bind(email)
.fetch_optional(&self.pool)
.await?;

Ok(record.map(|r| EmailVerification {
email: r.email,
code: Secret::new(r.verification_code.unwrap_or_default()),
created_at: r.verification_code_created_at.unwrap_or_else(Utc::now),
expires_at: r.verification_code_expires_at.unwrap_or_else(Utc::now),
verified: r.is_verified,
}))
}

pub async fn verify_code(&self, email: &str, code: &str) -> Result<bool, sqlx::Error> {
if let Some(mut verification) = self.get_verification(email).await? {
verification.verify(code, &self.pool).await
} else {
Ok(false)
}
}

pub async fn is_verified(&self, email: &str) -> Result<bool, sqlx::Error> {
let record = sqlx::query!(
r#"
SELECT is_verified
FROM subscribers
WHERE email = $1
"#,
email
)
.fetch_optional(&self.pool)
.await?;

Ok(record.map(|r| r.is_verified).unwrap_or(false))
}

pub async fn cleanup_expired(&self) -> Result<PgQueryResult, sqlx::Error> {
sqlx::query!(
r#"
UPDATE subscribers
SET verification_code = NULL,
verification_code_created_at = NULL,
verification_code_expires_at = NULL
WHERE verification_code_expires_at < CURRENT_TIMESTAMP
"#
)
.execute(&self.pool)
.await
}
}
75 changes: 75 additions & 0 deletions rook/src/domain/email_verification_service.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
use crate::domain::{SubscriberEmail, email_verification::EmailVerificationStore};
use crate::email_client::EmailClient;
use secrecy::ExposeSecret;
use sqlx::PgPool;

pub struct EmailVerificationService {
store: EmailVerificationStore,
email_client: EmailClient,
}

impl EmailVerificationService {
pub fn new(pool: PgPool, email_client: EmailClient) -> Self {
Self {
store: EmailVerificationStore::new(pool),
email_client,
}
}

pub async fn send_verification_email(&self, email: String) -> Result<(), String> {
let verification = self
.store
.create_verification(email.clone())
.await
.map_err(|e| format!("Failed to create verification: {}", e))?;

let subscriber_email =
SubscriberEmail::new(email.clone()).map_err(|_| "Invalid email format".to_string())?;

let subject = "이메일 인증 코드";
let html_content = format!(
r#"
<h1>이메일 인증</h1>
<p>귀하의 이메일 인증 코드는 다음과 같습니다:</p>
<h2>{}</h2>
<p>이 코드는 10분 후에 만료됩니다.</p>
"#,
verification.code.expose_secret()
);
let text_content = format!(
"귀하의 이메일 인증 코드는 {} 입니다. 이 코드는 10분 후에 만료됩니다.",
verification.code.expose_secret()
);

self.email_client
.send_email(
subscriber_email,
subject.to_string(),
html_content,
text_content,
)
.await
}

pub async fn verify_code(&self, email: &str, code: &str) -> Result<bool, String> {
self.store
.verify_code(email, code)
.await
.map_err(|e| format!("Failed to verify code: {}", e))
}

pub async fn is_verified(&self, email: &str) -> Result<bool, String> {
self.store
.is_verified(email)
.await
.map_err(|e| format!("Failed to check verification status: {}", e))
}

pub async fn cleanup_expired(&self) -> Result<(), String> {
self.store
.cleanup_expired()
.await
.map_err(|e| format!("Failed to cleanup expired verifications: {}", e))?;
Ok(())
}
}
4 changes: 4 additions & 0 deletions rook/src/domain/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
mod email_verification;
mod email_verification_service;
mod new_subscriber;
mod repository_url;
mod subscriber_email;

pub use email_verification::*;
pub use email_verification_service::*;
pub use new_subscriber::*;
pub use repository_url::*;
pub use subscriber_email::*;
Loading