duckmail 0.1.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.
duckmail/__init__.py ADDED
File without changes
duckmail/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Main entry point for DuckMail CLI."""
2
+
3
+ from duckmail.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
7
+
8
+ # Made with Bob
File without changes
@@ -0,0 +1,52 @@
1
+ """Address management API for DuckDuckGo Email Protection."""
2
+
3
+ from typing import Optional
4
+ from duckmail.api.client import APIClient
5
+
6
+
7
+ class AddressAPI:
8
+ """Address management API wrapper."""
9
+
10
+ def __init__(self, client: Optional[APIClient] = None):
11
+ """Initialize address API.
12
+
13
+ Args:
14
+ client: Optional API client instance
15
+ """
16
+ self.client = client or APIClient()
17
+
18
+ def generate(self) -> str:
19
+ """Generate a new random duck email address.
20
+
21
+ Returns:
22
+ The generated address alias (without @duck.com)
23
+
24
+ Raises:
25
+ Exception: If generation fails
26
+ """
27
+ try:
28
+ response = self.client.post(
29
+ "/email/addresses",
30
+ use_access_token=True,
31
+ headers={"content-length": "0"}
32
+ )
33
+
34
+ address = response.get("address", "")
35
+ return address
36
+ except Exception as e:
37
+ raise Exception(f"Failed to generate address: {str(e)}")
38
+
39
+ def get_full_address(self, alias: str) -> str:
40
+ """Get full email address from alias.
41
+
42
+ Args:
43
+ alias: Address alias
44
+
45
+ Returns:
46
+ Full email address (alias@duck.com)
47
+ """
48
+ if "@" in alias:
49
+ return alias
50
+ return f"{alias}@duck.com"
51
+
52
+ # Made with Bob
duckmail/api/auth.py ADDED
@@ -0,0 +1,314 @@
1
+ """Authentication API for DuckDuckGo Email Protection."""
2
+
3
+ from typing import Optional, Dict, Any
4
+ from duckmail.api.client import APIClient
5
+ from duckmail.utils.storage import CredentialStorage
6
+ from duckmail.config.settings import settings
7
+
8
+
9
+ class AuthAPI:
10
+ """Authentication API wrapper."""
11
+
12
+ def __init__(self, client: Optional[APIClient] = None):
13
+ """Initialize authentication API.
14
+
15
+ Args:
16
+ client: Optional API client instance
17
+ """
18
+ self.client = client or APIClient()
19
+
20
+ def request_login(self, username: str) -> bool:
21
+ """Request login OTP to be sent to user's email.
22
+
23
+ Args:
24
+ username: DuckDuckGo username
25
+
26
+ Returns:
27
+ True if request was successful
28
+
29
+ Raises:
30
+ Exception: If request fails
31
+ """
32
+ try:
33
+ self.client.get(
34
+ "/auth/loginlink",
35
+ params={"user": username}
36
+ )
37
+ return True
38
+ except Exception as e:
39
+ raise Exception(f"Failed to request login: {str(e)}")
40
+
41
+ def confirm_login(self, username: str, otp: str) -> Dict[str, Any]:
42
+ """Confirm login with OTP and get JWT token.
43
+
44
+ Args:
45
+ username: DuckDuckGo username
46
+ otp: OTP passphrase (e.g., "word1+word2+word3+word4")
47
+
48
+ Returns:
49
+ Dictionary with status, token, and user
50
+
51
+ Raises:
52
+ Exception: If login fails
53
+ """
54
+ try:
55
+ response = self.client.get(
56
+ "/auth/login",
57
+ params={
58
+ "user": username,
59
+ "otp": otp
60
+ }
61
+ )
62
+
63
+ # Save JWT token
64
+ if response.get("token"):
65
+ CredentialStorage.save_jwt_token(response["token"])
66
+ # Save username
67
+ settings.save_user_data(username)
68
+
69
+ return response
70
+ except Exception as e:
71
+ raise Exception(f"Login failed: {str(e)}")
72
+
73
+ def get_dashboard(self) -> Dict[str, Any]:
74
+ """Get dashboard information including access token.
75
+
76
+ Returns:
77
+ Dashboard data with user info and access token
78
+
79
+ Raises:
80
+ Exception: If request fails
81
+ """
82
+ try:
83
+ response = self.client.get(
84
+ "/email/dashboard",
85
+ use_jwt=True
86
+ )
87
+
88
+ # Save access token
89
+ if response.get("user", {}).get("access_token"):
90
+ CredentialStorage.save_access_token(response["user"]["access_token"])
91
+ # Save email if available
92
+ if response.get("user", {}).get("email"):
93
+ user_data = settings.load_user_data()
94
+ if user_data:
95
+ settings.save_user_data(
96
+ user_data.get("username", ""),
97
+ response["user"]["email"]
98
+ )
99
+
100
+ return response
101
+ except Exception as e:
102
+ raise Exception(f"Failed to get dashboard: {str(e)}")
103
+
104
+ def logout(self) -> None:
105
+ """Logout by clearing all stored credentials."""
106
+ CredentialStorage.clear_all()
107
+ settings.clear_user_data()
108
+
109
+ def is_authenticated(self) -> bool:
110
+ """Check if user is authenticated.
111
+
112
+ Returns:
113
+ True if JWT token exists
114
+ """
115
+ return CredentialStorage.has_jwt_token()
116
+
117
+ def has_access_token(self) -> bool:
118
+ """Check if access token exists.
119
+
120
+ Returns:
121
+ True if access token is stored
122
+ """
123
+ return CredentialStorage.has_access_token()
124
+
125
+ def verify_token(self) -> bool:
126
+ """Verify if current JWT token is valid.
127
+
128
+ Returns:
129
+ True if token is valid
130
+ """
131
+ if not self.is_authenticated():
132
+ return False
133
+
134
+ try:
135
+ self.get_dashboard()
136
+ return True
137
+ except:
138
+ return False
139
+
140
+ def request_email_change(self, username: str) -> bool:
141
+ """Request OTP to change forwarding email address.
142
+
143
+ Args:
144
+ username: DuckDuckGo username
145
+
146
+ Returns:
147
+ True if request was successful
148
+
149
+ Raises:
150
+ Exception: If request fails
151
+ """
152
+ try:
153
+ self.client.get(
154
+ "/auth/confirmlink",
155
+ params={
156
+ "action": "change_email_address",
157
+ "user": username
158
+ }
159
+ )
160
+ return True
161
+ except Exception as e:
162
+ raise Exception(f"Failed to request email change: {str(e)}")
163
+
164
+ def confirm_email_change_request(self, username: str, otp: str) -> Dict[str, Any]:
165
+ """Confirm email change request with OTP.
166
+
167
+ Args:
168
+ username: DuckDuckGo username
169
+ otp: OTP passphrase
170
+
171
+ Returns:
172
+ Response with temporary token
173
+
174
+ Raises:
175
+ Exception: If confirmation fails
176
+ """
177
+ try:
178
+ response = self.client.get(
179
+ "/auth/confirm",
180
+ params={
181
+ "action": "change_email_address",
182
+ "user": username,
183
+ "otp": otp
184
+ }
185
+ )
186
+ return response
187
+ except Exception as e:
188
+ raise Exception(f"Failed to confirm email change request: {str(e)}")
189
+
190
+ def validate_email(self, email: str) -> bool:
191
+ """Validate if email address is acceptable.
192
+
193
+ Args:
194
+ email: Email address to validate
195
+
196
+ Returns:
197
+ True if email is valid
198
+
199
+ Raises:
200
+ Exception: If validation fails
201
+ """
202
+ try:
203
+ response = self.client.get(
204
+ "/auth/validate-email-address",
205
+ params={"email": email}
206
+ )
207
+ return response.get("valid", False)
208
+ except Exception as e:
209
+ raise Exception(f"Failed to validate email: {str(e)}")
210
+
211
+ def signup_dryrun(self, username: str, email: str, disable_secure_reply: bool = False) -> Dict[str, Any]:
212
+ """Validate username availability before signup.
213
+
214
+ Args:
215
+ username: Desired username
216
+ email: Email address for forwarding
217
+ disable_secure_reply: Whether to disable secure reply
218
+
219
+ Returns:
220
+ Response with status and user
221
+
222
+ Raises:
223
+ Exception: If validation fails
224
+ """
225
+ try:
226
+ # Prepare form data as application/x-www-form-urlencoded
227
+ form_data = {
228
+ "user": username,
229
+ "email": email,
230
+ "disable_secure_reply": "1" if disable_secure_reply else "0",
231
+ "dry_run": "1"
232
+ }
233
+
234
+ response = self.client.post(
235
+ "/auth/signup",
236
+ data=form_data,
237
+ headers={"content-type": "application/x-www-form-urlencoded"}
238
+ )
239
+
240
+ return response
241
+ except Exception as e:
242
+ # Try to extract error message from response
243
+ error_msg = str(e)
244
+ if hasattr(e, 'response') and e.response is not None:
245
+ try:
246
+ error_data = e.response.json()
247
+ if 'error' in error_data:
248
+ error_msg = error_data['error']
249
+ except:
250
+ pass
251
+ raise Exception(f"Failed to validate username: {error_msg}")
252
+
253
+ def signup(self, username: str, email: str, disable_secure_reply: bool = False) -> Dict[str, Any]:
254
+ """Create new DuckDuckGo Email Protection account.
255
+
256
+ Args:
257
+ username: Desired username
258
+ email: Email address for forwarding
259
+ disable_secure_reply: Whether to disable secure reply
260
+
261
+ Returns:
262
+ Response with status and user
263
+
264
+ Raises:
265
+ Exception: If signup fails
266
+ """
267
+ try:
268
+ # Prepare form data as application/x-www-form-urlencoded
269
+ form_data = {
270
+ "user": username,
271
+ "email": email,
272
+ "disable_secure_reply": "1" if disable_secure_reply else "0"
273
+ }
274
+
275
+ response = self.client.post(
276
+ "/auth/signup",
277
+ data=form_data,
278
+ headers={"content-type": "application/x-www-form-urlencoded"}
279
+ )
280
+
281
+ return response
282
+ except Exception as e:
283
+ raise Exception(f"Signup failed: {str(e)}")
284
+
285
+ def verify_signup(self, username: str, otp: str) -> Dict[str, Any]:
286
+ """Verify email and activate account with OTP.
287
+
288
+ Args:
289
+ username: Username
290
+ otp: OTP passphrase from email
291
+
292
+ Returns:
293
+ Response with JWT token
294
+
295
+ Raises:
296
+ Exception: If verification fails
297
+ """
298
+ try:
299
+ response = self.client.get(
300
+ "/auth/verify",
301
+ params={
302
+ "user": username,
303
+ "otp": otp
304
+ }
305
+ )
306
+
307
+ # Save JWT token
308
+ if response.get("token"):
309
+ CredentialStorage.save_jwt_token(response["token"])
310
+ settings.save_user_data(username)
311
+
312
+ return response
313
+ except Exception as e:
314
+ raise Exception(f"Verification failed: {str(e)}")
duckmail/api/client.py ADDED
@@ -0,0 +1,194 @@
1
+ """HTTP client for DuckDuckGo API."""
2
+
3
+ import httpx
4
+ from typing import Optional, Dict, Any
5
+ from duckmail.config.settings import settings
6
+ from duckmail.utils.storage import CredentialStorage
7
+
8
+
9
+ class APIClient:
10
+ """HTTP client wrapper for DuckDuckGo API."""
11
+
12
+ def __init__(self):
13
+ """Initialize API client."""
14
+ self.base_url = settings.api_base_url
15
+ self.client = httpx.Client(timeout=settings.timeout)
16
+
17
+ def _get_headers(self, use_jwt: bool = False, use_access_token: bool = False) -> Dict[str, str]:
18
+ """Get request headers.
19
+
20
+ Args:
21
+ use_jwt: Include JWT token in Authorization header
22
+ use_access_token: Include access token in Authorization header
23
+
24
+ Returns:
25
+ Dictionary of headers
26
+ """
27
+ headers = {
28
+ "accept": "*/*",
29
+ "accept-language": "en-US,en;q=0.9",
30
+ "origin": "https://duckduckgo.com",
31
+ "referer": "https://duckduckgo.com/",
32
+ "sec-ch-ua": '"Chromium";v="147", "Not.A/Brand";v="8"',
33
+ "sec-ch-ua-mobile": "?0",
34
+ "sec-ch-ua-platform": '"Windows"',
35
+ "sec-fetch-dest": "empty",
36
+ "sec-fetch-mode": "cors",
37
+ "sec-fetch-site": "same-site",
38
+ "sec-gpc": "1",
39
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
40
+ }
41
+
42
+ if use_jwt:
43
+ token = CredentialStorage.get_jwt_token()
44
+ if token:
45
+ headers["authorization"] = f"Bearer {token}"
46
+
47
+ if use_access_token:
48
+ token = CredentialStorage.get_access_token()
49
+ if token:
50
+ headers["authorization"] = f"Bearer {token}"
51
+
52
+ return headers
53
+
54
+ def request(
55
+ self,
56
+ method: str,
57
+ endpoint: str,
58
+ data: Optional[Dict[str, Any]] = None,
59
+ params: Optional[Dict[str, Any]] = None,
60
+ use_jwt: bool = False,
61
+ use_access_token: bool = False,
62
+ **kwargs
63
+ ) -> Dict[str, Any]:
64
+ """Make HTTP request to API.
65
+
66
+ Args:
67
+ method: HTTP method (GET, POST, etc.)
68
+ endpoint: API endpoint path
69
+ data: Request body data
70
+ params: Query parameters
71
+ use_jwt: Use JWT token for authorization
72
+ use_access_token: Use access token for authorization
73
+ **kwargs: Additional arguments for httpx
74
+
75
+ Returns:
76
+ Response JSON data
77
+
78
+ Raises:
79
+ httpx.HTTPStatusError: If request fails
80
+ """
81
+ url = f"{self.base_url}/{endpoint.lstrip('/')}"
82
+ headers = self._get_headers(use_jwt=use_jwt, use_access_token=use_access_token)
83
+
84
+ # Merge custom headers if provided
85
+ if 'headers' in kwargs:
86
+ headers.update(kwargs.pop('headers'))
87
+
88
+ # Determine if we should send as form data or JSON
89
+ request_kwargs = {
90
+ 'method': method,
91
+ 'url': url,
92
+ 'params': params,
93
+ **kwargs
94
+ }
95
+
96
+ # If files parameter is provided (multipart/form-data), don't set content-type header
97
+ # httpx will automatically set it with the correct boundary
98
+ if 'files' not in request_kwargs:
99
+ request_kwargs['headers'] = headers
100
+ if data is not None:
101
+ content_type = headers.get('content-type', '')
102
+ if 'application/x-www-form-urlencoded' in content_type:
103
+ request_kwargs['data'] = data
104
+ else:
105
+ request_kwargs['json'] = data
106
+ else:
107
+ # For multipart/form-data, only include non-content-type headers
108
+ request_kwargs['headers'] = {k: v for k, v in headers.items() if k.lower() != 'content-type'}
109
+
110
+ response = self.client.request(**request_kwargs)
111
+ response.raise_for_status()
112
+
113
+ # Some endpoints return empty response
114
+ if response.text:
115
+ return response.json()
116
+ return {}
117
+
118
+ def get(
119
+ self,
120
+ endpoint: str,
121
+ params: Optional[Dict[str, Any]] = None,
122
+ use_jwt: bool = False,
123
+ use_access_token: bool = False
124
+ ) -> Dict[str, Any]:
125
+ """Make GET request.
126
+
127
+ Args:
128
+ endpoint: API endpoint path
129
+ params: Query parameters
130
+ use_jwt: Use JWT token for authorization
131
+ use_access_token: Use access token for authorization
132
+
133
+ Returns:
134
+ Response JSON data
135
+ """
136
+ return self.request("GET", endpoint, params=params, use_jwt=use_jwt, use_access_token=use_access_token)
137
+
138
+ def post(
139
+ self,
140
+ endpoint: str,
141
+ data: Optional[Dict[str, Any]] = None,
142
+ files: Optional[Dict[str, Any]] = None,
143
+ use_jwt: bool = False,
144
+ use_access_token: bool = False,
145
+ **kwargs
146
+ ) -> Dict[str, Any]:
147
+ """Make POST request.
148
+
149
+ Args:
150
+ endpoint: API endpoint path
151
+ data: Request body data
152
+ files: Files/form data for multipart/form-data
153
+ use_jwt: Use JWT token for authorization
154
+ use_access_token: Use access token for authorization
155
+ **kwargs: Additional arguments
156
+
157
+ Returns:
158
+ Response JSON data
159
+ """
160
+ if files:
161
+ kwargs['files'] = files
162
+ return self.request("POST", endpoint, data=data, use_jwt=use_jwt, use_access_token=use_access_token, **kwargs)
163
+
164
+ def delete(
165
+ self,
166
+ endpoint: str,
167
+ use_jwt: bool = False,
168
+ use_access_token: bool = False
169
+ ) -> Dict[str, Any]:
170
+ """Make DELETE request.
171
+
172
+ Args:
173
+ endpoint: API endpoint path
174
+ use_jwt: Use JWT token for authorization
175
+ use_access_token: Use access token for authorization
176
+
177
+ Returns:
178
+ Response JSON data
179
+ """
180
+ return self.request("DELETE", endpoint, use_jwt=use_jwt, use_access_token=use_access_token)
181
+
182
+ def close(self) -> None:
183
+ """Close HTTP client."""
184
+ self.client.close()
185
+
186
+ def __enter__(self):
187
+ """Context manager entry."""
188
+ return self
189
+
190
+ def __exit__(self, *args):
191
+ """Context manager exit."""
192
+ self.close()
193
+
194
+ # Made with Bob
duckmail/cli.py ADDED
@@ -0,0 +1,342 @@
1
+ """Command-line interface for DuckMail."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+ from rich.prompt import Prompt, Confirm
7
+ from typing import Optional
8
+ import pyperclip
9
+
10
+ from duckmail.api.auth import AuthAPI
11
+ from duckmail.api.addresses import AddressAPI
12
+ from duckmail.config.settings import settings
13
+
14
+ app = typer.Typer(
15
+ name="duckmail",
16
+ help="Lightweight CLI for DuckDuckGo Email Protection",
17
+ add_completion=True
18
+ )
19
+ auth_app = typer.Typer(help="Authentication commands")
20
+ app.add_typer(auth_app, name="auth")
21
+
22
+ console = Console()
23
+
24
+
25
+ @auth_app.command("login")
26
+ def login(
27
+ username: Optional[str] = typer.Option(None, "--username", "-u", help="DuckDuckGo username")
28
+ ):
29
+ """Login to DuckDuckGo Email Protection.
30
+
31
+ This will send an OTP to your registered email address.
32
+ """
33
+ auth = AuthAPI()
34
+
35
+ # Get username
36
+ if not username:
37
+ username = Prompt.ask("Enter your DuckDuckGo username")
38
+
39
+ try:
40
+ # Request OTP
41
+ console.print(f"[yellow]Requesting login OTP for user:[/yellow] {username}")
42
+ auth.request_login(username)
43
+ console.print("[green]OK[/green] OTP sent to your email!")
44
+
45
+ # Get OTP from user
46
+ console.print("\n[cyan]Check your email for the OTP passphrase.[/cyan]")
47
+ console.print("[dim]Format: word1+word2+word3+word4[/dim]")
48
+ otp = Prompt.ask("\nEnter OTP passphrase")
49
+
50
+ # Confirm login
51
+ console.print("\n[yellow]Authenticating...[/yellow]")
52
+ response = auth.confirm_login(username, otp)
53
+
54
+ if response.get("status") == "authenticated":
55
+ console.print("[green]OK[/green] Login successful!")
56
+
57
+ # Get dashboard to retrieve access token
58
+ console.print("[yellow]Fetching account information...[/yellow]")
59
+ dashboard = auth.get_dashboard()
60
+
61
+ user_email = dashboard.get("user", {}).get("email", "N/A")
62
+ addresses_count = dashboard.get("stats", {}).get("addresses_generated", 0)
63
+
64
+ console.print(f"\n[green]Welcome back![/green]")
65
+ console.print(f"Username: [cyan]{username}[/cyan]")
66
+ console.print(f"Email: [cyan]{user_email}[/cyan]")
67
+ console.print(f"Addresses generated: [cyan]{addresses_count}[/cyan]")
68
+ else:
69
+ console.print("[red]ERROR[/red] Login failed")
70
+ raise typer.Exit(1)
71
+
72
+ except Exception as e:
73
+ console.print(f"[red]Error:[/red] {e}")
74
+ raise typer.Exit(1)
75
+
76
+
77
+ @auth_app.command("signup", hidden=True)
78
+ def signup(
79
+ username: Optional[str] = typer.Option(None, "--username", "-u", help="Desired username"),
80
+ email: Optional[str] = typer.Option(None, "--email", "-e", help="Email for forwarding"),
81
+ disable_secure_reply: bool = typer.Option(False, "--disable-secure-reply", help="Disable secure reply")
82
+ ):
83
+ """Sign up for DuckDuckGo Email Protection.
84
+
85
+ NOTE: This command may not work due to DuckDuckGo's bot protection.
86
+ Please sign up via browser at https://duckduckgo.com/email/ first,
87
+ then use 'duckmail auth login' to authenticate.
88
+ """
89
+ auth = AuthAPI()
90
+
91
+ try:
92
+ # Get username
93
+ if not username:
94
+ username = Prompt.ask("Enter desired username")
95
+
96
+ # Get email
97
+ if not email:
98
+ email = Prompt.ask("Enter your email address")
99
+
100
+ # Validate username (dry run)
101
+ console.print(f"[yellow]Checking username availability...[/yellow]")
102
+ dryrun_response = auth.signup_dryrun(username, email, disable_secure_reply)
103
+
104
+ if dryrun_response.get("status") != "valid":
105
+ console.print(f"[red]X[/red] Username '{username}' is not available")
106
+ raise typer.Exit(1)
107
+
108
+ console.print(f"[green]OK[/green] Username '{username}' is available")
109
+
110
+ # Validate email
111
+ console.print(f"[yellow]Validating email address...[/yellow]")
112
+ if not auth.validate_email(email):
113
+ console.print(f"[red]X[/red] Email '{email}' is not valid")
114
+ raise typer.Exit(1)
115
+
116
+ console.print(f"[green]OK[/green] Email is valid")
117
+
118
+ # Confirm signup
119
+ console.print(f"\n[cyan]Account Details:[/cyan]")
120
+ console.print(f"Username: [cyan]{username}[/cyan]")
121
+ console.print(f"Email: [cyan]{email}[/cyan]")
122
+ console.print(f"Secure Reply: [cyan]{'Disabled' if disable_secure_reply else 'Enabled'}[/cyan]")
123
+
124
+ if not Confirm.ask("\nProceed with signup?", default=True):
125
+ console.print("[yellow]Signup cancelled[/yellow]")
126
+ raise typer.Exit(0)
127
+
128
+ # Create account
129
+ console.print(f"\n[yellow]Creating account...[/yellow]")
130
+ signup_response = auth.signup(username, email, disable_secure_reply)
131
+
132
+ if signup_response.get("status") != "created":
133
+ console.print(f"[red]ERROR[/red] Signup failed")
134
+ raise typer.Exit(1)
135
+
136
+ console.print(f"[green]OK[/green] Account created!")
137
+ console.print(f"[yellow]OTP sent to {email}[/yellow]")
138
+
139
+ # Get OTP
140
+ console.print("\n[cyan]Check your email for the OTP passphrase.[/cyan]")
141
+ console.print("[dim]Format: word1+word2+word3+word4[/dim]")
142
+ otp = Prompt.ask("\nEnter OTP passphrase")
143
+
144
+ # Verify email
145
+ console.print("\n[yellow]Verifying email...[/yellow]")
146
+ verify_response = auth.verify_signup(username, otp)
147
+
148
+ if verify_response.get("status") == "authenticated":
149
+ console.print("[green]OK[/green] Email verified!")
150
+
151
+ # Get dashboard
152
+ console.print("[yellow]Setting up account...[/yellow]")
153
+ dashboard = auth.get_dashboard()
154
+
155
+ console.print(f"\n[green]Welcome to DuckDuckGo Email Protection![/green]")
156
+ console.print(f"Username: [cyan]{username}[/cyan]")
157
+ console.print(f"Email: [cyan]{email}[/cyan]")
158
+ console.print(f"\n[dim]You can now generate duck email addresses with:[/dim]")
159
+ console.print(f"[dim] duckmail generate[/dim]")
160
+ else:
161
+ console.print("[red]ERROR[/red] Verification failed")
162
+ raise typer.Exit(1)
163
+
164
+ except Exception as e:
165
+ console.print(f"[red]Error:[/red] {e}")
166
+ raise typer.Exit(1)
167
+
168
+
169
+ @auth_app.command("logout")
170
+ def logout():
171
+ """Logout from DuckDuckGo Email Protection.
172
+
173
+ This will clear all stored credentials.
174
+ """
175
+ auth = AuthAPI()
176
+ auth.logout()
177
+ console.print("[green]OK[/green] Logged out successfully")
178
+ console.print("[dim]All credentials have been cleared[/dim]")
179
+
180
+
181
+ @auth_app.command("status")
182
+ def status():
183
+ """Check authentication status."""
184
+ auth = AuthAPI()
185
+
186
+ if auth.is_authenticated():
187
+ # Try to verify token
188
+ console.print("[yellow]Verifying authentication...[/yellow]")
189
+
190
+ if auth.verify_token():
191
+ user_data = settings.load_user_data()
192
+
193
+ console.print("[green]OK[/green] Authenticated")
194
+ if user_data:
195
+ console.print(f"Username: [cyan]{user_data.get('username', 'N/A')}[/cyan]")
196
+ console.print(f"Email: [cyan]{user_data.get('email', 'N/A')}[/cyan]")
197
+
198
+ if auth.has_access_token():
199
+ console.print("[green]OK[/green] Access token available")
200
+ else:
201
+ console.print("[yellow]WARNING[/yellow] Token expired")
202
+ console.print("[dim]Please login again[/dim]")
203
+ else:
204
+ console.print("[red]ERROR[/red] Not authenticated")
205
+ console.print("[dim]Use 'duckmail auth login' to authenticate[/dim]")
206
+
207
+
208
+ @app.command("generate")
209
+ def generate_address(
210
+ count: int = typer.Option(1, "--count", "-n", help="Number of addresses to generate"),
211
+ copy: bool = typer.Option(False, "--copy", "-c", help="Copy generated address to clipboard")
212
+ ):
213
+ """Generate new duck email address(es).
214
+
215
+ Generates random duck email addresses that forward to your registered email.
216
+ """
217
+ auth = AuthAPI()
218
+
219
+ # Check authentication
220
+ if not auth.has_access_token():
221
+ console.print("[red]ERROR[/red] Not authenticated")
222
+ console.print("[dim]Use 'duckmail auth login' first[/dim]")
223
+ raise typer.Exit(1)
224
+
225
+ address_api = AddressAPI()
226
+
227
+ try:
228
+ if count == 1:
229
+ console.print("[yellow]Generating address...[/yellow]")
230
+ alias = address_api.generate()
231
+ full_address = address_api.get_full_address(alias)
232
+ console.print(f"\n[green]OK[/green] Generated: [cyan]{full_address}[/cyan]")
233
+
234
+ # Copy to clipboard if requested
235
+ if copy:
236
+ try:
237
+ pyperclip.copy(full_address)
238
+ console.print("[green]OK[/green] Copied to clipboard!")
239
+ except Exception as e:
240
+ console.print(f"[yellow]WARNING[/yellow] Could not copy to clipboard: {e}")
241
+ else:
242
+ if copy:
243
+ console.print("[yellow]WARNING[/yellow] Copy option only works with single address generation")
244
+
245
+ console.print(f"[yellow]Generating {count} addresses...[/yellow]\n")
246
+
247
+ table = Table(title=f"Generated Addresses")
248
+ table.add_column("#", style="dim")
249
+ table.add_column("Address", style="cyan")
250
+
251
+ for i in range(count):
252
+ alias = address_api.generate()
253
+ full_address = address_api.get_full_address(alias)
254
+ table.add_row(str(i + 1), full_address)
255
+
256
+ console.print(table)
257
+ console.print(f"\n[green]OK[/green] Generated {count} addresses successfully")
258
+
259
+ except Exception as e:
260
+ console.print(f"[red]Error:[/red] {e}")
261
+ raise typer.Exit(1)
262
+
263
+
264
+ @app.command("info")
265
+ def show_info():
266
+ """Show account information and statistics."""
267
+ auth = AuthAPI()
268
+
269
+ # Check authentication
270
+ if not auth.is_authenticated():
271
+ console.print("[red]ERROR[/red] Not authenticated")
272
+ console.print("[dim]Use 'duckmail auth login' first[/dim]")
273
+ raise typer.Exit(1)
274
+
275
+ try:
276
+ console.print("[yellow]Fetching account information...[/yellow]\n")
277
+ dashboard = auth.get_dashboard()
278
+
279
+ user = dashboard.get("user", {})
280
+ stats = dashboard.get("stats", {})
281
+
282
+ # Create info table
283
+ table = Table(title="Account Information", show_header=False)
284
+ table.add_column("Field", style="cyan")
285
+ table.add_column("Value", style="green")
286
+
287
+ table.add_row("Username", user.get("username", "N/A"))
288
+ table.add_row("Email", user.get("email", "N/A"))
289
+ table.add_row("Cohort", user.get("cohort", "N/A"))
290
+ table.add_row("Addresses Generated", str(stats.get("addresses_generated", 0)))
291
+
292
+ console.print(table)
293
+
294
+ except Exception as e:
295
+ console.print(f"[red]Error:[/red] {e}")
296
+ raise typer.Exit(1)
297
+
298
+
299
+ # Aliases for commands
300
+ @app.command("g", hidden=True)
301
+ def g(
302
+ count: int = typer.Option(1, "--count", "-n", help="Number of addresses to generate"),
303
+ copy: bool = typer.Option(False, "--copy", "-c", help="Copy generated address to clipboard")
304
+ ):
305
+ """Alias for generate command."""
306
+ generate_address(count, copy)
307
+
308
+
309
+ @app.command("gen", hidden=True)
310
+ def gen(
311
+ count: int = typer.Option(1, "--count", "-n", help="Number of addresses to generate"),
312
+ copy: bool = typer.Option(False, "--copy", "-c", help="Copy generated address to clipboard")
313
+ ):
314
+ """Alias for generate command."""
315
+ generate_address(count, copy)
316
+
317
+
318
+ @app.command("i", hidden=True)
319
+ def i():
320
+ """Alias for info command."""
321
+ show_info()
322
+
323
+
324
+ @app.command("version")
325
+ def version():
326
+ """Show version information."""
327
+ console.print("[cyan]DuckMail CLI[/cyan] [green]v0.1.0[/green]")
328
+ console.print("[dim]Lightweight CLI for DuckDuckGo Email Protection[/dim]")
329
+
330
+
331
+ @app.callback()
332
+ def main():
333
+ """
334
+ DuckMail CLI - Manage DuckDuckGo Email Protection from the terminal.
335
+
336
+ Use 'duckmail --help' to see available commands.
337
+ """
338
+ pass
339
+
340
+
341
+ if __name__ == "__main__":
342
+ app()
File without changes
@@ -0,0 +1,110 @@
1
+ """Configuration management for DuckMail CLI."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ import json
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class Settings(BaseModel):
10
+ """Application settings."""
11
+
12
+ api_base_url: str = Field(
13
+ default="https://quack.duckduckgo.com/api",
14
+ description="Base URL for DuckDuckGo API"
15
+ )
16
+
17
+ config_dir: Path = Field(
18
+ default_factory=lambda: Path.home() / ".config" / "duckmail",
19
+ description="Configuration directory"
20
+ )
21
+
22
+ cache_dir: Path = Field(
23
+ default_factory=lambda: Path.home() / ".cache" / "duckmail",
24
+ description="Cache directory"
25
+ )
26
+
27
+ timeout: int = Field(
28
+ default=30,
29
+ description="HTTP request timeout in seconds"
30
+ )
31
+
32
+ class Config:
33
+ arbitrary_types_allowed = True
34
+
35
+ def __init__(self, **data):
36
+ super().__init__(**data)
37
+ # Create directories if they don't exist
38
+ self.config_dir.mkdir(parents=True, exist_ok=True)
39
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ @property
42
+ def config_file(self) -> Path:
43
+ """Path to configuration file."""
44
+ return self.config_dir / "config.json"
45
+
46
+ @property
47
+ def user_file(self) -> Path:
48
+ """Path to user data file."""
49
+ return self.config_dir / "user.json"
50
+
51
+ def save(self) -> None:
52
+ """Save settings to file."""
53
+ data = self.model_dump(mode='json')
54
+ # Convert Path objects to strings for JSON serialization
55
+ data['config_dir'] = str(self.config_dir)
56
+ data['cache_dir'] = str(self.cache_dir)
57
+
58
+ with open(self.config_file, 'w') as f:
59
+ json.dump(data, f, indent=2)
60
+
61
+ @classmethod
62
+ def load(cls) -> "Settings":
63
+ """Load settings from file or create default."""
64
+ settings = cls()
65
+
66
+ if settings.config_file.exists():
67
+ try:
68
+ with open(settings.config_file) as f:
69
+ data = json.load(f)
70
+ # Convert string paths back to Path objects
71
+ if 'config_dir' in data:
72
+ data['config_dir'] = Path(data['config_dir'])
73
+ if 'cache_dir' in data:
74
+ data['cache_dir'] = Path(data['cache_dir'])
75
+ return cls(**data)
76
+ except Exception:
77
+ # If loading fails, return default settings
78
+ pass
79
+
80
+ return settings
81
+
82
+ def save_user_data(self, username: str, email: Optional[str] = None) -> None:
83
+ """Save user data to file."""
84
+ data = {
85
+ "username": username,
86
+ "email": email
87
+ }
88
+ with open(self.user_file, 'w') as f:
89
+ json.dump(data, f, indent=2)
90
+
91
+ def load_user_data(self) -> Optional[dict]:
92
+ """Load user data from file."""
93
+ if self.user_file.exists():
94
+ try:
95
+ with open(self.user_file) as f:
96
+ return json.load(f)
97
+ except Exception:
98
+ pass
99
+ return None
100
+
101
+ def clear_user_data(self) -> None:
102
+ """Clear user data file."""
103
+ if self.user_file.exists():
104
+ self.user_file.unlink()
105
+
106
+
107
+ # Global settings instance
108
+ settings = Settings.load()
109
+
110
+ # Made with Bob
File without changes
File without changes
@@ -0,0 +1,106 @@
1
+ """Secure credential storage using system keyring."""
2
+
3
+ import keyring
4
+ from typing import Optional
5
+ import keyring.errors
6
+
7
+
8
+ SERVICE_NAME = "duckmail"
9
+
10
+
11
+ class CredentialStorage:
12
+ """Secure storage for authentication credentials."""
13
+
14
+ @staticmethod
15
+ def save_jwt_token(token: str) -> None:
16
+ """Save JWT authentication token securely.
17
+
18
+ Args:
19
+ token: JWT token from login
20
+ """
21
+ try:
22
+ keyring.set_password(SERVICE_NAME, "jwt_token", token)
23
+ except keyring.errors.NoKeyringError:
24
+ # Keyring not available, silently fail
25
+ pass
26
+
27
+ @staticmethod
28
+ def get_jwt_token() -> Optional[str]:
29
+ """Retrieve JWT authentication token.
30
+
31
+ Returns:
32
+ JWT token or None if not found
33
+ """
34
+ try:
35
+ return keyring.get_password(SERVICE_NAME, "jwt_token")
36
+ except keyring.errors.NoKeyringError:
37
+ # Keyring not available
38
+ return None
39
+
40
+ @staticmethod
41
+ def delete_jwt_token() -> None:
42
+ """Delete JWT authentication token."""
43
+ try:
44
+ keyring.delete_password(SERVICE_NAME, "jwt_token")
45
+ except (keyring.errors.PasswordDeleteError, keyring.errors.NoKeyringError):
46
+ pass
47
+
48
+ @staticmethod
49
+ def save_access_token(token: str) -> None:
50
+ """Save long-lived access token securely.
51
+
52
+ Args:
53
+ token: Access token from dashboard
54
+ """
55
+ try:
56
+ keyring.set_password(SERVICE_NAME, "access_token", token)
57
+ except keyring.errors.NoKeyringError:
58
+ # Keyring not available, silently fail
59
+ pass
60
+
61
+ @staticmethod
62
+ def get_access_token() -> Optional[str]:
63
+ """Retrieve access token.
64
+
65
+ Returns:
66
+ Access token or None if not found
67
+ """
68
+ try:
69
+ return keyring.get_password(SERVICE_NAME, "access_token")
70
+ except keyring.errors.NoKeyringError:
71
+ # Keyring not available
72
+ return None
73
+
74
+ @staticmethod
75
+ def delete_access_token() -> None:
76
+ """Delete access token."""
77
+ try:
78
+ keyring.delete_password(SERVICE_NAME, "access_token")
79
+ except (keyring.errors.PasswordDeleteError, keyring.errors.NoKeyringError):
80
+ pass
81
+
82
+ @staticmethod
83
+ def has_jwt_token() -> bool:
84
+ """Check if JWT token exists.
85
+
86
+ Returns:
87
+ True if JWT token is stored
88
+ """
89
+ return CredentialStorage.get_jwt_token() is not None
90
+
91
+ @staticmethod
92
+ def has_access_token() -> bool:
93
+ """Check if access token exists.
94
+
95
+ Returns:
96
+ True if access token is stored
97
+ """
98
+ return CredentialStorage.get_access_token() is not None
99
+
100
+ @staticmethod
101
+ def clear_all() -> None:
102
+ """Clear all stored credentials."""
103
+ CredentialStorage.delete_jwt_token()
104
+ CredentialStorage.delete_access_token()
105
+
106
+ # Made with Bob
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: duckmail
3
+ Version: 0.1.0
4
+ Summary: Lightweight CLI for DuckDuckGo Email Protection
5
+ Author: DuckMail Contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/yourusername/duckmail
8
+ Project-URL: Documentation, https://github.com/yourusername/duckmail/blob/main/README.md
9
+ Project-URL: Repository, https://github.com/yourusername/duckmail
10
+ Project-URL: Issues, https://github.com/yourusername/duckmail/issues
11
+ Keywords: duckduckgo,email,cli,privacy,email-protection
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Communications :: Email
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.8
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: typer>=0.9.0
28
+ Requires-Dist: httpx>=0.24.0
29
+ Requires-Dist: keyring>=24.0.0
30
+ Requires-Dist: rich>=13.0.0
31
+ Requires-Dist: pydantic>=2.0.0
32
+ Requires-Dist: pyperclip>=1.8.0
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
35
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
36
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
37
+ Requires-Dist: pytest-mock>=3.11.0; extra == "dev"
38
+ Requires-Dist: black>=23.0.0; extra == "dev"
39
+ Requires-Dist: ruff>=0.0.270; extra == "dev"
40
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
41
+ Dynamic: license-file
42
+
43
+ # DuckMail CLI
44
+
45
+ Lightweight CLI for DuckDuckGo Email Protection - Manage your duck email addresses from the terminal.
46
+
47
+ **[English](README.md)** | [Tiếng Việt](README.vi.md)
48
+
49
+ ---
50
+
51
+ ## Quick Start
52
+
53
+ ```bash
54
+ # Install
55
+ pip install -r requirements.txt
56
+ pip install -e .
57
+
58
+ # First time setup: Sign up via browser
59
+ # Visit https://duckduckgo.com/email/ to create account
60
+
61
+ # Login with your credentials
62
+ duckmail auth login
63
+
64
+ # Generate address and copy to clipboard
65
+ duckmail g -c
66
+ ```
67
+
68
+ > **Note:** Account signup must be done via browser at https://duckduckgo.com/email/ due to DuckDuckGo's bot protection. After signing up, use the CLI to login and manage your addresses.
69
+
70
+ ## Features
71
+
72
+ - 🔐 **Secure Authentication** - OTP-based login
73
+ - 📧 **Generate Addresses** - Create duck email addresses
74
+ - 📋 **Copy to Clipboard** - Auto-copy with `-c` flag
75
+ - ⚡ **Fast & Lightweight** - Quick commands with aliases
76
+ - 💾 **Secure Storage** - System keyring integration
77
+ - 🎨 **Beautiful Output** - Rich terminal UI
78
+
79
+ ## Command Aliases
80
+
81
+ - `duckmail auth` → `duckmail a`
82
+ - `duckmail generate` → `duckmail g` or `duckmail gen`
83
+ - `duckmail info` → `duckmail i`
84
+
85
+ ## Common Commands
86
+
87
+ ```bash
88
+ # Authentication
89
+ duckmail auth login # Login with username
90
+ duckmail auth logout # Logout
91
+ duckmail auth status # Check authentication status
92
+
93
+ # Generate
94
+ duckmail g # Generate 1 address
95
+ duckmail g -c # Generate & copy
96
+ duckmail g -n 5 # Generate 5 addresses
97
+
98
+ # Info
99
+ duckmail i # Account info
100
+ duckmail version # Version
101
+ ```
102
+
103
+ ## Documentation
104
+
105
+ 📖 **[Complete Documentation](DOCS.md)** - Full guide with:
106
+ - Installation instructions
107
+ - Usage examples
108
+ - API documentation
109
+ - Troubleshooting
110
+ - Command reference
111
+
112
+ ## Project Structure
113
+
114
+ ```
115
+ duckmail/
116
+ ├── duckmail/ # Main package
117
+ │ ├── api/ # API clients
118
+ │ ├── config/ # Configuration
119
+ │ ├── utils/ # Utilities
120
+ │ └── cli.py # CLI interface
121
+ ├── DOCS.md # Complete documentation
122
+ ├── PLAN.md # Project plan
123
+ ├── README.md # This file
124
+ └── requirements.txt # Dependencies
125
+ ```
126
+
127
+ ## Development
128
+
129
+ ```bash
130
+ # Setup
131
+ python -m venv venv
132
+ .\venv\Scripts\Activate.ps1
133
+ pip install -r requirements.txt
134
+ pip install -e .
135
+
136
+ # Run
137
+ duckmail --help
138
+ ```
139
+
140
+ ## License
141
+
142
+ MIT License - See [LICENSE](LICENSE) file
143
+
144
+ ## Disclaimer
145
+
146
+ Unofficial tool, not affiliated with DuckDuckGo. Use at your own risk.
147
+
148
+ ---
149
+
150
+ **Made with ❤️ for the DuckDuckGo community** 🦆
@@ -0,0 +1,18 @@
1
+ duckmail/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ duckmail/__main__.py,sha256=XJ4jaw9x0e-_eugPU4DAgaedKSRh4W2mp5cVfRZgFOI,134
3
+ duckmail/cli.py,sha256=mO4Sr_9YxNjjDFkYpmYdQoCd3NXJVAxs-sdmOOLOgGs,12691
4
+ duckmail/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ duckmail/api/addresses.py,sha256=kaCw1Ngf09E1RnDlwfFFBoIqmMJOwzp9G5BPI7VrdMc,1465
6
+ duckmail/api/auth.py,sha256=-Xax7uJY1bXeNe0afaWtm7zjZ8PdDZCJNpi5uH1phd8,10164
7
+ duckmail/api/client.py,sha256=mpTdLQ3W-VpHFiUnSwTkz5MfXzFpfPIwTg4PsODWgls,6655
8
+ duckmail/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ duckmail/config/settings.py,sha256=BWNT6e1PBnlyCFYbUXiC6C3HrWKfNu-VF-syCnxXwUA,3435
10
+ duckmail/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ duckmail/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ duckmail/utils/storage.py,sha256=YAF28RsRhcvHuWaDtKz6Fdk_Sz8qpNvbUlGrgFnoaD4,3121
13
+ duckmail-0.1.0.dist-info/licenses/LICENSE,sha256=K88YQZbkkLd_oNxQN1m-nwayWo4tpgHYvp5Faibi4M0,1097
14
+ duckmail-0.1.0.dist-info/METADATA,sha256=ANUeezxjd_i5B91YyvmwfQyMyBDSHHUGfc6cxLcrmdI,4492
15
+ duckmail-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
16
+ duckmail-0.1.0.dist-info/entry_points.txt,sha256=dIqw7jCfBBt0-70IeD5DZHwbQTSO4t5vJ1jI1HXGxfc,46
17
+ duckmail-0.1.0.dist-info/top_level.txt,sha256=vce8aekTrxrlK7ggsYSRPIKMspu3quxNsDsSF7I6PJI,9
18
+ duckmail-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ duckmail = duckmail.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DuckMail Contributors
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 @@
1
+ duckmail