diff --git a/bot/chorus_bot/cogs/email_ingest.py b/bot/chorus_bot/cogs/email_ingest.py index fb3075c..6030342 100644 --- a/bot/chorus_bot/cogs/email_ingest.py +++ b/bot/chorus_bot/cogs/email_ingest.py @@ -1,7 +1,6 @@ import json import os from pathlib import Path -import discord from discord.ext import commands, tasks from imaplib import IMAP4_SSL from email import message_from_bytes @@ -10,6 +9,13 @@ from chorus_bot.ping import build_ping_embed SEEN_UIDS_PATH = Path(os.environ.get("CHORUS_SEEN_UIDS_PATH", "seen_uids.json")) +# Discord embed field values cap at 1024 chars. Truncate the raw_content we pass +# into the embed with headroom below that cap for build_ping_embed's own +# truncation marker (see ping.py). This is intentionally lossy for very long +# emails -- acceptable for MVP; the full body still lives in the source mailbox. +_RAW_CONTENT_MAX = 1000 +_TRUNCATION_MARKER = "\n\n[...truncated for Discord embed limit...]" + # modality -> (imap host attr, imap user attr, imap password attr) on Config MAILBOXES = { "bh_email": ("bh_email_imap_host", "bh_email_imap_user", "bh_email_imap_password"), @@ -57,22 +63,28 @@ class EmailIngestCog(commands.Cog): try: conn.login(user, password) conn.select("INBOX") - _, data = conn.search(None, "UNSEEN") + # Use IMAP UIDs (stable across mailbox changes/expunges), not sequence + # numbers, which shift and would make persisted seen_uids.json entries + # refer to the wrong message later on. + _, data = conn.uid("SEARCH", None, "UNSEEN") for uid in data[0].split(): uid_str = uid.decode() if uid_str in seen: continue - _, msg_data = conn.fetch(uid, "(RFC822)") + # BODY.PEEK[] fetches without setting the \Seen flag server-side, + # so we don't silently mark the user's real emails as read. + _, msg_data = conn.uid("FETCH", uid, "(BODY.PEEK[])") raw_email = msg_data[0][1] msg = message_from_bytes(raw_email) sender = msg.get("From") captured_at = parsedate_to_datetime(msg.get("Date")) body = self._extract_body(msg) snippet = body if len(body) <= 120 else body[:117] + "..." + raw_content = self._truncate_for_embed(body) embed = build_ping_embed( modality=modality, sender_id=sender, snippet=snippet, - raw_content=body, captured_at=captured_at, + raw_content=raw_content, captured_at=captured_at, ) inbox = self.bot.get_channel(self.config.inbox_channel_id) role_mention = f"<@&{self.config.triage_role_id}>" @@ -83,6 +95,15 @@ class EmailIngestCog(commands.Cog): finally: conn.logout() + @staticmethod + def _truncate_for_embed(body: str) -> str: + """Cap the raw_content passed into the embed at a safe length below + Discord's 1024-char field limit. Lossy for very long emails but keeps + ingestion from failing (and retrying forever) on oversized bodies.""" + if len(body) <= _RAW_CONTENT_MAX: + return body + return body[:_RAW_CONTENT_MAX] + _TRUNCATION_MARKER + @staticmethod def _extract_body(msg) -> str: if msg.is_multipart(): @@ -90,15 +111,22 @@ class EmailIngestCog(commands.Cog): html_part = None for part in msg.walk(): if part.get_content_type() == "text/plain" and plain_part is None: - plain_part = part.get_payload(decode=True).decode(errors="replace") + payload = part.get_payload(decode=True) + if payload is not None: + plain_part = payload.decode(errors="replace") elif part.get_content_type() == "text/html" and html_part is None: - html_part = part.get_payload(decode=True).decode(errors="replace") + payload = part.get_payload(decode=True) + if payload is not None: + html_part = payload.decode(errors="replace") if plain_part is not None: return plain_part if html_part is not None: return html_part return "(no text content found)" - return msg.get_payload(decode=True).decode(errors="replace") + payload = msg.get_payload(decode=True) + if payload is None: + return "(no text content found)" + return payload.decode(errors="replace") async def setup(bot: commands.Bot, config): diff --git a/bot/chorus_bot/ping.py b/bot/chorus_bot/ping.py index 1fe3221..e6ec946 100644 --- a/bot/chorus_bot/ping.py +++ b/bot/chorus_bot/ping.py @@ -11,6 +11,26 @@ MODALITY_LABELS = { "nd_messenger": "ND Messenger", } +# Discord's API rejects embed field values of length 0 (min 1, max 1024 chars). +# Use a zero-width space as a round-trip-safe sentinel for "empty" values so we +# never send a literal "" to Discord. +_EMPTY_FIELD_SENTINEL = "​" + +# Hard cap for any field value, with headroom for a truncation marker so the +# final value never exceeds Discord's 1024-char limit regardless of caller. +_FIELD_VALUE_MAX = 1024 +_TRUNCATION_MARKER = "\n\n[...truncated for Discord embed limit...]" + + +def _safe_field_value(value: str) -> str: + """Return a Discord-safe embed field value: never empty, never over 1024 chars.""" + if not value: + return _EMPTY_FIELD_SENTINEL + if len(value) > _FIELD_VALUE_MAX: + keep = _FIELD_VALUE_MAX - len(_TRUNCATION_MARKER) + return value[:keep] + _TRUNCATION_MARKER + return value + def build_ping_embed( *, modality: str, sender_id: str | None, snippet: str, raw_content: str, @@ -18,21 +38,35 @@ def build_ping_embed( ) -> discord.Embed: label = MODALITY_LABELS.get(modality, modality) embed = discord.Embed( - title=f"{label}" + (f" — {sender_id}" if sender_id else ""), + title=f"{label}" + (f" - {sender_id}" if sender_id else ""), description=snippet, ) - embed.add_field(name="modality", value=modality, inline=True) - embed.add_field(name="sender_id", value=sender_id or "", inline=True) - embed.add_field(name="captured_at", value=captured_at.isoformat(), inline=True) - embed.add_field(name="raw_content", value=raw_content, inline=False) + embed.add_field(name="modality", value=_safe_field_value(modality), inline=True) + embed.add_field(name="sender_id", value=_safe_field_value(sender_id or ""), inline=True) + embed.add_field( + name="captured_at", value=_safe_field_value(captured_at.isoformat()), inline=True, + ) + embed.add_field( + name="raw_content", value=_safe_field_value(raw_content), inline=False, + ) return embed def parse_ping_embed(embed: discord.Embed) -> dict: values = {field.name: field.value for field in embed.fields} + sender_id = values.get("sender_id") + if sender_id == _EMPTY_FIELD_SENTINEL: + sender_id = None + else: + sender_id = sender_id or None + + raw_content = values["raw_content"] + if raw_content == _EMPTY_FIELD_SENTINEL: + raw_content = "" + return { "modality": values["modality"], - "sender_id": values.get("sender_id") or None, - "raw_content": values["raw_content"], + "sender_id": sender_id, + "raw_content": raw_content, "captured_at": datetime.fromisoformat(values["captured_at"]), } diff --git a/bot/tests/test_ping.py b/bot/tests/test_ping.py index 4094cde..e668117 100644 --- a/bot/tests/test_ping.py +++ b/bot/tests/test_ping.py @@ -45,3 +45,48 @@ def test_build_and_parse_empty_raw_content(): ) parsed = ping.parse_ping_embed(embed) assert parsed["raw_content"] == "", "Empty raw_content should round-trip as empty string, not '(empty)'" + + +def test_build_ping_embed_never_produces_empty_field_value_for_empty_raw_content(): + """Regression guard for C1: Discord's API rejects embed field values with + length 0 (HTTP 400). Every field value must have len() >= 1 after + build_ping_embed runs, even when raw_content is ''.""" + embed = ping.build_ping_embed( + modality="bh_email", + sender_id="test@example.com", + snippet="Empty message", + raw_content="", + captured_at=datetime(2026, 7, 13, 9, 0, 0, tzinfo=timezone.utc), + ) + for field in embed.fields: + assert len(field.value) >= 1, f"field {field.name!r} has an empty value" + + +def test_build_ping_embed_never_produces_empty_field_value_for_sender_id_none(): + """Regression guard for C1: manual_share.py always calls build_ping_embed + with sender_id=None. Every field value must still have len() >= 1.""" + embed = ping.build_ping_embed( + modality="voice", + sender_id=None, + snippet="call about donation", + raw_content="call about donation", + captured_at=datetime(2026, 7, 13, 8, 0, 0, tzinfo=timezone.utc), + ) + for field in embed.fields: + assert len(field.value) >= 1, f"field {field.name!r} has an empty value" + + +def test_build_ping_embed_truncates_long_raw_content(): + """Regression guard for C2: embed field values over 1024 chars are + rejected by Discord. build_ping_embed must truncate defensively.""" + long_body = "x" * 2000 + embed = ping.build_ping_embed( + modality="bh_email", + sender_id="jane@example.com", + snippet="long email", + raw_content=long_body, + captured_at=datetime(2026, 7, 13, 9, 0, 0, tzinfo=timezone.utc), + ) + raw_content_field = next(f for f in embed.fields if f.name == "raw_content") + assert len(raw_content_field.value) <= 1024 + assert "truncated" in raw_content_field.value