mirror of
https://github.com/khoaliber/LetterFeed.git
synced 2026-03-02 13:18:27 +00:00
feat: email validation
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
|
||||||
|
|
||||||
from app.core.slug import sanitize_slug
|
from app.core.slug import sanitize_slug
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ from app.core.slug import sanitize_slug
|
|||||||
class SenderBase(BaseModel):
|
class SenderBase(BaseModel):
|
||||||
"""Base schema for a sender."""
|
"""Base schema for a sender."""
|
||||||
|
|
||||||
email: str
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
class SenderCreate(SenderBase):
|
class SenderCreate(SenderBase):
|
||||||
@@ -43,13 +43,13 @@ class NewsletterBase(BaseModel):
|
|||||||
class NewsletterCreate(NewsletterBase):
|
class NewsletterCreate(NewsletterBase):
|
||||||
"""Schema for creating a new newsletter."""
|
"""Schema for creating a new newsletter."""
|
||||||
|
|
||||||
sender_emails: List[str]
|
sender_emails: List[EmailStr]
|
||||||
|
|
||||||
|
|
||||||
class NewsletterUpdate(NewsletterBase):
|
class NewsletterUpdate(NewsletterBase):
|
||||||
"""Schema for updating an existing newsletter."""
|
"""Schema for updating an existing newsletter."""
|
||||||
|
|
||||||
sender_emails: List[str]
|
sender_emails: List[EmailStr]
|
||||||
|
|
||||||
|
|
||||||
class Newsletter(NewsletterBase):
|
class Newsletter(NewsletterBase):
|
||||||
|
|||||||
47
backend/app/tests/test_email_validation.py
Normal file
47
backend/app/tests/test_email_validation.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_newsletter_with_invalid_email(client: TestClient):
|
||||||
|
"""Test creating a newsletter with an invalid sender email."""
|
||||||
|
newsletter_data = {
|
||||||
|
"name": "Invalid Email Test",
|
||||||
|
"sender_emails": ["not-an-email"],
|
||||||
|
}
|
||||||
|
response = client.post("/newsletters", json=newsletter_data)
|
||||||
|
assert response.status_code == 422 # Unprocessable Entity
|
||||||
|
data = response.json()
|
||||||
|
assert "value is not a valid email address" in data["detail"][0]["msg"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_newsletter_with_valid_and_invalid_emails(client: TestClient):
|
||||||
|
"""Test creating a newsletter with a mix of valid and invalid emails."""
|
||||||
|
newsletter_data = {
|
||||||
|
"name": "Mixed Emails Test",
|
||||||
|
"sender_emails": ["valid@example.com", "invalid-email"],
|
||||||
|
}
|
||||||
|
response = client.post("/newsletters", json=newsletter_data)
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert data["detail"][0]["loc"] == ["body", "sender_emails", 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_newsletter_with_invalid_email(client: TestClient):
|
||||||
|
"""Test updating a newsletter with an invalid sender email."""
|
||||||
|
# First, create a valid newsletter
|
||||||
|
create_response = client.post(
|
||||||
|
"/newsletters",
|
||||||
|
json={
|
||||||
|
"name": "Update Test",
|
||||||
|
"sender_emails": ["initial@example.com"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 200
|
||||||
|
newsletter_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Now, try to update it with an invalid email
|
||||||
|
update_data = {
|
||||||
|
"name": "Updated Name",
|
||||||
|
"sender_emails": ["not-a-valid-email"],
|
||||||
|
}
|
||||||
|
response = client.put(f"/newsletters/{newsletter_id}", json=update_data)
|
||||||
|
assert response.status_code == 422
|
||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"nanoid>=2.0.0",
|
"nanoid>=2.0.0",
|
||||||
"passlib>=1.7.4",
|
"passlib>=1.7.4",
|
||||||
"pydantic-settings>=2.10.1",
|
"pydantic-settings>=2.10.1",
|
||||||
|
"pydantic[email]>=2.11.7",
|
||||||
"python-dotenv>=1.1.1",
|
"python-dotenv>=1.1.1",
|
||||||
"python-jose[cryptography]>=3.5.0",
|
"python-jose[cryptography]>=3.5.0",
|
||||||
"python-multipart>=0.0.20",
|
"python-multipart>=0.0.20",
|
||||||
|
|||||||
29
backend/uv.lock
generated
29
backend/uv.lock
generated
@@ -265,6 +265,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" },
|
{ url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dnspython"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ecdsa"
|
name = "ecdsa"
|
||||||
version = "0.19.1"
|
version = "0.19.1"
|
||||||
@@ -277,6 +286,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email-validator"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "dnspython" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.116.1"
|
version = "0.116.1"
|
||||||
@@ -438,6 +460,7 @@ dependencies = [
|
|||||||
{ name = "feedgen" },
|
{ name = "feedgen" },
|
||||||
{ name = "nanoid" },
|
{ name = "nanoid" },
|
||||||
{ name = "passlib" },
|
{ name = "passlib" },
|
||||||
|
{ name = "pydantic", extra = ["email"] },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "python-jose", extra = ["cryptography"] },
|
{ name = "python-jose", extra = ["cryptography"] },
|
||||||
@@ -464,6 +487,7 @@ requires-dist = [
|
|||||||
{ name = "feedgen", specifier = ">=1.0.0" },
|
{ name = "feedgen", specifier = ">=1.0.0" },
|
||||||
{ name = "nanoid", specifier = ">=2.0.0" },
|
{ name = "nanoid", specifier = ">=2.0.0" },
|
||||||
{ name = "passlib", specifier = ">=1.7.4" },
|
{ name = "passlib", specifier = ">=1.7.4" },
|
||||||
|
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.10.1" },
|
{ name = "pydantic-settings", specifier = ">=2.10.1" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.1.1" },
|
{ name = "python-dotenv", specifier = ">=1.1.1" },
|
||||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
||||||
@@ -666,6 +690,11 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
email = [
|
||||||
|
{ name = "email-validator" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-core"
|
name = "pydantic-core"
|
||||||
version = "2.33.2"
|
version = "2.33.2"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { isValidEmail } from "@/lib/utils"
|
||||||
|
|
||||||
interface NewsletterDialogProps {
|
interface NewsletterDialogProps {
|
||||||
newsletter?: Newsletter | null
|
newsletter?: Newsletter | null
|
||||||
@@ -80,7 +81,7 @@ export function NewsletterDialog({ newsletter, isOpen, folderOptions, onOpenChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.name || !formData.emails.some((email) => email.trim())) {
|
if (!formData.name || !formData.emails.some((email) => email.trim() && isValidEmail(email))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +179,7 @@ export function NewsletterDialog({ newsletter, isOpen, folderOptions, onOpenChan
|
|||||||
onChange={(e) => handleEmailChange(index, e.target.value)}
|
onChange={(e) => handleEmailChange(index, e.target.value)}
|
||||||
placeholder="Enter email address"
|
placeholder="Enter email address"
|
||||||
type="email"
|
type="email"
|
||||||
|
aria-invalid={email.length > 0 && !isValidEmail(email)}
|
||||||
/>
|
/>
|
||||||
{formData.emails.length > 1 && (
|
{formData.emails.length > 1 && (
|
||||||
<Button variant="outline" size="sm" onClick={() => handleRemoveEmail(index)}>
|
<Button variant="outline" size="sm" onClick={() => handleRemoveEmail(index)}>
|
||||||
|
|||||||
@@ -4,3 +4,8 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isValidEmail(email: string): boolean {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
return emailRegex.test(email)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user