owenrusk.dev

errands

small jobs the runtime does for the studio.

git clone https://owenrusk.dev/errands.git

commit c708695ab10718280a5615c6df88a70c05a5be70
parent ebf370e163bf3f7792d33acb76676259589504b8
author Owen Rusk <owen@papermothgames.com>
date   2024-10-22 16:48:05 -0500
mail: reply and send
errands/mail.py+38-2
tests/test_mail.py+31-0
diff --git a/errands/mail.py b/errands/mail.py
index 7d77a2b..5cfbc6c 100644
--- a/errands/mail.py
+++ b/errands/mail.py
@@ -1,8 +1,10 @@
-# the support inbox. imap to read.
+# the support inbox. imap to read, smtp to send.
 import email
 import imaplib
+import smtplib
 from dataclasses import dataclass
-from email.utils import parseaddr
+from email.message import EmailMessage
+from email.utils import formatdate, make_msgid, parseaddr
 
 from . import settings
 
@@ -54,3 +56,37 @@ def mark_read(uid: str, open_box=connect) -> None:
         box.uid("store", uid, "+FLAGS", "(\\Seen)")
     finally:
         box.logout()
+
+
+def compose(to: str, subject: str, body: str, reply_to: Message | None = None) -> EmailMessage:
+    msg = EmailMessage()
+    msg["From"] = f"{settings.SUPPORT_NAME} <{settings.SUPPORT_ADDRESS}>"
+    msg["To"] = to
+    msg["Subject"] = subject
+    msg["Date"] = formatdate(localtime=True)
+    msg["Message-ID"] = make_msgid(domain="papermothgames.com")
+    if reply_to is not None:
+        msg["In-Reply-To"] = reply_to.message_id
+    msg.set_content(body)
+    return msg
+
+
+def smtp() -> smtplib.SMTP:
+    server = smtplib.SMTP(settings.env("ERRANDS_SMTP_HOST"), 587)
+    server.starttls()
+    server.login(settings.env("ERRANDS_MAIL_USER"), settings.env("ERRANDS_MAIL_PASSWORD"))
+    return server
+
+
+def send(msg: EmailMessage, open_server=smtp) -> str:
+    server = open_server()
+    try:
+        server.send_message(msg)
+    finally:
+        server.quit()
+    return msg["Message-ID"]
+
+
+def reply(original: Message, body: str, open_server=smtp) -> str:
+    subject = original.subject if original.subject.lower().startswith("re:") else f"Re: {original.subject}"
+    return send(compose(original.sender, subject, body, reply_to=original), open_server)
diff --git a/tests/test_mail.py b/tests/test_mail.py
index 00e343e..f1813f0 100644
--- a/tests/test_mail.py
+++ b/tests/test_mail.py
@@ -51,3 +51,34 @@ class UnreadTest(unittest.TestCase):
         box = FakeBox({})
         mail.mark_read("7", lambda: box)
         self.assertEqual(box.stored, [("7", "+FLAGS", "(\\Seen)")])
+
+
+class FakeServer:
+    def __init__(self):
+        self.sent = []
+        self.quit_called = False
+
+    def send_message(self, msg):
+        self.sent.append(msg)
+
+    def quit(self):
+        self.quit_called = True
+
+
+class SendTest(unittest.TestCase):
+    def test_reply_keeps_the_subject_and_thread(self):
+        original = mail.parse("7", raw(subject="a question", message_id="<q@example.com>"))
+        server = FakeServer()
+        mail.reply(original, "thanks for writing.\n", lambda: server)
+        sent = server.sent[0]
+        self.assertEqual(sent["To"], "someone@example.com")
+        self.assertEqual(sent["Subject"], "Re: a question")
+        self.assertEqual(sent["In-Reply-To"], "<q@example.com>")
+        self.assertEqual(sent["From"], "Paper Moth Games <support@papermothgames.com>")
+        self.assertTrue(server.quit_called)
+
+    def test_no_second_re(self):
+        original = mail.parse("7", raw(subject="Re: a question"))
+        server = FakeServer()
+        mail.reply(original, "ok\n", lambda: server)
+        self.assertEqual(server.sent[0]["Subject"], "Re: a question")