Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
276 lines
10 KiB
Python
276 lines
10 KiB
Python
# 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
|