errands
small jobs the runtime does for the studio.
git clone https://owenrusk.dev/errands.git
commit ebf370e163bf3f7792d33acb76676259589504b8 author Owen Rusk <owen@papermothgames.com> date 2024-10-22 14:12:37 -0500
start errands: read the support inbox
| .gitignore | +4 | -0 |
| README.md | +9 | -0 |
| errands/__init__.py | +0 | -0 |
| errands/mail.py | +56 | -0 |
| errands/settings.py | +13 | -0 |
| pyproject.toml | +12 | -0 |
| tests/__init__.py | +0 | -0 |
| tests/test_mail.py | +53 | -0 |
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26baca8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.egg-info/ +.venv/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d23c8a9 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# errands + +Things it can do for us so we don't have to. + +small connectors the runtime calls as tools. so far: the support inbox. + +configuration is all environment. see errands/settings.py. + + python -m unittest diff --git a/errands/__init__.py b/errands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/errands/mail.py b/errands/mail.py new file mode 100644 index 0000000..7d77a2b --- /dev/null +++ b/errands/mail.py @@ -0,0 +1,56 @@ +# the support inbox. imap to read. +import email +import imaplib +from dataclasses import dataclass +from email.utils import parseaddr + +from . import settings + + +@dataclass +class Message: + uid: str + sender: str + subject: str + message_id: str + body: str + + +def connect() -> imaplib.IMAP4: + box = imaplib.IMAP4_SSL(settings.env("ERRANDS_IMAP_HOST")) + box.login(settings.env("ERRANDS_MAIL_USER"), settings.env("ERRANDS_MAIL_PASSWORD")) + box.select("INBOX") + return box + + +def parse(uid: str, raw: bytes) -> Message: + msg = email.message_from_bytes(raw) + payload = msg.get_payload(decode=True) or b"" + return Message( + uid=uid, + sender=parseaddr(msg.get("From", ""))[1].lower(), + subject=msg.get("Subject", ""), + message_id=msg.get("Message-ID", ""), + body=payload.decode(msg.get_content_charset() or "utf-8", "replace"), + ) + + +def unread(open_box=connect) -> list[Message]: + box = open_box() + try: + _, found = box.uid("search", None, "UNSEEN") + messages = [] + for uid in found[0].split(): + _, parts = box.uid("fetch", uid, "(BODY.PEEK[])") + messages.append(parse(uid.decode(), parts[0][1])) + return messages + finally: + box.logout() + + +def mark_read(uid: str, open_box=connect) -> None: + box = open_box() + try: + box.uid("store", uid, "+FLAGS", "(\\Seen)") + finally: + box.logout() diff --git a/errands/settings.py b/errands/settings.py new file mode 100644 index 0000000..7fca9c8 --- /dev/null +++ b/errands/settings.py @@ -0,0 +1,13 @@ +# everything comes from the environment. nothing secret lives in this file. +import os + + +def env(name: str, default: str | None = None) -> str: + value = os.environ.get(name, default) + if value is None: + raise RuntimeError(f"{name} isn't set") + return value + + +SUPPORT_ADDRESS = "support@papermothgames.com" +SUPPORT_NAME = "Paper Moth Games" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..70621be --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "errands" +version = "0.1.0" +description = "small jobs the runtime does for the studio" +requires-python = ">=3.11" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["errands"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_mail.py b/tests/test_mail.py new file mode 100644 index 0000000..00e343e --- /dev/null +++ b/tests/test_mail.py @@ -0,0 +1,53 @@ +import unittest +from email.message import EmailMessage + +from errands import mail + + +def raw(sender="someone@example.com", subject="hello", body="hello\n", message_id="<1@example.com>") -> bytes: + msg = EmailMessage() + msg["From"] = sender + msg["To"] = "support@papermothgames.com" + msg["Subject"] = subject + msg["Message-ID"] = message_id + msg.set_content(body) + return bytes(msg) + + +class FakeBox: + def __init__(self, messages: dict[bytes, bytes]): + self.messages = messages + self.stored: list[tuple] = [] + self.closed = False + + def uid(self, command, *args): + if command == "search": + return "OK", [b" ".join(self.messages)] + if command == "fetch": + return "OK", [(b"", self.messages[args[0]])] + if command == "store": + self.stored.append(args) + return "OK", [] + raise AssertionError(command) + + def logout(self): + self.closed = True + + +class UnreadTest(unittest.TestCase): + def test_reads_every_unseen_message(self): + box = FakeBox({b"7": raw(subject="a question"), b"9": raw(sender="Ann <Ann@example.com>")}) + got = mail.unread(lambda: box) + self.assertEqual([m.uid for m in got], ["7", "9"]) + self.assertEqual(got[0].subject, "a question") + self.assertEqual(got[1].sender, "ann@example.com") + self.assertEqual(got[0].body, "hello\n") + self.assertTrue(box.closed) + + def test_nothing_unread(self): + self.assertEqual(mail.unread(lambda: FakeBox({})), []) + + def test_mark_read(self): + box = FakeBox({}) + mail.mark_read("7", lambda: box) + self.assertEqual(box.stored, [("7", "+FLAGS", "(\\Seen)")])