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 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
@@ -0,0 +1,4 @@
1
+ from burnbox.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
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