feat: email validation

This commit is contained in:
Leon
2025-07-24 13:58:59 +02:00
parent 24e65a8c86
commit 6ff4e817ef
6 changed files with 89 additions and 5 deletions

View File

@@ -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):

View 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

View File

@@ -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
View File

@@ -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"

View File

@@ -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)}>

View File

@@ -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)
}