xuanzhu 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +424 -0
- package/bin/xzh.js +3 -0
- package/dist/cli.js +5602 -0
- package/dist/cli.js.map +7 -0
- package/dist/skills/aws/SKILL.md +70 -0
- package/dist/skills/datadog/SKILL.md +261 -0
- package/dist/skills/elasticsearch/SKILL.md +88 -0
- package/dist/skills/elasticsearch/install.sh +91 -0
- package/dist/skills/email/SKILL.md +63 -0
- package/dist/skills/github/SKILL.md +69 -0
- package/dist/skills/gitlab/SKILL.md +68 -0
- package/dist/skills/gitlab/install.sh +74 -0
- package/dist/skills/gmail/README.md +139 -0
- package/dist/skills/gmail/SECURITY.md +552 -0
- package/dist/skills/gmail/SETUP.md +258 -0
- package/dist/skills/gmail/activate-gmail-cli.sh +15 -0
- package/dist/skills/gmail/generate-hashes.sh +82 -0
- package/dist/skills/gmail/gmail-cli-secure.py +599 -0
- package/dist/skills/gmail/install.sh +313 -0
- package/dist/skills/gmail/requirements.txt +38 -0
- package/dist/skills/gmail/skill.md +225 -0
- package/dist/skills/jira/SKILL.md +68 -0
- package/dist/skills/kubernetes/SKILL.md +70 -0
- package/dist/skills/linear/SKILL.md +48 -0
- package/dist/skills/mongodb/SKILL.md +53 -0
- package/dist/skills/mysql/SKILL.md +71 -0
- package/dist/skills/mysql/install.sh +74 -0
- package/dist/skills/officecli/SKILL.md +429 -0
- package/dist/skills/playwright/SKILL.md +171 -0
- package/dist/skills/postgres/SKILL.md +44 -0
- package/dist/skills/postgres/install.sh +73 -0
- package/dist/skills/redis/SKILL.md +58 -0
- package/dist/skills/redis/install.sh +39 -0
- package/dist/skills/sentry/SKILL.md +59 -0
- package/dist/skills/telegram/SKILL.md +151 -0
- package/package.json +46 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Gmail CLI - Secure Implementation
|
|
4
|
+
A command-line interface for Gmail with comprehensive security controls.
|
|
5
|
+
|
|
6
|
+
Security Features:
|
|
7
|
+
- Input validation for all user-supplied data
|
|
8
|
+
- Minimal OAuth2 scopes (gmail.readonly + gmail.send)
|
|
9
|
+
- Secure credential storage with proper file permissions
|
|
10
|
+
- Token rotation and revocation support
|
|
11
|
+
- Protection against command injection and path traversal
|
|
12
|
+
- Comprehensive error handling without information disclosure
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import re
|
|
18
|
+
import argparse
|
|
19
|
+
import pickle
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import List, Optional, Dict, Any
|
|
23
|
+
from datetime import datetime, timedelta
|
|
24
|
+
|
|
25
|
+
# Security: Check Python version
|
|
26
|
+
if sys.version_info < (3, 7):
|
|
27
|
+
print("Error: Python 3.7+ required for security features")
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from google.auth.transport.requests import Request
|
|
32
|
+
from google.oauth2.credentials import Credentials
|
|
33
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
34
|
+
from googleapiclient.discovery import build
|
|
35
|
+
from googleapiclient.errors import HttpError
|
|
36
|
+
import email
|
|
37
|
+
from email.mime.text import MIMEText
|
|
38
|
+
from email.mime.multipart import MIMEMultipart
|
|
39
|
+
from email.mime.base import MIMEBase
|
|
40
|
+
from email import encoders
|
|
41
|
+
import base64
|
|
42
|
+
except ImportError as e:
|
|
43
|
+
print(f"Error: Required package not installed: {e}")
|
|
44
|
+
print("\nInstall dependencies:")
|
|
45
|
+
print(" pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib")
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
# Configuration
|
|
49
|
+
# CONFIG_DIR inside .codebuddy folder
|
|
50
|
+
# This file is expected to be in .codebuddy/bin/ when running
|
|
51
|
+
# We want the config to be in .codebuddy/gmail-config
|
|
52
|
+
try:
|
|
53
|
+
# Attempt to locate .codebuddy root relative to this script
|
|
54
|
+
script_path = Path(__file__).resolve()
|
|
55
|
+
# If script is in .codebuddy/bin/script.py, parent.parent is .codebuddy
|
|
56
|
+
if '.codebuddy' in str(script_path):
|
|
57
|
+
# Find the .codebuddy directory by traversing up
|
|
58
|
+
codebuddy_dir = script_path
|
|
59
|
+
while codebuddy_dir.name != '.codebuddy' and codebuddy_dir.parent != codebuddy_dir:
|
|
60
|
+
codebuddy_dir = codebuddy_dir.parent
|
|
61
|
+
|
|
62
|
+
if codebuddy_dir.name == '.codebuddy':
|
|
63
|
+
CONFIG_DIR = codebuddy_dir / 'gmail-config'
|
|
64
|
+
else:
|
|
65
|
+
# Fallback
|
|
66
|
+
CONFIG_DIR = Path.home() / '.gmail-cli'
|
|
67
|
+
else:
|
|
68
|
+
# Fallback if not running from expected location
|
|
69
|
+
CONFIG_DIR = Path.home() / '.gmail-cli'
|
|
70
|
+
except Exception:
|
|
71
|
+
CONFIG_DIR = Path.home() / '.gmail-cli'
|
|
72
|
+
|
|
73
|
+
CREDENTIALS_FILE = CONFIG_DIR / 'credentials.json'
|
|
74
|
+
TOKEN_FILE = CONFIG_DIR / 'token.pickle'
|
|
75
|
+
|
|
76
|
+
# Security: Use MINIMAL scopes (not gmail.modify!)
|
|
77
|
+
# gmail.modify allows DELETION and MODIFICATION of emails - too dangerous
|
|
78
|
+
SCOPES = [
|
|
79
|
+
'https://www.googleapis.com/auth/gmail.readonly', # Read emails
|
|
80
|
+
'https://www.googleapis.com/auth/gmail.compose', # Create drafts and send emails
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
# Security settings
|
|
84
|
+
MAX_TOKEN_AGE_DAYS = 30 # Force re-auth after 30 days
|
|
85
|
+
MAX_ATTACHMENT_SIZE_MB = 25 # Gmail's limit
|
|
86
|
+
SENSITIVE_FILE_PATTERNS = [
|
|
87
|
+
'/etc/shadow', '/etc/passwd', '/.ssh/', '/.gnupg/',
|
|
88
|
+
'/.aws/', '/.gmail-cli/', '/proc/', '/sys/',
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SecurityError(Exception):
|
|
93
|
+
"""Raised when a security violation is detected."""
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ValidationError(Exception):
|
|
98
|
+
"""Raised when input validation fails."""
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def setup_secure_directory():
|
|
103
|
+
"""Create config directory with secure permissions."""
|
|
104
|
+
try:
|
|
105
|
+
CONFIG_DIR.mkdir(mode=0o700, exist_ok=True)
|
|
106
|
+
|
|
107
|
+
# Verify permissions (defense in depth)
|
|
108
|
+
current_perms = oct(CONFIG_DIR.stat().st_mode)[-3:]
|
|
109
|
+
if current_perms != '700':
|
|
110
|
+
CONFIG_DIR.chmod(0o700)
|
|
111
|
+
print(f"⚠️ Fixed directory permissions: {current_perms} -> 700")
|
|
112
|
+
|
|
113
|
+
except Exception as e:
|
|
114
|
+
print(f"❌ Failed to create secure directory: {e}")
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_email(email_addr: str) -> str:
|
|
119
|
+
"""
|
|
120
|
+
Validate email address format.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
email_addr: Email address to validate
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Validated email address
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
ValidationError: If email is invalid
|
|
130
|
+
"""
|
|
131
|
+
# Basic RFC 5322 validation (simplified)
|
|
132
|
+
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
|
133
|
+
|
|
134
|
+
if not re.match(pattern, email_addr):
|
|
135
|
+
raise ValidationError(f"Invalid email address format: {email_addr}")
|
|
136
|
+
|
|
137
|
+
# Check for header injection attempts
|
|
138
|
+
if '\n' in email_addr or '\r' in email_addr:
|
|
139
|
+
raise SecurityError("Email address contains line break characters (possible injection)")
|
|
140
|
+
|
|
141
|
+
# Length check (RFC 5321: max 254 characters)
|
|
142
|
+
if len(email_addr) > 254:
|
|
143
|
+
raise ValidationError(f"Email address too long: {len(email_addr)} chars (max 254)")
|
|
144
|
+
|
|
145
|
+
return email_addr
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def sanitize_text(text: str, field_name: str, max_length: int = 10000) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Sanitize text fields to prevent injection attacks.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
text: Text to sanitize
|
|
154
|
+
field_name: Name of field (for error messages)
|
|
155
|
+
max_length: Maximum allowed length
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Sanitized text
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ValidationError: If text is invalid
|
|
162
|
+
"""
|
|
163
|
+
if text is None:
|
|
164
|
+
return ""
|
|
165
|
+
|
|
166
|
+
# Remove control characters (except tab and newline for body)
|
|
167
|
+
if field_name == 'body':
|
|
168
|
+
# Allow newlines and tabs in body
|
|
169
|
+
text = re.sub(r'[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]', '', text)
|
|
170
|
+
else:
|
|
171
|
+
# Remove ALL control characters from subject/headers
|
|
172
|
+
text = re.sub(r'[\x00-\x1f\x7f]', '', text)
|
|
173
|
+
|
|
174
|
+
# Check for header injection in subject
|
|
175
|
+
if field_name == 'subject' and ('\n' in text or '\r' in text):
|
|
176
|
+
raise SecurityError(f"{field_name} contains line breaks (possible header injection)")
|
|
177
|
+
|
|
178
|
+
# Length limit (RFC 5322: 998 chars per line for headers)
|
|
179
|
+
if field_name == 'subject' and len(text) > 998:
|
|
180
|
+
raise ValidationError(f"Subject too long: {len(text)} chars (max 998)")
|
|
181
|
+
|
|
182
|
+
if len(text) > max_length:
|
|
183
|
+
raise ValidationError(f"{field_name} exceeds maximum length of {max_length}")
|
|
184
|
+
|
|
185
|
+
return text
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def validate_file_path(file_path: str) -> Path:
|
|
189
|
+
"""
|
|
190
|
+
Validate attachment file path for security.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
file_path: Path to file
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Resolved absolute path
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
ValidationError: If file is invalid
|
|
200
|
+
SecurityError: If file access is not allowed
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
# Resolve to absolute path (prevents relative path tricks)
|
|
204
|
+
path = Path(file_path).resolve()
|
|
205
|
+
except Exception as e:
|
|
206
|
+
raise ValidationError(f"Invalid file path: {e}")
|
|
207
|
+
|
|
208
|
+
# Check if file exists
|
|
209
|
+
if not path.is_file():
|
|
210
|
+
raise ValidationError(f"File not found: {file_path}")
|
|
211
|
+
|
|
212
|
+
# Check if readable
|
|
213
|
+
if not os.access(path, os.R_OK):
|
|
214
|
+
raise ValidationError(f"File not readable: {file_path}")
|
|
215
|
+
|
|
216
|
+
# Security: Prevent access to sensitive system files
|
|
217
|
+
path_str = str(path)
|
|
218
|
+
for pattern in SENSITIVE_FILE_PATTERNS:
|
|
219
|
+
if pattern in path_str:
|
|
220
|
+
raise SecurityError(f"Access to sensitive file denied: {file_path}")
|
|
221
|
+
|
|
222
|
+
# Check file size
|
|
223
|
+
size_mb = path.stat().st_size / (1024 * 1024)
|
|
224
|
+
if size_mb > MAX_ATTACHMENT_SIZE_MB:
|
|
225
|
+
raise ValidationError(f"File too large: {size_mb:.1f}MB (max {MAX_ATTACHMENT_SIZE_MB}MB)")
|
|
226
|
+
|
|
227
|
+
return path
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def check_file_permissions(file_path: Path, expected_perms: str = '600'):
|
|
231
|
+
"""Check and fix file permissions."""
|
|
232
|
+
try:
|
|
233
|
+
current_perms = oct(file_path.stat().st_mode)[-3:]
|
|
234
|
+
if current_perms != expected_perms:
|
|
235
|
+
file_path.chmod(int(expected_perms, 8))
|
|
236
|
+
print(f"⚠️ Fixed {file_path.name} permissions: {current_perms} -> {expected_perms}")
|
|
237
|
+
except Exception as e:
|
|
238
|
+
print(f"⚠️ Could not verify permissions for {file_path.name}: {e}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def check_token_age() -> bool:
|
|
242
|
+
"""Check if token is too old and needs refresh."""
|
|
243
|
+
if not TOKEN_FILE.exists():
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
token_age = datetime.now() - datetime.fromtimestamp(TOKEN_FILE.stat().st_mtime)
|
|
247
|
+
|
|
248
|
+
if token_age > timedelta(days=MAX_TOKEN_AGE_DAYS):
|
|
249
|
+
print(f"⚠️ Token is {token_age.days} days old (max: {MAX_TOKEN_AGE_DAYS})")
|
|
250
|
+
print(" Forcing re-authentication for security...")
|
|
251
|
+
TOKEN_FILE.unlink()
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def get_credentials(reset: bool = False) -> Credentials:
|
|
258
|
+
"""
|
|
259
|
+
Get valid user credentials with security checks.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
reset: If True, force re-authentication
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Valid credentials
|
|
266
|
+
"""
|
|
267
|
+
setup_secure_directory()
|
|
268
|
+
|
|
269
|
+
creds = None
|
|
270
|
+
|
|
271
|
+
# Check for existing token
|
|
272
|
+
if not reset and TOKEN_FILE.exists() and check_token_age():
|
|
273
|
+
try:
|
|
274
|
+
check_file_permissions(TOKEN_FILE, '600')
|
|
275
|
+
with open(TOKEN_FILE, 'rb') as token:
|
|
276
|
+
creds = pickle.load(token)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
print(f"⚠️ Could not load token: {e}")
|
|
279
|
+
creds = None
|
|
280
|
+
|
|
281
|
+
# Refresh or get new credentials
|
|
282
|
+
if not creds or not creds.valid:
|
|
283
|
+
if creds and creds.expired and creds.refresh_token:
|
|
284
|
+
try:
|
|
285
|
+
print("🔄 Refreshing access token...")
|
|
286
|
+
creds.refresh(Request())
|
|
287
|
+
except Exception as e:
|
|
288
|
+
print(f"⚠️ Could not refresh token: {e}")
|
|
289
|
+
creds = None
|
|
290
|
+
|
|
291
|
+
if not creds:
|
|
292
|
+
# Need new authentication
|
|
293
|
+
if not CREDENTIALS_FILE.exists():
|
|
294
|
+
print(f"❌ Credentials file not found: {CREDENTIALS_FILE}")
|
|
295
|
+
print("\nSetup instructions:")
|
|
296
|
+
print("1. Create OAuth2 credentials in Google Cloud Console")
|
|
297
|
+
print("2. Download credentials.json")
|
|
298
|
+
print("3. Save to ~/.gmail-cli/credentials.json")
|
|
299
|
+
print("4. Set permissions: chmod 600 ~/.gmail-cli/credentials.json")
|
|
300
|
+
print("\nSee SETUP.md for detailed instructions.")
|
|
301
|
+
sys.exit(1)
|
|
302
|
+
|
|
303
|
+
check_file_permissions(CREDENTIALS_FILE, '600')
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
307
|
+
str(CREDENTIALS_FILE), SCOPES)
|
|
308
|
+
creds = flow.run_local_server(port=0)
|
|
309
|
+
except Exception as e:
|
|
310
|
+
print(f"❌ Authentication failed: {e}")
|
|
311
|
+
sys.exit(1)
|
|
312
|
+
|
|
313
|
+
# Save credentials
|
|
314
|
+
try:
|
|
315
|
+
with open(TOKEN_FILE, 'wb') as token:
|
|
316
|
+
pickle.dump(creds, token)
|
|
317
|
+
check_file_permissions(TOKEN_FILE, '600')
|
|
318
|
+
print("✓ Credentials saved securely")
|
|
319
|
+
except Exception as e:
|
|
320
|
+
print(f"⚠️ Could not save token: {e}")
|
|
321
|
+
|
|
322
|
+
return creds
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def list_emails(args):
|
|
326
|
+
"""List emails with security controls."""
|
|
327
|
+
try:
|
|
328
|
+
creds = get_credentials()
|
|
329
|
+
service = build('gmail', 'v1', credentials=creds)
|
|
330
|
+
|
|
331
|
+
# Build query safely
|
|
332
|
+
query_parts = []
|
|
333
|
+
if args.unread:
|
|
334
|
+
query_parts.append('is:unread')
|
|
335
|
+
if args.query:
|
|
336
|
+
# Sanitize query to prevent Gmail query injection
|
|
337
|
+
safe_query = sanitize_text(args.query, 'query', max_length=500)
|
|
338
|
+
query_parts.append(safe_query)
|
|
339
|
+
|
|
340
|
+
query = ' '.join(query_parts) if query_parts else None
|
|
341
|
+
|
|
342
|
+
# Limit max results for safety
|
|
343
|
+
max_results = min(args.max, 100) # Cap at 100
|
|
344
|
+
|
|
345
|
+
results = service.users().messages().list(
|
|
346
|
+
userId='me',
|
|
347
|
+
q=query,
|
|
348
|
+
maxResults=max_results
|
|
349
|
+
).execute()
|
|
350
|
+
|
|
351
|
+
messages = results.get('messages', [])
|
|
352
|
+
|
|
353
|
+
if not messages:
|
|
354
|
+
print("No messages found.")
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
print(f"Found {len(messages)} message(s):\n")
|
|
358
|
+
|
|
359
|
+
for msg in messages:
|
|
360
|
+
msg_data = service.users().messages().get(
|
|
361
|
+
userId='me',
|
|
362
|
+
id=msg['id'],
|
|
363
|
+
format='metadata',
|
|
364
|
+
metadataHeaders=['From', 'Subject', 'Date']
|
|
365
|
+
).execute()
|
|
366
|
+
|
|
367
|
+
headers = {h['name']: h['value'] for h in msg_data['payload']['headers']}
|
|
368
|
+
|
|
369
|
+
print(f"ID: {msg['id']}")
|
|
370
|
+
print(f"From: {headers.get('From', 'N/A')}")
|
|
371
|
+
print(f"Subject: {headers.get('Subject', 'N/A')}")
|
|
372
|
+
print(f"Date: {headers.get('Date', 'N/A')}")
|
|
373
|
+
print("-" * 80)
|
|
374
|
+
|
|
375
|
+
except HttpError as e:
|
|
376
|
+
print(f"❌ Gmail API error: {e.resp.status} {e.resp.reason}")
|
|
377
|
+
# Don't print full error details (information disclosure)
|
|
378
|
+
sys.exit(1)
|
|
379
|
+
except Exception as e:
|
|
380
|
+
print(f"❌ Error: {e}")
|
|
381
|
+
sys.exit(1)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def send_email(args):
|
|
385
|
+
"""Send email with security validation."""
|
|
386
|
+
try:
|
|
387
|
+
# Validate all inputs
|
|
388
|
+
to_addr = validate_email(args.to)
|
|
389
|
+
cc_addrs = [validate_email(cc) for cc in (args.cc or [])]
|
|
390
|
+
subject = sanitize_text(args.subject, 'subject')
|
|
391
|
+
body = sanitize_text(args.body, 'body')
|
|
392
|
+
|
|
393
|
+
# Validate attachments
|
|
394
|
+
attachment_paths = []
|
|
395
|
+
if args.attach:
|
|
396
|
+
for attach_path in args.attach:
|
|
397
|
+
validated_path = validate_file_path(attach_path)
|
|
398
|
+
attachment_paths.append(validated_path)
|
|
399
|
+
|
|
400
|
+
# Create message
|
|
401
|
+
message = MIMEMultipart()
|
|
402
|
+
message['To'] = to_addr
|
|
403
|
+
if cc_addrs:
|
|
404
|
+
message['Cc'] = ', '.join(cc_addrs)
|
|
405
|
+
message['Subject'] = subject
|
|
406
|
+
|
|
407
|
+
# Attach body
|
|
408
|
+
message.attach(MIMEText(body, 'plain'))
|
|
409
|
+
|
|
410
|
+
# Attach files
|
|
411
|
+
for path in attachment_paths:
|
|
412
|
+
try:
|
|
413
|
+
with open(path, 'rb') as f:
|
|
414
|
+
part = MIMEBase('application', 'octet-stream')
|
|
415
|
+
part.set_payload(f.read())
|
|
416
|
+
encoders.encode_base64(part)
|
|
417
|
+
part.add_header(
|
|
418
|
+
'Content-Disposition',
|
|
419
|
+
f'attachment; filename= {path.name}'
|
|
420
|
+
)
|
|
421
|
+
message.attach(part)
|
|
422
|
+
print(f"✓ Attached: {path.name} ({path.stat().st_size} bytes)")
|
|
423
|
+
except Exception as e:
|
|
424
|
+
print(f"❌ Could not attach {path}: {e}")
|
|
425
|
+
sys.exit(1)
|
|
426
|
+
|
|
427
|
+
# Send email
|
|
428
|
+
creds = get_credentials()
|
|
429
|
+
service = build('gmail', 'v1', credentials=creds)
|
|
430
|
+
|
|
431
|
+
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
|
432
|
+
send_message = {'raw': raw_message}
|
|
433
|
+
|
|
434
|
+
if args.draft:
|
|
435
|
+
result = service.users().drafts().create(
|
|
436
|
+
userId='me',
|
|
437
|
+
body={'message': send_message}
|
|
438
|
+
).execute()
|
|
439
|
+
print(f"✓ Draft saved successfully (ID: {result['id']})")
|
|
440
|
+
else:
|
|
441
|
+
result = service.users().messages().send(
|
|
442
|
+
userId='me',
|
|
443
|
+
body=send_message
|
|
444
|
+
).execute()
|
|
445
|
+
print(f"✓ Email sent successfully (ID: {result['id']})")
|
|
446
|
+
|
|
447
|
+
except (ValidationError, SecurityError) as e:
|
|
448
|
+
print(f"❌ Validation error: {e}")
|
|
449
|
+
sys.exit(1)
|
|
450
|
+
except HttpError as e:
|
|
451
|
+
print(f"❌ Gmail API error: {e.resp.status} {e.resp.reason}")
|
|
452
|
+
sys.exit(1)
|
|
453
|
+
except Exception as e:
|
|
454
|
+
print(f"❌ Error: {e}")
|
|
455
|
+
sys.exit(1)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def auth_command(args):
|
|
459
|
+
"""Handle authentication."""
|
|
460
|
+
try:
|
|
461
|
+
if args.reset:
|
|
462
|
+
if TOKEN_FILE.exists():
|
|
463
|
+
TOKEN_FILE.unlink()
|
|
464
|
+
print("✓ Token deleted")
|
|
465
|
+
print("Starting fresh authentication...")
|
|
466
|
+
|
|
467
|
+
creds = get_credentials(reset=args.reset)
|
|
468
|
+
print("✓ Authentication successful")
|
|
469
|
+
|
|
470
|
+
# Show token info
|
|
471
|
+
if TOKEN_FILE.exists():
|
|
472
|
+
token_age = datetime.now() - datetime.fromtimestamp(TOKEN_FILE.stat().st_mtime)
|
|
473
|
+
print(f"Token age: {token_age.days} days (max: {MAX_TOKEN_AGE_DAYS})")
|
|
474
|
+
|
|
475
|
+
except Exception as e:
|
|
476
|
+
print(f"❌ Authentication failed: {e}")
|
|
477
|
+
sys.exit(1)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def token_command(args):
|
|
481
|
+
"""Handle token management."""
|
|
482
|
+
try:
|
|
483
|
+
if args.status:
|
|
484
|
+
if TOKEN_FILE.exists():
|
|
485
|
+
stat = TOKEN_FILE.stat()
|
|
486
|
+
token_age = datetime.now() - datetime.fromtimestamp(stat.st_mtime)
|
|
487
|
+
perms = oct(stat.st_mode)[-3:]
|
|
488
|
+
|
|
489
|
+
print("Token Status:")
|
|
490
|
+
print(f" File: {TOKEN_FILE}")
|
|
491
|
+
print(f" Age: {token_age.days} days (max: {MAX_TOKEN_AGE_DAYS})")
|
|
492
|
+
print(f" Permissions: {perms} (should be 600)")
|
|
493
|
+
print(f" Scopes: {', '.join(SCOPES)}")
|
|
494
|
+
else:
|
|
495
|
+
print("No token found. Run 'auth' command first.")
|
|
496
|
+
|
|
497
|
+
elif args.revoke:
|
|
498
|
+
if TOKEN_FILE.exists():
|
|
499
|
+
# Try to revoke via API
|
|
500
|
+
try:
|
|
501
|
+
creds = get_credentials()
|
|
502
|
+
creds.revoke(Request())
|
|
503
|
+
print("✓ Token revoked via API")
|
|
504
|
+
except Exception as e:
|
|
505
|
+
print(f"⚠️ Could not revoke via API: {e}")
|
|
506
|
+
|
|
507
|
+
# Always delete local token
|
|
508
|
+
TOKEN_FILE.unlink()
|
|
509
|
+
print("✓ Local token file deleted")
|
|
510
|
+
print("\nTo fully revoke access:")
|
|
511
|
+
print("Visit: https://myaccount.google.com/permissions")
|
|
512
|
+
else:
|
|
513
|
+
print("No token found.")
|
|
514
|
+
|
|
515
|
+
elif args.rotate:
|
|
516
|
+
print("Rotating token...")
|
|
517
|
+
auth_command(argparse.Namespace(reset=True))
|
|
518
|
+
|
|
519
|
+
except Exception as e:
|
|
520
|
+
print(f"❌ Error: {e}")
|
|
521
|
+
sys.exit(1)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def main():
|
|
525
|
+
"""Main entry point with security hardening."""
|
|
526
|
+
parser = argparse.ArgumentParser(
|
|
527
|
+
description='Gmail CLI - Secure Gmail access from command line',
|
|
528
|
+
epilog='See SECURITY.md for security best practices'
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
subparsers = parser.add_subparsers(dest='command', help='Commands')
|
|
532
|
+
|
|
533
|
+
# Auth command
|
|
534
|
+
auth_parser = subparsers.add_parser('auth', help='Authenticate with Gmail')
|
|
535
|
+
auth_parser.add_argument('--reset', action='store_true',
|
|
536
|
+
help='Force re-authentication')
|
|
537
|
+
|
|
538
|
+
# Token command
|
|
539
|
+
token_parser = subparsers.add_parser('token', help='Manage tokens')
|
|
540
|
+
token_parser.add_argument('--status', action='store_true',
|
|
541
|
+
help='Show token status')
|
|
542
|
+
token_parser.add_argument('--revoke', action='store_true',
|
|
543
|
+
help='Revoke and delete token')
|
|
544
|
+
token_parser.add_argument('--rotate', action='store_true',
|
|
545
|
+
help='Rotate token (re-authenticate)')
|
|
546
|
+
|
|
547
|
+
# List command
|
|
548
|
+
list_parser = subparsers.add_parser('list', help='List emails')
|
|
549
|
+
list_parser.add_argument('--max', type=int, default=10,
|
|
550
|
+
help='Maximum emails to show (default: 10, max: 100)')
|
|
551
|
+
list_parser.add_argument('--unread', action='store_true',
|
|
552
|
+
help='Show only unread emails')
|
|
553
|
+
list_parser.add_argument('--query', type=str,
|
|
554
|
+
help='Gmail search query')
|
|
555
|
+
|
|
556
|
+
# Send command
|
|
557
|
+
send_parser = subparsers.add_parser('send', help='Send email')
|
|
558
|
+
send_parser.add_argument('--to', required=True,
|
|
559
|
+
help='Recipient email address')
|
|
560
|
+
send_parser.add_argument('--draft', action='store_true',
|
|
561
|
+
help='Save as draft instead of sending')
|
|
562
|
+
send_parser.add_argument('--cc', action='append',
|
|
563
|
+
help='CC email address (can be used multiple times)')
|
|
564
|
+
send_parser.add_argument('--subject', required=True,
|
|
565
|
+
help='Email subject')
|
|
566
|
+
send_parser.add_argument('--body', required=True,
|
|
567
|
+
help='Email body')
|
|
568
|
+
send_parser.add_argument('--attach', action='append',
|
|
569
|
+
help='File to attach (can be used multiple times)')
|
|
570
|
+
|
|
571
|
+
args = parser.parse_args()
|
|
572
|
+
|
|
573
|
+
if not args.command:
|
|
574
|
+
parser.print_help()
|
|
575
|
+
sys.exit(1)
|
|
576
|
+
|
|
577
|
+
# Route to appropriate command
|
|
578
|
+
if args.command == 'auth':
|
|
579
|
+
auth_command(args)
|
|
580
|
+
elif args.command == 'token':
|
|
581
|
+
token_command(args)
|
|
582
|
+
elif args.command == 'list':
|
|
583
|
+
list_emails(args)
|
|
584
|
+
elif args.command == 'send':
|
|
585
|
+
send_email(args)
|
|
586
|
+
else:
|
|
587
|
+
parser.print_help()
|
|
588
|
+
sys.exit(1)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
if __name__ == '__main__':
|
|
592
|
+
try:
|
|
593
|
+
main()
|
|
594
|
+
except KeyboardInterrupt:
|
|
595
|
+
print("\n\nInterrupted by user")
|
|
596
|
+
sys.exit(130)
|
|
597
|
+
except Exception as e:
|
|
598
|
+
print(f"❌ Unexpected error: {e}")
|
|
599
|
+
sys.exit(1)
|