burnbox 1.0.0__py3-none-any.whl
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.
- burnbox/__init__.py +34 -0
- burnbox/__main__.py +4 -0
- burnbox/api.py +181 -0
- burnbox/cli.py +349 -0
- burnbox/client.py +67 -0
- burnbox/config.py +120 -0
- burnbox/detectors/__init__.py +31 -0
- burnbox/detectors/base.py +28 -0
- burnbox/detectors/clipboard.py +80 -0
- burnbox/detectors/engine.py +70 -0
- burnbox/detectors/i18n.py +142 -0
- burnbox/detectors/parsers/__init__.py +13 -0
- burnbox/detectors/parsers/alphanumeric_otp.py +85 -0
- burnbox/detectors/parsers/labeled_otp.py +51 -0
- burnbox/detectors/parsers/numeric_otp.py +74 -0
- burnbox/detectors/parsers/reset_link.py +53 -0
- burnbox/detectors/parsers/url_code.py +51 -0
- burnbox/exceptions.py +31 -0
- burnbox/models.py +36 -0
- burnbox/notifications.py +53 -0
- burnbox/providers/__init__.py +3 -0
- burnbox/providers/base.py +46 -0
- burnbox/providers/guerrillamail.py +164 -0
- burnbox/providers/mailgw.py +20 -0
- burnbox/providers/mailtm.py +237 -0
- burnbox/providers/onesecmail.py +138 -0
- burnbox/providers/registry.py +84 -0
- burnbox/providers/sanitize.py +14 -0
- burnbox/providers/utils.py +25 -0
- burnbox/retry.py +105 -0
- burnbox/session.py +79 -0
- burnbox-1.0.0.dist-info/METADATA +31 -0
- burnbox-1.0.0.dist-info/RECORD +36 -0
- burnbox-1.0.0.dist-info/WHEEL +4 -0
- burnbox-1.0.0.dist-info/entry_points.txt +2 -0
- burnbox-1.0.0.dist-info/licenses/LICENSE +21 -0
burnbox/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from burnbox.api import BurnBox, Message, create
|
|
2
|
+
from burnbox.client import BurnBoxClient
|
|
3
|
+
from burnbox.config import AppConfig, load_config
|
|
4
|
+
from burnbox.exceptions import (
|
|
5
|
+
APIError,
|
|
6
|
+
AuthExpiredError,
|
|
7
|
+
BurnBoxError,
|
|
8
|
+
NoDomainsError,
|
|
9
|
+
ProviderError,
|
|
10
|
+
SessionError,
|
|
11
|
+
TokenError,
|
|
12
|
+
)
|
|
13
|
+
from burnbox.models import InboxMessage, MessagePreview, Session
|
|
14
|
+
|
|
15
|
+
__version__ = "1.0.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BurnBox",
|
|
19
|
+
"BurnBoxClient",
|
|
20
|
+
"Message",
|
|
21
|
+
"AppConfig",
|
|
22
|
+
"load_config",
|
|
23
|
+
"create",
|
|
24
|
+
"BurnBoxError",
|
|
25
|
+
"Session",
|
|
26
|
+
"InboxMessage",
|
|
27
|
+
"MessagePreview",
|
|
28
|
+
"APIError",
|
|
29
|
+
"NoDomainsError",
|
|
30
|
+
"ProviderError",
|
|
31
|
+
"SessionError",
|
|
32
|
+
"TokenError",
|
|
33
|
+
"AuthExpiredError",
|
|
34
|
+
]
|
burnbox/__main__.py
ADDED
burnbox/api.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from typing import AsyncIterator
|
|
6
|
+
|
|
7
|
+
from burnbox.client import BurnBoxClient
|
|
8
|
+
from burnbox.config import AppConfig, load_config
|
|
9
|
+
from burnbox.detectors.base import CodeMatch, MessageContext
|
|
10
|
+
from burnbox.detectors.engine import ParserEngine
|
|
11
|
+
from burnbox.exceptions import AuthExpiredError, BurnBoxError
|
|
12
|
+
from burnbox.models import InboxMessage, Session
|
|
13
|
+
from burnbox.providers.base import Provider
|
|
14
|
+
from burnbox.providers.registry import select_provider
|
|
15
|
+
from burnbox.providers.utils import build_registry
|
|
16
|
+
from burnbox.session import SessionStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _select(config: AppConfig) -> Provider:
|
|
20
|
+
registry = build_registry(config.custom_url)
|
|
21
|
+
all_providers = registry.all()
|
|
22
|
+
provider = await select_provider(all_providers, preferred=config.provider_default)
|
|
23
|
+
if not provider:
|
|
24
|
+
for p in all_providers:
|
|
25
|
+
try:
|
|
26
|
+
await p.aclose()
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
raise RuntimeError("No available providers. Check your network.")
|
|
30
|
+
for p in all_providers:
|
|
31
|
+
if p is not provider:
|
|
32
|
+
try:
|
|
33
|
+
await p.aclose()
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
return provider
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Message:
|
|
40
|
+
def __init__(self, inner: InboxMessage, engine: ParserEngine) -> None:
|
|
41
|
+
self._inner = inner
|
|
42
|
+
self._engine = engine
|
|
43
|
+
self._codes: list[CodeMatch] | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def id(self) -> str:
|
|
47
|
+
return self._inner.id
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def sender(self) -> str:
|
|
51
|
+
return self._inner.sender
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def subject(self) -> str:
|
|
55
|
+
return self._inner.subject
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def content(self) -> str:
|
|
59
|
+
return self._inner.content
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def codes(self) -> list[CodeMatch]:
|
|
63
|
+
if self._codes is None:
|
|
64
|
+
ctx = MessageContext(sender=self.sender, subject=self.subject)
|
|
65
|
+
self._codes = self._engine.parse(self.content, ctx)
|
|
66
|
+
return self._codes
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def best_code(self) -> str | None:
|
|
70
|
+
best = self._engine.best_code(self.codes)
|
|
71
|
+
return best.value if best else None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def links(self) -> list[str]:
|
|
75
|
+
return self._engine.detect_links(self.content)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BurnBox:
|
|
79
|
+
"""High-level async interface for temporary email.
|
|
80
|
+
|
|
81
|
+
Usage::
|
|
82
|
+
|
|
83
|
+
async with burnbox.create() as box:
|
|
84
|
+
print(box.address)
|
|
85
|
+
msg = await box.wait_for_message(timeout=60)
|
|
86
|
+
if msg:
|
|
87
|
+
print(msg.best_code)
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
provider: Provider,
|
|
93
|
+
client: BurnBoxClient,
|
|
94
|
+
config: AppConfig,
|
|
95
|
+
engine: ParserEngine | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
self._provider = provider
|
|
98
|
+
self._client = client
|
|
99
|
+
self._config = config
|
|
100
|
+
self._engine = engine or ParserEngine()
|
|
101
|
+
self._seen_ids: set[str] = set()
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def address(self) -> str | None:
|
|
105
|
+
s = self._client.session
|
|
106
|
+
return s.address if s else None
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def session(self) -> Session | None:
|
|
110
|
+
return self._client.session
|
|
111
|
+
|
|
112
|
+
async def fetch_new(self) -> list[Message]:
|
|
113
|
+
raw = await self._client.fetch_new(self._seen_ids)
|
|
114
|
+
messages = [Message(m, self._engine) for m in raw]
|
|
115
|
+
for m in raw:
|
|
116
|
+
self._seen_ids.add(m.id)
|
|
117
|
+
return messages
|
|
118
|
+
|
|
119
|
+
async def wait_for_message(self, timeout: float = 60.0) -> Message | None:
|
|
120
|
+
loop = asyncio.get_running_loop()
|
|
121
|
+
deadline = loop.time() + timeout
|
|
122
|
+
while True:
|
|
123
|
+
messages = await self.fetch_new()
|
|
124
|
+
if messages:
|
|
125
|
+
return messages[0]
|
|
126
|
+
remaining = deadline - loop.time()
|
|
127
|
+
if remaining <= 0:
|
|
128
|
+
return None
|
|
129
|
+
await asyncio.sleep(min(self._config.poll_interval, remaining))
|
|
130
|
+
|
|
131
|
+
_MAX_CONSECUTIVE_ERRORS = 5
|
|
132
|
+
|
|
133
|
+
async def messages(self, poll_interval: float | None = None) -> AsyncIterator[Message]:
|
|
134
|
+
interval = poll_interval or self._config.poll_interval
|
|
135
|
+
consecutive_errors = 0
|
|
136
|
+
while True:
|
|
137
|
+
try:
|
|
138
|
+
new = await self.fetch_new()
|
|
139
|
+
consecutive_errors = 0
|
|
140
|
+
for m in new:
|
|
141
|
+
yield m
|
|
142
|
+
except (AuthExpiredError, BurnBoxError):
|
|
143
|
+
raise
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
consecutive_errors += 1
|
|
146
|
+
if consecutive_errors >= self._MAX_CONSECUTIVE_ERRORS:
|
|
147
|
+
raise BurnBoxError(
|
|
148
|
+
f"Too many consecutive errors ({consecutive_errors}). Last: {exc}"
|
|
149
|
+
) from exc
|
|
150
|
+
await asyncio.sleep(interval)
|
|
151
|
+
|
|
152
|
+
async def burn(self) -> bool:
|
|
153
|
+
return await self._client.burn()
|
|
154
|
+
|
|
155
|
+
async def __aenter__(self) -> BurnBox:
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
async def __aexit__(self, *args: object) -> None:
|
|
159
|
+
try:
|
|
160
|
+
await self.burn()
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
try:
|
|
164
|
+
await self._provider.aclose()
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def create(
|
|
170
|
+
provider: str | None = None,
|
|
171
|
+
config: AppConfig | None = None,
|
|
172
|
+
) -> BurnBox:
|
|
173
|
+
cfg = config or load_config()
|
|
174
|
+
if provider:
|
|
175
|
+
cfg = replace(cfg, provider_default=provider)
|
|
176
|
+
|
|
177
|
+
prov = await _select(cfg)
|
|
178
|
+
store = SessionStore()
|
|
179
|
+
client = BurnBoxClient(provider=prov, session_store=store, config=cfg)
|
|
180
|
+
await client.register()
|
|
181
|
+
return BurnBox(provider=prov, client=client, config=cfg)
|
burnbox/cli.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import Annotated, Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.status import Status
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from burnbox import __version__
|
|
15
|
+
from burnbox.client import BurnBoxClient
|
|
16
|
+
from burnbox.config import AppConfig, load_config
|
|
17
|
+
from burnbox.detectors import copy_to_clipboard, detect_codes, detect_links, extract_best_code, MessageContext
|
|
18
|
+
from burnbox.exceptions import AuthExpiredError, BurnBoxError, SessionError
|
|
19
|
+
from burnbox.models import InboxMessage
|
|
20
|
+
from burnbox.notifications import send_notification
|
|
21
|
+
from burnbox.providers.base import Provider
|
|
22
|
+
from burnbox.providers.registry import ProviderRegistry, select_provider
|
|
23
|
+
from burnbox.providers.utils import build_registry
|
|
24
|
+
from burnbox.session import SessionStore
|
|
25
|
+
|
|
26
|
+
logging.basicConfig(
|
|
27
|
+
level=logging.WARNING,
|
|
28
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
29
|
+
datefmt="%H:%M:%S",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
invoke_without_command=True,
|
|
34
|
+
add_completion=False,
|
|
35
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
36
|
+
no_args_is_help=False,
|
|
37
|
+
)
|
|
38
|
+
console = Console()
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
_MAX_CONSECUTIVE_ERRORS = 5
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _version_callback(value: bool) -> None:
|
|
46
|
+
if value:
|
|
47
|
+
print(f"burnbox {__version__}")
|
|
48
|
+
raise typer.Exit()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _render_message(msg: InboxMessage, config: AppConfig) -> None:
|
|
52
|
+
header = Text()
|
|
53
|
+
header.append("From: ", style="bold cyan")
|
|
54
|
+
header.append(msg.sender)
|
|
55
|
+
header.append(" ")
|
|
56
|
+
header.append("Subject: ", style="bold yellow")
|
|
57
|
+
header.append(msg.subject)
|
|
58
|
+
|
|
59
|
+
escaped = msg.content.replace("[", "\\[")
|
|
60
|
+
content_parts: list[Text] = [Text(escaped)]
|
|
61
|
+
codes = detect_codes(msg.content, MessageContext(sender=msg.sender, subject=msg.subject))
|
|
62
|
+
links = detect_links(msg.content)
|
|
63
|
+
|
|
64
|
+
if codes and config.copy_code:
|
|
65
|
+
best = extract_best_code(codes)
|
|
66
|
+
if best:
|
|
67
|
+
copy_to_clipboard(best)
|
|
68
|
+
content_parts.append(Text.from_markup(f"\n [dim]Copied code: {best}[/dim]"))
|
|
69
|
+
if config.notifications:
|
|
70
|
+
send_notification("burnbox", f"Code: {best}")
|
|
71
|
+
|
|
72
|
+
if codes:
|
|
73
|
+
code_str = ", ".join(c.value for c in codes)
|
|
74
|
+
content_parts.append(Text.from_markup(f"\n [bold green]Codes: {code_str}[/bold green]"))
|
|
75
|
+
if links:
|
|
76
|
+
content_parts.append(Text.from_markup(f"\n [bold blue]Links: {len(links)} found[/bold blue]"))
|
|
77
|
+
|
|
78
|
+
combined = Text()
|
|
79
|
+
for part in content_parts:
|
|
80
|
+
combined.append(part)
|
|
81
|
+
|
|
82
|
+
panel = Panel(
|
|
83
|
+
combined,
|
|
84
|
+
title=header,
|
|
85
|
+
border_style="red",
|
|
86
|
+
padding=(1, 2),
|
|
87
|
+
)
|
|
88
|
+
console.print(panel)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _build_registry(config: AppConfig) -> ProviderRegistry:
|
|
92
|
+
return build_registry(config.custom_url)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _select_provider(config: AppConfig) -> tuple[Provider, list[Provider]]:
|
|
96
|
+
registry = _build_registry(config)
|
|
97
|
+
all_providers = registry.all()
|
|
98
|
+
provider = await select_provider(all_providers, preferred=config.provider_default)
|
|
99
|
+
if not provider:
|
|
100
|
+
raise BurnBoxError("No available providers. Check your network.")
|
|
101
|
+
unused = [p for p in all_providers if p is not provider]
|
|
102
|
+
return provider, unused
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _get_provider_by_name(config: AppConfig, name: str) -> tuple[Provider | None, list[Provider]]:
|
|
106
|
+
registry = _build_registry(config)
|
|
107
|
+
provider = registry.get(name)
|
|
108
|
+
unused = [p for p in registry.all() if p is not provider] if provider else registry.all()
|
|
109
|
+
return provider, unused
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _close_unused(unused: list[Provider]) -> None:
|
|
113
|
+
for p in unused:
|
|
114
|
+
try:
|
|
115
|
+
await p.aclose()
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def _poll_loop(client: BurnBoxClient, config: AppConfig) -> None:
|
|
121
|
+
seen_ids: set[str] = set()
|
|
122
|
+
consecutive_errors = 0
|
|
123
|
+
|
|
124
|
+
with Status("burnbox: waiting for drops...", console=console, spinner="dots") as status:
|
|
125
|
+
while True:
|
|
126
|
+
try:
|
|
127
|
+
new_mails = await client.fetch_new(seen_ids)
|
|
128
|
+
consecutive_errors = 0
|
|
129
|
+
if new_mails:
|
|
130
|
+
status.stop()
|
|
131
|
+
for mail in new_mails:
|
|
132
|
+
_render_message(mail, config)
|
|
133
|
+
seen_ids.add(mail.id)
|
|
134
|
+
status.update(f"burnbox: waiting for drops... [dim]({len(seen_ids)} seen)[/dim]")
|
|
135
|
+
status.start()
|
|
136
|
+
else:
|
|
137
|
+
status.update(f"burnbox: waiting for drops... [dim]({len(seen_ids)} seen)[/dim]")
|
|
138
|
+
except AuthExpiredError:
|
|
139
|
+
raise
|
|
140
|
+
except BurnBoxError as exc:
|
|
141
|
+
consecutive_errors += 1
|
|
142
|
+
if consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
|
|
143
|
+
raise BurnBoxError(
|
|
144
|
+
f"Too many consecutive errors ({consecutive_errors}). Last: {exc}"
|
|
145
|
+
) from exc
|
|
146
|
+
console.print(f"[red]Error: {exc}[/red]")
|
|
147
|
+
except KeyboardInterrupt:
|
|
148
|
+
return
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
consecutive_errors += 1
|
|
151
|
+
if consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
|
|
152
|
+
raise BurnBoxError(
|
|
153
|
+
f"Too many consecutive errors ({consecutive_errors}). Last: {exc}"
|
|
154
|
+
) from exc
|
|
155
|
+
logger.warning("Unexpected error in poll loop: %s", exc)
|
|
156
|
+
|
|
157
|
+
await asyncio.sleep(config.poll_interval)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.callback()
|
|
161
|
+
def main(
|
|
162
|
+
ctx: typer.Context,
|
|
163
|
+
poll: Annotated[
|
|
164
|
+
Optional[float],
|
|
165
|
+
typer.Option("--poll", "-p", help="Polling interval in seconds"),
|
|
166
|
+
] = None,
|
|
167
|
+
timeout: Annotated[
|
|
168
|
+
Optional[float],
|
|
169
|
+
typer.Option("--timeout", "-t", help="HTTP request timeout"),
|
|
170
|
+
] = None,
|
|
171
|
+
keep: Annotated[
|
|
172
|
+
bool,
|
|
173
|
+
typer.Option("--keep", "-k", help="Keep account alive after exit"),
|
|
174
|
+
] = False,
|
|
175
|
+
provider: Annotated[
|
|
176
|
+
Optional[str],
|
|
177
|
+
typer.Option("--provider", help="Provider to use: mailtm, mailgw, guerrillamail, 1secmail"),
|
|
178
|
+
] = None,
|
|
179
|
+
version: Annotated[
|
|
180
|
+
bool,
|
|
181
|
+
typer.Option("--version", "-v", help="Show version", callback=_version_callback, is_eager=True),
|
|
182
|
+
] = False,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""burnbox - Temporary email that burns after reading."""
|
|
185
|
+
if ctx.invoked_subcommand is not None:
|
|
186
|
+
ctx.ensure_object(dict)
|
|
187
|
+
ctx.obj = {
|
|
188
|
+
"poll": poll,
|
|
189
|
+
"timeout": timeout,
|
|
190
|
+
"keep": keep,
|
|
191
|
+
"provider": provider,
|
|
192
|
+
}
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
config = load_config()
|
|
196
|
+
if poll is not None:
|
|
197
|
+
config = replace(config, poll_interval=poll)
|
|
198
|
+
if timeout is not None:
|
|
199
|
+
config = replace(config, timeout=timeout)
|
|
200
|
+
if provider is not None:
|
|
201
|
+
config = replace(config, provider_default=provider)
|
|
202
|
+
|
|
203
|
+
asyncio.run(_run(config, keep))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def _run(config: AppConfig, keep: bool) -> None:
|
|
207
|
+
provider, unused = await _select_provider(config)
|
|
208
|
+
store = SessionStore()
|
|
209
|
+
client = BurnBoxClient(provider=provider, session_store=store, config=config)
|
|
210
|
+
|
|
211
|
+
console.print(Panel(
|
|
212
|
+
"Temp email that burns after reading",
|
|
213
|
+
title="[bold red]burnbox[/bold red]",
|
|
214
|
+
border_style="red",
|
|
215
|
+
padding=(0, 2),
|
|
216
|
+
))
|
|
217
|
+
try:
|
|
218
|
+
session = await client.register()
|
|
219
|
+
console.print()
|
|
220
|
+
console.print(f" [bold]Provider:[/bold] {provider.name}")
|
|
221
|
+
console.print(f" [bold]Address:[/bold] [green]{session.address}[/green]")
|
|
222
|
+
if config.copy_address:
|
|
223
|
+
copy_to_clipboard(session.address)
|
|
224
|
+
console.print("[dim] Address copied to clipboard[/dim]")
|
|
225
|
+
console.print()
|
|
226
|
+
console.print("[dim] Ctrl+C to exit and burn · --keep to preserve · burnbox resume[/dim]\n")
|
|
227
|
+
await _poll_loop(client, config)
|
|
228
|
+
except KeyboardInterrupt:
|
|
229
|
+
pass
|
|
230
|
+
except BurnBoxError as exc:
|
|
231
|
+
console.print(f"[bold red]Critical failure: {exc}[/bold red]")
|
|
232
|
+
finally:
|
|
233
|
+
if not keep and client.session:
|
|
234
|
+
try:
|
|
235
|
+
if await client.burn():
|
|
236
|
+
console.print("[dim]Burned.[/dim]")
|
|
237
|
+
else:
|
|
238
|
+
console.print("[bold red]Failed to burn account.[/bold red]")
|
|
239
|
+
except Exception:
|
|
240
|
+
console.print("[bold red]Failed to burn account.[/bold red]")
|
|
241
|
+
elif keep and client.session:
|
|
242
|
+
console.print("[dim]Kept alive. Resume with: [bold]burnbox resume[/bold][/dim]")
|
|
243
|
+
await _close_unused(unused)
|
|
244
|
+
await provider.aclose()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.command()
|
|
248
|
+
def address(
|
|
249
|
+
ctx: typer.Context,
|
|
250
|
+
provider: Annotated[
|
|
251
|
+
Optional[str],
|
|
252
|
+
typer.Option("--provider", help="Provider to use: mailtm, mailgw, guerrillamail, 1secmail"),
|
|
253
|
+
] = None,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Generate a temp email address and exit."""
|
|
256
|
+
obj = ctx.obj or {}
|
|
257
|
+
config = load_config()
|
|
258
|
+
|
|
259
|
+
provider_name = provider or obj.get("provider")
|
|
260
|
+
if provider_name:
|
|
261
|
+
config = replace(config, provider_default=provider_name)
|
|
262
|
+
|
|
263
|
+
asyncio.run(_run_address(config))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def _run_address(config: AppConfig) -> None:
|
|
267
|
+
provider, unused = await _select_provider(config)
|
|
268
|
+
store = SessionStore()
|
|
269
|
+
client = BurnBoxClient(provider=provider, session_store=store, config=config)
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
session = await client.register()
|
|
273
|
+
console.print(f"[green]{session.address}[/green]")
|
|
274
|
+
if config.copy_address:
|
|
275
|
+
copy_to_clipboard(session.address)
|
|
276
|
+
console.print("[dim]Address copied to clipboard.[/dim]")
|
|
277
|
+
except KeyboardInterrupt:
|
|
278
|
+
pass
|
|
279
|
+
finally:
|
|
280
|
+
if client.session:
|
|
281
|
+
try:
|
|
282
|
+
await client.burn()
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
store.delete()
|
|
286
|
+
await _close_unused(unused)
|
|
287
|
+
await provider.aclose()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@app.command()
|
|
291
|
+
def resume(
|
|
292
|
+
ctx: typer.Context,
|
|
293
|
+
keep: Annotated[
|
|
294
|
+
bool,
|
|
295
|
+
typer.Option("--keep", "-k", help="Keep account alive after exit"),
|
|
296
|
+
] = False,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Reconnect to the last saved session."""
|
|
299
|
+
config = load_config()
|
|
300
|
+
|
|
301
|
+
asyncio.run(_run_resume(config, keep))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def _run_resume(config: AppConfig, keep: bool) -> None:
|
|
305
|
+
store = SessionStore()
|
|
306
|
+
saved = store.load()
|
|
307
|
+
if not saved:
|
|
308
|
+
console.print("[bold red]No saved session found. Run 'burnbox' first.[/bold red]")
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
provider, unused = _get_provider_by_name(config, saved.provider_name)
|
|
312
|
+
if not provider:
|
|
313
|
+
console.print(f"[bold red]Unknown provider '{saved.provider_name}' in saved session.[/bold red]")
|
|
314
|
+
store.delete()
|
|
315
|
+
await _close_unused(unused)
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
client = BurnBoxClient(provider=provider, session_store=store, config=config)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
session = await client.resume()
|
|
322
|
+
console.print(f" [bold]Provider:[/bold] {provider.name}")
|
|
323
|
+
console.print(f" [bold]Address:[/bold] [green]{session.address}[/green]")
|
|
324
|
+
console.print()
|
|
325
|
+
console.print("[dim] Ctrl+C to exit and burn · --keep to preserve[/dim]\n")
|
|
326
|
+
await _poll_loop(client, config)
|
|
327
|
+
except KeyboardInterrupt:
|
|
328
|
+
pass
|
|
329
|
+
except SessionError as exc:
|
|
330
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
331
|
+
except BurnBoxError as exc:
|
|
332
|
+
console.print(f"[bold red]Critical failure: {exc}[/bold red]")
|
|
333
|
+
finally:
|
|
334
|
+
if not keep and client.session:
|
|
335
|
+
try:
|
|
336
|
+
if await client.burn():
|
|
337
|
+
console.print("[dim]Burned.[/dim]")
|
|
338
|
+
else:
|
|
339
|
+
console.print("[bold red]Failed to burn account.[/bold red]")
|
|
340
|
+
except Exception:
|
|
341
|
+
console.print("[bold red]Failed to burn account.[/bold red]")
|
|
342
|
+
elif keep and client.session:
|
|
343
|
+
console.print("[dim]Kept alive. Resume with: [bold]burnbox resume[/bold][/dim]")
|
|
344
|
+
await _close_unused(unused)
|
|
345
|
+
await provider.aclose()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
if __name__ == "__main__":
|
|
349
|
+
app()
|
burnbox/client.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from burnbox.config import AppConfig
|
|
6
|
+
from burnbox.exceptions import AuthExpiredError, SessionError
|
|
7
|
+
from burnbox.models import InboxMessage, Session
|
|
8
|
+
from burnbox.providers.base import Provider
|
|
9
|
+
from burnbox.session import SessionStore
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BurnBoxClient:
|
|
15
|
+
"""Core client orchestrating provider lifecycle.
|
|
16
|
+
|
|
17
|
+
register() → fetch_new() → burn() is the normal flow.
|
|
18
|
+
resume() → fetch_new() → burn() reconnects to a saved session.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
provider: Provider,
|
|
24
|
+
session_store: SessionStore,
|
|
25
|
+
config: AppConfig,
|
|
26
|
+
) -> None:
|
|
27
|
+
self._provider = provider
|
|
28
|
+
self._store = session_store
|
|
29
|
+
self._config = config
|
|
30
|
+
self._session: Session | None = None
|
|
31
|
+
|
|
32
|
+
async def register(self) -> Session:
|
|
33
|
+
"""Create a new temp email account and save the session."""
|
|
34
|
+
session = await self._provider.register()
|
|
35
|
+
self._session = session
|
|
36
|
+
self._store.save(session)
|
|
37
|
+
return session
|
|
38
|
+
|
|
39
|
+
async def resume(self) -> Session:
|
|
40
|
+
"""Restore a saved session. Raises SessionError if expired or missing."""
|
|
41
|
+
session = self._store.load()
|
|
42
|
+
if not session:
|
|
43
|
+
raise SessionError("No saved session found. Run 'burnbox' first.")
|
|
44
|
+
self._session = session
|
|
45
|
+
await self._provider.restore(session)
|
|
46
|
+
try:
|
|
47
|
+
await self._provider.fetch_messages(seen_ids=set())
|
|
48
|
+
except AuthExpiredError:
|
|
49
|
+
self._store.delete()
|
|
50
|
+
raise SessionError("Session expired. Start a new one with 'burnbox'.")
|
|
51
|
+
return session
|
|
52
|
+
|
|
53
|
+
async def fetch_new(self, seen_ids: set[str]) -> list[InboxMessage]:
|
|
54
|
+
return await self._provider.fetch_messages(seen_ids)
|
|
55
|
+
|
|
56
|
+
async def burn(self) -> bool:
|
|
57
|
+
"""Delete the account and session file. Returns True if successful."""
|
|
58
|
+
if not self._session:
|
|
59
|
+
return False
|
|
60
|
+
result = await self._provider.delete_account(self._session.account_id)
|
|
61
|
+
if result:
|
|
62
|
+
self._store.delete()
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def session(self) -> Session | None:
|
|
67
|
+
return self._session
|