owenrusk.dev

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)")])