# COM helpers preserved from the current Windows utility bot. import os, json, time, email import smtplib import tempfile from pathlib import Path from email import encoders from email.header import Header from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formatdate, make_msgid, parsedate_to_datetime CONFIG_PATH = Path(os.environ.get("MAIL_COM_CONFIG", str(Path(__file__).with_name("com-private.json")))) CONFIG = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) HMAIL_ADMIN_PASSWORD = CONFIG["hmail_admin_password"] LIST_MAX = 10 class _ComSession: """线程内 COM 会话:CoInitialize + Dispatch + Authenticate,退出时 CoUninitialize。""" def __enter__(self): import pythoncom import win32com.client self._pycom = pythoncom pythoncom.CoInitialize() try: app = win32com.client.Dispatch("hMailServer.Application") except Exception: pythoncom.CoUninitialize() raise if not app.Authenticate("Administrator", HMAIL_ADMIN_PASSWORD): pythoncom.CoUninitialize() raise RuntimeError("hMailServer 管理员认证失败") self.app = app return app def __exit__(self, *exc): self._pycom.CoUninitialize() return False def _get_account(app, address): domain_name = address.split("@", 1)[1] domain = app.Domains.ItemByName(domain_name) return domain.Accounts.ItemByAddress(address) def _message_ts(m): for attr in ("InternalDate", "Date"): try: v = getattr(m, attr) except Exception: continue if v is None: continue if isinstance(v, str): try: return parsedate_to_datetime(v).timestamp() except Exception: continue else: # datetime-like try: return v.timestamp() except Exception: continue return 0.0 def _decode_mime(s): if not s: return "" try: from email.header import decode_header parts = decode_header(s) out = [] for p, cs in parts: if isinstance(p, bytes): out.append(p.decode(cs or "utf-8", "replace")) else: out.append(p) return "".join(out) except Exception: return s def _analyze_message(m): """解析 .eml,返回 (image_count, attachment_names)。""" image_count = 0 attachment_names = [] try: fn = m.Filename if not fn or not os.path.exists(fn): return image_count, attachment_names with open(fn, "rb") as f: msg = email.message_from_bytes(f.read()) for part in msg.walk(): if part.is_multipart(): continue ctype = (part.get_content_type() or "").lower() disp = (part.get("Content-Disposition") or "").lower() cid = part.get("Content-ID") filename = part.get_filename() if ctype.startswith("image/") or cid: image_count += 1 continue if "attachment" in disp or filename: name = _decode_mime(filename) if filename else "" attachment_names.append(name or "(未命名附件)") except Exception: pass return image_count, attachment_names def _list_inbox_messages(app, address, window_seconds=None, limit=LIST_MAX): account = _get_account(app, address) inbox = account.IMAPFolders.ItemByName("INBOX") msgs = inbox.Messages items = [] now = time.time() for i in range(msgs.Count): m = msgs[i] ts = _message_ts(m) if window_seconds is not None and (now - ts) > window_seconds: continue items.append({ "id": int(m.ID), "subject": (m.Subject or "").strip() or "(无主题)", "from": (m.From or "").strip(), "from_address": (m.FromAddress or "").strip(), "ts": ts, }) items.sort(key=lambda x: x["ts"], reverse=True) return items[:limit] def _find_message_by_id(app, addresses, msg_id): for addr in addresses: try: account = _get_account(app, addr) inbox = account.IMAPFolders.ItemByName("INBOX") return inbox.Messages.ItemByDBID(int(msg_id)) except Exception: continue return None def _send_mail(app, from_address, to_address, subject, body_text): import win32com.client msg = win32com.client.Dispatch("hMailServer.Message") msg.From = f"{from_address.split('@', 1)[0]} <{from_address}>" msg.FromAddress = from_address msg.Subject = subject msg.Body = body_text msg.AddRecipient("", to_address) msg.Save() def _build_attachment_mime(from_address, to_address, subject, body_text, attachments): """构造与 mailWeb.py 相同风格的 multipart/mixed UTF-8 邮件。 attachments 中只含已经解码的 bytes,绝不含服务器文件路径。 """ msg = MIMEMultipart("mixed") msg["From"] = f"{from_address.split('@', 1)[0]} <{from_address}>" msg["To"] = to_address msg["Subject"] = subject msg["Date"] = formatdate(localtime=True) msg["Message-ID"] = make_msgid() msg.attach(MIMEText(body_text, "plain", "utf-8")) names = [] for attachment in attachments: filename = attachment["filename"] content_type = attachment["content_type"] maintype, subtype = content_type.split("/", 1) part = MIMEBase(maintype, subtype) part.set_payload(attachment["content"]) encoders.encode_base64(part) # email 包会对中文文件名生成 RFC 2231 编码参数。 part.add_header("Content-Disposition", "attachment", filename=filename) msg.attach(part) names.append(filename) if names: # 保留 mailWeb.py 的兼容元数据;真正的附件仍以标准 MIME part 为准。 msg["X-Attachments"] = ",".join(str(Header(name, "utf-8")) for name in names) return msg def _deliver_mime(from_address, to_address, mime_bytes): """经本机 hMailServer SMTP 投递原始 MIME,避免猜测 COM 原始 MIME 属性。 Windows 实机验证点:hMailServer 的 127.0.0.1 IP range 必须允许本机 发件账号向目标地址投递;如生产使用 SSL/STARTTLS/认证,在 com-private.json 配置 smtp_ssl / smtp_starttls / smtp_username / smtp_password。 """ host = CONFIG.get("smtp_host", "127.0.0.1") port = int(CONFIG.get("smtp_port", 465 if CONFIG.get("smtp_ssl") else 25)) timeout = float(CONFIG.get("smtp_timeout", 30)) smtp_class = smtplib.SMTP_SSL if CONFIG.get("smtp_ssl") else smtplib.SMTP with smtp_class(host, port, timeout=timeout) as smtp: if CONFIG.get("smtp_starttls"): smtp.starttls() username = CONFIG.get("smtp_username") if username: smtp.login(username, CONFIG.get("smtp_password", "")) refused = smtp.sendmail(from_address, [to_address], mime_bytes) if refused: raise RuntimeError("hMailServer SMTP 未接受全部收件人") def _save_attachment_copy_to_sent(app, from_address, to_address, subject, body_text, attachments): """best-effort 保存同一逻辑邮件(正文及附件)到 Sent,不再次投递。 hMailServer 5.6 COM Message 没有公开的原始 MIME 写入接口;这里只使用 文档化的 Messages.Add / Attachments.Add。附件先落在后端自建的临时目录, 文件名已经过端点校验,客户端不能指定任何服务器路径。COM 生成的 Sent MIME 可能与线上 MIME 的边界、日期等字节不同。 Windows 实机验证点:确认 Attachments.Add 能读取服务账号临时目录,且 中文附件名在 Sent 和 WebMail 中显示正确。 """ try: account = _get_account(app, from_address) try: sent = account.IMAPFolders.ItemByName("Sent") except Exception: return False with tempfile.TemporaryDirectory(prefix="mail-attachments-") as tmp: m = sent.Messages.Add() m.From = f"{from_address.split('@', 1)[0]} <{from_address}>" m.FromAddress = from_address m.Subject = subject m.Body = body_text m.AddRecipient("", to_address) for index, attachment in enumerate(attachments): entry_dir = os.path.join(tmp, str(index)) os.mkdir(entry_dir) disk_path = os.path.join(entry_dir, attachment["filename"]) with open(disk_path, "wb") as fh: fh.write(attachment["content"]) m.Attachments.Add(disk_path) m.Save() return True except Exception: return False def _send_mail_with_attachments(app, from_address, to_address, subject, body_text, attachments): """投递附件邮件并 best-effort 保存 Sent;无附件沿用旧 COM 路径。""" if not attachments: _send_mail(app, from_address, to_address, subject, body_text) _save_to_sent(app, from_address, to_address, subject, body_text) return None msg = _build_attachment_mime(from_address, to_address, subject, body_text, attachments) _deliver_mime(from_address, to_address, msg.as_bytes()) return _save_attachment_copy_to_sent( app, from_address, to_address, subject, body_text, attachments) def _save_to_sent(app, from_address, to_address, subject, body_text): """把发件副本写入发件人账号的 Sent 文件夹(best-effort,不投递)。""" try: account = _get_account(app, from_address) try: sent = account.IMAPFolders.ItemByName("Sent") except Exception: return m = sent.Messages.Add() m.From = f"{from_address.split('@', 1)[0]} <{from_address}>" m.FromAddress = from_address m.Subject = subject m.Body = body_text try: m.AddRecipient("", to_address) except Exception: pass m.Save() except Exception: pass