#!/usr/bin/env python3 """ 邮件接收脚本 支持 IMAP 协议接收邮件、查看未读邮件、下载附件 """ import imaplib import os import sys import json import re from email.header import decode_header from email.utils import parseaddr import email as em from typing import List, Optional, Dict, Tuple from datetime import datetime # 配置文件路径 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) CONFIG_FILE = os.path.join(SCRIPT_DIR, "smtp_config.json") ATTACHMENTS_DIR = os.path.join(SCRIPT_DIR, "attachments") def load_config() -> dict: """加载配置""" if not os.path.exists(CONFIG_FILE): return {"accounts": []} with open(CONFIG_FILE, 'r', encoding='utf-8') as f: return json.load(f) def get_account(name: Optional[str] = None) -> Optional[dict]: """获取账号配置""" config = load_config() if not config["accounts"]: return None if name is None: return config["accounts"][0] for acc in config["accounts"]: if acc["name"] == name: return acc return None def get_imap_server(email_addr: str) -> Tuple[str, int, bool]: """ 根据邮箱地址推断 IMAP 服务器 返回: (服务器地址, 端口, 是否使用SSL) """ domain = email_addr.split("@")[1] if "@" in email_addr else "" # 常见邮箱 IMAP 配置 imap_configs = { "gmail.com": ("imap.gmail.com", 993, True), "outlook.com": ("outlook.office365.com", 993, True), "hotmail.com": ("outlook.office365.com", 993, True), "qq.com": ("imap.qq.com", 993, True), "163.com": ("imap.163.com", 993, True), "tphai.com": ("mail.tphai.com", 143, False), # 企业邮箱 } # 尽找匹配的配置 for key, config in imap_configs.items(): if key in domain: return config # 默认使用企业邮箱配置 return ("mail.tphai.com", 143, False) def decode_str(s: str) -> str: """解码邮件头字符串""" if s is None: return "" decoded_parts = decode_header(s) result = [] for part, charset in decoded_parts: if isinstance(part, bytes): charset = charset or "utf-8" try: result.append(part.decode(charset)) except: result.append(part.decode("utf-8", errors="ignore")) else: result.append(part) return "".join(result) def get_email_content(msg) -> str: """提取邮件正文""" body = "" if msg.is_multipart(): for part in msg.walk(): content_type = part.get_content_type() content_disposition = str(part.get("Content-Disposition", "")) # 跳过附件 if "attachment" in content_disposition: continue # 提取文本内容 if content_type == "text/plain": charset = part.get_content_charset() or "utf-8" try: payload = part.get_payload(decode=True) if payload: body += payload.decode(charset, errors="ignore") except: pass elif content_type == "text/html" and not body: charset = part.get_content_charset() or "utf-8" try: payload = part.get_payload(decode=True) if payload: # 简单去除HTML标签,获取纯文本 html_content = payload.decode(charset, errors="ignore") text = re.sub(r'<[^>]+>', '', html_content) body += text.strip() except: pass else: content_type = msg.get_content_type() if content_type in ["text/plain", "text/html"]: charset = msg.get_content_charset() or "utf-8" try: payload = msg.get_payload(decode=True) if payload: body = payload.decode(charset, errors="ignore") if content_type == "text/html": body = re.sub(r'<[^>]+>', '', body).strip() except: pass return body def get_attachments(msg) -> List[Dict]: """提取附件信息""" attachments = [] for part in msg.walk(): content_disposition = str(part.get("Content-Disposition", "")) if "attachment" in content_disposition: filename = part.get_filename() if filename: filename = decode_str(filename) content_type = part.get_content_type() size = len(part.get_payload(decode=True) or b"") attachments.append({ "filename": filename, "content_type": content_type, "size": size, "part": part }) return attachments def save_attachment(part, filename: str, email_id: str) -> str: """保存附件到本地""" # 确保附件目录存在 os.makedirs(ATTACHMENTS_DIR, exist_ok=True) # 为每封邮件创建子目录 email_dir = os.path.join(ATTACHMENTS_DIR, email_id) os.makedirs(email_dir, exist_ok=True) # 保存附件 filepath = os.path.join(email_dir, filename) payload = part.get_payload(decode=True) if payload: with open(filepath, "wb") as f: f.write(payload) return filepath def read_text_attachment(filepath: str) -> Optional[str]: """读取文本附件内容""" # 检查是否是文本文件 text_extensions = ['.txt', '.md', '.json', '.csv', '.log', '.py', '.js', '.html', '.css', '.xml', '.yaml', '.yml', '.ini', '.cfg', '.conf', '.sh', '.bat'] ext = os.path.splitext(filepath)[1].lower() if ext not in text_extensions: return None try: with open(filepath, 'r', encoding='utf-8') as f: return f.read() except UnicodeDecodeError: try: with open(filepath, 'r', encoding='gbk') as f: return f.read() except: return None def connect_imap(account: dict) -> Optional[imaplib.IMAP4]: """连接 IMAP 服务器""" # 获取 IMAP 配置 imap_server, imap_port, use_ssl = get_imap_server(account["email"]) try: if use_ssl: imap = imaplib.IMAP4_SSL(imap_server, imap_port) else: imap = imaplib.IMAP4(imap_server, imap_port) # 登录 imap.login(account["email"], account["password"]) return imap except Exception as e: print(f"❌ IMAP 连接失败: {e}") return None def list_unread(account_name: Optional[str] = None, limit: int = 10, verbose: bool = False) -> List[Dict]: """ 列出未读邮件 返回: List[Dict]: 邮件列表,每项包含 id, subject, from, date, has_attachment """ account = get_account(account_name) if account is None: print("❌ 错误:未找到邮箱配置") return [] imap = connect_imap(account) if imap is None: return [] try: # 选择收件箱 imap.select("INBOX") # 搜索未读邮件 status, data = imap.search(None, "UNSEEN") if status != "OK": print("❌ 搜索未读邮件失败") return [] email_ids = data[0].split() total_unread = len(email_ids) if verbose: print(f"📊 共有 {total_unread} 封未读邮件") # 限制返回数量 email_ids = email_ids[:limit] emails = [] for email_id in email_ids: email_id_str = email_id.decode() # 获取邮件头 status, msg_data = imap.fetch(email_id, "(BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)])") if status != "OK": continue # 解析邮件头 raw_header = msg_data[0][1] header_msg = em.message_from_bytes(raw_header) subject = decode_str(header_msg.get("Subject", "")) from_addr = decode_str(header_msg.get("From", "")) date_str = header_msg.get("Date", "") # 检查是否有附件 status, msg_data = imap.fetch(email_id, "(BODYSTRUCTURE)") has_attachment = False if status == "OK" and msg_data: # 简单检查 BODYSTRUCTURE 中是否有 attachment structure = str(msg_data[0]) if "attachment" in structure.lower() or "filename" in structure.lower(): has_attachment = True emails.append({ "id": email_id_str, "subject": subject, "from": from_addr, "date": date_str, "has_attachment": has_attachment }) imap.close() imap.logout() return emails except Exception as e: print(f"❌ 获取未读邮件失败: {e}") return [] def read_email(email_id: str, account_name: Optional[str] = None, save_attachments: bool = True, verbose: bool = False) -> Optional[Dict]: """ 读取指定邮件内容 参数: email_id: 邮件ID account_name: 账号名称 save_attachments: 是否保存附件 verbose: 显示详细日志 返回: Dict: 邮件详情,包含 subject, from, to, date, body, attachments """ account = get_account(account_name) if account is None: print("❌ 错误:未找到邮箱配置") return None imap = connect_imap(account) if imap is None: return None try: imap.select("INBOX") # 获取完整邮件 status, msg_data = imap.fetch(email_id, "(RFC822)") if status != "OK": print(f"❌ 获取邮件 {email_id} 失败") return None # 解析邮件 raw_email = msg_data[0][1] msg = em.message_from_bytes(raw_email) # 提取邮件信息 subject = decode_str(msg.get("Subject", "")) from_addr = decode_str(msg.get("From", "")) to_addr = decode_str(msg.get("To", "")) date_str = msg.get("Date", "") # 提取正文 body = get_email_content(msg) # 提取附件 attachments_info = get_attachments(msg) attachments = [] for att in attachments_info: if save_attachments: filepath = save_attachment(att["part"], att["filename"], email_id) if verbose: print(f"📎 已保存附件: {filepath}") # 如果是文本文件,读取内容 text_content = read_text_attachment(filepath) attachments.append({ "filename": att["filename"], "filepath": filepath, "size": att["size"], "is_text": text_content is not None, "content": text_content }) else: attachments.append({ "filename": att["filename"], "size": att["size"], "is_text": False, "content": None }) # 标记为已读 imap.store(email_id, "+FLAGS", "\\Seen") imap.close() imap.logout() return { "id": email_id, "subject": subject, "from": from_addr, "to": to_addr, "date": date_str, "body": body, "attachments": attachments } except Exception as e: print(f"❌ 读取邮件失败: {e}") return None def print_email_summary(emails: List[Dict]): """打印邮件摘要""" if not emails: print("📭 没有未读邮件") return print(f"\n📬 未读邮件 ({len(emails)} 封)") print("=" * 60) for i, e in enumerate(emails, 1): attachment_mark = "📎" if e.get("has_attachment") else "" print(f"\n{i}. [{e['id']}] {attachment_mark}") print(f" 主题: {e['subject']}") print(f" 发件人: {e['from']}") print(f" 时间: {e['date']}") def print_email_detail(email_data: Dict): """打印邮件详情""" print(f"\n📧 邮件详情") print("=" * 60) print(f"ID: {email_data['id']}") print(f"主题: {email_data['subject']}") print(f"发件人: {email_data['from']}") print(f"收件人: {email_data['to']}") print(f"时间: {email_data['date']}") print("\n正文:") print("-" * 40) print(email_data['body'][:500] if len(email_data['body']) > 500 else email_data['body']) if len(email_data['body']) > 500: print(f"\n... (正文共 {len(email_data['body'])} 字符,已截断)") if email_data['attachments']: print("\n附件:") print("-" * 40) for att in email_data['attachments']: text_mark = "📝" if att['is_text'] else "📎" print(f" {text_mark} {att['filename']} ({att['size']} bytes)") if att['is_text'] and att['content']: preview = att['content'][:200] print(f" 内容预览: {preview}...") def main(): """命令行入口""" import argparse parser = argparse.ArgumentParser(description="邮件接收工具") subparsers = parser.add_subparsers(dest="command", help="可用命令") # 列出未读邮件命令 list_parser = subparsers.add_parser("unread", help="列出未读邮件") list_parser.add_argument("--account", "-a", help="使用指定配置") list_parser.add_argument("--limit", "-l", type=int, default=10, help="最大返回数量") list_parser.add_argument("-v", "--verbose", action="store_true", help="显示详细日志") # 读取邮件命令 read_parser = subparsers.add_parser("read", help="读取指定邮件") read_parser.add_argument("id", help="邮件ID") read_parser.add_argument("--account", "-a", help="使用指定配置") read_parser.add_argument("--no-save", action="store_true", help="不保存附件") read_parser.add_argument("-v", "--verbose", action="store_true", help="显示详细日志") # 查看附件命令 att_parser = subparsers.add_parser("attachment", help="查看附件内容") att_parser.add_argument("id", help="邮件ID") att_parser.add_argument("--filename", "-f", help="指定附件文件名") att_parser.add_argument("--account", "-a", help="使用指定配置") args = parser.parse_args() if args.command == "unread": emails = list_unread( account_name=args.account, limit=args.limit, verbose=args.verbose ) print_email_summary(emails) elif args.command == "read": email_data = read_email( email_id=args.id, account_name=args.account, save_attachments=not args.no_save, verbose=args.verbose ) if email_data: print_email_detail(email_data) elif args.command == "attachment": email_data = read_email( email_id=args.id, account_name=args.account, save_attachments=True, verbose=False ) if email_data and email_data['attachments']: # 找到指定附件或第一个文本附件 target_att = None for att in email_data['attachments']: if args.filename and att['filename'] == args.filename: target_att = att break elif not args.filename and att['is_text']: target_att = att break if target_att: print(f"\n📄 附件: {target_att['filename']}") print("=" * 60) print(target_att['content'] or "(非文本附件)") else: print(f"❌ 未找到附件: {args.filename or '文本附件'}") else: print("❌ 邮件无附件或读取失败") else: parser.print_help() if __name__ == "__main__": main()