Skip to content
Open
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
16 changes: 13 additions & 3 deletions plugins/platforms/email/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email.utils import formatdate
from email.utils import formataddr, formatdate
from email import encoders
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
Expand Down Expand Up @@ -342,6 +342,11 @@ def __init__(self, config: PlatformConfig):
tls_verify = lambda env, key: _esecret_bool(env, is_truthy_value(extra.get(key), default=True)) # noqa: E731
self._address = setting("EMAIL_ADDRESS", "address").strip()
self._password = _get_secret("EMAIL_PASSWORD", "")
# Display name for the outbound From: header — a bare address renders
# nameless in clients and hurts spam-filter placement (Gmail → Outlook).
self._sender_name = (
_get_secret("EMAIL_SENDER_NAME", "") or extra.get("sender_name", "Hermes")
).strip() or "Hermes"
self._imap_host = setting("EMAIL_IMAP_HOST", "imap_host").strip()
self._imap_port = _esecret_int("EMAIL_IMAP_PORT", 993)
self._imap_security = _normalize_security(setting("EMAIL_IMAP_SECURITY", "imap_security"))
Expand Down Expand Up @@ -662,6 +667,10 @@ def _message_id_domain(self) -> str:
"""Domain for generated Message-IDs; ``localhost`` when EMAIL_ADDRESS lacks ``@``."""
return (self._address.rsplit("@", 1)[-1] if "@" in self._address else "") or "localhost"

def _from_header(self) -> str:
"""RFC 2047-safe From: header (formataddr encodes non-ASCII names)."""
return formataddr((self._sender_name, self._address))

