#!/usr/bin/env python3 """ IMAP Email CLI - Python version Supports: check, fetch, search, mark-read, mark-unread, list-mailboxes """ import imaplib import email import os import sys import argparse import json from pathlib import Path from email.header import decode_header from datetime import datetime, timedelta import re # Load .env env_file = Path(__file__).parent.parent / '.env' if env_file.exists(): for line in env_file.read_text().splitlines(): if line.strip() and not line.startswith('#') and '=' in line: key, _, value = line.partition('=') os.environ[key.strip()] = value.strip() def connect_imap(): """Connect and login to IMAP server""" host = os.environ.get('IMAP_HOST', 'imap.gmail.com') port = int(os.environ.get('IMAP_PORT', '993')) user = os.environ.get('IMAP_USER') password = os.environ.get('IMAP_PASS') if not user or not password: print("Error: IMAP_USER and IMAP_PASS must be set", file=sys.stderr) sys.exit(1) mail = imaplib.IMAP4_SSL(host, port) mail.login(user, password) return mail def decode_header_value(value): """Decode email header value""" if not value: return "" decoded_parts = decode_header(value) result = [] for part, encoding in decoded_parts: if isinstance(part, bytes): result.append(part.decode(encoding or 'utf-8', errors='ignore')) else: result.append(part) return ' '.join(result) def parse_time_filter(time_str): """Parse time filter like '30m', '2h', '7d' into datetime""" match = re.match(r'(\d+)([mhd])', time_str) if not match: return None value, unit = int(match.group(1)), match.group(2) if unit == 'm': delta = timedelta(minutes=value) elif unit == 'h': delta = timedelta(hours=value) elif unit == 'd': delta = timedelta(days=value) else: return None return datetime.now() - delta def cmd_check(args): """Check for recent emails""" mail = connect_imap() mailbox = args.mailbox or 'INBOX' mail.select(mailbox) # Search criteria criteria = ['ALL'] if args.recent: since_date = parse_time_filter(args.recent) if since_date: date_str = since_date.strftime('%d-%b-%Y') criteria = [f'SINCE {date_str}'] # Search status, messages = mail.search(None, *criteria) if status != 'OK': print("Error searching mailbox", file=sys.stderr) sys.exit(1) msg_nums = messages[0].split() if not msg_nums: print("No messages found") mail.close() mail.logout() return # Get last N messages limit = int(args.limit) if args.limit else 10 msg_nums = msg_nums[-limit:] print(f"Showing {len(msg_nums)} most recent messages:\n") for num in reversed(msg_nums): typ, data = mail.fetch(num, '(FLAGS BODY[HEADER.FIELDS (FROM SUBJECT DATE)])') if data[0]: msg_data = data[0][1].decode('utf-8', errors='ignore') msg = email.message_from_string(msg_data) from_addr = decode_header_value(msg.get('From', '')) subject = decode_header_value(msg.get('Subject', '')) date = msg.get('Date', '') # Check if unread flags = data[1].decode('utf-8', errors='ignore') if len(data) > 1 else '' unread = '●' if '\\Seen' not in flags else ' ' print(f"{unread} UID: {num.decode()}") print(f" From: {from_addr}") print(f" Subject: {subject}") print(f" Date: {date}") print() mail.close() mail.logout() def cmd_fetch(args): """Fetch full email content""" if not args.uid: print("Error: UID required", file=sys.stderr) sys.exit(1) mail = connect_imap() mailbox = args.mailbox or 'INBOX' mail.select(mailbox) uid = args.uid typ, data = mail.fetch(uid, '(RFC822)') if data[0] is None: print(f"Error: Message {uid} not found", file=sys.stderr) sys.exit(1) msg = email.message_from_bytes(data[0][1]) print(f"From: {decode_header_value(msg.get('From', ''))}") print(f"To: {decode_header_value(msg.get('To', ''))}") print(f"Subject: {decode_header_value(msg.get('Subject', ''))}") print(f"Date: {msg.get('Date', '')}") print("-" * 80) # Get body if msg.is_multipart(): for part in msg.walk(): content_type = part.get_content_type() if content_type == 'text/plain': body = part.get_payload(decode=True) if body: print(body.decode('utf-8', errors='ignore')) break elif content_type == 'text/html' and args.html: body = part.get_payload(decode=True) if body: print(body.decode('utf-8', errors='ignore')) break else: body = msg.get_payload(decode=True) if body: print(body.decode('utf-8', errors='ignore')) mail.close() mail.logout() def cmd_search(args): """Search emails with filters""" mail = connect_imap() mailbox = args.mailbox or 'INBOX' mail.select(mailbox) # Build search criteria criteria = [] if args.unseen: criteria.append('UNSEEN') if args.seen: criteria.append('SEEN') if args.from_: criteria.extend(['FROM', f'"{args.from_}"']) if args.subject: criteria.extend(['SUBJECT', f'"{args.subject}"']) if args.since: criteria.extend(['SINCE', args.since]) if args.before: criteria.extend(['BEFORE', args.before]) if args.recent: since_date = parse_time_filter(args.recent) if since_date: date_str = since_date.strftime('%d-%b-%Y') criteria.extend(['SINCE', date_str]) if not criteria: criteria = ['ALL'] # Search status, messages = mail.search(None, *criteria) if status != 'OK': print("Error searching mailbox", file=sys.stderr) sys.exit(1) msg_nums = messages[0].split() if not msg_nums: print("No messages found") mail.close() mail.logout() return # Get last N messages limit = int(args.limit) if args.limit else 20 msg_nums = msg_nums[-limit:] print(f"Found {len(msg_nums)} messages:\n") for num in reversed(msg_nums): typ, data = mail.fetch(num, '(FLAGS BODY[HEADER.FIELDS (FROM SUBJECT DATE)])') if data[0]: msg_data = data[0][1].decode('utf-8', errors='ignore') msg = email.message_from_string(msg_data) from_addr = decode_header_value(msg.get('From', '')) subject = decode_header_value(msg.get('Subject', '')) date = msg.get('Date', '') flags = data[1].decode('utf-8', errors='ignore') if len(data) > 1 else '' unread = '●' if '\\Seen' not in flags else ' ' print(f"{unread} UID: {num.decode()}") print(f" From: {from_addr}") print(f" Subject: {subject}") print(f" Date: {date}") print() mail.close() mail.logout() def cmd_mark_read(args): """Mark message(s) as read""" if not args.uids: print("Error: At least one UID required", file=sys.stderr) sys.exit(1) mail = connect_imap() mailbox = args.mailbox or 'INBOX' mail.select(mailbox) for uid in args.uids: mail.store(uid, '+FLAGS', '\\Seen') print(f"Marked {uid} as read") mail.close() mail.logout() def cmd_mark_unread(args): """Mark message(s) as unread""" if not args.uids: print("Error: At least one UID required", file=sys.stderr) sys.exit(1) mail = connect_imap() mailbox = args.mailbox or 'INBOX' mail.select(mailbox) for uid in args.uids: mail.store(uid, '-FLAGS', '\\Seen') print(f"Marked {uid} as unread") mail.close() mail.logout() def cmd_list_mailboxes(args): """List all mailboxes""" mail = connect_imap() status, mailboxes = mail.list() if status == 'OK': print("Available mailboxes:") for mb in mailboxes: parts = mb.decode().split(' "/" ') if len(parts) >= 2: name = parts[1].strip('"') print(f" {name}") mail.logout() def main(): parser = argparse.ArgumentParser(description='IMAP Email CLI') subparsers = parser.add_subparsers(dest='command', help='Command to execute') # check check_parser = subparsers.add_parser('check', help='Check for new/unread emails') check_parser.add_argument('--limit', help='Max results', default='10') check_parser.add_argument('--mailbox', help='Mailbox name', default='INBOX') check_parser.add_argument('--recent', help='Only show emails from last X time (e.g., 30m, 2h, 7d)') # fetch fetch_parser = subparsers.add_parser('fetch', help='Fetch full email content') fetch_parser.add_argument('uid', help='Email UID') fetch_parser.add_argument('--mailbox', help='Mailbox name', default='INBOX') fetch_parser.add_argument('--html', action='store_true', help='Show HTML content') # search search_parser = subparsers.add_parser('search', help='Search emails') search_parser.add_argument('--unseen', action='store_true', help='Only unread messages') search_parser.add_argument('--seen', action='store_true', help='Only read messages') search_parser.add_argument('--from', dest='from_', help='From address contains') search_parser.add_argument('--subject', help='Subject contains') search_parser.add_argument('--recent', help='From last X time (e.g., 30m, 2h, 7d)') search_parser.add_argument('--since', help='After date (DD-Mon-YYYY)') search_parser.add_argument('--before', help='Before date (DD-Mon-YYYY)') search_parser.add_argument('--limit', help='Max results', default='20') search_parser.add_argument('--mailbox', help='Mailbox name', default='INBOX') # mark-read read_parser = subparsers.add_parser('mark-read', help='Mark message(s) as read') read_parser.add_argument('uids', nargs='+', help='Email UIDs') read_parser.add_argument('--mailbox', help='Mailbox name', default='INBOX') # mark-unread unread_parser = subparsers.add_parser('mark-unread', help='Mark message(s) as unread') unread_parser.add_argument('uids', nargs='+', help='Email UIDs') unread_parser.add_argument('--mailbox', help='Mailbox name', default='INBOX') # list-mailboxes subparsers.add_parser('list-mailboxes', help='List all mailboxes') args = parser.parse_args() if not args.command: parser.print_help() sys.exit(1) # Execute command if args.command == 'check': cmd_check(args) elif args.command == 'fetch': cmd_fetch(args) elif args.command == 'search': cmd_search(args) elif args.command == 'mark-read': cmd_mark_read(args) elif args.command == 'mark-unread': cmd_mark_unread(args) elif args.command == 'list-mailboxes': cmd_list_mailboxes(args) if __name__ == '__main__': main()