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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/bin/xzh.js +3 -0
  4. package/dist/cli.js +5602 -0
  5. package/dist/cli.js.map +7 -0
  6. package/dist/skills/aws/SKILL.md +70 -0
  7. package/dist/skills/datadog/SKILL.md +261 -0
  8. package/dist/skills/elasticsearch/SKILL.md +88 -0
  9. package/dist/skills/elasticsearch/install.sh +91 -0
  10. package/dist/skills/email/SKILL.md +63 -0
  11. package/dist/skills/github/SKILL.md +69 -0
  12. package/dist/skills/gitlab/SKILL.md +68 -0
  13. package/dist/skills/gitlab/install.sh +74 -0
  14. package/dist/skills/gmail/README.md +139 -0
  15. package/dist/skills/gmail/SECURITY.md +552 -0
  16. package/dist/skills/gmail/SETUP.md +258 -0
  17. package/dist/skills/gmail/activate-gmail-cli.sh +15 -0
  18. package/dist/skills/gmail/generate-hashes.sh +82 -0
  19. package/dist/skills/gmail/gmail-cli-secure.py +599 -0
  20. package/dist/skills/gmail/install.sh +313 -0
  21. package/dist/skills/gmail/requirements.txt +38 -0
  22. package/dist/skills/gmail/skill.md +225 -0
  23. package/dist/skills/jira/SKILL.md +68 -0
  24. package/dist/skills/kubernetes/SKILL.md +70 -0
  25. package/dist/skills/linear/SKILL.md +48 -0
  26. package/dist/skills/mongodb/SKILL.md +53 -0
  27. package/dist/skills/mysql/SKILL.md +71 -0
  28. package/dist/skills/mysql/install.sh +74 -0
  29. package/dist/skills/officecli/SKILL.md +429 -0
  30. package/dist/skills/playwright/SKILL.md +171 -0
  31. package/dist/skills/postgres/SKILL.md +44 -0
  32. package/dist/skills/postgres/install.sh +73 -0
  33. package/dist/skills/redis/SKILL.md +58 -0
  34. package/dist/skills/redis/install.sh +39 -0
  35. package/dist/skills/sentry/SKILL.md +59 -0
  36. package/dist/skills/telegram/SKILL.md +151 -0
  37. 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)