理论成功

This commit is contained in:
2026-04-18 23:58:27 +08:00
parent 626a4ab2dc
commit 540bb96e55
6 changed files with 616 additions and 242 deletions
+122 -46
View File
@@ -6,8 +6,9 @@ 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 re
import time
from email.utils import parseaddr
from config import Config
from logger import logger
@@ -20,7 +21,6 @@ class MailHandler:
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)
@@ -33,12 +33,10 @@ class MailHandler:
return conn, "POP3"
def fetch_unread_emails(self):
"""获取未读邮件列表,返回列表 [ (msg_id, raw_email) ]"""
conn, proto = self.connect_inbox()
emails = []
try:
if proto == "IMAP":
# 搜索未读邮件
typ, data = conn.search(None, 'UNSEEN')
if typ != 'OK':
logger.error("IMAP搜索未读邮件失败")
@@ -51,8 +49,7 @@ class MailHandler:
emails.append((num, raw_email))
if len(emails) >= Config.MAX_EMAILS_PER_RUN:
break
else: # POP3
# POP3 不支持未读标志,获取所有邮件列表
else:
msg_count = len(conn.list()[1])
for i in range(msg_count, 0, -1):
raw_email = b'\n'.join(conn.retr(i)[1])
@@ -66,25 +63,39 @@ class MailHandler:
return []
finally:
if proto == "IMAP":
conn.close()
conn.quit()
try:
conn.close()
conn.logout()
except:
pass
else:
conn.quit()
def mark_as_deleted(self, msg_id, proto_conn=None):
"""标记邮件为已删除(仅IMAP支持删除,POP3需额外实现)"""
if Config.MAIL_PROTOCOL == "IMAP" and proto_conn:
proto_conn.store(msg_id, '+FLAGS', '\\Deleted')
logger.info(f"标记邮件 {msg_id} 为已删除")
# POP3 删除需要重新连接并执行DELE,这里简化,留痕已足够
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:] # .example.com
suffix = allowed[1:]
if domain.endswith(suffix):
return True
elif domain == allowed.lower():
@@ -92,42 +103,112 @@ class MailHandler:
return False
def download_attachments(self, raw_email, temp_dir):
"""解析邮件,下载附件(图片、压缩包、manifest.txt)到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():
if part.get_content_maintype() == 'multipart':
continue
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':
pass
supported = True
elif ext in ['.zip', '.7z']:
pass
supported = True
elif ext[1:] in Config.SUPPORTED_INPUT_FORMATS:
pass
else:
supported = True
if not supported:
logger.debug(f"跳过不支持的文件: {filename} (扩展名 {ext})")
continue
# 下载附件
filepath = os.path.join(temp_dir, filename)
with open(filepath, 'wb') as f:
f.write(part.get_payload(decode=True))
attachments.append(filepath)
# 提取邮件正文作为指令备用
body = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == 'text/plain':
body = part.get_payload(decode=True).decode('utf-8', errors='ignore')
break
else:
body = msg.get_payload(decode=True).decode('utf-8', errors='ignore')
return attachments, body
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
@@ -135,23 +216,19 @@ class MailHandler:
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
zip_path = att + ".zip"
from zip_handler import ZipHandler
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:
from zip_handler import ZipHandler
parts = ZipHandler.split_zip(att, split_volume_mb)
final_attachments.extend(parts)
else:
final_attachments.append(att)
# 检查每个附件大小是否超过SMTP限制
max_bytes = Config.MAX_ATTACHMENT_SIZE_MB * 1024 * 1024
for att in final_attachments:
if os.path.getsize(att) > max_bytes:
@@ -180,7 +257,6 @@ class MailHandler:
raise
def send_error_report(self, recipient, error_msg):
"""发送脱敏后的错误报告"""
subject = "图片转换工具 - 处理出错"
body = f"处理您的邮件时发生错误,错误信息(已脱敏):\n\n{error_msg}\n\n请检查指令格式或图片格式。"
msg = MIMEText(body, 'plain', 'utf-8')
@@ -198,4 +274,4 @@ class MailHandler:
server.login(self.user, self.password)
server.send_message(msg)
except Exception as e:
logger.error(f"错误报告发送失败: {e}")
logger.error(f"错误报告发送失败: {e}")