temptf 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.
- temptf-0.1.0/.gitignore +19 -0
- temptf-0.1.0/LICENSE +21 -0
- temptf-0.1.0/PKG-INFO +104 -0
- temptf-0.1.0/README.md +74 -0
- temptf-0.1.0/pyproject.toml +55 -0
- temptf-0.1.0/src/temptf/__init__.py +121 -0
- temptf-0.1.0/src/temptf/client.py +225 -0
- temptf-0.1.0/src/temptf/exceptions.py +19 -0
- temptf-0.1.0/src/temptf/models.py +86 -0
- temptf-0.1.0/src/temptf/py.typed +0 -0
- temptf-0.1.0/tests/test_client.py +125 -0
temptf-0.1.0/.gitignore
ADDED
temptf-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Liaksol
|
|
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.
|
temptf-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: temptf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the temp.tf disposable email API
|
|
5
|
+
Project-URL: Homepage, https://github.com/patrikdzejn88-ux/temptf
|
|
6
|
+
Project-URL: Repository, https://github.com/patrikdzejn88-ux/temptf
|
|
7
|
+
Project-URL: Issues, https://github.com/patrikdzejn88-ux/temptf/issues
|
|
8
|
+
Author: Liaksol
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api-client,disposable-email,inbox,temp-tf,tempmail
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Communications :: Email
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.3.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# temptf
|
|
32
|
+
|
|
33
|
+
Python client library for the [temp.tf](https://temp.tf/) disposable email API. Zero external dependencies.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install git+https://github.com/patrikdzejn88-ux/temptf.git
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or install locally from source:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git clone https://github.com/patrikdzejn88-ux/temptf.git
|
|
45
|
+
cd temptf
|
|
46
|
+
pip install .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick Start
|
|
50
|
+
|
|
51
|
+
### Creating Addresses by Provider
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import temptf
|
|
55
|
+
|
|
56
|
+
# Random address across all providers
|
|
57
|
+
email = temptf.create_email()
|
|
58
|
+
|
|
59
|
+
# Provider-specific addresses
|
|
60
|
+
gmail_any = temptf.create_gmail() # @gmail.com (dot or plus)
|
|
61
|
+
gmail_dot = temptf.create_gmail_dot() # u.s.e.r@gmail.com
|
|
62
|
+
gmail_plus = temptf.create_gmail_plus() # user+tag@gmail.com
|
|
63
|
+
outlook = temptf.create_outlook() # user+tag@outlook.com
|
|
64
|
+
hotmail = temptf.create_hotmail() # user+tag@hotmail.com
|
|
65
|
+
edu = temptf.create_edu() # @high.edu.pl
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Reading and Streaming Messages
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import temptf
|
|
72
|
+
|
|
73
|
+
email = temptf.create_outlook()
|
|
74
|
+
print("Listening on:", email)
|
|
75
|
+
|
|
76
|
+
# Fetch current inbox contents once
|
|
77
|
+
messages = temptf.get_messages(email)
|
|
78
|
+
|
|
79
|
+
# Block until the first matching message arrives
|
|
80
|
+
msg = temptf.wait_for_message(email, timeout=60.0)
|
|
81
|
+
print(msg.subject, msg.text)
|
|
82
|
+
|
|
83
|
+
# Stream incoming messages continuously
|
|
84
|
+
for msg in temptf.stream_messages(email, interval=5.0):
|
|
85
|
+
print(f"[{msg.date}] {msg.sender}: {msg.subject}")
|
|
86
|
+
print(msg.text)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Using `TempTFClient` Directly
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from temptf import TempTFClient
|
|
93
|
+
|
|
94
|
+
client = TempTFClient(timeout=10.0)
|
|
95
|
+
email = client.create_gmail_plus()
|
|
96
|
+
|
|
97
|
+
for msg in client.stream_messages(email):
|
|
98
|
+
for att in msg.attachments:
|
|
99
|
+
client.download_attachment(email, msg.id, att.id, dest=f"./downloads/{att.name}")
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
temptf-0.1.0/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# temptf
|
|
2
|
+
|
|
3
|
+
Python client library for the [temp.tf](https://temp.tf/) disposable email API. Zero external dependencies.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install git+https://github.com/patrikdzejn88-ux/temptf.git
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install locally from source:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
git clone https://github.com/patrikdzejn88-ux/temptf.git
|
|
15
|
+
cd temptf
|
|
16
|
+
pip install .
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
### Creating Addresses by Provider
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import temptf
|
|
25
|
+
|
|
26
|
+
# Random address across all providers
|
|
27
|
+
email = temptf.create_email()
|
|
28
|
+
|
|
29
|
+
# Provider-specific addresses
|
|
30
|
+
gmail_any = temptf.create_gmail() # @gmail.com (dot or plus)
|
|
31
|
+
gmail_dot = temptf.create_gmail_dot() # u.s.e.r@gmail.com
|
|
32
|
+
gmail_plus = temptf.create_gmail_plus() # user+tag@gmail.com
|
|
33
|
+
outlook = temptf.create_outlook() # user+tag@outlook.com
|
|
34
|
+
hotmail = temptf.create_hotmail() # user+tag@hotmail.com
|
|
35
|
+
edu = temptf.create_edu() # @high.edu.pl
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Reading and Streaming Messages
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import temptf
|
|
42
|
+
|
|
43
|
+
email = temptf.create_outlook()
|
|
44
|
+
print("Listening on:", email)
|
|
45
|
+
|
|
46
|
+
# Fetch current inbox contents once
|
|
47
|
+
messages = temptf.get_messages(email)
|
|
48
|
+
|
|
49
|
+
# Block until the first matching message arrives
|
|
50
|
+
msg = temptf.wait_for_message(email, timeout=60.0)
|
|
51
|
+
print(msg.subject, msg.text)
|
|
52
|
+
|
|
53
|
+
# Stream incoming messages continuously
|
|
54
|
+
for msg in temptf.stream_messages(email, interval=5.0):
|
|
55
|
+
print(f"[{msg.date}] {msg.sender}: {msg.subject}")
|
|
56
|
+
print(msg.text)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Using `TempTFClient` Directly
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from temptf import TempTFClient
|
|
63
|
+
|
|
64
|
+
client = TempTFClient(timeout=10.0)
|
|
65
|
+
email = client.create_gmail_plus()
|
|
66
|
+
|
|
67
|
+
for msg in client.stream_messages(email):
|
|
68
|
+
for att in msg.attachments:
|
|
69
|
+
client.download_attachment(email, msg.id, att.id, dest=f"./downloads/{att.name}")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "temptf"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the temp.tf disposable email API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Liaksol" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["tempmail", "disposable-email", "temp-tf", "inbox", "api-client"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.8",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Communications :: Email",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/patrikdzejn88-ux/temptf"
|
|
34
|
+
Repository = "https://github.com/patrikdzejn88-ux/temptf"
|
|
35
|
+
Issues = "https://github.com/patrikdzejn88-ux/temptf/issues"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=7.0.0",
|
|
40
|
+
"ruff>=0.3.0",
|
|
41
|
+
"mypy>=1.0.0",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/temptf"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 100
|
|
49
|
+
target-version = "py38"
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from typing import Callable, Iterator, List, Optional, Sequence, Union
|
|
2
|
+
|
|
3
|
+
from .client import PROVIDERS, TempTFClient
|
|
4
|
+
from .exceptions import APIError, RateLimitError, TempTFError
|
|
5
|
+
from .models import Attachment, Message, Stats
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
_default_client: Optional[TempTFClient] = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_client() -> TempTFClient:
|
|
13
|
+
global _default_client
|
|
14
|
+
if _default_client is None:
|
|
15
|
+
_default_client = TempTFClient()
|
|
16
|
+
return _default_client
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_email(
|
|
20
|
+
providers: Optional[Union[str, Sequence[str]]] = None,
|
|
21
|
+
dot: bool = True,
|
|
22
|
+
plus: bool = True,
|
|
23
|
+
) -> str:
|
|
24
|
+
"""Generate a random temporary email address."""
|
|
25
|
+
return _get_client().create_email(providers=providers, dot=dot, plus=plus)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_gmail(dot: bool = True, plus: bool = True) -> str:
|
|
29
|
+
"""Generate a @gmail.com address using dot syntax, plus alias, or both."""
|
|
30
|
+
return _get_client().create_gmail(dot=dot, plus=plus)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_gmail_dot() -> str:
|
|
34
|
+
"""Generate a @gmail.com dot-variant address."""
|
|
35
|
+
return _get_client().create_gmail_dot()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_gmail_plus() -> str:
|
|
39
|
+
"""Generate a @gmail.com plus-alias address."""
|
|
40
|
+
return _get_client().create_gmail_plus()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_outlook() -> str:
|
|
44
|
+
"""Generate an @outlook.com address."""
|
|
45
|
+
return _get_client().create_outlook()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_hotmail() -> str:
|
|
49
|
+
"""Generate a @hotmail.com address."""
|
|
50
|
+
return _get_client().create_hotmail()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create_edu() -> str:
|
|
54
|
+
"""Generate a @high.edu.pl address."""
|
|
55
|
+
return _get_client().create_edu()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_messages(email: str) -> List[Message]:
|
|
59
|
+
"""Fetch all messages in the inbox for the given address."""
|
|
60
|
+
return _get_client().get_messages(email)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def wait_for_message(
|
|
64
|
+
email: str,
|
|
65
|
+
timeout: float = 120.0,
|
|
66
|
+
interval: float = 4.0,
|
|
67
|
+
predicate: Optional[Callable[[Message], bool]] = None,
|
|
68
|
+
) -> Message:
|
|
69
|
+
"""Poll the inbox until a message arrives or timeout expires."""
|
|
70
|
+
return _get_client().wait_for_message(
|
|
71
|
+
email=email,
|
|
72
|
+
timeout=timeout,
|
|
73
|
+
interval=interval,
|
|
74
|
+
predicate=predicate,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def stream_messages(
|
|
79
|
+
email: str,
|
|
80
|
+
interval: float = 5.0,
|
|
81
|
+
skip_existing: bool = False,
|
|
82
|
+
) -> Iterator[Message]:
|
|
83
|
+
"""Yield new messages for the given address as they arrive."""
|
|
84
|
+
return _get_client().stream_messages(
|
|
85
|
+
email=email,
|
|
86
|
+
interval=interval,
|
|
87
|
+
skip_existing=skip_existing,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_stats(
|
|
92
|
+
providers: Optional[Union[str, Sequence[str]]] = None,
|
|
93
|
+
dot: bool = True,
|
|
94
|
+
plus: bool = True,
|
|
95
|
+
) -> Stats:
|
|
96
|
+
"""Return pool availability and message statistics."""
|
|
97
|
+
return _get_client().get_stats(providers=providers, dot=dot, plus=plus)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"__version__",
|
|
102
|
+
"PROVIDERS",
|
|
103
|
+
"TempTFClient",
|
|
104
|
+
"TempTFError",
|
|
105
|
+
"APIError",
|
|
106
|
+
"RateLimitError",
|
|
107
|
+
"Message",
|
|
108
|
+
"Attachment",
|
|
109
|
+
"Stats",
|
|
110
|
+
"create_email",
|
|
111
|
+
"create_gmail",
|
|
112
|
+
"create_gmail_dot",
|
|
113
|
+
"create_gmail_plus",
|
|
114
|
+
"create_outlook",
|
|
115
|
+
"create_hotmail",
|
|
116
|
+
"create_edu",
|
|
117
|
+
"get_messages",
|
|
118
|
+
"wait_for_message",
|
|
119
|
+
"stream_messages",
|
|
120
|
+
"get_stats",
|
|
121
|
+
]
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import urllib.error
|
|
4
|
+
import urllib.parse
|
|
5
|
+
import urllib.request
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Set, Tuple, Union
|
|
8
|
+
|
|
9
|
+
from .exceptions import APIError, RateLimitError, TempTFError
|
|
10
|
+
from .models import Message, Stats
|
|
11
|
+
|
|
12
|
+
BASE_URL = "https://temp.tf/api"
|
|
13
|
+
PROVIDERS = ("gmail", "outlook", "hotmail", "high.edu.pl")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TempTFClient:
|
|
17
|
+
"""Client for the temp.tf disposable email API."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
base_url: str = BASE_URL,
|
|
22
|
+
timeout: float = 15.0,
|
|
23
|
+
auto_retry_rate_limit: bool = True,
|
|
24
|
+
user_agent: str = "temptf-python/0.1.0",
|
|
25
|
+
opener: Optional[urllib.request.OpenerDirector] = None,
|
|
26
|
+
):
|
|
27
|
+
self.base_url = base_url.rstrip("/")
|
|
28
|
+
self.timeout = timeout
|
|
29
|
+
self.auto_retry_rate_limit = auto_retry_rate_limit
|
|
30
|
+
self.user_agent = user_agent
|
|
31
|
+
self._opener = opener or urllib.request.build_opener()
|
|
32
|
+
|
|
33
|
+
def _request_raw(
|
|
34
|
+
self,
|
|
35
|
+
method: str,
|
|
36
|
+
endpoint: str,
|
|
37
|
+
params: Optional[Dict[str, str]] = None,
|
|
38
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
39
|
+
) -> Tuple[bytes, Dict[str, str]]:
|
|
40
|
+
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
|
41
|
+
if params:
|
|
42
|
+
query = urllib.parse.urlencode(params)
|
|
43
|
+
url = f"{url}?{query}"
|
|
44
|
+
|
|
45
|
+
headers = {
|
|
46
|
+
"Accept": "application/json",
|
|
47
|
+
"User-Agent": self.user_agent,
|
|
48
|
+
}
|
|
49
|
+
data: Optional[bytes] = None
|
|
50
|
+
if json_body is not None:
|
|
51
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
52
|
+
headers["Content-Type"] = "application/json"
|
|
53
|
+
|
|
54
|
+
while True:
|
|
55
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
56
|
+
try:
|
|
57
|
+
with self._opener.open(req, timeout=self.timeout) as resp:
|
|
58
|
+
resp_headers = {k.lower(): v for k, v in resp.headers.items()}
|
|
59
|
+
return resp.read(), resp_headers
|
|
60
|
+
except urllib.error.HTTPError as exc:
|
|
61
|
+
status = exc.code
|
|
62
|
+
raw_err = exc.read()
|
|
63
|
+
if status == 429:
|
|
64
|
+
retry_header = exc.headers.get("Retry-After") if exc.headers else None
|
|
65
|
+
retry_after = int(retry_header) if retry_header and retry_header.isdigit() else 5
|
|
66
|
+
if self.auto_retry_rate_limit:
|
|
67
|
+
time.sleep(retry_after)
|
|
68
|
+
continue
|
|
69
|
+
raise RateLimitError(retry_after=retry_after) from exc
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
parsed = json.loads(raw_err.decode("utf-8", errors="replace"))
|
|
73
|
+
err_msg = parsed.get("error", raw_err.decode("utf-8", errors="replace"))
|
|
74
|
+
except ValueError:
|
|
75
|
+
err_msg = raw_err.decode("utf-8", errors="replace")
|
|
76
|
+
raise APIError(status, str(err_msg)) from exc
|
|
77
|
+
except urllib.error.URLError as exc:
|
|
78
|
+
raise TempTFError(f"Network error: {exc.reason}") from exc
|
|
79
|
+
|
|
80
|
+
def _request_json(
|
|
81
|
+
self,
|
|
82
|
+
method: str,
|
|
83
|
+
endpoint: str,
|
|
84
|
+
params: Optional[Dict[str, str]] = None,
|
|
85
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
86
|
+
) -> Dict[str, Any]:
|
|
87
|
+
raw, _ = self._request_raw(method, endpoint, params=params, json_body=json_body)
|
|
88
|
+
try:
|
|
89
|
+
return dict(json.loads(raw.decode("utf-8")))
|
|
90
|
+
except ValueError as exc:
|
|
91
|
+
raise TempTFError("Invalid JSON response from server") from exc
|
|
92
|
+
|
|
93
|
+
def create_email(
|
|
94
|
+
self,
|
|
95
|
+
providers: Optional[Union[str, Sequence[str]]] = None,
|
|
96
|
+
dot: bool = True,
|
|
97
|
+
plus: bool = True,
|
|
98
|
+
) -> str:
|
|
99
|
+
"""Generate a random temporary email address."""
|
|
100
|
+
if not dot and not plus:
|
|
101
|
+
raise ValueError("At least one of 'dot' or 'plus' must be True.")
|
|
102
|
+
|
|
103
|
+
params: Dict[str, str] = {}
|
|
104
|
+
if dot:
|
|
105
|
+
params["dot"] = "1"
|
|
106
|
+
if plus:
|
|
107
|
+
params["plus"] = "1"
|
|
108
|
+
|
|
109
|
+
if providers:
|
|
110
|
+
params["providers"] = (
|
|
111
|
+
providers if isinstance(providers, str) else ",".join(providers)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
payload = self._request_json("GET", "/account", params=params)
|
|
115
|
+
email = payload.get("email")
|
|
116
|
+
if not email:
|
|
117
|
+
raise TempTFError("No email returned for the requested parameters.")
|
|
118
|
+
return str(email)
|
|
119
|
+
|
|
120
|
+
def create_gmail(self, dot: bool = True, plus: bool = True) -> str:
|
|
121
|
+
"""Generate a @gmail.com address using dot syntax, plus alias, or both."""
|
|
122
|
+
return self.create_email(providers="gmail", dot=dot, plus=plus)
|
|
123
|
+
|
|
124
|
+
def create_gmail_dot(self) -> str:
|
|
125
|
+
"""Generate a @gmail.com dot-variant address (e.g. u.s.e.r@gmail.com)."""
|
|
126
|
+
return self.create_email(providers="gmail", dot=True, plus=False)
|
|
127
|
+
|
|
128
|
+
def create_gmail_plus(self) -> str:
|
|
129
|
+
"""Generate a @gmail.com plus-alias address (e.g. user+tag@gmail.com)."""
|
|
130
|
+
return self.create_email(providers="gmail", dot=False, plus=True)
|
|
131
|
+
|
|
132
|
+
def create_outlook(self) -> str:
|
|
133
|
+
"""Generate an @outlook.com plus-alias address."""
|
|
134
|
+
return self.create_email(providers="outlook", dot=False, plus=True)
|
|
135
|
+
|
|
136
|
+
def create_hotmail(self) -> str:
|
|
137
|
+
"""Generate a @hotmail.com plus-alias address."""
|
|
138
|
+
return self.create_email(providers="hotmail", dot=False, plus=True)
|
|
139
|
+
|
|
140
|
+
def create_edu(self) -> str:
|
|
141
|
+
"""Generate a @high.edu.pl educational email address."""
|
|
142
|
+
return self.create_email(providers="high.edu.pl", dot=False, plus=True)
|
|
143
|
+
|
|
144
|
+
def get_messages(self, email: str) -> List[Message]:
|
|
145
|
+
"""Fetch all messages currently in the inbox for the given address."""
|
|
146
|
+
payload = self._request_json("POST", "/check", json_body={"email": email})
|
|
147
|
+
items = payload.get("data") or []
|
|
148
|
+
return [Message.from_dict(item) for item in items]
|
|
149
|
+
|
|
150
|
+
def wait_for_message(
|
|
151
|
+
self,
|
|
152
|
+
email: str,
|
|
153
|
+
timeout: float = 120.0,
|
|
154
|
+
interval: float = 4.0,
|
|
155
|
+
predicate: Optional[Callable[[Message], bool]] = None,
|
|
156
|
+
) -> Message:
|
|
157
|
+
"""Poll the inbox until a matching message arrives or timeout is reached."""
|
|
158
|
+
deadline = time.monotonic() + timeout
|
|
159
|
+
while True:
|
|
160
|
+
for msg in self.get_messages(email):
|
|
161
|
+
if predicate is None or predicate(msg):
|
|
162
|
+
return msg
|
|
163
|
+
if time.monotonic() >= deadline:
|
|
164
|
+
raise TimeoutError(f"Timed out waiting for message on {email}")
|
|
165
|
+
time.sleep(interval)
|
|
166
|
+
|
|
167
|
+
def stream_messages(
|
|
168
|
+
self,
|
|
169
|
+
email: str,
|
|
170
|
+
interval: float = 5.0,
|
|
171
|
+
skip_existing: bool = False,
|
|
172
|
+
) -> Iterator[Message]:
|
|
173
|
+
"""Continuously poll the inbox and yield new messages as they arrive."""
|
|
174
|
+
seen: Set[str] = set()
|
|
175
|
+
if skip_existing:
|
|
176
|
+
for msg in self.get_messages(email):
|
|
177
|
+
seen.add(msg.id or f"{msg.date}:{msg.subject}")
|
|
178
|
+
|
|
179
|
+
while True:
|
|
180
|
+
messages = self.get_messages(email)
|
|
181
|
+
for msg in reversed(messages):
|
|
182
|
+
key = msg.id or f"{msg.date}:{msg.subject}"
|
|
183
|
+
if key not in seen:
|
|
184
|
+
seen.add(key)
|
|
185
|
+
yield msg
|
|
186
|
+
time.sleep(interval)
|
|
187
|
+
|
|
188
|
+
def download_attachment(
|
|
189
|
+
self,
|
|
190
|
+
email: str,
|
|
191
|
+
message_id: str,
|
|
192
|
+
attachment_id: str,
|
|
193
|
+
dest: Optional[Union[str, Path]] = None,
|
|
194
|
+
) -> bytes:
|
|
195
|
+
"""Download attachment content and optionally write it to disk."""
|
|
196
|
+
params = {
|
|
197
|
+
"email": email,
|
|
198
|
+
"messageId": message_id,
|
|
199
|
+
"attachmentId": attachment_id,
|
|
200
|
+
}
|
|
201
|
+
content, _ = self._request_raw("GET", "/attachment", params=params)
|
|
202
|
+
if dest is not None:
|
|
203
|
+
path = Path(dest)
|
|
204
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
path.write_bytes(content)
|
|
206
|
+
return content
|
|
207
|
+
|
|
208
|
+
def get_stats(
|
|
209
|
+
self,
|
|
210
|
+
providers: Optional[Union[str, Sequence[str]]] = None,
|
|
211
|
+
dot: bool = True,
|
|
212
|
+
plus: bool = True,
|
|
213
|
+
) -> Stats:
|
|
214
|
+
"""Return pool availability and message statistics."""
|
|
215
|
+
params: Dict[str, str] = {}
|
|
216
|
+
if dot:
|
|
217
|
+
params["dot"] = "1"
|
|
218
|
+
if plus:
|
|
219
|
+
params["plus"] = "1"
|
|
220
|
+
if providers:
|
|
221
|
+
params["providers"] = (
|
|
222
|
+
providers if isinstance(providers, str) else ",".join(providers)
|
|
223
|
+
)
|
|
224
|
+
payload = self._request_json("GET", "/stats", params=params)
|
|
225
|
+
return Stats.from_dict(payload)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class TempTFError(Exception):
|
|
2
|
+
"""Base exception for temptf errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class APIError(TempTFError):
|
|
6
|
+
"""Raised when the temp.tf API returns an error status code."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, status_code: int, message: str):
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
self.message = message
|
|
11
|
+
super().__init__(f"HTTP {status_code}: {message}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RateLimitError(APIError):
|
|
15
|
+
"""Raised when rate limit (HTTP 429) is exceeded and auto-retry is disabled."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, retry_after: int, message: str = "Rate limit exceeded"):
|
|
18
|
+
self.retry_after = retry_after
|
|
19
|
+
super().__init__(429, message)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import html
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _strip_html(raw: str) -> str:
|
|
8
|
+
if not raw:
|
|
9
|
+
return ""
|
|
10
|
+
cleaned = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", raw, flags=re.DOTALL | re.IGNORECASE)
|
|
11
|
+
cleaned = re.sub(r"<br\s*/?>", "\n", cleaned, flags=re.IGNORECASE)
|
|
12
|
+
cleaned = re.sub(r"</(p|div|tr|li|h[1-6])>", "\n", cleaned, flags=re.IGNORECASE)
|
|
13
|
+
cleaned = re.sub(r"<[^>]+>", "", cleaned)
|
|
14
|
+
cleaned = html.unescape(cleaned)
|
|
15
|
+
return "\n".join(line.strip() for line in cleaned.splitlines() if line.strip())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Attachment:
|
|
20
|
+
id: str
|
|
21
|
+
name: str
|
|
22
|
+
content_type: str
|
|
23
|
+
size: int
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Attachment":
|
|
27
|
+
return cls(
|
|
28
|
+
id=str(data.get("id", "")),
|
|
29
|
+
name=str(data.get("name", "")),
|
|
30
|
+
content_type=str(data.get("contentType", "application/octet-stream")),
|
|
31
|
+
size=int(data.get("size", 0)),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Message:
|
|
37
|
+
id: str
|
|
38
|
+
sender: str
|
|
39
|
+
subject: str
|
|
40
|
+
date: str
|
|
41
|
+
body: str
|
|
42
|
+
body_content_type: str
|
|
43
|
+
attachments: List[Attachment] = field(default_factory=list)
|
|
44
|
+
inline_cids: Dict[str, str] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def text(self) -> str:
|
|
48
|
+
"""Plain-text representation of the message body."""
|
|
49
|
+
if self.body_content_type.lower() == "html":
|
|
50
|
+
return _strip_html(self.body)
|
|
51
|
+
return self.body.strip()
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Message":
|
|
55
|
+
attachments = [Attachment.from_dict(item) for item in (data.get("attachments") or [])]
|
|
56
|
+
return cls(
|
|
57
|
+
id=str(data.get("id", "")),
|
|
58
|
+
sender=str(data.get("from", "")),
|
|
59
|
+
subject=str(data.get("subject", "")),
|
|
60
|
+
date=str(data.get("date", "")),
|
|
61
|
+
body=str(data.get("body", "")),
|
|
62
|
+
body_content_type=str(data.get("bodyContentType", "text")),
|
|
63
|
+
attachments=attachments,
|
|
64
|
+
inline_cids=dict(data.get("inlineCids") or {}),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Stats:
|
|
70
|
+
total_addresses: int
|
|
71
|
+
total_formatted: str
|
|
72
|
+
total_received: int
|
|
73
|
+
breakdown: Dict[str, int]
|
|
74
|
+
breakdown_formatted: Dict[str, str]
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Stats":
|
|
78
|
+
raw_breakdown = data.get("breakdown") or {}
|
|
79
|
+
breakdown = {k: int(v) for k, v in raw_breakdown.items()}
|
|
80
|
+
return cls(
|
|
81
|
+
total_addresses=int(data.get("totalAddresses", 0)),
|
|
82
|
+
total_formatted=str(data.get("totalFormatted", "0")),
|
|
83
|
+
total_received=int(data.get("totalReceived", 0)),
|
|
84
|
+
breakdown=breakdown,
|
|
85
|
+
breakdown_formatted=dict(data.get("breakdownFormatted") or {}),
|
|
86
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import unittest
|
|
4
|
+
import urllib.error
|
|
5
|
+
from email.message import Message as HTTPHeaders
|
|
6
|
+
from unittest.mock import MagicMock
|
|
7
|
+
|
|
8
|
+
from temptf import TempTFClient
|
|
9
|
+
from temptf.exceptions import APIError, RateLimitError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _mock_opener(payload=None, raw_bytes: bytes = b""):
|
|
13
|
+
opener = MagicMock()
|
|
14
|
+
resp = MagicMock()
|
|
15
|
+
if payload is not None:
|
|
16
|
+
raw_bytes = json.dumps(payload).encode("utf-8")
|
|
17
|
+
resp.read.return_value = raw_bytes
|
|
18
|
+
resp.headers = {"Content-Type": "application/json"}
|
|
19
|
+
resp.__enter__.return_value = resp
|
|
20
|
+
resp.__exit__.return_value = False
|
|
21
|
+
opener.open.return_value = resp
|
|
22
|
+
return opener
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestTempTFClient(unittest.TestCase):
|
|
26
|
+
def test_provider_specific_creation(self) -> None:
|
|
27
|
+
opener = _mock_opener({"email": "u.s.e.r@gmail.com"})
|
|
28
|
+
client = TempTFClient(opener=opener)
|
|
29
|
+
|
|
30
|
+
self.assertEqual(client.create_gmail_dot(), "u.s.e.r@gmail.com")
|
|
31
|
+
req = opener.open.call_args[0][0]
|
|
32
|
+
self.assertEqual(req.full_url, "https://temp.tf/api/account?dot=1&providers=gmail")
|
|
33
|
+
|
|
34
|
+
opener = _mock_opener({"email": "user+123@gmail.com"})
|
|
35
|
+
client = TempTFClient(opener=opener)
|
|
36
|
+
self.assertEqual(client.create_gmail_plus(), "user+123@gmail.com")
|
|
37
|
+
req = opener.open.call_args[0][0]
|
|
38
|
+
self.assertEqual(req.full_url, "https://temp.tf/api/account?plus=1&providers=gmail")
|
|
39
|
+
|
|
40
|
+
opener = _mock_opener({"email": "user+123@outlook.com"})
|
|
41
|
+
client = TempTFClient(opener=opener)
|
|
42
|
+
self.assertEqual(client.create_outlook(), "user+123@outlook.com")
|
|
43
|
+
req = opener.open.call_args[0][0]
|
|
44
|
+
self.assertEqual(req.full_url, "https://temp.tf/api/account?plus=1&providers=outlook")
|
|
45
|
+
|
|
46
|
+
opener = _mock_opener({"email": "user+123@hotmail.com"})
|
|
47
|
+
client = TempTFClient(opener=opener)
|
|
48
|
+
self.assertEqual(client.create_hotmail(), "user+123@hotmail.com")
|
|
49
|
+
req = opener.open.call_args[0][0]
|
|
50
|
+
self.assertEqual(req.full_url, "https://temp.tf/api/account?plus=1&providers=hotmail")
|
|
51
|
+
|
|
52
|
+
opener = _mock_opener({"email": "abc@high.edu.pl"})
|
|
53
|
+
client = TempTFClient(opener=opener)
|
|
54
|
+
self.assertEqual(client.create_edu(), "abc@high.edu.pl")
|
|
55
|
+
req = opener.open.call_args[0][0]
|
|
56
|
+
self.assertEqual(req.full_url, "https://temp.tf/api/account?plus=1&providers=high.edu.pl")
|
|
57
|
+
|
|
58
|
+
def test_get_messages_and_html_stripping(self) -> None:
|
|
59
|
+
payload = {
|
|
60
|
+
"data": [
|
|
61
|
+
{
|
|
62
|
+
"id": "101",
|
|
63
|
+
"from": "noreply@service.com",
|
|
64
|
+
"subject": "Verification Code",
|
|
65
|
+
"date": "2026-11-06T12:00:00.000Z",
|
|
66
|
+
"body": "<html><body><p>Your code is <b>482910</b></p></body></html>",
|
|
67
|
+
"bodyContentType": "html",
|
|
68
|
+
"attachments": [
|
|
69
|
+
{
|
|
70
|
+
"id": "att_1",
|
|
71
|
+
"name": "receipt.pdf",
|
|
72
|
+
"contentType": "application/pdf",
|
|
73
|
+
"size": 512,
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"totalReceived": 500,
|
|
79
|
+
}
|
|
80
|
+
opener = _mock_opener(payload)
|
|
81
|
+
client = TempTFClient(opener=opener)
|
|
82
|
+
|
|
83
|
+
messages = client.get_messages("user+123@gmail.com")
|
|
84
|
+
self.assertEqual(len(messages), 1)
|
|
85
|
+
msg = messages[0]
|
|
86
|
+
self.assertEqual(msg.id, "101")
|
|
87
|
+
self.assertEqual(msg.sender, "noreply@service.com")
|
|
88
|
+
self.assertEqual(msg.subject, "Verification Code")
|
|
89
|
+
self.assertEqual(msg.text, "Your code is 482910")
|
|
90
|
+
self.assertEqual(len(msg.attachments), 1)
|
|
91
|
+
self.assertEqual(msg.attachments[0].name, "receipt.pdf")
|
|
92
|
+
|
|
93
|
+
def test_error_handling(self) -> None:
|
|
94
|
+
opener = MagicMock()
|
|
95
|
+
client = TempTFClient(opener=opener, auto_retry_rate_limit=False)
|
|
96
|
+
|
|
97
|
+
headers = HTTPHeaders()
|
|
98
|
+
headers["Retry-After"] = "12"
|
|
99
|
+
err_429 = urllib.error.HTTPError(
|
|
100
|
+
url="https://temp.tf/api/check",
|
|
101
|
+
code=429,
|
|
102
|
+
msg="Too Many Requests",
|
|
103
|
+
hdrs=headers,
|
|
104
|
+
fp=io.BytesIO(b'{"error":"Rate limit"}'),
|
|
105
|
+
)
|
|
106
|
+
opener.open.side_effect = err_429
|
|
107
|
+
with self.assertRaises(RateLimitError) as exc_info:
|
|
108
|
+
client.get_messages("user+123@gmail.com")
|
|
109
|
+
self.assertEqual(exc_info.exception.retry_after, 12)
|
|
110
|
+
|
|
111
|
+
err_403 = urllib.error.HTTPError(
|
|
112
|
+
url="https://temp.tf/api/check",
|
|
113
|
+
code=403,
|
|
114
|
+
msg="Forbidden",
|
|
115
|
+
hdrs=HTTPHeaders(),
|
|
116
|
+
fp=io.BytesIO(b'{"error":"Forbidden address"}'),
|
|
117
|
+
)
|
|
118
|
+
opener.open.side_effect = err_403
|
|
119
|
+
with self.assertRaises(APIError) as api_exc:
|
|
120
|
+
client.get_messages("invalid@example.com")
|
|
121
|
+
self.assertEqual(api_exc.exception.status_code, 403)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
unittest.main()
|