import imaplib import poplib import smtplib import email from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase from email import encoders from email.header import decode_header import os import time from email.utils import parseaddr from config import Config from logger import logger class MailHandler: def __init__(self): self.user = Config.MAIL_USER self.password = Config.MAIL_PASS self.smtp_server = Config.SMTP_SERVER self.smtp_port = Config.SMTP_PORT def connect_inbox(self): if Config.MAIL_PROTOCOL == "IMAP": conn = imaplib.IMAP4_SSL(Config.IMAP_SERVER, Config.IMAP_PORT) conn.login(self.user, self.password) conn.select('INBOX') return conn, "IMAP" else: conn = poplib.POP3_SSL(Config.POP3_SERVER, Config.POP3_PORT) conn.user(self.user) conn.pass_(self.password) return conn, "POP3" def fetch_unread_emails(self): conn, proto = self.connect_inbox() emails = [] try: if proto == "IMAP": typ, data = conn.search(None, 'UNSEEN') if typ != 'OK': logger.error("IMAP搜索未读邮件失败") return [] for num in data[0].split(): typ, msg_data = conn.fetch(num, '(RFC822)') if typ != 'OK': continue raw_email = msg_data[0][1] emails.append((num, raw_email)) if len(emails) >= Config.MAX_EMAILS_PER_RUN: break else: msg_count = len(conn.list()[1]) for i in range(msg_count, 0, -1): raw_email = b'\n'.join(conn.retr(i)[1]) emails.append((i, raw_email)) if len(emails) >= Config.MAX_EMAILS_PER_RUN: break logger.info(f"获取到 {len(emails)} 封待处理邮件") return emails except Exception as e: logger.error(f"收信失败: {e}") return [] finally: if proto == "IMAP": try: conn.close() conn.logout() except: pass else: conn.quit() def delete_email(self, msg_id, proto_conn=None, proto_type=None): try: if proto_type == "IMAP" and proto_conn: proto_conn.store(msg_id, '+FLAGS', '\\Deleted') logger.info(f"IMAP标记删除邮件 {msg_id}") elif proto_type == "POP3" and proto_conn: proto_conn.dele(msg_id) logger.info(f"POP3删除邮件 {msg_id}") except Exception as e: logger.error(f"删除邮件失败 {msg_id}: {e}") def expunge_imap(self, conn): try: conn.expunge() logger.info("IMAP已执行expunge,永久删除标记邮件") except Exception as e: logger.error(f"expunge失败: {e}") def is_domain_allowed(self, sender_email): if not Config.ALLOWED_DOMAINS: return True domain = sender_email.split('@')[-1].lower() for allowed in Config.ALLOWED_DOMAINS: if allowed.startswith('*.'): suffix = allowed[1:] if domain.endswith(suffix): return True elif domain == allowed.lower(): return True return False def download_attachments(self, raw_email, temp_dir): """ 解析邮件,提取所有附件(图片、压缩包、manifest.txt),包括内联图片。 同时输出邮件结构到日志,便于调试。 """ msg = email.message_from_bytes(raw_email) attachments = [] body = "" # 打印邮件基本信息 subject = msg.get('Subject', '无主题') from_addr = msg.get('From', '未知') logger.info(f"解析邮件: 主题={subject}, 发件人={from_addr}") # 遍历所有 part part_count = 0 for part in msg.walk(): part_count += 1 content_type = part.get_content_type() content_disposition = str(part.get("Content-Disposition", "")) filename = part.get_filename() # 解码文件名 if filename: decoded_filename = decode_header(filename) real_filename = "" for text, enc in decoded_filename: if isinstance(text, bytes): try: real_filename += text.decode(enc or 'utf-8', errors='ignore') except: real_filename += text.decode('utf-8', errors='ignore') else: real_filename += text filename = real_filename else: filename = None # 输出每个 part 的详细信息 logger.debug( f"Part {part_count}: Content-Type={content_type}, " f"Disposition={content_disposition}, Filename={filename}, " f"Content-Transfer-Encoding={part.get('Content-Transfer-Encoding', 'none')}" ) # 提取正文 if content_type == "text/plain" and filename is None: try: payload = part.get_payload(decode=True) if payload: body = payload.decode('utf-8', errors='ignore') logger.debug(f"获取到正文,长度 {len(body)}") except Exception as e: logger.warning(f"解析正文失败: {e}") continue # 如果没有文件名,但 Content-Type 是图片类型,尝试生成一个文件名 if not filename and content_type.startswith('image/'): ext = content_type.split('/')[-1] # 使用时间戳和随机数 filename = f"inline_image_{int(time.time())}_{part_count}.{ext}" logger.debug(f"内联图片无文件名,自动命名为 {filename}") if not filename: continue # 判断是否为支持的文件类型 ext = os.path.splitext(filename)[1].lower() supported = False if ext in ['.txt'] and filename == 'manifest.txt': supported = True elif ext in ['.zip', '.7z']: supported = True elif ext[1:] in Config.SUPPORTED_INPUT_FORMATS: supported = True if not supported: logger.debug(f"跳过不支持的文件: {filename} (扩展名 {ext})") continue # 下载附件 filepath = os.path.join(temp_dir, filename) payload = part.get_payload(decode=True) if payload: with open(filepath, 'wb') as f: f.write(payload) attachments.append(filepath) logger.info(f"成功提取附件: {filename} -> {filepath}") else: logger.warning(f"附件 {filename} payload 为空,跳过") # 如果仍未获取到正文,尝试直接获取 if not body: if msg.is_multipart(): for part in msg.walk(): if part.get_content_type() == 'text/plain': try: body = part.get_payload(decode=True).decode('utf-8', errors='ignore') break except: pass else: body = msg.get_payload(decode=True).decode('utf-8', errors='ignore') if msg.get_payload() else "" logger.info(f"附件提取完成,共 {len(attachments)} 个,正文长度 {len(body)} 字符") return attachments, body, msg def send_result(self, recipient, subject, attachments, split_volume_mb=0): msg = MIMEMultipart() msg['From'] = self.user msg['To'] = recipient msg['Subject'] = subject text = MIMEText("图片转换完成,请查收附件。", 'plain', 'utf-8') msg.attach(text) from zip_handler import ZipHandler final_attachments = [] for att in attachments: if os.path.isdir(att): zip_path = att + ".zip" ZipHandler.pack_to_zip(att, zip_path) att = zip_path if split_volume_mb > 0 and os.path.getsize(att) > split_volume_mb * 1024 * 1024: parts = ZipHandler.split_zip(att, split_volume_mb) final_attachments.extend(parts) else: final_attachments.append(att) max_bytes = Config.MAX_ATTACHMENT_SIZE_MB * 1024 * 1024 for att in final_attachments: if os.path.getsize(att) > max_bytes: logger.warning(f"附件 {att} 超过限制,跳过发送") continue with open(att, 'rb') as f: part = MIMEBase('application', 'octet-stream') part.set_payload(f.read()) encoders.encode_base64(part) part.add_header('Content-Disposition', f'attachment; filename="{os.path.basename(att)}"') msg.attach(part) try: if Config.SMTP_PORT == 465: with smtplib.SMTP_SSL(self.smtp_server, self.smtp_port) as server: server.login(self.user, self.password) server.send_message(msg) else: with smtplib.SMTP(self.smtp_server, self.smtp_port) as server: server.starttls() server.login(self.user, self.password) server.send_message(msg) logger.info(f"结果邮件已发送至 {recipient}") except Exception as e: logger.error(f"发送失败: {e}") raise def send_error_report(self, recipient, error_msg): subject = "图片转换工具 - 处理出错" body = f"处理您的邮件时发生错误,错误信息(已脱敏):\n\n{error_msg}\n\n请检查指令格式或图片格式。" msg = MIMEText(body, 'plain', 'utf-8') msg['From'] = self.user msg['To'] = recipient msg['Subject'] = subject try: if Config.SMTP_PORT == 465: with smtplib.SMTP_SSL(self.smtp_server, self.smtp_port) as server: server.login(self.user, self.password) server.send_message(msg) else: with smtplib.SMTP(self.smtp_server, self.smtp_port) as server: server.starttls() server.login(self.user, self.password) server.send_message(msg) except Exception as e: logger.error(f"错误报告发送失败: {e}")