From 6ff4e817ef2a60a36b236d4f066c188d097f5d9c Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 24 Jul 2025 13:58:59 +0200 Subject: [PATCH] feat: email validation --- backend/app/schemas/newsletters.py | 8 ++-- backend/app/tests/test_email_validation.py | 47 +++++++++++++++++++ backend/pyproject.toml | 1 + backend/uv.lock | 29 ++++++++++++ .../letterfeed/NewsletterDialog.tsx | 4 +- frontend/src/lib/utils.ts | 5 ++ 6 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 backend/app/tests/test_email_validation.py diff --git a/backend/app/schemas/newsletters.py b/backend/app/schemas/newsletters.py index 9a535e0..c2e4b7a 100644 --- a/backend/app/schemas/newsletters.py +++ b/backend/app/schemas/newsletters.py @@ -1,6 +1,6 @@ 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 @@ -8,7 +8,7 @@ from app.core.slug import sanitize_slug class SenderBase(BaseModel): """Base schema for a sender.""" - email: str + email: EmailStr class SenderCreate(SenderBase): @@ -43,13 +43,13 @@ class NewsletterBase(BaseModel): class NewsletterCreate(NewsletterBase): """Schema for creating a new newsletter.""" - sender_emails: List[str] + sender_emails: List[EmailStr] class NewsletterUpdate(NewsletterBase): """Schema for updating an existing newsletter.""" - sender_emails: List[str] + sender_emails: List[EmailStr] class Newsletter(NewsletterBase): diff --git a/backend/app/tests/test_email_validation.py b/backend/app/tests/test_email_validation.py new file mode 100644 index 0000000..b12b792 --- /dev/null +++ b/backend/app/tests/test_email_validation.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5d50b1e..a1c29a4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "nanoid>=2.0.0", "passlib>=1.7.4", "pydantic-settings>=2.10.1", + "pydantic[email]>=2.11.7", "python-dotenv>=1.1.1", "python-jose[cryptography]>=3.5.0", "python-multipart>=0.0.20", diff --git a/backend/uv.lock b/backend/uv.lock index 2379217..50195f8 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" }, ] +[[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]] name = "ecdsa" 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" }, ] +[[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]] name = "fastapi" version = "0.116.1" @@ -438,6 +460,7 @@ dependencies = [ { name = "feedgen" }, { name = "nanoid" }, { name = "passlib" }, + { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "python-jose", extra = ["cryptography"] }, @@ -464,6 +487,7 @@ requires-dist = [ { name = "feedgen", specifier = ">=1.0.0" }, { name = "nanoid", specifier = ">=2.0.0" }, { name = "passlib", specifier = ">=1.7.4" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "python-dotenv", specifier = ">=1.1.1" }, { 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" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.33.2" diff --git a/frontend/src/components/letterfeed/NewsletterDialog.tsx b/frontend/src/components/letterfeed/NewsletterDialog.tsx index 1aa2ab4..f73a807 100644 --- a/frontend/src/components/letterfeed/NewsletterDialog.tsx +++ b/frontend/src/components/letterfeed/NewsletterDialog.tsx @@ -20,6 +20,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { isValidEmail } from "@/lib/utils" interface NewsletterDialogProps { newsletter?: Newsletter | null @@ -80,7 +81,7 @@ export function NewsletterDialog({ newsletter, isOpen, folderOptions, onOpenChan } const handleSubmit = async () => { - if (!formData.name || !formData.emails.some((email) => email.trim())) { + if (!formData.name || !formData.emails.some((email) => email.trim() && isValidEmail(email))) { return } @@ -178,6 +179,7 @@ export function NewsletterDialog({ newsletter, isOpen, folderOptions, onOpenChan onChange={(e) => handleEmailChange(index, e.target.value)} placeholder="Enter email address" type="email" + aria-invalid={email.length > 0 && !isValidEmail(email)} /> {formData.emails.length > 1 && (