Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## [Unreleased]
- 텍스트 본문에서 유효한 ASCII 이메일 주소를 찾아 최초 출현 순서로 중복을 제거하는 "이메일 주소 추출기 (Email Address Extractor)" 도구를 추가했습니다.
- 텍스트 본문에서 HTTP 및 HTTPS URL을 추출하여 중복 없이 반환하는 유틸리티 도구인 `url_extractor` (URL 추출기)를 추가했습니다.
- 분석·유틸리티 도구 2종(`hash_generator`, `email_phone_masker`)을 추가했습니다. 해시 도구는 MD5·SHA-1 호환 fingerprint와 SHA-256을 구분하고, 연락처 도구는 제한된 길이 안에서 이메일 주소와 전화번호를 단순 마스킹합니다.
### Source-bound 요약·업무·관계·일정 경계
Expand Down
32 changes: 32 additions & 0 deletions backend/api/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,38 @@ async def email_phone_masker_handler(params: Dict[str, Any]) -> Dict[str, str]:
)


async def email_address_extractor_handler(params: Dict[str, Any]) -> Dict[str, Any]:
"""Extract valid ASCII email addresses in first-occurrence order."""
text = params.get("text", "")
if len(text) > ANALYSIS_TEXT_MAX_CHARS:
Comment on lines +641 to +644

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Registry enforces text input

The execution path requires text and validates its string type before invoking the handler. The empty fallback only affects direct internal calls.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

확인했습니다. public registry path가 text 존재와 문자열 형식을 검증하므로 handler의 빈 fallback은 direct internal call에서만 작동합니다. 이 informational finding은 trust-boundary 변경을 요구하지 않으며 현재 signed API envelope test가 public 경로를 검증합니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Acknowledged.

raise ValueError(
f"Analysis text must not exceed {ANALYSIS_TEXT_MAX_CHARS} characters"
)

unique_emails: list[str] = []
seen_addresses: set[str] = set()
for match in _EMAIL_PATTERN.finditer(text):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject matches inside malformed multi-@ tokens

When the input contains a malformed token such as alice@example.com@evil.test, the shared _EMAIL_PATTERN stops at .com because its trailing boundary does not exclude @, so this loop returns alice@example.com as a valid extracted address; conversely, x@alice@example.com returns the suffix alice@example.com because the leading boundary also permits @. This turns malformed mailbox text into contact data contrary to the new valid-address contract. Exclude @ at both match boundaries and add these cases to pytest backend/tests/test_tools_api.py::test_email_address_extractor_handler -q.

Useful? React with 👍 / 👎.

email_address = match.group(0)
normalized_address = email_address.casefold()
if normalized_address not in seen_addresses:
seen_addresses.add(normalized_address)
unique_emails.append(email_address)

return {"emails": unique_emails, "count": len(unique_emails)}


registry.register(
ToolInfo(
code="email_address_extractor",
name="이메일 주소 추출기 (Email Address Extractor)",
description="텍스트 본문에서 유효한 ASCII 이메일 주소를 찾아 중복을 제거하여 추출합니다.",
category="이메일 분석",
parameters={"text": "string"},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Wire the extractor to usable console inputs and output

Registering this parameterized tool automatically exposes it in the existing /tools console, but the cross-file frontend implementation never lets the user supply text: buildDefaultParameters() posts the literal "test_value", and the card only renders the parameter schema. The console also hides the returned email list because every successful backend response has a nonempty message and resultMessage() prefers that message over result. Consequently, browser users can neither submit email-containing text nor see extracted addresses. Add editable parameter controls and render the structured success result, with coverage in pnpm --dir frontend test src/app/tools/page.test.tsx.

Useful? React with 👍 / 👎.

),
email_address_extractor_handler,
)


async def uuid_v4_generator_handler(params: Dict[str, Any]) -> Dict[str, str]:
"""Generate one RFC 9562 UUID version 4 for the retained built-in utility."""
return {"uuid": str(uuid.uuid4())}
Expand Down
64 changes: 64 additions & 0 deletions backend/tests/test_tools_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1224,6 +1224,70 @@ async def test_keyword_extractor_handler():
assert empty == {"keywords": [], "keyword_count": 0}


@pytest.mark.asyncio
async def test_email_address_extractor_handler():
from api.tools import email_address_extractor_handler

text = (
"Please contact John.Doe@example.com or support@example.com. "
"Then john.doe@EXAMPLE.COM or user@mail.example.com"
)
first = await email_address_extractor_handler({"text": text})
second = await email_address_extractor_handler({"text": text})

assert first == second
assert first == {
"emails": [
"John.Doe@example.com",
"support@example.com",
"user@mail.example.com",
],
"count": 3,
}
assert await email_address_extractor_handler({"text": "No emails here."}) == {
"emails": [],
"count": 0,
}
assert await email_address_extractor_handler(
{"text": "Reject a@b..com but keep support@example.com..."}
) == {"emails": ["support@example.com"], "count": 1}


def test_execute_email_address_extractor_envelope():
with TestClient(app) as client:
response = client.post(
"/api/tools/email_address_extractor/execute",
headers={"Authorization": f"Bearer {_signed_session_token()}"},
json={"parameters": {"text": "Contact test@example.com."}},
)

assert response.status_code == 200
assert response.json()["result"] == {
"emails": ["test@example.com"],
"count": 1,
}


def test_email_address_extractor_rejects_oversized_text():
from api.tools import ANALYSIS_TEXT_MAX_CHARS

with TestClient(app) as client:
response = client.post(
"/api/tools/email_address_extractor/execute",
headers={"Authorization": f"Bearer {_signed_session_token()}"},
json={"parameters": {"text": "x" * (ANALYSIS_TEXT_MAX_CHARS + 1)}},
)

assert response.status_code == 200
assert response.json() == {
"status": "failed",
"result": None,
"message": (
f"Analysis text must not exceed {ANALYSIS_TEXT_MAX_CHARS} characters"
),
}


def test_execute_analysis_tool_rejects_oversized_text():
from api.tools import ANALYSIS_TEXT_MAX_CHARS

Expand Down
34 changes: 34 additions & 0 deletions docs/doctoring/email-address-extractor-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Email address extractor contract

## Problem

The first extractor used a second permissive regular expression. It accepted
empty domain labels such as `a@b..com` and then tried to repair sentence
punctuation after matching. That disagreed with the email masker and allowed
the two tools to classify the same address differently.

## Boundary

The extractor and masker now share `_EMAIL_PATTERN` in `backend/api/tools.py`.
It accepts a bounded ASCII dot-atom local part and DNS-style domain labels,
preserves the first spelling encountered, and deduplicates case-insensitively.
Quoted local parts, comments, internationalized addresses, domain literals,
and full mailbox parsing remain outside this utility tool's claim.

This is an extraction aid, not an RFC-complete mailbox validator. Sending and
identity boundaries must still use their protocol-specific validation.

## Verification

`backend/tests/test_tools_api.py` covers mixed-case duplicate addresses,
subdomains, sentence punctuation, ellipses, malformed empty domain labels,
the signed API envelope, empty input, and the shared input-size limit.

## Reference

Resnick, P. (2008). *Internet message format* (RFC 5322). Internet Engineering
Task Force. https://doi.org/10.17487/RFC5322

RFC 5322 sections 3.2.3 and 3.4.1 define dot-atoms and address syntax. The
bounded matcher deliberately implements only the common ASCII dot-atom and
DNS-label subset described above, avoiding claims of complete RFC parsing.