diff --git a/tests/test_fastmail_client.py b/tests/test_fastmail_client.py new file mode 100644 index 0000000..9bd65e3 --- /dev/null +++ b/tests/test_fastmail_client.py @@ -0,0 +1,165 @@ +"""Guards for tools/fastmail_client.py (JMAP email/calendar/draft client). + +The transport is stdlib urllib; everything above it is pure or mockable. +These tests pin: + +- filter builders emit only the conditions actually supplied (an empty + condition would over-match the whole mailbox); +- reply drafts carry RFC 5322 threading (inReplyTo/references from the + original's messageId), a single "Re:" prefix, and default recipients from + Reply-To before From; +- drafts always land in the Drafts mailbox with $draft set - the client has + no send path at all; +- the client surfaces JMAP method-level errors instead of returning partial + results, and resolves method responses by name. +""" +import unittest +from datetime import UTC, datetime +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools")) + +from fastmail_client import ( # noqa: E402 + FastmailClient, + FastmailError, + build_draft, + build_email_filter, + build_event_filter, + iso_days_ago, +) + + +class FilterBuilderTests(unittest.TestCase): + def test_only_supplied_conditions_appear(self): + self.assertEqual(build_email_filter(query="interview"), {"text": "interview"}) + self.assertEqual(build_email_filter(), {}) + self.assertEqual(build_event_filter(query="onsite"), {"text": "onsite"}) + + def test_iso_days_ago_is_utc_zulu(self): + fixed = datetime(2026, 8, 26, 12, 0, 0, tzinfo=UTC) + self.assertEqual(iso_days_ago(14, now=fixed), "2026-08-12T12:00:00Z") + + +class DraftBuilderTests(unittest.TestCase): + ORIGINAL = { + "messageId": [""], + "references": [""], + "subject": "Interview availability", + "from": [{"email": "recruiter@acme.com", "name": "R"}], + "replyTo": [{"email": "talent@acme.com"}], + } + + def test_plain_draft_shape(self): + d = build_draft("mb1", "me@example.com", ["a@b.com"], "Hi", "body text") + self.assertEqual(d["mailboxIds"], {"mb1": True}) + self.assertEqual(d["keywords"], {"$draft": True, "$seen": True}) + self.assertEqual(d["bodyValues"]["body"]["value"], "body text") + self.assertNotIn("inReplyTo", d) + + def test_reply_threads_and_prefixes_subject_once(self): + d = build_draft("mb1", "me@example.com", [], "", "thanks", + in_reply_to=self.ORIGINAL) + self.assertEqual(d["inReplyTo"], [""]) + self.assertEqual(d["references"], ["", ""]) + self.assertEqual(d["subject"], "Re: Interview availability") + # Reply-To wins over From for the default recipient. + self.assertEqual(d["to"], [{"email": "talent@acme.com"}]) + + def test_re_subject_not_doubled(self): + orig = dict(self.ORIGINAL, subject="Re: Interview availability") + d = build_draft("mb1", "me@example.com", [], "", "x", in_reply_to=orig) + self.assertEqual(d["subject"], "Re: Interview availability") + + def test_no_send_path_exists(self): + # The client must never submit mail: no EmailSubmission anywhere. + source = (Path(__file__).resolve().parent.parent + / "tools" / "fastmail_client.py").read_text(encoding="utf-8") + self.assertNotIn("EmailSubmission/set", source) + + +class MockedClient(FastmailClient): + """Transport replaced: records requests, replays canned JMAP responses.""" + + def __init__(self, responses): + super().__init__(token="test-token") + self._canned = responses + self.requests = [] + self._session = { + "apiUrl": "https://api.test/jmap", + "username": "me@example.com", + "primaryAccounts": { + "urn:ietf:params:jmap:mail": "acct-mail", + "urn:ietf:params:jmap:calendars": "acct-cal", + }, + } + + def _http(self, url, body=None): + self.requests.append((url, body)) + return self._canned.pop(0) + + +class ClientWiringTests(unittest.TestCase): + def test_search_email_uses_backreference_and_returns_list(self): + client = MockedClient([{ + "methodResponses": [ + ["Email/query", {"ids": ["e1"]}, "q"], + ["Email/get", {"list": [{"id": "e1", "subject": "s"}]}, "g"], + ] + }]) + out = client.search_email(query="interview", days=14) + self.assertEqual(out, [{"id": "e1", "subject": "s"}]) + _url, body = client.requests[0] + query_call, get_call = body["methodCalls"] + self.assertEqual(query_call[1]["filter"]["text"], "interview") + self.assertIn("after", query_call[1]["filter"]) + self.assertEqual(get_call[1]["#ids"], + {"resultOf": "q", "name": "Email/query", "path": "/ids"}) + + def test_jmap_method_error_raises_instead_of_partial_result(self): + client = MockedClient([{ + "methodResponses": [["error", {"type": "unknownMethod"}, "q"]] + }]) + with self.assertRaises(FastmailError): + client.search_email(query="x") + + def test_events_use_calendar_account(self): + client = MockedClient([{ + "methodResponses": [ + ["CalendarEvent/query", {"ids": ["ev1"]}, "q"], + ["CalendarEvent/get", {"list": [{"id": "ev1", "title": "Onsite"}]}, "g"], + ] + }]) + out = client.search_events(days=7) + self.assertEqual(out[0]["title"], "Onsite") + _url, body = client.requests[0] + self.assertEqual(body["methodCalls"][0][1]["accountId"], "acct-cal") + + def test_create_draft_rejection_surfaces(self): + client = MockedClient([ + # Mailbox/query for drafts + {"methodResponses": [["Mailbox/query", {"ids": ["mb-drafts"]}, "q"]]}, + # Email/set rejection + {"methodResponses": [["Email/set", + {"notCreated": {"draft": {"type": "invalidProperties"}}, + "created": {}}, "s"]]}, + ]) + with self.assertRaises(FastmailError): + client.create_draft(["a@b.com"], "Hi", "body") + + def test_create_draft_happy_path_lands_in_drafts(self): + client = MockedClient([ + {"methodResponses": [["Mailbox/query", {"ids": ["mb-drafts"]}, "q"]]}, + {"methodResponses": [["Email/set", + {"created": {"draft": {"id": "new-draft"}}}, "s"]]}, + ]) + out = client.create_draft(["a@b.com"], "Hi", "body") + self.assertEqual(out, {"id": "new-draft"}) + _url, body = client.requests[1] + create = body["methodCalls"][0][1]["create"]["draft"] + self.assertEqual(create["mailboxIds"], {"mb-drafts": True}) + self.assertEqual(create["from"], [{"email": "me@example.com"}]) + + +if __name__ == "__main__": + unittest.main()