Compare commits

...

5 Commits

Author SHA1 Message Date
Zamil Majdy
54645c9747 fix(blocks): add HasRecipients Protocol, extract validate_all_recipients, add forward test
- Add typed HasRecipients Protocol for validate_all_recipients parameter
- Extract duplicate validation into validate_all_recipients() helper
- Remove duplicate comments at three call sites
- Add TestForwardMessageValidation integration tests
2026-03-31 12:20:37 +02:00
Zamil Majdy
fbb3af2c14 test(blocks): add integration tests for email validation in create_mime_message and _build_reply_message
Verify that ValueError is raised for invalid recipients in to/cc/bcc
fields, including auto-resolved recipients from parent message headers.
2026-03-25 19:13:23 +07:00
Zamil Majdy
b217d47de5 fix(blocks): strip whitespace in MIME headers and align to-validation in reply path
- Updated serialize_email_recipients() to strip whitespace from each
  address, keeping MIME headers consistent with the .strip() already
  applied in validate_email_recipients().
- Replaced all raw ", ".join() calls for recipient headers with
  serialize_email_recipients() across create_mime_message,
  _build_reply_message, and _forward_message.
- Made to-field validation unconditional in _build_reply_message(),
  matching the pattern in create_mime_message() and _forward_message().
2026-03-25 18:59:55 +07:00
Zamil Majdy
a9776b58cc fix(blocks): add email validation to GmailForwardBlock._forward_message()
The forward block bypassed the new validate_email_recipients() check
since it constructs its own MIME message inline rather than going
through create_mime_message() or _build_reply_message().
2026-03-25 18:04:57 +07:00
Krishna Chaitanya Balusu
aa749c347d fix(blocks): validate email recipients in Gmail blocks before API call
Addresses #11954 — GmailSendBlock crashes with an opaque "Invalid To
header" HttpError 400 when the LLM (or user) supplies a malformed
recipient such as a bare username, a JSON string, or an empty value.

Add a lightweight `validate_email_recipients()` check in the shared
`create_mime_message()` path and in `_build_reply_message()` so that
every Gmail block that sends or drafts email gets upfront validation
with a clear, actionable error message listing the invalid entries.
2026-03-24 23:17:38 -04:00
2 changed files with 321 additions and 12 deletions

View File

