Files
mailc/mail_handler.py
T
2026-04-18 23:58:27 +08:00

278 lines
11 KiB
Python

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}")