Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 17 additions & 4 deletions plugins/platforms/email/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,8 @@ def _extract_attachments(
class EmailAdapter(BasePlatformAdapter):
"""Email gateway adapter using IMAP (receive) and SMTP (send)."""

splits_long_messages = True # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.EMAIL)

Expand Down Expand Up @@ -942,12 +944,23 @@ async def send(
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an email reply to the given address."""
"""Send an email reply to the given address.

Long content is split into multiple emails at ``MAX_MESSAGE_LENGTH``
(Gmail-safe body size) rather than being cut off. This is what
``splits_long_messages = True`` promises the delivery router: cron
output above the router's non-chunking cap reaches the mailbox whole
instead of truncated at 4,000 chars (a Telegram-era limit that email
never shared).
"""
try:
loop = asyncio.get_running_loop()
message_id = await loop.run_in_executor(
None, self._send_email, chat_id, content, reply_to
)
chunks = self.truncate_message(content, MAX_MESSAGE_LENGTH) or [content]
message_id = None
for chunk in chunks:
message_id = await loop.run_in_executor(
None, self._send_email, chat_id, chunk, reply_to
)
return SendResult(success=True, message_id=message_id)
except Exception as e:
logger.error("[Email] Send failed to %s: %s", chat_id, e)
Expand Down
70 changes: 70 additions & 0 deletions tests/gateway/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,76 @@ def _make_adapter(self):
adapter = EmailAdapter(PlatformConfig(enabled=True))
return adapter

def test_declares_native_chunking(self):
"""Email must advertise chunking so the delivery router skips its
non-chunking truncation cap (a Telegram-era 4,000-char limit that
silently severed a 5,199-char cron memo)."""
from plugins.platforms.email.adapter import EmailAdapter
self.assertTrue(EmailAdapter.splits_long_messages)

def test_send_delivers_long_content_whole(self):
"""Content above the router's 4,000-char cap must arrive intact."""
import asyncio
adapter = self._make_adapter()
sent = []
adapter._send_email = lambda to, body, reply=None: (
sent.append(body), "<id@test>")[1]

long_content = "line\n" * 1200 # 6,000 chars — over the old cap
result = asyncio.run(adapter.send("user@test.com", long_content))

self.assertTrue(result.success)
self.assertEqual(len(sent), 1) # under MAX_MESSAGE_LENGTH: one email
self.assertEqual(sent[0], long_content)
self.assertNotIn("truncated", sent[0].lower())

def test_send_splits_above_body_limit(self):
"""Beyond the Gmail-safe body size, send() splits across emails
instead of dropping the overflow."""
import asyncio
from plugins.platforms.email.adapter import MAX_MESSAGE_LENGTH
adapter = self._make_adapter()
sent = []
adapter._send_email = lambda to, body, reply=None: (
sent.append(body), "<id@test>")[1]

huge = "z" * (MAX_MESSAGE_LENGTH * 2 + 100)
result = asyncio.run(adapter.send("user@test.com", huge))

self.assertTrue(result.success)
self.assertGreater(len(sent), 1)
# Every original character survives across the chunks.
self.assertEqual(sum(c.count("z") for c in sent), huge.count("z"))

def test_router_does_not_truncate_email_delivery(self):
"""End-to-end through the real DeliveryRouter: an oversized cron
payload reaches the email adapter whole, with the audit copy still
written to disk."""
import asyncio, tempfile, pathlib
from gateway.delivery import DeliveryRouter, DeliveryTarget
from gateway.config import GatewayConfig, Platform

adapter = self._make_adapter()
sent = []
adapter._send_email = lambda to, body, reply=None: (
sent.append(body), "<id@test>")[1]

payload = "memo body\n" * 600 # 6,000 chars
tmp = pathlib.Path(tempfile.mkdtemp())
with patch("gateway.delivery.get_hermes_home", lambda: tmp):
router = DeliveryRouter(
GatewayConfig(), adapters={Platform.EMAIL: adapter})
asyncio.run(router._deliver_to_platform(
DeliveryTarget.parse("email:user@test.com"),
payload,
metadata={"job_id": "shift1"},
))

self.assertEqual("".join(sent), payload)
self.assertNotIn("truncated", "".join(sent).lower())
saved = list(tmp.glob("cron/output/shift1_*.txt"))
self.assertEqual(len(saved), 1)
self.assertEqual(saved[0].read_text(), payload)

def test_send_document_with_attachment(self):
"""send_document should send email with file attachment."""
Expand Down