mailpier-inbox 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Solutions Group Unlimited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: mailpier-inbox
3
+ Version: 0.1.0
4
+ Summary: MailPier Inbox client for Python tests: a fresh email address per test, wait for the mail, read its code and links. Includes a pytest fixture.
5
+ Author: Solutions Group Unlimited
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://mailpier.io
8
+ Project-URL: Documentation, https://mailpier.io/docs
9
+ Keywords: email,testing,inbox,otp,pytest,e2e,mailpier
10
+ Classifier: Framework :: Pytest
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Testing
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: requests>=2.28
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=7; extra == "test"
20
+ Dynamic: license-file
21
+
22
+ # mailpier-inbox
23
+
24
+ Python client for [MailPier Inbox](https://mailpier.io): give each test its own email address, wait for the mail your app
25
+ sends, and read its one-time code and links. Python 3.9+, typed, one dependency (`requests`). Includes a **pytest fixture** and a
26
+ **fake MailPier API** for offline tests.
27
+
28
+ ## Quick start
29
+
30
+ ```python
31
+ from mailpier_inbox import MailPierInbox
32
+ inbox = MailPierInbox() # key from MAILPIER_API_KEY
33
+ address = inbox.new_address(prefix="signup") # signup-k3v9q2mz7a@qa.acme.mailpier.io
34
+ sign_up(address) # your app sends the mail
35
+ mail = inbox.wait_for_message(address, subject_contains="Confirm", timeout=60)
36
+ print(mail.code, mail.first_link("verify")) # 482913 https://app.example.com/verify?t=...
37
+ ```
38
+
39
+ Create the API key on your environment's **API keys** page and keep it in a secret (`MAILPIER_API_KEY`).
40
+
41
+ ## pytest
42
+
43
+ Installing the package registers the plugin. The `mailpier_inbox` fixture returns the next mail to an address on each wait:
44
+
45
+ ```python
46
+ def test_sign_up(mailpier_inbox, page):
47
+ email = mailpier_inbox.new_address()
48
+ page.fill("#email", email); page.click("text=Create account")
49
+ page.fill("#code", mailpier_inbox.wait_for_code(email))
50
+ # or: page.goto(mailpier_inbox.wait_for_link(email, "verify"))
51
+ ```
52
+
53
+ `@pytest.mark.mailpier_cleanup` (or `pytest --mailpier-cleanup`) deletes the test's mail afterwards. Without `MAILPIER_API_KEY`
54
+ the fixture fails with a clear message. For offline tests also request `mailpier_fake`: the fixture then talks to an in-memory fake
55
+ API and you "send" mail with `mailpier_fake.deliver(email, subject=..., text=..., html=...)` (see `examples/`).
56
+
57
+ ## API
58
+
59
+ | Call | What it does |
60
+ |---|---|
61
+ | `MailPierInbox(api_key=None, base_url=None, *, max_retries=5, max_retry_delay=60.0, session=None)` | Key from `MAILPIER_API_KEY`, root from `MAILPIER_BASE_URL` or `https://api.mailpier.io`. Context manager. |
62
+ | `new_address(prefix=None, domain=None)` | A fresh random address of the key's environment. |
63
+ | `wait_for_message(to, *, subject_contains=None, timeout=60, since=None, after_id=None)` | The **oldest** match received at or after `since`; long-polls; raises `MailPierTimeoutError` (a `TimeoutError`). |
64
+ | `wait_for_next_message(previous, to, ...)` | The mail after `previous` (never `previous` again). |
65
+ | `list_messages(to=None, *, subject_contains=None, since=None, take=50, skip=0)` | Newest first, at most 100 per call. |
66
+ | `get_message(id)` / `get_raw(id)` / `get_attachment(id, index)` | Full mail / `.eml` bytes / attachment bytes; `None` when gone. |
67
+ | `delete_message(id)` / `delete_all(to)` | Clean up. |
68
+ | `message.code` / `message.first_link(contains=None)` | The first one-time code; the first link whose URL or text contains it. |
69
+
70
+ Errors raise `MailPierInboxError` with `status` (0 = unreachable) and the server's message. 429 is retried after `Retry-After`,
71
+ 502/503/504 and connection errors with backoff, up to `max_retries` times.
72
+
73
+ ## Offline: the fake API
74
+
75
+ ```python
76
+ from mailpier_inbox.testing import FakeMailPier
77
+ with FakeMailPier() as fake: # 127.0.0.1, key "mpi_test_key", zone qa.demo.mailpier.io
78
+ inbox = MailPierInbox(api_key=fake.api_key, base_url=fake.url)
79
+ fake.deliver(address, subject="Confirm", text="Your code is 482913")
80
+ ```
81
+
82
+ ## Develop
83
+
84
+ `python -m venv .venv`, `.venv\Scripts\pip install -e .[test]`, `.venv\Scripts\python -m pytest`; runnable example:
85
+ `python examples/quickstart.py`. `..\test-all.ps1` runs everything. License: MIT.
@@ -0,0 +1,64 @@
1
+ # mailpier-inbox
2
+
3
+ Python client for [MailPier Inbox](https://mailpier.io): give each test its own email address, wait for the mail your app
4
+ sends, and read its one-time code and links. Python 3.9+, typed, one dependency (`requests`). Includes a **pytest fixture** and a
5
+ **fake MailPier API** for offline tests.
6
+
7
+ ## Quick start
8
+
9
+ ```python
10
+ from mailpier_inbox import MailPierInbox
11
+ inbox = MailPierInbox() # key from MAILPIER_API_KEY
12
+ address = inbox.new_address(prefix="signup") # signup-k3v9q2mz7a@qa.acme.mailpier.io
13
+ sign_up(address) # your app sends the mail
14
+ mail = inbox.wait_for_message(address, subject_contains="Confirm", timeout=60)
15
+ print(mail.code, mail.first_link("verify")) # 482913 https://app.example.com/verify?t=...
16
+ ```
17
+
18
+ Create the API key on your environment's **API keys** page and keep it in a secret (`MAILPIER_API_KEY`).
19
+
20
+ ## pytest
21
+
22
+ Installing the package registers the plugin. The `mailpier_inbox` fixture returns the next mail to an address on each wait:
23
+
24
+ ```python
25
+ def test_sign_up(mailpier_inbox, page):
26
+ email = mailpier_inbox.new_address()
27
+ page.fill("#email", email); page.click("text=Create account")
28
+ page.fill("#code", mailpier_inbox.wait_for_code(email))
29
+ # or: page.goto(mailpier_inbox.wait_for_link(email, "verify"))
30
+ ```
31
+
32
+ `@pytest.mark.mailpier_cleanup` (or `pytest --mailpier-cleanup`) deletes the test's mail afterwards. Without `MAILPIER_API_KEY`
33
+ the fixture fails with a clear message. For offline tests also request `mailpier_fake`: the fixture then talks to an in-memory fake
34
+ API and you "send" mail with `mailpier_fake.deliver(email, subject=..., text=..., html=...)` (see `examples/`).
35
+
36
+ ## API
37
+
38
+ | Call | What it does |
39
+ |---|---|
40
+ | `MailPierInbox(api_key=None, base_url=None, *, max_retries=5, max_retry_delay=60.0, session=None)` | Key from `MAILPIER_API_KEY`, root from `MAILPIER_BASE_URL` or `https://api.mailpier.io`. Context manager. |
41
+ | `new_address(prefix=None, domain=None)` | A fresh random address of the key's environment. |
42
+ | `wait_for_message(to, *, subject_contains=None, timeout=60, since=None, after_id=None)` | The **oldest** match received at or after `since`; long-polls; raises `MailPierTimeoutError` (a `TimeoutError`). |
43
+ | `wait_for_next_message(previous, to, ...)` | The mail after `previous` (never `previous` again). |
44
+ | `list_messages(to=None, *, subject_contains=None, since=None, take=50, skip=0)` | Newest first, at most 100 per call. |
45
+ | `get_message(id)` / `get_raw(id)` / `get_attachment(id, index)` | Full mail / `.eml` bytes / attachment bytes; `None` when gone. |
46
+ | `delete_message(id)` / `delete_all(to)` | Clean up. |
47
+ | `message.code` / `message.first_link(contains=None)` | The first one-time code; the first link whose URL or text contains it. |
48
+
49
+ Errors raise `MailPierInboxError` with `status` (0 = unreachable) and the server's message. 429 is retried after `Retry-After`,
50
+ 502/503/504 and connection errors with backoff, up to `max_retries` times.
51
+
52
+ ## Offline: the fake API
53
+
54
+ ```python
55
+ from mailpier_inbox.testing import FakeMailPier
56
+ with FakeMailPier() as fake: # 127.0.0.1, key "mpi_test_key", zone qa.demo.mailpier.io
57
+ inbox = MailPierInbox(api_key=fake.api_key, base_url=fake.url)
58
+ fake.deliver(address, subject="Confirm", text="Your code is 482913")
59
+ ```
60
+
61
+ ## Develop
62
+
63
+ `python -m venv .venv`, `.venv\Scripts\pip install -e .[test]`, `.venv\Scripts\python -m pytest`; runnable example:
64
+ `python examples/quickstart.py`. `..\test-all.ps1` runs everything. License: MIT.
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mailpier-inbox"
7
+ version = "0.1.0"
8
+ description = "MailPier Inbox client for Python tests: a fresh email address per test, wait for the mail, read its code and links. Includes a pytest fixture."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Solutions Group Unlimited" }]
13
+ requires-python = ">=3.9"
14
+ dependencies = ["requests>=2.28"]
15
+ keywords = ["email", "testing", "inbox", "otp", "pytest", "e2e", "mailpier"]
16
+ classifiers = [
17
+ "Framework :: Pytest",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Testing",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ test = ["pytest>=7"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://mailpier.io"
28
+ Documentation = "https://mailpier.io/docs"
29
+
30
+ [project.entry-points.pytest11]
31
+ mailpier_inbox = "mailpier_inbox.pytest_plugin"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.setuptools.package-data]
37
+ mailpier_inbox = ["py.typed"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests", "examples"]
41
+ python_files = ["test_*.py"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,12 @@
1
+ """MailPier Inbox client: a fresh email address per test, wait for the mail, read its code and links."""
2
+
3
+ from .client import DEFAULT_BASE_URL, MAX_POLL_SECONDS, VERSION, MailPierInbox
4
+ from .errors import MailPierInboxError, MailPierTimeoutError
5
+ from .message import InboxAttachment, InboxLink, InboxMessage, InboxMessageSummary
6
+ from .session import InboxSession
7
+
8
+ __version__ = VERSION
9
+ __all__ = [
10
+ "DEFAULT_BASE_URL", "MAX_POLL_SECONDS", "MailPierInbox", "MailPierInboxError", "MailPierTimeoutError",
11
+ "InboxMessage", "InboxMessageSummary", "InboxLink", "InboxAttachment", "InboxSession", "__version__",
12
+ ]
@@ -0,0 +1,217 @@
1
+ """Client for the MailPier Inbox API (https://api.mailpier.io/v1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import email.utils
6
+ import math
7
+ import os
8
+ import time
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Dict, List, Optional, Union
11
+
12
+ import requests
13
+
14
+ from .errors import MailPierInboxError, MailPierTimeoutError
15
+ from .message import InboxMessage, InboxMessageSummary
16
+
17
+ DEFAULT_BASE_URL = "https://api.mailpier.io"
18
+ MAX_POLL_SECONDS = 120
19
+ """The server caps one long-poll at 120 seconds; longer waits are made of several polls."""
20
+ VERSION = "0.1.0"
21
+
22
+ Since = Union[datetime, str, None]
23
+
24
+
25
+ def _iso(t: Since) -> Optional[str]:
26
+ if t is None:
27
+ return None
28
+ if isinstance(t, str):
29
+ return t
30
+ if t.tzinfo is None:
31
+ t = t.replace(tzinfo=timezone.utc)
32
+ return t.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
33
+
34
+
35
+ class MailPierInbox:
36
+ """Client for MailPier Inbox. Create one per API key and reuse it (thread-safe for separate calls).
37
+
38
+ >>> inbox = MailPierInbox() # key from MAILPIER_API_KEY
39
+ >>> address = inbox.new_address(prefix="signup")
40
+ >>> # ... make your app send a mail to address ...
41
+ >>> mail = inbox.wait_for_message(address, subject_contains="Confirm", timeout=60)
42
+ >>> mail.code, mail.first_link("verify")
43
+ """
44
+
45
+ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, *, max_retries: int = 5,
46
+ max_retry_delay: float = 60.0, session: Optional[requests.Session] = None) -> None:
47
+ """
48
+ :param api_key: An API key from the environment's API keys page (``mpi_...``); default MAILPIER_API_KEY.
49
+ :param base_url: API root; default MAILPIER_BASE_URL or https://api.mailpier.io.
50
+ :param max_retries: How often one call is retried on 429 (after Retry-After), 502/503/504 and connection errors.
51
+ :param max_retry_delay: The longest single pause between retries, in seconds (Retry-After is honoured up to this).
52
+ """
53
+ key = (api_key or os.environ.get("MAILPIER_API_KEY") or "").strip()
54
+ if not key:
55
+ raise ValueError("An API key (mpi_...) is required: pass api_key or set MAILPIER_API_KEY.")
56
+ self._key = key
57
+ self.base_url = (base_url or os.environ.get("MAILPIER_BASE_URL") or DEFAULT_BASE_URL).strip().rstrip("/")
58
+ self.max_retries = max(0, max_retries)
59
+ self.max_retry_delay = max(0.0, max_retry_delay)
60
+ self._session = session or requests.Session()
61
+ self._owns_session = session is None
62
+
63
+ def __enter__(self) -> "MailPierInbox":
64
+ return self
65
+
66
+ def __exit__(self, *exc: Any) -> None:
67
+ self.close()
68
+
69
+ def close(self) -> None:
70
+ if self._owns_session:
71
+ self._session.close()
72
+
73
+ def __repr__(self) -> str: # never show the key
74
+ return f"MailPierInbox(base_url={self.base_url!r})"
75
+
76
+ # ------------------------------------------------------------------ API
77
+
78
+ def new_address(self, prefix: Optional[str] = None, domain: Optional[str] = None) -> str:
79
+ """A fresh random address of the key's environment, e.g. ``signup-k3v9q2mz7a@qa.acme.mailpier.io``."""
80
+ body = {k: v for k, v in (("prefix", prefix), ("domain", domain)) if v}
81
+ r = self._request("POST", "/v1/addresses", json=body)
82
+ address = (r.json() or {}).get("address")
83
+ if not address:
84
+ raise MailPierInboxError(r.status_code, "The server returned no address.")
85
+ return address
86
+
87
+ def wait_for_message(self, to: str, *, subject_contains: Optional[str] = None, timeout: float = 60.0,
88
+ since: Since = None, after_id: Optional[str] = None) -> InboxMessage:
89
+ """Waits until a mail to ``to`` arrives and returns it (the OLDEST match received at or after ``since``).
90
+
91
+ :param timeout: seconds to wait in total.
92
+ :param since: only mail received at or after this time (pass the time your test started).
93
+ :param after_id: with ``since``: skip this mail (see wait_for_next_message).
94
+ :raises MailPierTimeoutError: when nothing arrives in time.
95
+ """
96
+ to = (to or "").strip()
97
+ if not to:
98
+ raise ValueError("wait_for_message needs the address (to).")
99
+ if not timeout or timeout <= 0:
100
+ raise ValueError("timeout must be greater than 0.")
101
+ deadline = time.monotonic() + timeout
102
+ since_text = _iso(since)
103
+ while True:
104
+ left = deadline - time.monotonic()
105
+ seconds = min(max(math.ceil(left), 1), MAX_POLL_SECONDS)
106
+ r = self._request("GET", "/v1/messages/wait", params={
107
+ "to": to, "subject": subject_contains, "since": since_text,
108
+ "afterId": after_id if since_text else None, "timeoutSeconds": seconds,
109
+ }, timeout=seconds + 30)
110
+ if r.status_code != 204:
111
+ return InboxMessage.from_json(r.json())
112
+ if time.monotonic() >= deadline:
113
+ raise MailPierTimeoutError(to, timeout, subject_contains)
114
+
115
+ def wait_for_next_message(self, previous: InboxMessageSummary, to: str, *, subject_contains: Optional[str] = None,
116
+ timeout: float = 60.0) -> InboxMessage:
117
+ """The next mail to ``to`` after ``previous`` (one you already received); never ``previous`` itself."""
118
+ return self.wait_for_message(to, subject_contains=subject_contains, timeout=timeout,
119
+ since=previous.received_utc, after_id=previous.id)
120
+
121
+ def list_messages(self, to: Optional[str] = None, *, subject_contains: Optional[str] = None, since: Since = None,
122
+ after_id: Optional[str] = None, take: int = 50, skip: int = 0) -> List[InboxMessageSummary]:
123
+ """Mail of the environment, newest first (at most 100 per call)."""
124
+ since_text = _iso(since)
125
+ r = self._request("GET", "/v1/messages", params={
126
+ "to": to, "subject": subject_contains, "since": since_text, "afterId": after_id if since_text else None,
127
+ "take": take, "skip": skip,
128
+ })
129
+ return [InboxMessageSummary.from_json(i) for i in (r.json() or {}).get("items") or []]
130
+
131
+ def get_message(self, message_id: str) -> Optional[InboxMessage]:
132
+ """One mail with text, HTML, headers, links, codes and attachments; None when it does not exist (or expired)."""
133
+ r = self._request("GET", f"/v1/messages/{message_id}", allow_404=True)
134
+ return None if r.status_code == 404 else InboxMessage.from_json(r.json())
135
+
136
+ def get_raw(self, message_id: str) -> Optional[bytes]:
137
+ """The original message (.eml bytes); None when it does not exist."""
138
+ r = self._request("GET", f"/v1/messages/{message_id}/raw", allow_404=True, accept="*/*")
139
+ return None if r.status_code == 404 else r.content
140
+
141
+ def get_attachment(self, message_id: str, index: int) -> Optional[bytes]:
142
+ """One attachment's bytes; None when it does not exist."""
143
+ r = self._request("GET", f"/v1/messages/{message_id}/attachments/{int(index)}", allow_404=True, accept="*/*")
144
+ return None if r.status_code == 404 else r.content
145
+
146
+ def delete_message(self, message_id: str) -> bool:
147
+ """Deletes one mail. False when it did not exist. Raises (409) when it was also sent outside this environment."""
148
+ return self._request("DELETE", f"/v1/messages/{message_id}", allow_404=True).status_code != 404
149
+
150
+ def delete_all(self, to: str) -> int:
151
+ """Deletes every mail to one address; returns how many were deleted."""
152
+ if not (to or "").strip():
153
+ raise ValueError("delete_all needs the address.")
154
+ return int((self._request("DELETE", "/v1/messages", params={"to": to.strip()}).json() or {}).get("deleted") or 0)
155
+
156
+ def me(self) -> Dict[str, Any]:
157
+ """The key's account, environment, domains, plan and usage."""
158
+ return self._request("GET", "/v1/me").json()
159
+
160
+ # ------------------------------------------------------------------ plumbing
161
+
162
+ def _request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json: Any = None,
163
+ timeout: float = 60.0, allow_404: bool = False, accept: str = "application/json") -> requests.Response:
164
+ """One call with retries: 429 waits Retry-After (default 1 s), 502/503/504 and connection errors back off
165
+ 0.5, 1, 2, 4 ... s (both capped at max_retry_delay). Other errors raise MailPierInboxError with the server's text."""
166
+ url = self.base_url + path
167
+ clean = {k: v for k, v in (params or {}).items() if v is not None and v != ""}
168
+ headers = {"Authorization": f"Bearer {self._key}", "Accept": accept, "User-Agent": f"mailpier-inbox-python/{VERSION}"}
169
+ attempt = 0
170
+ while True:
171
+ try:
172
+ r = self._session.request(method, url, params=clean, json=json, headers=headers, timeout=(10, timeout))
173
+ except (requests.ConnectionError, requests.Timeout) as ex:
174
+ if attempt >= self.max_retries:
175
+ raise MailPierInboxError(0, f"Could not reach MailPier Inbox at {self.base_url}: {ex}") from ex
176
+ time.sleep(self._backoff(attempt))
177
+ attempt += 1
178
+ continue
179
+ if r.ok or (allow_404 and r.status_code == 404):
180
+ return r
181
+ message = _error_text(r)
182
+ if r.status_code == 429:
183
+ retry_after = _retry_after(r.headers.get("Retry-After"))
184
+ if attempt < self.max_retries:
185
+ time.sleep(min(retry_after if retry_after is not None else 1, self.max_retry_delay))
186
+ attempt += 1
187
+ continue
188
+ raise MailPierInboxError(429, message or "Too many requests.", retry_after)
189
+ if r.status_code in (502, 503, 504) and attempt < self.max_retries:
190
+ time.sleep(self._backoff(attempt))
191
+ attempt += 1
192
+ continue
193
+ raise MailPierInboxError(r.status_code, message or f"MailPier Inbox answered {r.status_code}.")
194
+
195
+ def _backoff(self, attempt: int) -> float:
196
+ return min(0.5 * (2 ** attempt), self.max_retry_delay)
197
+
198
+
199
+ def _error_text(r: requests.Response) -> Optional[str]:
200
+ try:
201
+ e = r.json().get("error")
202
+ return f"{e} (HTTP {r.status_code})" if isinstance(e, str) and e else None
203
+ except (ValueError, AttributeError):
204
+ return None
205
+
206
+
207
+ def _retry_after(v: Optional[str]) -> Optional[int]:
208
+ if not v:
209
+ return None
210
+ try:
211
+ return max(0, int(v))
212
+ except ValueError:
213
+ try:
214
+ when = email.utils.parsedate_to_datetime(v)
215
+ return max(0, math.ceil((when - datetime.now(timezone.utc)).total_seconds()))
216
+ except (TypeError, ValueError):
217
+ return None
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class MailPierInboxError(Exception):
7
+ """The API answered an error: 400 (bad address/prefix), 401 (bad key), 403 (account suspended), 404, 409, 429, 503.
8
+
9
+ ``status`` is 0 when the server could not be reached at all.
10
+ """
11
+
12
+ def __init__(self, status: int, message: str, retry_after: Optional[int] = None) -> None:
13
+ super().__init__(message)
14
+ self.status = status
15
+ self.retry_after = retry_after
16
+ """Set on 429: seconds to wait before the next call."""
17
+
18
+
19
+ class MailPierTimeoutError(TimeoutError):
20
+ """No matching mail arrived before the timeout."""
21
+
22
+ def __init__(self, address: str, timeout: float, subject_contains: Optional[str] = None) -> None:
23
+ subject = f' with "{subject_contains}" in the subject' if subject_contains else ""
24
+ super().__init__(f"No mail to {address}{subject} arrived within {timeout:g} seconds.")
25
+ self.address = address
26
+ self.timeout = timeout
@@ -0,0 +1,111 @@
1
+ """The JSON shapes of the MailPier Inbox API (camelCase on the wire, snake_case here), mirroring the .NET client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class InboxLink:
11
+ href: str
12
+ text: Optional[str] = None
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class InboxAttachment:
17
+ index: int
18
+ file_name: Optional[str] = None
19
+ content_type: Optional[str] = None
20
+ size: int = 0
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class InboxMessageSummary:
25
+ """A mail as the list call returns it. ``to`` holds the environment's own (envelope) recipients."""
26
+
27
+ id: str
28
+ received_utc: str
29
+ """ISO 8601, UTC."""
30
+ to: List[str] = field(default_factory=list)
31
+ from_: Optional[str] = None
32
+ subject: Optional[str] = None
33
+ size_bytes: int = 0
34
+ has_attachments: bool = False
35
+ """Exact up to 16 KiB, a best guess above; get_message is exact."""
36
+
37
+ @classmethod
38
+ def from_json(cls, d: Dict[str, Any]) -> "InboxMessageSummary":
39
+ return cls(**_summary_fields(d))
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class InboxMessage(InboxMessageSummary):
44
+ """A full mail with helpers: ``message.code`` and ``message.first_link("verify")``.
45
+
46
+ ``html`` is the HTML part as received, NOT sanitised: never render it in a page of your own origin.
47
+ """
48
+
49
+ text: Optional[str] = None
50
+ html: Optional[str] = None
51
+ headers: Optional[Dict[str, Any]] = None
52
+ links: List[InboxLink] = field(default_factory=list)
53
+ codes: List[str] = field(default_factory=list)
54
+ """One-time codes found in the mail (verification codes, OTPs, PINs), best guess first."""
55
+ attachments: List[InboxAttachment] = field(default_factory=list)
56
+ auth: Any = None
57
+ spam: Any = None
58
+ raw_json: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
59
+ """The JSON exactly as the API returned it."""
60
+
61
+ @property
62
+ def code(self) -> Optional[str]:
63
+ """The first one-time code, or None when the mail has none."""
64
+ return self.codes[0] if self.codes else None
65
+
66
+ def first_link(self, contains: Optional[str] = None) -> Optional[str]:
67
+ """The first link whose address (or text) contains ``contains`` (case-insensitive); the first link when omitted."""
68
+ if not contains:
69
+ return self.links[0].href if self.links else None
70
+ needle = contains.lower()
71
+ for link in self.links:
72
+ if needle in link.href.lower() or needle in (link.text or "").lower():
73
+ return link.href
74
+ return None
75
+
76
+ @classmethod
77
+ def from_json(cls, d: Dict[str, Any]) -> "InboxMessage":
78
+ links = [InboxLink(href=l["href"], text=l.get("text")) for l in (d.get("links") or [])
79
+ if isinstance(l, dict) and isinstance(l.get("href"), str)]
80
+ attachments = [InboxAttachment(index=int(a.get("index", i)), file_name=a.get("fileName"),
81
+ content_type=a.get("contentType"), size=int(a.get("size") or 0))
82
+ for i, a in enumerate(d.get("attachments") or []) if isinstance(a, dict)]
83
+ return cls(**_summary_fields(d), text=d.get("text"), html=d.get("html"), headers=d.get("headers"), links=links,
84
+ codes=_codes(d.get("codes")), attachments=attachments, auth=d.get("auth"), spam=d.get("spam"), raw_json=d)
85
+
86
+
87
+ def _summary_fields(d: Dict[str, Any]) -> Dict[str, Any]:
88
+ return {
89
+ "id": str(d.get("id", "")),
90
+ "received_utc": str(d.get("receivedUtc", "")),
91
+ "to": list(d.get("to") or []),
92
+ "from_": d.get("from"),
93
+ "subject": d.get("subject"),
94
+ "size_bytes": int(d.get("sizeBytes") or 0),
95
+ "has_attachments": bool(d.get("hasAttachments")),
96
+ }
97
+
98
+
99
+ def _codes(value: Any) -> List[str]:
100
+ """Codes are strings; a richer shape (objects with value/code) is accepted like the .NET client does."""
101
+ out: List[str] = []
102
+ for c in value or []:
103
+ if isinstance(c, str) and c:
104
+ out.append(c)
105
+ elif isinstance(c, dict):
106
+ for k in ("value", "code", "Value", "Code"):
107
+ v = c.get(k)
108
+ if isinstance(v, str) and v:
109
+ out.append(v)
110
+ break
111
+ return out
File without changes
@@ -0,0 +1,56 @@
1
+ """pytest plugin (registered automatically when mailpier-inbox is installed).
2
+
3
+ Fixtures:
4
+ - ``mailpier_inbox``: an InboxSession for one test (new_address, wait_for_message, wait_for_code, wait_for_link, plus every
5
+ client call). Key from MAILPIER_API_KEY, API root from MAILPIER_BASE_URL (default https://api.mailpier.io).
6
+ Mark a test ``@pytest.mark.mailpier_cleanup`` (or pass ``--mailpier-cleanup``) to delete its mail afterwards.
7
+ - ``mailpier_fake``: a FAKE MailPier API on 127.0.0.1 for offline tests; ``mailpier_inbox`` uses it automatically
8
+ when the test also requests ``mailpier_fake``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from typing import Iterator
15
+
16
+ import pytest
17
+
18
+ from .client import MailPierInbox
19
+ from .session import InboxSession
20
+ from .testing import FakeMailPier
21
+
22
+
23
+ def pytest_addoption(parser: pytest.Parser) -> None:
24
+ parser.addoption("--mailpier-cleanup", action="store_true", default=False,
25
+ help="MailPier Inbox: delete the mail of every address a test created when it ends.")
26
+
27
+
28
+ def pytest_configure(config: pytest.Config) -> None:
29
+ config.addinivalue_line("markers", "mailpier_cleanup: delete this test's MailPier Inbox mail when it ends.")
30
+
31
+
32
+ @pytest.fixture
33
+ def mailpier_fake() -> Iterator[FakeMailPier]:
34
+ """A fake MailPier Inbox API (in memory, 127.0.0.1). Send mail with ``mailpier_fake.deliver(to=..., subject=..., text=...)``."""
35
+ with FakeMailPier() as fake:
36
+ yield fake
37
+
38
+
39
+ @pytest.fixture
40
+ def mailpier_inbox(request: pytest.FixtureRequest) -> Iterator[InboxSession]:
41
+ """An InboxSession for this test (see the module docstring)."""
42
+ if "mailpier_fake" in request.fixturenames:
43
+ fake: FakeMailPier = request.getfixturevalue("mailpier_fake")
44
+ client = MailPierInbox(api_key=fake.api_key, base_url=fake.url, max_retry_delay=0.05)
45
+ else:
46
+ if not (os.environ.get("MAILPIER_API_KEY") or "").strip():
47
+ pytest.fail("mailpier_inbox needs an API key: set MAILPIER_API_KEY (from your environment's API keys page).",
48
+ pytrace=False)
49
+ client = MailPierInbox()
50
+ session = InboxSession(client)
51
+ try:
52
+ yield session
53
+ finally:
54
+ if request.config.getoption("--mailpier-cleanup") or request.node.get_closest_marker("mailpier_cleanup"):
55
+ session.cleanup()
56
+ client.close()