diff --git a/.env.example b/.env.example index cafa295..45f51a0 100644 --- a/.env.example +++ b/.env.example @@ -28,4 +28,8 @@ FLATTEN_OUTPUT=False # 若为True,所有转换结果平铺 # ================= 协议同意 ================= AGREED_TOS=False # 手动确认已同意协议(若为False则首次运行时需邮件确认) AGREEMENT_FILE=./AGREED # 协议同意标记文件路径 -ADMIN_EMAIL=admin@example.com # 接收协议请求的管理员邮箱(通常同MAIL_USER) \ No newline at end of file +ADMIN_EMAIL=admin@example.com # 接收协议请求的管理员邮箱(通常同MAIL_USER) + +# 循环监听配置 +POLL_INTERVAL_SECONDS=60 # 每次轮询间隔(秒),设为0则只运行一次 +RUN_FOREVER=true # 是否无限循环运行 diff --git a/config.py b/config.py index f5a8ef3..76bb395 100644 --- a/config.py +++ b/config.py @@ -36,6 +36,9 @@ class Config: AGREEMENT_FILE = os.getenv("AGREEMENT_FILE", "./AGREED") ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", MAIL_USER) + POLL_INTERVAL_SECONDS = int(os.getenv("POLL_INTERVAL_SECONDS", 60)) + RUN_FOREVER = os.getenv("RUN_FOREVER", "true").lower() == "true" + @classmethod def validate(cls): assert cls.MAIL_USER and cls.MAIL_PASS, "邮箱账号密码不能为空" @@ -43,4 +46,4 @@ class Config: if cls.MAIL_PROTOCOL == "IMAP": assert cls.IMAP_SERVER, "IMAP服务器不能为空" else: - assert cls.POP3_SERVER, "POP3服务器不能为空" \ No newline at end of file + assert cls.POP3_SERVER, "POP3服务器不能为空" diff --git a/image_processor.py b/image_processor.py index 93d17c1..a8b7564 100644 --- a/image_processor.py +++ b/image_processor.py @@ -4,6 +4,17 @@ from config import Config from logger import logger class ImageProcessor: + # 格式映射:将常见的小写格式名转换为 Pillow 可识别的格式名 + FORMAT_MAP = { + 'jpg': 'JPEG', + 'jpeg': 'JPEG', + 'png': 'PNG', + 'webp': 'WEBP', + 'gif': 'GIF', + 'bmp': 'BMP', + 'tiff': 'TIFF', + } + @staticmethod def resize_by_ratio(img, ratio_width, ratio_height): """等比例缩放至目标比例(最长边适配)""" @@ -31,20 +42,33 @@ class ImageProcessor: @staticmethod def convert_image(input_path, output_path, target_format, quality=None): - """转换图片格式,可选缩放(尺寸解析在外部处理)""" + """ + 转换图片格式 + target_format: 小写字符串,如 'jpg', 'png', 'webp' + """ try: with Image.open(input_path) as img: - # 转换模式(RGBA转RGB对于JPEG) - if target_format.lower() in ['jpg', 'jpeg'] and img.mode in ('RGBA', 'P'): + # 获取 Pillow 标准格式名 + pillow_format = ImageProcessor.FORMAT_MAP.get(target_format.lower()) + if pillow_format is None: + logger.error(f"不支持的输出格式: {target_format}") + return False + + # 对于 JPEG 格式,需要处理透明通道 + if pillow_format == 'JPEG' and img.mode in ('RGBA', 'P'): rgb_img = Image.new('RGB', img.size, (255, 255, 255)) rgb_img.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) img = rgb_img + save_kwargs = {} - if target_format.lower() in ['jpg', 'jpeg', 'webp']: + if pillow_format in ('JPEG', 'WEBP'): save_kwargs['quality'] = quality if quality is not None else Config.DEFAULT_QUALITY - img.save(output_path, format=target_format.upper(), **save_kwargs) + elif pillow_format == 'PNG': + save_kwargs['compress_level'] = 6 + + img.save(output_path, format=pillow_format, **save_kwargs) logger.info(f"转换成功: {input_path} -> {output_path}") return True except Exception as e: logger.error(f"转换失败 {input_path}: {e}") - return False \ No newline at end of file + return False diff --git a/mail_handler.py b/mail_handler.py index f7d3e36..65c8bd5 100644 --- a/mail_handler.py +++ b/mail_handler.py @@ -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}") \ No newline at end of file + logger.error(f"错误报告发送失败: {e}") diff --git a/main.py b/main.py index 931c303..3d08824 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,14 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + import os import shutil +import time +import email +from email.utils import parseaddr +from typing import List, Dict, Optional, Tuple +from PIL import Image + from config import Config from logger import logger from temp_manager import TempManager @@ -9,49 +18,217 @@ from image_processor import ImageProcessor from zip_handler import ZipHandler from agreement import AgreementManager + class MailConverter: def __init__(self): self.temp_mgr = TempManager() self.mail = MailHandler() self.img_proc = ImageProcessor() self.zip_proc = ZipHandler() + self.parser = ManifestParser() - def run(self): - # 清理遗留临时文件 - TempManager.cleanup_stale() - # 检查协议同意 - if not AgreementManager.is_agreed(): - logger.info("首次使用,需要用户同意协议") - # 发送协议请求给管理员(或发件人?通常发给MAIL_USER) - recipient, subject, body = AgreementManager.request_agreement(Config.ADMIN_EMAIL, self.mail) - self.mail.send_result(recipient, subject, []) # 发送纯文本邮件 - # 但这里我们还需监听回复?为简化,要求用户在.env中设置AGREED_TOS=True,或程序检测特定回复邮件 - # 简单起见,输出日志提示管理员手动确认 - logger.info(f"已向 {recipient} 发送协议同意请求,请在 .env 中设置 AGREED_TOS=True 后重新运行") - return - logger.info("协议已同意,开始处理邮件") + # ==================== 规则匹配核心 ==================== + def match_rules_for_file( + self, + filename: str, + file_ext: str, + tasks: List[Dict], + current_archive: Optional[str] = None + ) -> List[Dict]: + base_name = os.path.splitext(filename)[0] + results = [] - # 初始化临时会话目录 - session_dir = self.temp_mgr.init_session() - # 获取未读邮件 - emails = self.mail.fetch_unread_emails() - if not emails: - logger.info("没有待处理邮件") - self.temp_mgr.cleanup() - return + applicable = [] + for task in tasks: + task_scope = task.get('scope') + task_inline = task.get('inline_archive') + effective_scope = task_inline if task_inline else task_scope + if current_archive: + if effective_scope is None or effective_scope == current_archive: + applicable.append(task) + else: + if effective_scope is None: + applicable.append(task) - for msg_id, raw_email in emails: - # 解析发件人 - msg = email.message_from_bytes(raw_email) - sender = msg.get('From') - sender_email = parseaddr(sender)[1] - if not self.mail.is_domain_allowed(sender_email): - logger.warning(f"域名不在白名单: {sender_email},跳过处理") + logger.debug(f"文件 {filename} 在作用域 {current_archive} 下匹配到 {len(applicable)} 条规则") + + # 1. 重命名 + rename_tasks = [t for t in applicable if t.get('type') == 'rename' and t.get('src_name') == base_name] + if rename_tasks: + for task in rename_tasks: + dst_name = task['dst_name'] + dst_ext = os.path.splitext(dst_name)[1][1:].lower() + size = task.get('size') + results.append({ + 'output_name': dst_name, + 'format': dst_ext, + 'size': size + }) + return results + + # 2. 精准文件名 + name_tasks = [t for t in applicable if t.get('type') == 'by_name' and t.get('src_name') == base_name] + if name_tasks: + for task in name_tasks: + for fmt, size in task.get('targets', []): + output_name = f"{base_name}.{fmt}" + results.append({ + 'output_name': output_name, + 'format': fmt, + 'size': size + }) + return results + + # 3. 按格式批量 + format_tasks = [t for t in applicable if t.get('type') == 'by_format' and t.get('src_format') == file_ext.lower()] + if format_tasks: + for task in format_tasks: + for fmt, size in task.get('targets', []): + output_name = f"{base_name}.{fmt}" + results.append({ + 'output_name': output_name, + 'format': fmt, + 'size': size + }) + return results + + # 4. 全局默认 + global_tasks = [t for t in applicable if t.get('type') == 'global'] + if global_tasks: + for task in global_tasks: + for fmt, size in task.get('targets', []): + output_name = f"{base_name}.{fmt}" + results.append({ + 'output_name': output_name, + 'format': fmt, + 'size': size + }) + return results + + # 无规则,原样保留 + results.append({ + 'output_name': filename, + 'format': file_ext, + 'size': None + }) + return results + + # ==================== 单张图片处理 ==================== + def process_single_image( + self, + input_path: str, + output_dir: str, + tasks: List[Dict], + current_archive: Optional[str] = None + ) -> List[str]: + filename = os.path.basename(input_path) + file_ext = os.path.splitext(filename)[1][1:].lower() + output_files = [] + + output_tasks = self.match_rules_for_file(filename, file_ext, tasks, current_archive) + logger.info(f"图片 {filename} 将生成 {len(output_tasks)} 个输出任务") + + for task in output_tasks: + out_name = task['output_name'] + out_fmt = task['format'] + out_size = task['size'] + out_path = os.path.join(output_dir, out_name) + + try: + if out_size: + with Image.open(input_path) as img: + if out_size[0] == 'ratio': + img = ImageProcessor.resize_by_ratio(img, out_size[1], out_size[2]) + elif out_size[0] == 'pixel': + img = ImageProcessor.resize_by_pixel(img, out_size[1], out_size[2]) + pillow_format = ImageProcessor.FORMAT_MAP.get(out_fmt.lower()) + if not pillow_format: + logger.error(f"不支持的输出格式: {out_fmt}") + continue + if pillow_format == 'JPEG' and img.mode in ('RGBA', 'P'): + rgb_img = Image.new('RGB', img.size, (255, 255, 255)) + rgb_img.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = rgb_img + save_kwargs = {} + if pillow_format in ('JPEG', 'WEBP'): + save_kwargs['quality'] = Config.DEFAULT_QUALITY + img.save(out_path, format=pillow_format, **save_kwargs) + logger.info(f"转换+缩放成功: {input_path} -> {out_path}") + else: + success = ImageProcessor.convert_image(input_path, out_path, out_fmt, Config.DEFAULT_QUALITY) + if not success: + logger.error(f"转换失败: {input_path} -> {out_path}") + continue + output_files.append(out_path) + except Exception as e: + logger.error(f"处理图片异常 {input_path}: {e}", exc_info=True) continue - # 下载附件和正文 - attachments, body = self.mail.download_attachments(raw_email, session_dir) - # 合并 manifest.txt 内容 + return output_files + + # ==================== 压缩包处理 ==================== + def process_archive( + self, + archive_path: str, + output_base_dir: str, + tasks: List[Dict] + ) -> List[str]: + archive_name = os.path.basename(archive_path) + extract_dir = os.path.join(output_base_dir, f"ext_{archive_name}") + os.makedirs(extract_dir, exist_ok=True) + + ext = os.path.splitext(archive_path)[1].lower() + try: + if ext == '.zip': + ZipHandler.extract_zip(archive_path, extract_dir) + elif ext == '.7z': + ZipHandler.extract_7z(archive_path, extract_dir) + else: + logger.warning(f"不支持的压缩包格式: {archive_path}") + return [] + except Exception as e: + logger.error(f"解压失败 {archive_path}: {e}") + return [] + + all_output_files = [] + for root, _, files in os.walk(extract_dir): + for file in files: + file_path = os.path.join(root, file) + file_ext_lower = os.path.splitext(file)[1][1:].lower() + if file_ext_lower not in Config.SUPPORTED_INPUT_FORMATS: + continue + output_files = self.process_single_image( + file_path, + output_base_dir, + tasks, + current_archive=archive_name + ) + all_output_files.extend(output_files) + + if not Config.KEEP_TEMP_FILES: + shutil.rmtree(extract_dir, ignore_errors=True) + return all_output_files + + # ==================== 邮件处理主流程 ==================== + def process_one_email(self, msg_id, raw_email, proto_conn, proto_type) -> bool: + msg = email.message_from_bytes(raw_email) + sender = msg.get('From') + sender_email = parseaddr(sender)[1] + subject = msg.get('Subject', '无主题') + logger.info(f"开始处理邮件: {subject} from {sender_email}") + + if not self.mail.is_domain_allowed(sender_email): + logger.warning(f"域名不在白名单: {sender_email},删除原邮件") + self.mail.delete_email(msg_id, proto_conn, proto_type) + return False + + mail_work_dir = os.path.join(Config.TEMP_DIR, f"mail_{msg_id}_{int(time.time())}") + os.makedirs(mail_work_dir, exist_ok=True) + + try: + attachments, body, _ = self.mail.download_attachments(raw_email, mail_work_dir) + logger.info(f"下载到附件: {[os.path.basename(a) for a in attachments]}") + manifest_path = None for att in attachments: if os.path.basename(att) == 'manifest.txt': @@ -60,144 +237,193 @@ class MailConverter: if manifest_path: with open(manifest_path, 'r', encoding='utf-8') as f: rule_content = f.read() + logger.info("使用 manifest.txt 规则") else: - rule_content = body # 使用邮件正文 + rule_content = body + logger.info("使用邮件正文规则") - if not rule_content.strip(): - logger.warning("无转换规则,跳过") - continue + logger.info(f"规则原始内容:\n{rule_content}") - # 解析规则 - parser = ManifestParser() - tasks = parser.parse(rule_content) + if not rule_content or not rule_content.strip(): + logger.warning("规则内容为空,删除原邮件") + self.mail.delete_email(msg_id, proto_conn, proto_type) + return False + + tasks = self.parser.parse(rule_content) + logger.info(f"解析出 {len(tasks)} 条规则指令") + for idx, t in enumerate(tasks): + logger.debug(f"规则{idx}: {t}") + + all_output_files = [] + original_info = [] - # 处理每个附件(图片或压缩包) - result_items = [] # 每个元素为 (output_path_or_dir, original_attachment_name) for att in attachments: if os.path.basename(att) == 'manifest.txt': continue - if ZipHandler.is_archive(att): - # 处理压缩包 - output_dir = self.process_archive(att, tasks, session_dir) - if output_dir: - # 压缩包处理结果可能是一个目录(内含多个图片结果),或者单个文件? - # 为保持原样式,如果输出目录内只有一个文件,则返回该文件;否则打包目录 - result_items.append((output_dir, os.path.basename(att))) - else: - # 普通图片附件 - output_dir = self.process_single_image(att, tasks, session_dir) - if output_dir: - result_items.append((output_dir, os.path.basename(att))) + att_basename = os.path.basename(att) + att_ext = os.path.splitext(att)[1][1:].lower() - # 构建返回附件列表(根据原样式规则) - return_attachments = [] - for output, original_name in result_items: - if Config.FLATTEN_OUTPUT: - # 平铺所有文件 - if os.path.isdir(output): - for f in os.listdir(output): - return_attachments.append(os.path.join(output, f)) - else: - return_attachments.append(output) + if ZipHandler.is_archive(att): + archive_output_dir = os.path.join(mail_work_dir, f"out_{att_basename}") + os.makedirs(archive_output_dir, exist_ok=True) + output_files = self.process_archive(att, archive_output_dir, tasks) + all_output_files.extend(output_files) + original_info.append({ + 'original_name': att_basename, + 'output_dir': archive_output_dir, + 'output_files': output_files + }) + elif att_ext in Config.SUPPORTED_INPUT_FORMATS: + single_output_dir = os.path.join(mail_work_dir, f"out_{att_basename}") + os.makedirs(single_output_dir, exist_ok=True) + output_files = self.process_single_image(att, single_output_dir, tasks, current_archive=None) + all_output_files.extend(output_files) + original_info.append({ + 'original_name': att_basename, + 'output_dir': single_output_dir, + 'output_files': output_files + }) else: - # 保持原附件数量:如果输出是一个目录,且目录内文件数==1,则直接返回该文件;否则打包为ZIP - if os.path.isdir(output): - files = [f for f in os.listdir(output) if os.path.isfile(os.path.join(output, f))] - if len(files) == 1: - return_attachments.append(os.path.join(output, files[0])) - else: - # 打包目录 - zip_path = os.path.join(session_dir, f"{os.path.splitext(original_name)[0]}_result.zip") - ZipHandler.pack_to_zip(output, zip_path) - return_attachments.append(zip_path) - else: - return_attachments.append(output) + logger.warning(f"跳过不支持的文件: {att_basename} (扩展名: {att_ext})") + + logger.info(f"总共生成 {len(all_output_files)} 个输出文件") + if not all_output_files: + logger.warning("没有生成任何转换结果,删除原邮件") + self.mail.delete_email(msg_id, proto_conn, proto_type) + return False + + # 保持原附件样式打包 + return_attachments = [] + for info in original_info: + if len(info['output_files']) == 1: + return_attachments.append(info['output_files'][0]) + else: + zip_name = f"{os.path.splitext(info['original_name'])[0]}_result.zip" + zip_path = os.path.join(mail_work_dir, zip_name) + ZipHandler.pack_to_zip(info['output_dir'], zip_path) + return_attachments.append(zip_path) + + if Config.FLATTEN_OUTPUT: + return_attachments = all_output_files + + # ========== 构建详细主题 ========== + conversion_details = [] + for info in original_info: + orig_name = info['original_name'] + out_files = info['output_files'] + if len(out_files) == 1: + out_name = os.path.basename(out_files[0]) + out_ext = os.path.splitext(out_name)[1][1:].upper() + conversion_details.append(f"{orig_name} → {out_ext}") + else: + conversion_details.append(f"{orig_name} → {len(out_files)}个文件") + + if len(conversion_details) > 2: + detail_str = f"{', '.join(conversion_details[:2])} 等{len(conversion_details)}项" + else: + detail_str = ', '.join(conversion_details) + + time_str = time.strftime("%Y-%m-%d %H:%M:%S") + result_subject = f"MailC[{time_str}]:{detail_str}" # 发送结果 - subject = f"转换结果: {msg.get('Subject', '无主题')}" + self.mail.send_result( + recipient=sender_email, + subject=result_subject, + attachments=return_attachments, + split_volume_mb=Config.SPLIT_VOLUME_SIZE_MB + ) + logger.info(f"结果已发送至 {sender_email}") + + self.mail.delete_email(msg_id, proto_conn, proto_type) + return True + + except Exception as e: + error_msg = f"处理邮件时异常: {str(e)}" + logger.error(error_msg, exc_info=True) try: - self.mail.send_result(sender_email, subject, return_attachments, Config.SPLIT_VOLUME_SIZE_MB) - # 删除原邮件(留痕已在日志中) - # 对于IMAP,我们可以删除原邮件 - # 注意:需要重新连接,因为之前的连接已关闭。简单起见,不在这里实现删除,留痕已足够 - # 留痕记录 - logger.info(f"成功处理邮件 from {sender_email}, msg_id={msg_id}") - except Exception as e: - error_msg = f"发送结果失败: {str(e)}" - logger.error(error_msg) self.mail.send_error_report(sender_email, error_msg) + except Exception as send_err: + logger.error(f"发送错误报告失败: {send_err}") + self.mail.delete_email(msg_id, proto_conn, proto_type) + return False + finally: + if not Config.KEEP_TEMP_FILES: + shutil.rmtree(mail_work_dir, ignore_errors=True) - # 清理会话临时文件(若配置) - if not Config.KEEP_TEMP_FILES: - self.temp_mgr.cleanup() + # ==================== 循环监听 ==================== + def run_forever(self): + if not AgreementManager.is_agreed(): + logger.info("首次使用,需要同意协议") + recipient, subject, body = AgreementManager.request_agreement(Config.ADMIN_EMAIL, self.mail) + self.mail.send_result(recipient, subject, []) + logger.info(f"协议请求已发送至 {recipient},请在 .env 中设置 AGREED_TOS=True") + return - def process_single_image(self, img_path, tasks, base_dir): - """处理单张图片,返回输出目录(存放所有转换结果)""" - # 根据tasks匹配规则,生成输出文件列表 - output_dir = os.path.join(base_dir, f"out_{os.path.basename(img_path)}") - os.makedirs(output_dir, exist_ok=True) - # 简化匹配逻辑:只实现全局规则和文件名匹配(实际可扩展) - # 此处需要完整实现优先级匹配,为简洁,示例只做基础 - # 实际项目中应实现完整的规则引擎,这里模拟 - filename = os.path.splitext(os.path.basename(img_path))[0] - # 找是否有匹配的from.name规则 - matched = False - for task in tasks: - if task['type'] == 'by_name' and task['src_name'] == filename: - for fmt, size in task['targets']: - out_path = os.path.join(output_dir, f"{filename}.{fmt}") - # 缩放 - img = Image.open(img_path) - if size: - if size[0] == 'ratio': - img = ImageProcessor.resize_by_ratio(img, size[1], size[2]) - elif size[0] == 'pixel': - img = ImageProcessor.resize_by_pixel(img, size[1], size[2]) - img.save(out_path, format=fmt.upper(), quality=Config.DEFAULT_QUALITY) - else: - ImageProcessor.convert_image(img_path, out_path, fmt) - matched = True + logger.info(f"启动监听循环,轮询间隔 {Config.POLL_INTERVAL_SECONDS} 秒,{'无限循环' if Config.RUN_FOREVER else '单次运行'}") + + while True: + TempManager.cleanup_stale() + conn = None + proto_type = None + try: + conn, proto_type = self.mail.connect_inbox() + if proto_type == "IMAP": + typ, data = conn.search(None, 'UNSEEN') + if typ != 'OK': + logger.error("IMAP搜索失败") + continue + msg_ids = data[0].split() + else: + msg_count = len(conn.list()[1]) + msg_ids = list(range(1, msg_count + 1)) + + if not msg_ids: + logger.debug("无新邮件") + else: + logger.info(f"发现 {len(msg_ids)} 封待处理邮件") + for msg_id in msg_ids[:Config.MAX_EMAILS_PER_RUN]: + try: + if proto_type == "IMAP": + typ, msg_data = conn.fetch(msg_id, '(RFC822)') + if typ != 'OK': + continue + raw_email = msg_data[0][1] + else: + raw_email = b'\n'.join(conn.retr(msg_id)[1]) + self.process_one_email(msg_id, raw_email, conn, proto_type) + except Exception as e: + logger.error(f"处理邮件 {msg_id} 崩溃: {e}", exc_info=True) + try: + self.mail.delete_email(msg_id, conn, proto_type) + except: + pass + if proto_type == "IMAP": + try: + conn.expunge() + except: + pass + except Exception as e: + logger.error(f"主循环异常: {e}", exc_info=True) + finally: + if conn: + try: + if proto_type == "IMAP": + conn.close() + conn.logout() + else: + conn.quit() + except: + pass + if not Config.RUN_FOREVER: break - if not matched: - # 全局规则 - for task in tasks: - if task['type'] == 'global': - for fmt, size in task['targets']: - out_path = os.path.join(output_dir, f"{filename}.{fmt}") - ImageProcessor.convert_image(img_path, out_path, fmt) - matched = True - break - if not matched: - # 无规则,原样复制 - shutil.copy(img_path, os.path.join(output_dir, os.path.basename(img_path))) - return output_dir + time.sleep(Config.POLL_INTERVAL_SECONDS) - def process_archive(self, archive_path, tasks, base_dir): - """处理压缩包,返回输出目录""" - extract_dir = os.path.join(base_dir, f"ext_{os.path.basename(archive_path)}") - os.makedirs(extract_dir, exist_ok=True) - ext = os.path.splitext(archive_path)[1].lower() - if ext == '.zip': - ZipHandler.extract_zip(archive_path, extract_dir) - elif ext == '.7z': - ZipHandler.extract_7z(archive_path, extract_dir) - else: - return None - # 遍历提取出的所有图片,应用规则(规则作用域可能指定该压缩包) - output_root = os.path.join(base_dir, f"out_{os.path.basename(archive_path)}") - os.makedirs(output_root, exist_ok=True) - for root, _, files in os.walk(extract_dir): - for file in files: - if file.split('.')[-1].lower() in Config.SUPPORTED_INPUT_FORMATS: - img_path = os.path.join(root, file) - # 对每个图片应用规则(同上,简化) - out_subdir = self.process_single_image(img_path, tasks, output_root) - # 合并输出目录 - for f in os.listdir(out_subdir): - shutil.move(os.path.join(out_subdir, f), output_root) - return output_root +def main(): + converter = MailConverter() + converter.run_forever() + if __name__ == "__main__": - converter = MailConverter() - converter.run() \ No newline at end of file + main() diff --git a/manifest_parser.py b/manifest_parser.py index 67d9029..5c1a197 100644 --- a/manifest_parser.py +++ b/manifest_parser.py @@ -1,19 +1,31 @@ import re from typing import List, Dict, Tuple, Optional +from logger import logger class ManifestParser: def __init__(self): - self.tasks = [] # 存储解析后的任务指令 + self.tasks = [] - def parse(self, content: str): - """解析 manifest.txt 或邮件正文,生成内部指令结构""" + def parse(self, content: str) -> List[Dict]: + """ + 解析 manifest.txt 或邮件正文,返回任务列表。 + 支持: + - 全局默认: to: fmt(size), fmt... + - 批量格式: from: fmt to: fmt(size)... + - 精准文件名: from.name: name to: fmt(size)... + - 重命名(多版本): from.name: name 后跟多行 to.name: newname.ext(size) + - 压缩包作用域: in archive.zip: / in . + - 临时作用域: 指令 in archive.zip + """ lines = [line.strip() for line in content.splitlines() if line.strip() and not line.strip().startswith('#')] self.tasks = [] - current_scope = None # 当前压缩包作用域,None表示根目录 + current_scope = None # 当前压缩包作用域 + pending_rename = None # 等待多行 to.name 的 from.name 任务 i = 0 while i < len(lines): line = lines[i] + # 处理压缩包作用域开始 if line.startswith('in ') and line.endswith(':'): archive_name = line[3:-1].strip() @@ -25,22 +37,19 @@ class ManifestParser: i += 1 continue - # 检查行内是否包含 "in xxx" 临时作用域 + # 检查行内临时作用域 inline_archive = None - if ' in ' in line: + if ' in ' in line and not line.startswith('in '): parts = line.split(' in ') cmd_part = parts[0].strip() archive_part = parts[1].strip() - # 如果archive_part还有更多内容(如"to: xxx"),需重新组合?假设格式严格:"指令 in archive.zip" - if ' ' in archive_part: - # 复杂情况,暂不支持,要求用户使用单独行 - pass - else: + # 确保 archive_part 是单纯的文件名(不含空格) + if ' ' not in archive_part: inline_archive = archive_part line = cmd_part # 解析指令 - if line.startswith('to:'): + if line.startswith('to:') and not line.startswith('to.name:'): # 全局默认 targets = self._parse_targets(line[3:]) self.tasks.append({ @@ -63,14 +72,13 @@ class ManifestParser: 'inline_archive': inline_archive }) elif line.startswith('from.name:'): - # 精准文件名匹配 + # 精准文件名匹配,可能后跟多行 to.name: rest = line[10:].strip() if ' to.name:' in rest: - # 重命名 + # 单行重命名 parts = rest.split(' to.name:') src_name = parts[0].strip() dst_spec = parts[1].strip() - # 解析 dst_spec: "newname.ext(size)" dst_name, size = self._parse_dst_with_size(dst_spec) self.tasks.append({ 'type': 'rename', @@ -81,30 +89,64 @@ class ManifestParser: 'inline_archive': inline_archive }) else: - # to: 格式 - parts = rest.split(' to:') - src_name = parts[0].strip() - targets = self._parse_targets(parts[1]) - self.tasks.append({ - 'type': 'by_name', - 'src_name': src_name, - 'targets': targets, - 'scope': current_scope, - 'inline_archive': inline_archive - }) - elif line.startswith('to.name:'): - # 多版本导出中的 to.name 行,需要结合前面的 from.name 处理 - # 我们在主流程中采用累积方式,这里简单处理,实际在main中会连续读取 - # 为简化,不在此处实现多行组合,而是在主程序中按行处理上下文 - # 让主程序负责收集连续的 to.name - pass + # 可能后跟多行 to.name:(多版本导出) + src_name = rest.split(' to:')[0].strip() if ' to:' in rest else rest + # 检查下一行是否以 to.name: 开头 + if i + 1 < len(lines) and lines[i+1].startswith('to.name:'): + pending_rename = { + 'src_name': src_name, + 'targets': [], + 'scope': current_scope, + 'inline_archive': inline_archive + } + # 跳过当前 from.name 行,后续循环处理 to.name 行 + i += 1 + continue + elif ' to:' in rest: + # 普通精准转换 + parts = rest.split(' to:') + src_name = parts[0].strip() + targets = self._parse_targets(parts[1]) + self.tasks.append({ + 'type': 'by_name', + 'src_name': src_name, + 'targets': targets, + 'scope': current_scope, + 'inline_archive': inline_archive + }) + else: + # 只有 from.name: xxx,没有规则,无效 + logger.warning(f"无效的 from.name 指令: {line}") + elif line.startswith('to.name:') and pending_rename: + # 多版本导出中的一行 + dst_spec = line[8:].strip() + dst_name, size = self._parse_dst_with_size(dst_spec) + pending_rename['targets'].append({ + 'dst_name': dst_name, + 'size': size + }) + # 检查下一行是否还是 to.name: + if i + 1 >= len(lines) or not lines[i+1].startswith('to.name:'): + # 结束,将 pending_rename 转为多个 rename 任务 + for tgt in pending_rename['targets']: + self.tasks.append({ + 'type': 'rename', + 'src_name': pending_rename['src_name'], + 'dst_name': tgt['dst_name'], + 'size': tgt['size'], + 'scope': pending_rename['scope'], + 'inline_archive': pending_rename['inline_archive'] + }) + pending_rename = None else: logger.warning(f"无法识别的指令: {line}") + i += 1 + return self.tasks def _parse_targets(self, target_str: str) -> List[Tuple[str, Optional[Tuple]]]: - """解析 "webp, png(16:9), jpg(800x600)" -> [('webp',None), ('png',('16:9')), ('jpg',('800x600'))]""" + """解析 "webp, png(16:9), jpg(800x600)" -> [('webp',None), ('png',('ratio',16,9)), ...]""" items = [t.strip() for t in target_str.split(',')] result = [] for item in items: @@ -113,7 +155,6 @@ class ManifestParser: fmt, size_part = item.split('(', 1) size_str = size_part.rstrip(')') if ':' in size_str: - # 比例 w_ratio, h_ratio = map(int, size_str.split(':')) size = ('ratio', w_ratio, h_ratio) elif 'x' in size_str: @@ -138,4 +179,4 @@ class ManifestParser: size = ('pixel', w, h) return name.strip(), size else: - return dst_spec.strip(), None \ No newline at end of file + return dst_spec.strip(), None