Files
mailc/mail_handler.py
T

278 lines
11 KiB
Python
Raw Normal View History

2026-04-18 23:04:03 +08:00
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
2026-04-18 23:58:27 +08:00
from email.header import decode_header
2026-04-18 23:04:03 +08:00
import os
2026-04-18 23:58:27 +08:00
import time
2026-04-18 23:04:03 +08:00
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
2026-04-18 23:58:27 +08:00
else:
2026-04-18 23:04:03 +08:00
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":
2026-04-18 23:58:27 +08:00
try:
conn.close()
conn.logout()
except:
pass
else:
conn.quit()
2026-04-18 23:04:03 +08:00
2026-04-18 23:58:27 +08:00
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}")
2026-04-18 23:04:03 +08:00
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('*.'):
2026-04-18 23:58:27 +08:00
suffix = allowed[1:]
2026-04-18 23:04:03 +08:00
if domain.endswith(suffix):
return True
elif domain == allowed.lower():
return True
return False
def download_attachments(self, raw_email, temp_dir):
2026-04-18 23:58:27 +08:00
"""
解析邮件,提取所有附件(图片、压缩包、manifest.txt),包括内联图片。
同时输出邮件结构到日志,便于调试。
"""
2026-04-18 23:04:03 +08:00
msg = email.message_from_bytes(raw_email)
attachments = []
2026-04-18 23:58:27 +08:00
body = ""
# 打印邮件基本信息
subject = msg.get('Subject', '无主题')
from_addr = msg.get('From', '未知')
logger.info(f"解析邮件: 主题={subject}, 发件人={from_addr}")
# 遍历所有 part
part_count = 0
2026-04-18 23:04:03 +08:00
for part in msg.walk():
2026-04-18 23:58:27 +08:00
part_count += 1
content_type = part.get_content_type()
content_disposition = str(part.get("Content-Disposition", ""))
2026-04-18 23:04:03 +08:00
filename = part.get_filename()
2026-04-18 23:58:27 +08:00
# 解码文件名
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}")
2026-04-18 23:04:03 +08:00
if not filename:
continue
2026-04-18 23:58:27 +08:00
# 判断是否为支持的文件类型
2026-04-18 23:04:03 +08:00
ext = os.path.splitext(filename)[1].lower()
2026-04-18 23:58:27 +08:00
supported = False
2026-04-18 23:04:03 +08:00
if ext in ['.txt'] and filename == 'manifest.txt':
2026-04-18 23:58:27 +08:00
supported = True
2026-04-18 23:04:03 +08:00
elif ext in ['.zip', '.7z']:
2026-04-18 23:58:27 +08:00
supported = True
2026-04-18 23:04:03 +08:00
elif ext[1:] in Config.SUPPORTED_INPUT_FORMATS:
2026-04-18 23:58:27 +08:00
supported = True
if not supported:
logger.debug(f"跳过不支持的文件: {filename} (扩展名 {ext})")
2026-04-18 23:04:03 +08:00
continue
2026-04-18 23:58:27 +08:00
# 下载附件
2026-04-18 23:04:03 +08:00
filepath = os.path.join(temp_dir, filename)
2026-04-18 23:58:27 +08:00
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
2026-04-18 23:04:03 +08:00
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)
2026-04-18 23:58:27 +08:00
from zip_handler import ZipHandler
2026-04-18 23:04:03 +08:00
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:
2026-04-18 23:58:27 +08:00
logger.error(f"错误报告发送失败: {e}")