From d1fb1d34a11ce4f1502081881dff61f880566a61 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 10 Jun 2026 19:21:51 +0200 Subject: [PATCH] :bug: Fix email validation to reject consecutive dots in domain (#10096) * :bug: Tighten email validation regex to reject consecutive dots in domain * :paperclip: Add minor adjustments to gh-issue-from-pr skill --- .opencode/skills/gh-issue-from-pr/SKILL.md | 11 ++--- common/src/app/common/schema.cljc | 14 ++++-- common/test/common_tests/schema_test.cljc | 57 ++++++++++++++++++++++ 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/.opencode/skills/gh-issue-from-pr/SKILL.md b/.opencode/skills/gh-issue-from-pr/SKILL.md index 012d58f7be..bb7fe10cf9 100644 --- a/.opencode/skills/gh-issue-from-pr/SKILL.md +++ b/.opencode/skills/gh-issue-from-pr/SKILL.md @@ -39,8 +39,8 @@ Identify: | Field | Source | Rule | |-------|--------|------| -| **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. | -| **Labels** | PR labels | Copy user-facing labels (`bug`, `enhancement`, `community contribution`). Skip workflow labels (`backport candidate`, `team-qa`). | +| **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. Use the `issue-title` skill to generate this. | +| **Labels** | PR labels | Copy `community contribution` if present. Skip `bug` and `enhancement` (redundant with Issue Type). Skip workflow labels (`backport candidate`, `team-qa`). | | **Milestone** | PR milestone | **Always copy what's on the PR.** Fetch with: `gh pr view --json milestone --jq '.milestone.title'` If the PR has no milestone, create the issue without one. | | **Project** | Always `Main` | Penpot uses the `Main` project (number 8) for all issues. | | **Body** | PR's user-facing section | Extract steps to reproduce or feature description. Omit internal details. Use templates below. | @@ -101,8 +101,7 @@ Create: gh issue create \ --repo penpot/penpot \ --title "" \ - --label "<label1>" \ - --label "<label2>" \ + --label "community contribution" \ # only if PR has this label --milestone "<milestone>" \ --project "Main" \ --body-file /tmp/issue-body.md @@ -198,12 +197,10 @@ rm -f /tmp/issue-body.md /tmp/pr-body.md | PR has | Issue gets | |--------|-----------| -| `bug` | `bug` | -| `enhancement` | `enhancement` | | `community contribution` | `community contribution` | +| `bug`, `enhancement` | *(skip — redundant with Issue Type)* | | `backport candidate` | *(skip — workflow label)* | | `team-qa` | *(skip — workflow label)* | -| No user-facing label | Infer from title: `:bug:` → `bug`, `:sparkles:` → `enhancement` | ## Issue Type mapping diff --git a/common/src/app/common/schema.cljc b/common/src/app/common/schema.cljc index 8ba699984c..fba8169bcd 100644 --- a/common/src/app/common/schema.cljc +++ b/common/src/app/common/schema.cljc @@ -448,18 +448,22 @@ ::oapi/type "string" ::oapi/format "uuid"}}) -(def email-re #"[a-zA-Z0-9_.+-\\\\]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+") +;; Strict email regex aligned with app.common.spec/email-re. +;; Local part: valid RFC chars, no leading/trailing dot, no consecutive dots. +;; Domain: labels can't start/end with hyphen, no empty labels. +;; TLD: at least 2 alphabetic chars. +(def email-re + #"[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,63}") (defn parse-email [s] - (if (string? s) - (first (re-seq email-re s)) - nil)) + (when (and (string? s) (re-matches email-re s)) + s)) (defn email-string? [s] (and (string? s) - (re-seq email-re s))) + (some? (re-matches email-re s)))) (register! {:type ::email diff --git a/common/test/common_tests/schema_test.cljc b/common/test/common_tests/schema_test.cljc index b94d4ae4a4..b14f1df0f5 100644 --- a/common/test/common_tests/schema_test.cljc +++ b/common/test/common_tests/schema_test.cljc @@ -188,3 +188,60 @@ (t/is (= false (decode-s "f"))) (t/is (= true (decode-s "1"))) (t/is (= false (decode-s "0"))))) + +(t/deftest test-email-validation + (t/testing "accepts well-formed email addresses" + (doseq [email ["user@domain.com" + "user.name@domain.com" + "user+tag@domain.com" + "user-name@domain.com" + "user_name@domain.com" + "user123@domain.com" + "USER@DOMAIN.COM" + "u@domain.io" + "user@sub.domain.com" + "user@domain.co.uk" + "user@domain.dev" + "a@bc.co"]] + (t/is (sm/validate ::sm/email email) (str "should accept: " email)) + (t/is (= email (sm/decode ::sm/email email sm/json-transformer))))) + + (t/testing "rejects domain with consecutive dots (dot-dot)" + (t/is (false? (sm/validate ::sm/email "user@gmail.com.."))) + (t/is (false? (sm/validate ::sm/email "user@sub..domain.com"))) + (t/is (false? (sm/validate ::sm/email "eissaalbothigi@gmail.com.."))) + (t/is (nil? (sm/parse-email "user@gmail.com.."))) + (t/is (nil? (sm/parse-email "eissaalbothigi@gmail.com..")))) + + (t/testing "rejects domain ending with a dot" + (t/is (false? (sm/validate ::sm/email "user@domain."))) + (t/is (nil? (sm/parse-email "user@domain.")))) + + (t/testing "rejects domain starting with a dot" + (t/is (false? (sm/validate ::sm/email "user@.domain.com"))) + (t/is (nil? (sm/parse-email "user@.domain.com")))) + + (t/testing "rejects local part with consecutive dots" + (t/is (false? (sm/validate ::sm/email "user..name@domain.com"))) + (t/is (nil? (sm/parse-email "user..name@domain.com")))) + + (t/testing "rejects local part starting with a dot" + (t/is (false? (sm/validate ::sm/email ".user@domain.com"))) + (t/is (nil? (sm/parse-email ".user@domain.com")))) + + (t/testing "rejects label starting or ending with hyphen" + (t/is (false? (sm/validate ::sm/email "user@-domain.com"))) + (t/is (false? (sm/validate ::sm/email "user@domain-.com")))) + + (t/testing "rejects TLD shorter than 2 chars" + (t/is (false? (sm/validate ::sm/email "user@domain.c")))) + + (t/testing "rejects domain without a dot" + (t/is (false? (sm/validate ::sm/email "user@domain")))) + + (t/testing "rejects empty or malformed emails" + (t/is (false? (sm/validate ::sm/email ""))) + (t/is (false? (sm/validate ::sm/email "@domain.com"))) + (t/is (false? (sm/validate ::sm/email "user@"))) + (t/is (false? (sm/validate ::sm/email "userdomain.com"))) + (t/is (false? (sm/validate ::sm/email "user@@domain.com")))))