@@ -1,5 +1,6 @@
import asyncio
import base64
import re
from abc import ABC
from email import encoders
from email.mime.base import MIMEBase
@@ -8,7 +9,7 @@ from email.mime.text import MIMEText
from email.policy import SMTP
from email.utils import getaddresses, parseaddr
from pathlib import Path
from typing import List, Literal, Optional
from typing import List, Literal, Optional, Protocol, runtime_checkable
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
@@ -42,8 +43,47 @@ NO_WRAP_POLICY = SMTP.clone(max_line_length=0)
def serialize_email_recipients(recipients: list[str]) -> str:
"""Serialize recipients list to comma-separated string."""
return ", ".join(recipients)
"""Serialize recipients list to comma-separated string.
Strips leading/trailing whitespace from each address to keep MIME
headers clean (mirrors the strip done in ``validate_email_recipients``).
"""
return ", ".join(addr.strip() for addr in recipients)
# RFC 5322 simplified pattern: local@domain where domain has at least one dot
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@runtime_checkable
class HasRecipients(Protocol):
to: list[str]
cc: list[str]
bcc: list[str]
def validate_all_recipients(input_data: HasRecipients) -> None:
"""Validate to/cc/bcc recipient lists on the given input data."""
validate_email_recipients(input_data.to, "to")
if input_data.cc:
validate_email_recipients(input_data.cc, "cc")
if input_data.bcc:
validate_email_recipients(input_data.bcc, "bcc")
def validate_email_recipients(recipients: list[str], field_name: str = "to") -> None:
"""Validate that all recipients are plausible email addresses.
Raises ``ValueError`` with a user-friendly message listing every
invalid entry so the caller (or LLM) can correct them in one pass.
"""
invalid = [addr for addr in recipients if not _EMAIL_RE.match(addr.strip())]
if invalid:
formatted = ", ".join(f"'{a}'" for a in invalid)
raise ValueError(
f"Invalid email address(es) in '{field_name}': {formatted}. "
f"Each entry must be a valid email address (e.g. user@example.com)."
)
def _make_mime_text(
@@ -100,14 +140,16 @@ async def create_mime_message(
) -> str:
"""Create a MIME message with attachments and return base64-encoded raw message."""
validate_all_recipients(input_data)
message = MIMEMultipart()
message["to"] = serialize_email_recipients(input_data.to)
message["subject"] = input_data.subject
if input_data.cc:
message["cc"] = ", ".join(input_data.cc)
message["cc"] = serialize_email_recipients(input_data.cc)
if input_data.bcc:
message["bcc"] = ", ".join(input_data.bcc)
message["bcc"] = serialize_email_recipients(input_data.bcc)
# Use the new helper function with content_type if available
content_type = getattr(input_data, "content_type", None)
@@ -1167,13 +1209,15 @@ async def _build_reply_message(
references.append(headers["message-id"])
# Create MIME message
validate_all_recipients(input_data)
msg = MIMEMultipart()
if input_data.to:
msg["To"] = ", ".join(input_data.to)
msg["To"] = serialize_email_recipients(input_data.to)
if input_data.cc:
msg["Cc"] = ", ".join(input_data.cc)
msg["Cc"] = serialize_email_recipients(input_data.cc)
if input_data.bcc:
msg["Bcc"] = ", ".join(input_data.bcc)
msg["Bcc"] = serialize_email_recipients(input_data.bcc)
msg["Subject"] = subject
if headers.get("message-id"):
msg["In-Reply-To"] = headers["message-id"]
@@ -1685,13 +1729,15 @@ To: {original_to}
else:
body = f"{forward_header}\n\n{original_body}"
validate_all_recipients(input_data)
# Create MIME message
msg = MIMEMultipart()
msg["To"] = ", ".join(input_data.to)
msg["To"] = serialize_email_recipients(input_data.to)
if input_data.cc:
msg["Cc"] = ", ".join(input_data.cc)
msg["Cc"] = serialize_email_recipients(input_data.cc)
if input_data.bcc:
msg["Bcc"] = ", ".join(input_data.bcc)
msg["Bcc"] = serialize_email_recipients(input_data.bcc)
msg["Subject"] = subject
# Add body with proper content type

View File

@@ -1,9 +1,17 @@
import base64
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from backend.blocks.google.gmail import GmailReadBlock
from backend.blocks.google.gmail import (
GmailForwardBlock,
GmailReadBlock,
_build_reply_message,
create_mime_message,
validate_email_recipients,
)
from backend.data.execution import ExecutionContext
class TestGmailReadBlock:
@@ -250,3 +258,258 @@ class TestGmailReadBlock:
result = await self.gmail_block._get_email_body(msg, self.mock_service)
assert result == "This email does not contain a readable body."
class TestValidateEmailRecipients:
"""Test cases for validate_email_recipients."""
def test_valid_single_email(self):
validate_email_recipients(["user@example.com"])
def test_valid_multiple_emails(self):
validate_email_recipients(["a@b.com", "x@y.org", "test@sub.domain.co"])
def test_invalid_missing_at(self):
with pytest.raises(ValueError, match="Invalid email address"):
validate_email_recipients(["not-an-email"])
def test_invalid_missing_domain_dot(self):
with pytest.raises(ValueError, match="Invalid email address"):
validate_email_recipients(["user@localhost"])
def test_invalid_empty_string(self):
with pytest.raises(ValueError, match="Invalid email address"):
validate_email_recipients([""])
def test_invalid_json_object_string(self):
with pytest.raises(ValueError, match="Invalid email address"):
validate_email_recipients(['{"email": "user@example.com"}'])
def test_mixed_valid_and_invalid(self):
with pytest.raises(ValueError, match="'bad-addr'"):
validate_email_recipients(["good@example.com", "bad-addr"])
def test_field_name_in_error(self):
with pytest.raises(ValueError, match="'cc'"):
validate_email_recipients(["nope"], field_name="cc")
def test_whitespace_trimmed(self):
validate_email_recipients([" user@example.com "])
def test_empty_list_passes(self):
validate_email_recipients([])
class TestCreateMimeMessageValidation:
"""Test that create_mime_message() raises ValueError for invalid recipients."""
@staticmethod
def _make_input(to=None, cc=None, bcc=None):
return SimpleNamespace(
to=to or ["valid@example.com"],
cc=cc or [],
bcc=bcc or [],
subject="Test",
body="Hello",
content_type=None,
attachments=[],
)
@staticmethod
def _exec_ctx():
return ExecutionContext(user_id="u1", graph_exec_id="g1")
@pytest.mark.asyncio
async def test_invalid_to_raises(self):
with pytest.raises(ValueError, match="Invalid email address.*'to'"):
await create_mime_message(
self._make_input(to=["not-an-email"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_invalid_cc_raises(self):
with pytest.raises(ValueError, match="Invalid email address.*'cc'"):
await create_mime_message(
self._make_input(cc=["bad-addr"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_invalid_bcc_raises(self):
with pytest.raises(ValueError, match="Invalid email address.*'bcc'"):
await create_mime_message(
self._make_input(bcc=["nope"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_valid_recipients_no_error(self):
"""Sanity check: valid emails should not raise."""
result = await create_mime_message(
self._make_input(
to=["alice@example.com"],
cc=["bob@example.com"],
bcc=["carol@example.com"],
),
self._exec_ctx(),
)
assert isinstance(result, str) # base64-encoded message
class TestBuildReplyMessageValidation:
"""Test that _build_reply_message() raises ValueError for invalid recipients."""
@staticmethod
def _make_input(to=None, cc=None, bcc=None):
return SimpleNamespace(
threadId="t1",
parentMessageId="m1",
to=to or [],
cc=cc or [],
bcc=bcc or [],
replyAll=False,
subject="",
body="Reply body",
content_type=None,
attachments=[],
)
@staticmethod
def _exec_ctx():
return ExecutionContext(user_id="u1", graph_exec_id="g1")
@staticmethod
def _mock_service(from_addr="sender@example.com"):
"""Build a mock Gmail service that returns a parent message."""
parent_message = {
"payload": {
"headers": [
{"name": "Subject", "value": "Original subject"},
{"name": "Message-ID", "value": "<abc@mail.example.com>"},
{"name": "From", "value": from_addr},
{"name": "To", "value": "me@example.com"},
]
}
}
svc = Mock()
svc.users().messages().get().execute.return_value = parent_message
return svc
@pytest.mark.asyncio
async def test_invalid_to_raises(self):
with pytest.raises(ValueError, match="Invalid email address.*'to'"):
await _build_reply_message(
self._mock_service(),
self._make_input(to=["bad-addr"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_invalid_cc_raises(self):
with pytest.raises(ValueError, match="Invalid email address.*'cc'"):
await _build_reply_message(
self._mock_service(),
self._make_input(to=["valid@example.com"], cc=["not-valid"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_invalid_bcc_raises(self):
with pytest.raises(ValueError, match="Invalid email address.*'bcc'"):
await _build_reply_message(
self._mock_service(),
self._make_input(to=["valid@example.com"], bcc=["nope"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_auto_resolved_invalid_from_raises(self):
"""When to/cc/bcc are empty, recipients are resolved from the parent.
If the parent From header is invalid, validation should still fire."""
with pytest.raises(ValueError, match="Invalid email address.*'to'"):
await _build_reply_message(
self._mock_service(from_addr="bad-sender"),
self._make_input(), # to=[] triggers auto-resolution
self._exec_ctx(),
)
class TestForwardMessageValidation:
"""Test that _forward_message() raises ValueError for invalid recipients."""
@staticmethod
def _make_input(
to: list[str] | None = None,
cc: list[str] | None = None,
bcc: list[str] | None = None,
) -> "GmailForwardBlock.Input":
mock = Mock(spec=GmailForwardBlock.Input)
mock.messageId = "m1"
mock.to = to or []
mock.cc = cc or []
mock.bcc = bcc or []
mock.subject = ""
mock.forwardMessage = "FYI"
mock.includeAttachments = False
mock.content_type = None
mock.additionalAttachments = []
mock.credentials = None
return mock
@staticmethod
def _exec_ctx():
return ExecutionContext(user_id="u1", graph_exec_id="g1")
@staticmethod
def _mock_service():
"""Build a mock Gmail service that returns a parent message."""
parent_message = {
"id": "m1",
"payload": {
"headers": [
{"name": "Subject", "value": "Original subject"},
{"name": "From", "value": "sender@example.com"},
{"name": "To", "value": "me@example.com"},
{"name": "Date", "value": "Mon, 31 Mar 2026 00:00:00 +0000"},
],
"mimeType": "text/plain",
"body": {
"data": base64.urlsafe_b64encode(b"Hello world").decode(),
},
"parts": [],
},
}
svc = Mock()
svc.users().messages().get().execute.return_value = parent_message
return svc
@pytest.mark.asyncio
async def test_invalid_to_raises(self):
block = GmailForwardBlock()
with pytest.raises(ValueError, match="Invalid email address.*'to'"):
await block._forward_message(
self._mock_service(),
self._make_input(to=["bad-addr"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_invalid_cc_raises(self):
block = GmailForwardBlock()
with pytest.raises(ValueError, match="Invalid email address.*'cc'"):
await block._forward_message(
self._mock_service(),
self._make_input(to=["valid@example.com"], cc=["not-valid"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_invalid_bcc_raises(self):
block = GmailForwardBlock()
with pytest.raises(ValueError, match="Invalid email address.*'bcc'"):
await block._forward_message(
self._mock_service(),
self._make_input(to=["valid@example.com"], bcc=["nope"]),
self._exec_ctx(),
)