mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Compare commits
5 Commits
master
...
fix/gmail-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54645c9747 | ||
|
|
fbb3af2c14 | ||
|
|
b217d47de5 | ||
|
|
a9776b58cc | ||
|
|
aa749c347d |
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user