def _new_reply(self, to_addr: str, body: str, reply_to_msg_id: Optional[str] = None, *,
attach_empty_body: bool = False) -> Tuple[MIMEMultipart, str, str]:
"""Build a threaded reply skeleton. Returns ``(msg, msg_id, subject)``."""
Expand All @@ -672,7 +681,7 @@ def _new_reply(self, to_addr: str, body: str, reply_to_msg_id: Optional[str] = N
original_msg_id = reply_to_msg_id or ctx.get("message_id")
threading = (("In-Reply-To", original_msg_id), ("References", original_msg_id)) if original_msg_id else ()
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._message_id_domain()}>"
for key, value in (("From", self._address), ("To", to_addr), ("Subject", subject), *threading,
for key, value in (("From", self._from_header()), ("To", to_addr), ("Subject", subject), *threading,
("Date", formatdate(localtime=True)), ("Message-ID", msg_id)):
msg[key] = value
if body or attach_empty_body:
Expand Down Expand Up @@ -765,14 +774,15 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f
"""Out-of-process Email delivery via SMTP (one-shot); standalone_sender_fn contract."""
extra = getattr(pconfig, "extra", {}) or {}
address, password = extra.get("address") or _get_secret("EMAIL_ADDRESS", ""), _get_secret("EMAIL_PASSWORD", "")
sender_name = (_get_secret("EMAIL_SENDER_NAME", "") or extra.get("sender_name", "Hermes")).strip() or "Hermes"
smtp_host, smtp_port = extra.get("smtp_host") or _get_secret("EMAIL_SMTP_HOST", ""), _esecret_int("EMAIL_SMTP_PORT", 587)
smtp_security = _normalize_security(_get_secret("EMAIL_SMTP_SECURITY", "") or extra.get("smtp_security"), default="tls" if smtp_port == 465 else "starttls")
smtp_tls_verify = _esecret_bool("EMAIL_SMTP_TLS_VERIFY", is_truthy_value(extra.get("smtp_tls_verify"), default=True))
if not all([address, password, smtp_host]):
return {"error": "Email not configured (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_SMTP_HOST required)"}
try:
msg = MIMEText(message, "plain", "utf-8")
for key, value in (("From", address), ("To", chat_id), ("Subject", "Hermes Agent"), ("Date", formatdate(localtime=True))):
for key, value in (("From", formataddr((sender_name, address))), ("To", chat_id), ("Subject", "Hermes Agent"), ("Date", formatdate(localtime=True))):
msg[key] = value
server = _open_smtp(smtp_host, smtp_port, smtp_security, _tls_context(smtp_tls_verify, smtp_host), smtplib.SMTP, smtplib.SMTP_SSL)
server.login(address, password)
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -831,7 +831,7 @@ async def _send_email(extra, chat_id, message):
self.assertEqual(send_call["Subject"], "Hermes Agent")
self.assertIn("Date", send_call)
self.assertEqual(send_call["To"], "user@test.com")
self.assertEqual(send_call["From"], "hermes@test.com")
self.assertEqual(send_call["From"], "Hermes <hermes@test.com>")


class TestSmtpConnectionCleanup(unittest.TestCase):
Expand Down
84 changes: 84 additions & 0 deletions tests/gateway/test_email_sender_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Outbound From: header carries a configurable display name.

A bare address renders nameless in mail clients and worsens spam-filter
placement; EMAIL_SENDER_NAME / platforms.email.extra.sender_name give the
operator a per-instance display name (default "Hermes"), RFC 2047-encoded
for non-ASCII names via formataddr.
"""

import os
import unittest
from unittest.mock import patch


def _make_adapter(extra=None):
from gateway.config import PlatformConfig
from plugins.platforms.email.adapter import EmailAdapter
cfg = PlatformConfig(enabled=True)
if extra is not None:
cfg.extra = extra
return EmailAdapter(cfg)


class TestSenderNameHeader(unittest.TestCase):

def test_default_sender_name(self):
with patch.dict(os.environ, {
"EMAIL_ADDRESS": "hermes@test.com",
"EMAIL_PASSWORD": "secret",
"EMAIL_IMAP_HOST": "imap.test.com",
"EMAIL_SMTP_HOST": "smtp.test.com",
}):
adapter = _make_adapter()
self.assertEqual(adapter._sender_name, "Hermes")
self.assertEqual(adapter._from_header(), "Hermes <hermes@test.com>")

def test_env_overrides_default(self):
with patch.dict(os.environ, {
"EMAIL_ADDRESS": "hermes@test.com",
"EMAIL_PASSWORD": "secret",
"EMAIL_IMAP_HOST": "imap.test.com",
"EMAIL_SMTP_HOST": "smtp.test.com",
"EMAIL_SENDER_NAME": "Dev Gateway",
}):
adapter = _make_adapter()
self.assertEqual(adapter._from_header(), "Dev Gateway <hermes@test.com>")

def test_extra_config_used_when_env_unset(self):
with patch.dict(os.environ, {
"EMAIL_ADDRESS": "hermes@test.com",
"EMAIL_PASSWORD": "secret",
"EMAIL_IMAP_HOST": "imap.test.com",
"EMAIL_SMTP_HOST": "smtp.test.com",
}):
adapter = _make_adapter(extra={"sender_name": "VPS Bot"})
self.assertEqual(adapter._from_header(), "VPS Bot <hermes@test.com>")

def test_non_ascii_name_is_rfc2047_encoded(self):
with patch.dict(os.environ, {
"EMAIL_ADDRESS": "hermes@test.com",
"EMAIL_PASSWORD": "secret",
"EMAIL_IMAP_HOST": "imap.test.com",
"EMAIL_SMTP_HOST": "smtp.test.com",
"EMAIL_SENDER_NAME": "Эркюль",
}):
adapter = _make_adapter()
header = adapter._from_header()
self.assertIn("hermes@test.com", header)
self.assertNotIn("Эркюль", header) # encoded, not raw UTF-8

def test_reply_uses_display_name(self):
with patch.dict(os.environ, {
"EMAIL_ADDRESS": "hermes@test.com",
"EMAIL_PASSWORD": "secret",
"EMAIL_IMAP_HOST": "imap.test.com",
"EMAIL_SMTP_HOST": "smtp.test.com",
"EMAIL_SENDER_NAME": "Dev Gateway",
}):
adapter = _make_adapter()
msg, _msg_id, _subject = adapter._new_reply("user@test.com", "hi")
self.assertEqual(msg["From"], "Dev Gateway <hermes@test.com>")


if __name__ == "__main__":
unittest.main()
Loading