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.
- mailpier_inbox-0.1.0/LICENSE +21 -0
- mailpier_inbox-0.1.0/PKG-INFO +85 -0
- mailpier_inbox-0.1.0/README.md +64 -0
- mailpier_inbox-0.1.0/pyproject.toml +41 -0
- mailpier_inbox-0.1.0/setup.cfg +4 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox/__init__.py +12 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox/client.py +217 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox/errors.py +26 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox/message.py +111 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox/py.typed +0 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox/pytest_plugin.py +56 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox/session.py +69 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox/testing.py +381 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox.egg-info/PKG-INFO +85 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox.egg-info/SOURCES.txt +19 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox.egg-info/dependency_links.txt +1 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox.egg-info/entry_points.txt +2 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox.egg-info/requires.txt +4 -0
- mailpier_inbox-0.1.0/src/mailpier_inbox.egg-info/top_level.txt +1 -0
- mailpier_inbox-0.1.0/tests/test_client.py +266 -0
- mailpier_inbox-0.1.0/tests/test_plugin.py +71 -0
|
@@ -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,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()
|