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 +0 -0
- duckmail/__main__.py +8 -0
- duckmail/api/__init__.py +0 -0
- duckmail/api/addresses.py +52 -0
- duckmail/api/auth.py +314 -0
- duckmail/api/client.py +194 -0
- duckmail/cli.py +342 -0
- duckmail/config/__init__.py +0 -0
- duckmail/config/settings.py +110 -0
- duckmail/models/__init__.py +0 -0
- duckmail/utils/__init__.py +0 -0
- duckmail/utils/storage.py +106 -0
- duckmail-0.1.0.dist-info/METADATA +150 -0
- duckmail-0.1.0.dist-info/RECORD +18 -0
- duckmail-0.1.0.dist-info/WHEEL +5 -0
- duckmail-0.1.0.dist-info/entry_points.txt +2 -0
- duckmail-0.1.0.dist-info/licenses/LICENSE +21 -0
- duckmail-0.1.0.dist-info/top_level.txt +1 -0
duckmail/__init__.py
ADDED
|
File without changes
|
duckmail/__main__.py
ADDED
duckmail/api/__init__.py
ADDED
|
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,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
|