C1: build_ping_embed set sender_id field value="" whenever sender_id was None, which manual_share.py always passes -- Discord rejects empty embed field values (min length 1), so every manual-share ping failed to send. Fixed by using a zero-width space sentinel that round-trips back to "" (raw_content) or None (sender_id) in parse_ping_embed. Added regression tests asserting no field value is ever empty after build_ping_embed runs. C2: raw_content over 1024 chars (Discord's embed field cap) caused inbox.send() to raise before the UID was marked seen, so oversized emails were retried forever and never ingested. Added a defensive hard truncation in build_ping_embed itself, plus an explicit truncation in email_ingest.py before the embed is built (lossy for very long emails, acceptable for MVP).
133 lines
5.4 KiB
Python
133 lines
5.4 KiB
Python
import json
|
|
import os
|
|
from pathlib import Path
|
|
from discord.ext import commands, tasks
|
|
from imaplib import IMAP4_SSL
|
|
from email import message_from_bytes
|
|
from email.utils import parsedate_to_datetime
|
|
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"),
|
|
"personal_email": (
|
|
"personal_email_imap_host", "personal_email_imap_user", "personal_email_imap_password",
|
|
),
|
|
"bh_text": ("google_voice_imap_host", "google_voice_imap_user", "google_voice_imap_password"),
|
|
}
|
|
|
|
|
|
def _load_seen() -> dict:
|
|
if SEEN_UIDS_PATH.exists():
|
|
return json.loads(SEEN_UIDS_PATH.read_text())
|
|
return {}
|
|
|
|
|
|
def _save_seen(seen: dict) -> None:
|
|
SEEN_UIDS_PATH.write_text(json.dumps(seen))
|
|
|
|
|
|
class EmailIngestCog(commands.Cog):
|
|
def __init__(self, bot: commands.Bot, config):
|
|
self.bot = bot
|
|
self.config = config
|
|
self.seen_uids = _load_seen()
|
|
self.poll_mailboxes.start()
|
|
|
|
def cog_unload(self):
|
|
self.poll_mailboxes.cancel()
|
|
|
|
@tasks.loop(minutes=2)
|
|
async def poll_mailboxes(self):
|
|
for modality, (host_attr, user_attr, pass_attr) in MAILBOXES.items():
|
|
host = getattr(self.config, host_attr)
|
|
user = getattr(self.config, user_attr)
|
|
password = getattr(self.config, pass_attr)
|
|
try:
|
|
await self._poll_one(modality, host, user, password)
|
|
except Exception as exc:
|
|
print(f"Chorus: failed to poll {modality} mailbox ({host}): {exc} — will retry next cycle")
|
|
|
|
async def _poll_one(self, modality: str, host: str, user: str, password: str):
|
|
seen = self.seen_uids.setdefault(modality, [])
|
|
conn = IMAP4_SSL(host)
|
|
try:
|
|
conn.login(user, password)
|
|
conn.select("INBOX")
|
|
# 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
|
|
# 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=raw_content, captured_at=captured_at,
|
|
)
|
|
inbox = self.bot.get_channel(self.config.inbox_channel_id)
|
|
role_mention = f"<@&{self.config.triage_role_id}>"
|
|
await inbox.send(content=role_mention, embed=embed)
|
|
|
|
seen.append(uid_str)
|
|
_save_seen(self.seen_uids)
|
|
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():
|
|
plain_part = None
|
|
html_part = None
|
|
for part in msg.walk():
|
|
if part.get_content_type() == "text/plain" and plain_part is None:
|
|
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:
|
|
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)"
|
|
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):
|
|
await bot.add_cog(EmailIngestCog(bot, config))
|