278 lines
11 KiB
Python
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}")
|