email_authorizations_service.py 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079
  1. import threading
  2. import socket
  3. import socks
  4. import imaplib
  5. import smtplib
  6. import email
  7. import asyncio
  8. import re
  9. import time
  10. import base64
  11. from datetime import datetime, timedelta, timezone
  12. import email.policy
  13. from email.message import EmailMessage
  14. from email.utils import formatdate, make_msgid
  15. from email.header import decode_header
  16. from typing import List, Optional
  17. from sqlalchemy.orm import Session
  18. from sqlalchemy.ext.asyncio import AsyncSession
  19. from sqlalchemy import select, text
  20. from starlette.concurrency import run_in_threadpool
  21. from app.core.logger import logger
  22. from app.core.biz_exception import NotFoundError, BizLogicError
  23. from app.models.email_authorizations import EmailAuthorization
  24. from app.schemas.email_authorizations import EmailAuthorizationCreate, EmailAuthorizationUpdate
  25. # 保持锁逻辑不变
  26. _PROXY_LOCK = threading.Lock()
  27. class EmailAuthorizationService:
  28. DEFAULT_READ_TOP_N_EMAIL = 10
  29. RETRY_DELAY_SECONDS = 3
  30. # =================================================================
  31. # 数据库操作 (DB CRUD) - 使用 AsyncSession
  32. # =================================================================
  33. @staticmethod
  34. async def get_all(db: AsyncSession) -> List[EmailAuthorization]:
  35. # AsyncSession 不支持 db.query,需要用 select(Model)
  36. stmt = select(EmailAuthorization).order_by(EmailAuthorization.id.desc())
  37. result = await db.execute(stmt)
  38. return result.scalars().all()
  39. @staticmethod
  40. async def get_by_id(db: AsyncSession, id: int) -> Optional[EmailAuthorization]:
  41. stmt = select(EmailAuthorization).where(EmailAuthorization.id == id)
  42. result = await db.execute(stmt)
  43. # scalar_one_or_none 类似于 .first(),但更严格(如果有多个会报错,这里id是主键所以没问题)
  44. obj = result.scalar_one_or_none()
  45. if not obj:
  46. raise NotFoundError("Email authorization not found")
  47. return obj
  48. @staticmethod
  49. async def get_by_email(db: AsyncSession, email: str) -> Optional[EmailAuthorization]:
  50. stmt = select(EmailAuthorization).where(EmailAuthorization.email == email)
  51. result = await db.execute(stmt)
  52. obj = result.scalar_one_or_none()
  53. if not obj:
  54. raise NotFoundError("Email authorization not found")
  55. return obj
  56. @staticmethod
  57. async def create(db: AsyncSession, obj_in: EmailAuthorizationCreate) -> EmailAuthorization:
  58. # 先检查是否存在
  59. stmt = select(EmailAuthorization).where(EmailAuthorization.email == obj_in.email)
  60. result = await db.execute(stmt)
  61. if result.scalar_one_or_none():
  62. raise BizLogicError(f"Email {obj_in.email} already exist")
  63. # 创建对象
  64. db_obj = EmailAuthorization(**obj_in.dict(exclude_unset=True))
  65. # db.add 是同步方法(只是添加到 session 上下文)
  66. db.add(db_obj)
  67. # commit 和 refresh 是异步的
  68. await db.commit()
  69. await db.refresh(db_obj)
  70. return db_obj
  71. @staticmethod
  72. async def update(db: AsyncSession, id: int, obj_in: EmailAuthorizationUpdate) -> Optional[EmailAuthorization]:
  73. stmt = select(EmailAuthorization).where(EmailAuthorization.id == id)
  74. result = await db.execute(stmt)
  75. db_obj = result.scalar_one_or_none()
  76. if not db_obj:
  77. raise NotFoundError("Email authorization not found")
  78. for field, value in obj_in.dict(exclude_unset=True).items():
  79. setattr(db_obj, field, value)
  80. db.add(db_obj)
  81. await db.commit()
  82. await db.refresh(db_obj)
  83. return db_obj
  84. @staticmethod
  85. async def delete(db: AsyncSession, id: int) -> Optional[EmailAuthorization]:
  86. stmt = select(EmailAuthorization).where(EmailAuthorization.id == id)
  87. result = await db.execute(stmt)
  88. db_obj = result.scalar_one_or_none()
  89. if not db_obj:
  90. raise NotFoundError("Email authorization not found")
  91. # delete 也是同步标记
  92. await db.delete(db_obj)
  93. await db.commit()
  94. return db_obj
  95. @staticmethod
  96. def _connect_imap_with_proxy(
  97. host: str,
  98. port: int,
  99. proxy_host: Optional[str] = None,
  100. proxy_port: Optional[int] = None,
  101. proxy_user: Optional[str] = None,
  102. proxy_password: Optional[str] = None,
  103. ) -> imaplib.IMAP4_SSL:
  104. """
  105. 创建连接 (同步方法,将在线程中运行)
  106. 使用 Lock 确保 socket patching 不会影响其他并发请求
  107. """
  108. if proxy_host and proxy_port and proxy_port > 0:
  109. with _PROXY_LOCK: # 加锁,防止多线程同时修改全局 socket
  110. original_socket = socket.socket
  111. socks.setdefaultproxy(
  112. proxy_type=socks.SOCKS5,
  113. addr=proxy_host,
  114. port=proxy_port,
  115. username=proxy_user or None,
  116. password=proxy_password or None,
  117. )
  118. socket.socket = socks.socksocket
  119. try:
  120. imap = imaplib.IMAP4_SSL(host, port)
  121. finally:
  122. socket.socket = original_socket
  123. else:
  124. imap = imaplib.IMAP4_SSL(host, port)
  125. return imap
  126. @staticmethod
  127. def _connect_smtp_with_proxy(
  128. host: str,
  129. port: int,
  130. proxy_host: Optional[str] = None,
  131. proxy_port: Optional[int] = None,
  132. proxy_user: Optional[str] = None,
  133. proxy_password: Optional[str] = None,
  134. ) -> smtplib.SMTP_SSL:
  135. """
  136. 创建连接 (同步方法,将在线程中运行)
  137. """
  138. if proxy_host and proxy_port and proxy_port > 0:
  139. with _PROXY_LOCK: # 加锁
  140. original_socket = socket.socket
  141. socks.setdefaultproxy(
  142. proxy_type=socks.SOCKS5,
  143. addr=proxy_host,
  144. port=proxy_port,
  145. username=proxy_user or None,
  146. password=proxy_password or None,
  147. )
  148. socket.socket = socks.socksocket
  149. try:
  150. smtp = smtplib.SMTP_SSL(host, port)
  151. finally:
  152. socket.socket = original_socket
  153. else:
  154. smtp = smtplib.SMTP_SSL(host, port)
  155. return smtp
  156. @staticmethod
  157. async def fetch_email_authorizations2(
  158. db: Session,
  159. auth,
  160. sender: str,
  161. recipient: str,
  162. subject_keywords: str,
  163. body_keywords: str
  164. ):
  165. # =========================================================
  166. # 第一步:在数据库中查找最新的 UID (主线程/DB线程执行)
  167. # =========================================================
  168. # 1. 构建动态 SQL
  169. # 假设表名为 emails,字段为 uid, sender, recipient, subject, body_text
  170. sql = "SELECT uid, subject, body_text FROM emails WHERE 1=1"
  171. params = {}
  172. # 2. 处理发件人 (模糊匹配)
  173. if sender.strip():
  174. sql += " AND sender LIKE :sender"
  175. params['sender'] = f"%{sender.strip()}%"
  176. # 3. 处理收件人 (模糊匹配)
  177. if recipient.strip():
  178. sql += " AND recipient LIKE :recipient"
  179. params['recipient'] = f"%{recipient.strip()}%"
  180. # 4. 处理主题关键词 (OR 关系)
  181. subj_keys = [k.strip() for k in subject_keywords.split(',') if k.strip()]
  182. if subj_keys:
  183. for i, k in enumerate(subj_keys):
  184. key_name = f"subj_{i}"
  185. # 直接拼接到主 SQL 中,要求同时满足
  186. sql += f" AND subject LIKE :{key_name}"
  187. params[key_name] = f"%{k}%"
  188. # 5. 处理内容关键词 (OR 关系)
  189. body_keys = [k.strip() for k in body_keywords.split(',') if k.strip()]
  190. if body_keys:
  191. for i, k in enumerate(body_keys):
  192. key_name = f"body_{i}"
  193. # 直接拼接到主 SQL 中,要求同时满足
  194. sql += f" AND body_text LIKE :{key_name}"
  195. params[key_name] = f"%{k}%"
  196. # 6. 获取最新的一条
  197. sql += " ORDER BY uid DESC LIMIT 1"
  198. # 执行查询
  199. result_proxy = await db.execute(text(sql), params)
  200. result = result_proxy.fetchone()
  201. if not result:
  202. logger.info(f"DB Search: No email found for {sender} -> {recipient}")
  203. return None
  204. target_uid = result.uid
  205. target_subject = result.subject
  206. target_body_text = result.body_text
  207. logger.info(f"DB Search: Found UID {target_uid} matching criteria. Subject: {target_subject}")
  208. return f'{target_subject}\n{target_body_text}'
  209. @staticmethod
  210. async def fetch_email_authorizations(
  211. auth,
  212. sender: str,
  213. recipient: str,
  214. subject_keywords: str,
  215. body_keywords: str,
  216. sent_date: str,
  217. expiry: int = 300,
  218. only_text: bool = True
  219. ) -> Optional[str]:
  220. """
  221. 在有效期内循环读取邮箱,找到符合条件的邮件(使用最后一条 Received 头作为收件时间)
  222. """
  223. def _worker():
  224. EMAIL_ACCOUNT = auth.email
  225. EMAIL_PASSWORD = auth.authorization_code
  226. IMAP_SERVER = auth.imap_server
  227. IMAP_PORT = auth.imap_port
  228. subject_keys = [s.strip() for s in subject_keywords.split(",") if s.strip()]
  229. body_keys = [s.strip() for s in body_keywords.split(",") if s.strip()]
  230. # === 时间计算 ===
  231. sent_dt = datetime.strptime(sent_date, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
  232. max_wait_time = min(5 * 60, expiry) # 最长等待5分钟
  233. expiry_at = time.time() + max_wait_time
  234. def get_received_time(msg):
  235. """
  236. 使用最后一条 Received 头解析收件时间
  237. """
  238. received_headers = msg.get_all("Received", [])
  239. if not received_headers:
  240. return None
  241. for i, header in enumerate(received_headers, 1):
  242. logger.debug(f" [{i}] {header}")
  243. last_received = received_headers[-1]
  244. if ";" not in last_received:
  245. return None
  246. time_str = last_received.split(";")[-1].strip()
  247. dt_tuple = email.utils.parsedate_tz(time_str)
  248. if not dt_tuple:
  249. return None
  250. return datetime.fromtimestamp(email.utils.mktime_tz(dt_tuple), tz=timezone.utc)
  251. mail = EmailAuthorizationService._connect_imap_with_proxy(
  252. IMAP_SERVER,
  253. IMAP_PORT,
  254. auth.proxy_host,
  255. auth.proxy_port,
  256. auth.proxy_username,
  257. auth.proxy_password,
  258. )
  259. mail.login(EMAIL_ACCOUNT, EMAIL_PASSWORD)
  260. mail.select("INBOX")
  261. while time.time() < expiry_at:
  262. mail.noop() # 刷新邮箱状态
  263. _, data = mail.search(None, "ALL")
  264. mail_ids = data[0].split()
  265. if not mail_ids:
  266. time.sleep(EmailAuthorizationService.RETRY_DELAY_SECONDS)
  267. continue
  268. recent_ids = mail_ids[-EmailAuthorizationService.DEFAULT_READ_TOP_N_EMAIL:]
  269. messages = []
  270. debug = True
  271. for email_id in reversed(recent_ids):
  272. res, msg_data = mail.fetch(email_id, "(RFC822)")
  273. if res != "OK" or not msg_data:
  274. if debug:
  275. logger.debug(f"[WARN] 邮件 ID={email_id.decode()} 获取失败")
  276. continue
  277. msg_bytes = None
  278. for part in msg_data:
  279. if isinstance(part, tuple):
  280. msg_bytes = part[1]
  281. if not msg_bytes:
  282. if debug:
  283. logger.debug(f"[WARN] 邮件 ID={email_id.decode()} 无正文")
  284. continue
  285. msg = email.message_from_bytes(msg_bytes)
  286. received_dt = get_received_time(msg)
  287. if not received_dt:
  288. if debug:
  289. logger.debug(f"[WARN] 邮件 ID={email_id.decode()} 未解析出 Received 时间")
  290. continue
  291. messages.append((msg, received_dt))
  292. if debug:
  293. logger.debug(f"[DEBUG] 成功解析邮件数: {len(messages)}")
  294. logger.debug(f"[DEBUG] 收件时间列表: {[m[1] for m in messages]}")
  295. # 按收件时间降序排序
  296. messages.sort(key=lambda x: x[1], reverse=True)
  297. for msg, received_dt in messages:
  298. # 判断是否在发送时间后的有效窗口内
  299. if received_dt < sent_dt:
  300. if debug:
  301. logger.debug(f"[INFO] 邮件太旧: {received_dt}")
  302. continue
  303. if received_dt > sent_dt + timedelta(seconds=expiry):
  304. if debug:
  305. logger.debug(f"[INFO] 邮件太新: {received_dt}")
  306. continue
  307. # 匹配发件人/收件人
  308. msg_from = msg.get("From", "")
  309. msg_to = msg.get("To", "")
  310. if sender.lower() not in msg_from.lower():
  311. if debug:
  312. logger.debug("发件人不匹配")
  313. continue
  314. if recipient.lower() not in msg_to.lower():
  315. if debug:
  316. logger.debug("收件人不匹配")
  317. continue
  318. # 匹配主题
  319. subject, enc = decode_header(msg.get("Subject"))[0]
  320. if isinstance(subject, bytes):
  321. subject = subject.decode(enc or "utf-8", errors="ignore")
  322. if subject_keys and not any(k.lower() in subject.lower() for k in subject_keys):
  323. continue
  324. # 提取正文
  325. body = EmailAuthorizationService._extract_body(msg, only_text)
  326. if body_keys and not any(k.lower() in body.lower() for k in body_keys):
  327. continue
  328. # 找到匹配邮件 → 返回内容
  329. mail.close()
  330. mail.logout()
  331. return body.strip()
  332. # 未匹配到 → 等待重试
  333. time.sleep(EmailAuthorizationService.RETRY_DELAY_SECONDS)
  334. mail.close()
  335. mail.logout()
  336. raise NotFoundError("Get email timeout")
  337. return await run_in_threadpool(_worker)
  338. @staticmethod
  339. def _process_recent_emails_sync(
  340. mail, recent_ids, sent_dt, expiry, sender, recipient, subject_keys, body_keys, only_text
  341. ) -> Optional[str]:
  342. """
  343. 同步辅助函数:处理邮件解析逻辑。
  344. 将这段繁琐的逻辑放入线程运行,避免阻塞 Async Loop。
  345. """
  346. messages = []
  347. debug = True
  348. for email_id in reversed(recent_ids):
  349. res, msg_data = mail.fetch(email_id, "(RFC822)")
  350. if res != "OK" or not msg_data:
  351. continue
  352. msg_bytes = None
  353. for part in msg_data:
  354. if isinstance(part, tuple):
  355. msg_bytes = part[1]
  356. if not msg_bytes:
  357. continue
  358. msg = email.message_from_bytes(msg_bytes)
  359. # 解析时间
  360. received_dt = None
  361. received_headers = msg.get_all("Received", [])
  362. if received_headers:
  363. last_received = received_headers[-1]
  364. if ";" in last_received:
  365. time_str = last_received.split(";")[-1].strip()
  366. dt_tuple = email.utils.parsedate_tz(time_str)
  367. if dt_tuple:
  368. received_dt = datetime.fromtimestamp(email.utils.mktime_tz(dt_tuple), tz=timezone.utc)
  369. if not received_dt:
  370. continue
  371. messages.append((msg, received_dt))
  372. # 排序
  373. messages.sort(key=lambda x: x[1], reverse=True)
  374. for msg, received_dt in messages:
  375. # 时间判定
  376. if received_dt < sent_dt:
  377. continue
  378. if received_dt > sent_dt + timedelta(seconds=expiry):
  379. continue
  380. # 匹配逻辑
  381. msg_from = msg.get("From", "")
  382. msg_to = msg.get("To", "")
  383. if sender.lower() not in msg_from.lower():
  384. continue
  385. if recipient.lower() not in msg_to.lower():
  386. continue
  387. subject_raw = msg.get("Subject")
  388. subject = ""
  389. if subject_raw:
  390. decoded_list = decode_header(subject_raw)
  391. if decoded_list:
  392. sub_bytes, enc = decoded_list[0]
  393. if isinstance(sub_bytes, bytes):
  394. subject = sub_bytes.decode(enc or "utf-8", errors="ignore")
  395. else:
  396. subject = str(sub_bytes)
  397. if subject_keys and not any(k.lower() in subject.lower() for k in subject_keys):
  398. continue
  399. body = EmailAuthorizationService._extract_body(msg, only_text)
  400. if body_keys and not any(k.lower() in body.lower() for k in body_keys):
  401. continue
  402. return body.strip()
  403. return None
  404. @staticmethod
  405. async def fetch_email_authorizations_from_top_n(
  406. auth,
  407. sender: str,
  408. recipient: str,
  409. subject_keywords: str,
  410. body_keywords: str,
  411. top: int = 10,
  412. only_text: bool = True
  413. ) -> Optional[str]:
  414. # 定义一个纯同步的 worker 函数来执行所有 IMAP 逻辑
  415. def _worker():
  416. subject_keys = [s.strip() for s in subject_keywords.split(",") if s.strip()]
  417. body_keys = [s.strip() for s in body_keywords.split(",") if s.strip()]
  418. mail = EmailAuthorizationService._connect_imap_with_proxy(
  419. auth.imap_server,
  420. auth.imap_port,
  421. auth.proxy_host,
  422. auth.proxy_port,
  423. auth.proxy_username,
  424. auth.proxy_password,
  425. )
  426. try:
  427. mail.login(auth.email, auth.authorization_code)
  428. mail.select("INBOX")
  429. _, data = mail.search(None, "ALL")
  430. mail_ids = data[0].split()
  431. if not mail_ids:
  432. return None
  433. recent_ids = mail_ids[-top:]
  434. # 复用上面的解析逻辑,但稍作调整,因为这个方法不需要时间过滤
  435. # 这里为了简单,直接写精简版解析
  436. for email_id in reversed(recent_ids):
  437. res, msg_data = mail.fetch(email_id, "(RFC822)")
  438. if res != "OK" or not msg_data: continue
  439. msg_bytes = None
  440. for part in msg_data:
  441. if isinstance(part, tuple): msg_bytes = part[1]
  442. if not msg_bytes: continue
  443. msg = email.message_from_bytes(msg_bytes)
  444. # 匹配逻辑
  445. msg_from = msg.get("From", "")
  446. msg_to = msg.get("To", "")
  447. if sender.lower() not in msg_from.lower(): continue
  448. if recipient.lower() not in msg_to.lower(): continue
  449. subject_raw = msg.get("Subject")
  450. subject = ""
  451. if subject_raw:
  452. d = decode_header(subject_raw)[0]
  453. subject = d[0].decode(d[1] or "utf-8", errors="ignore") if isinstance(d[0], bytes) else str(d[0])
  454. if subject_keys and not any(k.lower() in subject.lower() for k in subject_keys): continue
  455. body = EmailAuthorizationService._extract_body(msg, only_text)
  456. if body_keys and not any(k.lower() in body.lower() for k in body_keys): continue
  457. return body.strip()
  458. return None
  459. finally:
  460. try:
  461. mail.close()
  462. mail.logout()
  463. except:
  464. pass
  465. return await run_in_threadpool(_worker)
  466. @staticmethod
  467. async def forward_first_matching_email(
  468. auth,
  469. forward_to: str,
  470. sender: str,
  471. recipient: str,
  472. subject_keywords: str,
  473. body_keywords: str
  474. ):
  475. def _worker():
  476. subject_keys = [s.strip() for s in subject_keywords.split(",") if s.strip()]
  477. body_keys = [s.strip() for s in body_keywords.split(",") if s.strip()]
  478. mail = EmailAuthorizationService._connect_imap_with_proxy(
  479. auth.imap_server, auth.imap_port, auth.proxy_host, auth.proxy_port, auth.proxy_username, auth.proxy_password
  480. )
  481. try:
  482. mail.login(auth.email, auth.authorization_code)
  483. mail.select("INBOX")
  484. # 1. 搜索目标邮件
  485. target = recipient
  486. query = f'(HEADER To "{target}")'
  487. res, data = mail.uid("search", None, query)
  488. if res != "OK": return None
  489. uids = data[0].split()
  490. msgs_to_check = []
  491. for uid in uids:
  492. # 使用 RFC822 获取完整内容
  493. res, msg_data = mail.uid("fetch", uid, "(RFC822)")
  494. if res != "OK" or not msg_data: continue
  495. # 临时解析用于排序和初步过滤
  496. raw_bytes = msg_data[0][1]
  497. tmp_msg = email.message_from_bytes(raw_bytes, policy=email.policy.default)
  498. date_str = tmp_msg.get("Date")
  499. if date_str:
  500. try:
  501. date_dt = parsedate_to_datetime(date_str)
  502. msgs_to_check.append((date_dt, tmp_msg, raw_bytes))
  503. except:
  504. continue
  505. # 按时间降序排序(最新的优先)
  506. msgs_to_check.sort(key=lambda x: x[0], reverse=True)
  507. for _, orig_msg, raw_bytes in msgs_to_check:
  508. # --- 过滤逻辑 ---
  509. msg_from = orig_msg.get("From", "")
  510. if sender.lower() not in msg_from.lower(): continue
  511. subject = orig_msg.get("Subject", "")
  512. if subject_keys and not any(k.lower() in subject.lower() for k in subject_keys): continue
  513. body_content = EmailAuthorizationService._extract_body(orig_msg, True)
  514. if body_keys and not any(k.lower() in body_content.lower() for k in body_keys): continue
  515. # --- 匹配成功:开始构造转发邮件 ---
  516. # 1. 提取原始信息用于视觉转发头
  517. orig_from = orig_msg.get("From", "Unknown")
  518. orig_date = orig_msg.get("Date", "Unknown")
  519. orig_subject = orig_msg.get("Subject", "No Subject")
  520. orig_to = orig_msg.get("To", "Unknown")
  521. orig_msg_id = orig_msg.get("Message-ID")
  522. fwd_info = (
  523. f"\n\n---------- Forwarded message ----------\n"
  524. f"From: {orig_from}\n"
  525. f"Date: {orig_date}\n"
  526. f"Subject: {orig_subject}\n"
  527. f"To: {orig_to}\n\n"
  528. )
  529. # 2. 构造新的邮件对象 (重新基于原始字节解析,确保附件完整)
  530. msg = email.message_from_bytes(raw_bytes, policy=email.policy.default)
  531. # 3. 清理并重置 Header
  532. headers_to_clean = ['From', 'To', 'Cc', 'Bcc', 'Subject', 'Date', 'Message-ID', 'In-Reply-To', 'References']
  533. for h in headers_to_clean:
  534. del msg[h]
  535. msg['From'] = auth.email
  536. msg['To'] = forward_to
  537. msg['Subject'] = f"Fwd: {orig_subject}"
  538. msg['Date'] = formatdate(localtime=True)
  539. msg['Message-ID'] = make_msgid(domain=auth.email.split('@')[-1])
  540. # 4. 【核心】建立上下文关联 (Threading)
  541. if orig_msg_id:
  542. msg['In-Reply-To'] = orig_msg_id
  543. msg['References'] = orig_msg_id
  544. # 5. 【核心】注入视觉转发头 (Visual Prepend)
  545. try:
  546. if msg.is_multipart():
  547. # 遍历部分,找到主要正文并插入
  548. for part in msg.walk():
  549. ctype = part.get_content_type()
  550. if ctype == "text/plain":
  551. part.set_content(fwd_info + part.get_content())
  552. break
  553. elif ctype == "text/html":
  554. html_fwd = fwd_info.replace("\n", "<br>")
  555. part.set_content(f"<div>{html_fwd}</div>" + part.get_content(), subtype="html")
  556. break
  557. else:
  558. msg.set_content(fwd_info + msg.get_content())
  559. except Exception as e:
  560. logger.warning(f"Prepend visual header failed: {e}")
  561. # 6. 发送邮件
  562. EmailAuthorizationService.send_email_smtp(auth, msg)
  563. # 7. 同步发件记录 (IMAP Sent)
  564. EmailAuthorizationService._append_to_sent(auth, msg)
  565. return f"邮件 '{orig_subject}' 已成功关联转发至: {forward_to}"
  566. return None
  567. except Exception as e:
  568. logger.error(f"Forward matching email error: {e}")
  569. return None
  570. finally:
  571. try:
  572. mail.logout()
  573. except: pass
  574. return await run_in_threadpool(_worker)
  575. @staticmethod
  576. async def forward_first_matching_email2(
  577. db: Session,
  578. auth,
  579. forward_to: str,
  580. sender: str,
  581. recipient: str,
  582. subject_keywords: str,
  583. body_keywords: str
  584. ):
  585. # =========================================================
  586. # 第一步:在数据库中查找最新的 UID (主线程/DB线程执行)
  587. # =========================================================
  588. # 1. 构建动态 SQL
  589. # 假设表名为 emails,字段为 uid, sender, recipient, subject, body_text
  590. sql = "SELECT uid, subject FROM emails WHERE 1=1"
  591. params = {}
  592. # 2. 处理发件人 (模糊匹配)
  593. if sender.strip():
  594. sql += " AND sender LIKE :sender"
  595. params['sender'] = f"%{sender.strip()}%"
  596. # 3. 处理收件人 (模糊匹配)
  597. if recipient.strip():
  598. sql += " AND recipient LIKE :recipient"
  599. params['recipient'] = f"%{recipient.strip()}%"
  600. # 4. 处理主题关键词 (OR 关系)
  601. subj_keys = [k.strip() for k in subject_keywords.split(',') if k.strip()]
  602. if subj_keys:
  603. for i, k in enumerate(subj_keys):
  604. key_name = f"subj_{i}"
  605. # 直接拼接到主 SQL 中,要求同时满足
  606. sql += f" AND subject LIKE :{key_name}"
  607. params[key_name] = f"%{k}%"
  608. # 5. 处理内容关键词 (OR 关系)
  609. body_keys = [k.strip() for k in body_keywords.split(',') if k.strip()]
  610. if body_keys:
  611. for i, k in enumerate(body_keys):
  612. key_name = f"body_{i}"
  613. # 直接拼接到主 SQL 中,要求同时满足
  614. sql += f" AND body_text LIKE :{key_name}"
  615. params[key_name] = f"%{k}%"
  616. # 6. 获取最新的一条
  617. sql += " ORDER BY uid DESC LIMIT 1"
  618. try:
  619. # 执行查询
  620. result_proxy = await db.execute(text(sql), params)
  621. result = result_proxy.fetchone()
  622. if not result:
  623. logger.info(f"DB Search: No email found for {sender} -> {recipient}")
  624. return None
  625. target_uid = result.uid
  626. target_subject = result.subject
  627. logger.info(f"DB Search: Found UID {target_uid} matching criteria. Subject: {target_subject}")
  628. except Exception as e:
  629. logger.error(f"DB Search Error: {e}")
  630. return f"数据库查询失败: {str(e)}"
  631. # =========================================================
  632. # 第二步:去 IMAP 拉取原始内容并转发 (放入线程池执行 IO 操作)
  633. # =========================================================
  634. def _worker():
  635. mail = None
  636. try:
  637. # 1. 连接 IMAP
  638. mail = EmailAuthorizationService._connect_imap_with_proxy(
  639. auth.imap_server, auth.imap_port,
  640. auth.proxy_host, auth.proxy_port,
  641. auth.proxy_username, auth.proxy_password
  642. )
  643. mail.login(auth.email, auth.authorization_code)
  644. mail.select("INBOX")
  645. # 2. 根据 UID 精准拉取 (使用 fetch)
  646. # 注意:IMAPClient 的 fetch 方法
  647. # UID 必须转为 int 或者 sequence set 字符串
  648. res, data = mail.uid('fetch', str(target_uid), '(RFC822)')
  649. # 🔴 修正点:不要写 if target_uid in res
  650. # res 是状态字符串 "OK",data 是包含邮件内容的列表
  651. if res != 'OK':
  652. return f"IMAP Fetch 失败,状态码: {res}"
  653. if not data or not data[0]:
  654. return f"未找到 UID {target_uid} 的邮件内容 (可能已被物理删除)"
  655. # data[0] 通常是 tuple (byte_header, byte_content),但也可能是 None
  656. if isinstance(data[0], tuple):
  657. raw_email_bytes = data[0][1]
  658. else:
  659. # 如果 data[0] 只是 bytes (例如 b')'),说明没拿到邮件体
  660. return f"邮件数据格式异常,无法解析: {str(data)}"
  661. # 使用 default policy 解析,方便后续修改
  662. orig_msg = email.message_from_bytes(data[0][1], policy=email.policy.default)
  663. # --- 1. 提取原始邮件信息用于构造转发头 ---
  664. orig_from = orig_msg.get("From", "Unknown")
  665. orig_date = orig_msg.get("Date", "Unknown")
  666. orig_subject = orig_msg.get("Subject", "No Subject")
  667. orig_to = orig_msg.get("To", "Unknown")
  668. orig_msg_id = orig_msg.get("Message-ID")
  669. # --- 2. 构造视觉上的“转发信息栏” ---
  670. fwd_header_text = (
  671. f"\n\n---------- Forwarded message ----------\n"
  672. f"From: {orig_from}\n"
  673. f"Date: {orig_date}\n"
  674. f"Subject: {orig_subject}\n"
  675. f"To: {orig_to}\n\n"
  676. )
  677. # --- 3. 构造新的邮件对象 ---
  678. # 为了保持上下文关联,我们克隆或重新构造,并设置 Threading Headers
  679. msg = email.message_from_bytes(data[0][1], policy=email.policy.default)
  680. # 清除旧头
  681. for h in ['From', 'To', 'Cc', 'Bcc', 'Subject', 'Date', 'Message-ID', 'In-Reply-To', 'References']:
  682. del msg[h]
  683. msg['From'] = auth.email
  684. msg['To'] = forward_to
  685. msg['Subject'] = f"Fwd: {target_subject}"
  686. msg['Date'] = formatdate(localtime=True)
  687. msg['Message-ID'] = make_msgid(domain=auth.email.split('@')[-1])
  688. # --- 4. 关键:建立线索关联 (Threading) ---
  689. if orig_msg_id:
  690. # 这两个头告诉 Gmail 这封信是原邮件的后续
  691. msg['In-Reply-To'] = orig_msg_id
  692. msg['References'] = orig_msg_id
  693. # --- 5. 修改正文,注入转发视觉头 ---
  694. # 处理 Multipart 或简单邮件,将 fwd_header_text 插入到正文最前面
  695. try:
  696. if msg.is_multipart():
  697. # 找到第一个文本部分并修改
  698. for part in msg.walk():
  699. if part.get_content_type() == "text/plain":
  700. content = part.get_content()
  701. part.set_content(fwd_header_text + content)
  702. break
  703. elif part.get_content_type() == "text/html":
  704. # HTML 转发头稍微复杂点,这里简单处理
  705. content = part.get_content()
  706. html_fwd = fwd_header_text.replace("\n", "<br>")
  707. part.set_content(f"<div>{html_fwd}</div>" + content, subtype="html")
  708. break
  709. else:
  710. content = msg.get_content()
  711. msg.set_content(fwd_header_text + content)
  712. except Exception as e:
  713. logger.warning(f"Failed to prepend forward header: {e}")
  714. # 4. 发送邮件 (SMTP)
  715. EmailAuthorizationService.send_email_smtp(auth, msg)
  716. return f"邮件 '{target_subject}' (UID: {target_uid}) 已成功转发至: {forward_to}"
  717. except Exception as e:
  718. logger.error(f"IMAP Forward Error: {e}")
  719. return f"邮件转发过程出错: {str(e)}"
  720. finally:
  721. if mail:
  722. try:
  723. mail.logout()
  724. except: pass
  725. # 在线程池中运行耗时 IO 操作
  726. return await run_in_threadpool(_worker)
  727. @staticmethod
  728. def _append_to_sent(auth, msg: EmailMessage):
  729. """
  730. 同步发件记录到 IMAP Sent 文件夹
  731. """
  732. imap = None
  733. try:
  734. # 确保消息包含必要的指纹,否则同步后 Gmail 搜索不到
  735. if 'Date' not in msg:
  736. msg["Date"] = formatdate(localtime=True)
  737. if 'Message-ID' not in msg:
  738. msg["Message-ID"] = make_msgid(domain=auth.email.split('@')[-1])
  739. imap = EmailAuthorizationService._connect_imap_with_proxy(
  740. auth.imap_server, auth.imap_port,
  741. auth.proxy_host, auth.proxy_port,
  742. auth.proxy_username, auth.proxy_password,
  743. )
  744. imap.login(auth.email, auth.authorization_code)
  745. # --- 自动探测已发送文件夹 (兼容 Gmail/Outlook/域名邮) ---
  746. sent_folder = None
  747. typ, data = imap.list()
  748. if typ == "OK":
  749. for entry in data:
  750. line = entry.decode()
  751. # 寻找包含 \Sent 属性的系统文件夹
  752. if '\\Sent' in line:
  753. # 兼容各种分隔符,提取最后一个引号内的内容
  754. parts = re.findall(r'"([^"]+)"', line)
  755. if parts:
  756. sent_folder = f'"{parts[-1]}"' # 强制带引号防止空格导致 BAD
  757. break
  758. # 兜底逻辑
  759. if not sent_folder:
  760. sent_folder = '"[Gmail]/Sent Mail"' if "gmail" in auth.email.lower() else '"Sent"'
  761. # 执行写入 (使用 \\Seen 标记为已读)
  762. # imap.append 的参数顺序: 文件夹, 标志, 时间, 内容
  763. imap.append(
  764. sent_folder,
  765. '(\\Seen)',
  766. imaplib.Time2Internaldate(time.time()),
  767. msg.as_bytes()
  768. )
  769. logger.info(f"Successfully synced to folder: {sent_folder}")
  770. except Exception as e:
  771. logger.error(f"Append sent mail failed: {str(e)}")
  772. finally:
  773. if imap:
  774. try: imap.logout()
  775. except: pass
  776. @staticmethod
  777. async def send_email(
  778. auth,
  779. send_to: str,
  780. subject: str,
  781. content_type: str,
  782. content: str
  783. ):
  784. def _worker():
  785. msg = EmailMessage()
  786. msg["From"] = auth.email
  787. msg["To"] = send_to
  788. msg["Subject"] = subject
  789. msg["Date"] = formatdate(localtime=True)
  790. msg["Message-ID"] = make_msgid(domain=auth.email.split('@')[-1])
  791. msg["MIME-Version"] = "1.0"
  792. msg["X-Mailer"] = "Python-Client-v1.0"
  793. if content_type.lower() == "html":
  794. msg.set_content("") # 占位
  795. msg.add_alternative(content, subtype="html")
  796. else:
  797. msg.set_content(content)
  798. # 2. 执行发送
  799. logger.info(f"[DEBUG] 准备发送邮件: ID={msg['Message-ID']}")
  800. EmailAuthorizationService.send_email_smtp(auth, msg)
  801. return f"邮件 '{subject}' 成功发送至: {send_to}"
  802. return await run_in_threadpool(_worker)
  803. @staticmethod
  804. async def send_email_bulk(
  805. auth,
  806. send_to: str,
  807. subject: str,
  808. content_type: str,
  809. content: str
  810. ):
  811. def _worker():
  812. bcc_list = [s.strip() for s in send_to.split(",") if s.strip()]
  813. msg = EmailMessage()
  814. msg["From"] = auth.email
  815. msg["To"] = bcc_list[0] if bcc_list else auth.email # Fallback
  816. msg["Subject"] = subject
  817. if content_type.lower() == "html":
  818. msg.set_content("")
  819. msg.add_alternative(content, subtype="html")
  820. else:
  821. msg.set_content(content)
  822. EmailAuthorizationService.send_email_smtp(auth, msg, bcc_list=bcc_list)
  823. return f"邮件 '{subject}' 成功发送至: {send_to}"
  824. return await run_in_threadpool(_worker)
  825. # ----------------------------------------------------------------------
  826. # 底层 SMTP 发送 (保持同步,供 Worker 调用)
  827. # ----------------------------------------------------------------------
  828. @staticmethod
  829. def send_email_smtp(auth, msg, bcc_list=None):
  830. if bcc_list is None:
  831. bcc_list = []
  832. # 这里的 connect 内部已经加了锁,是安全的
  833. mail = EmailAuthorizationService._connect_smtp_with_proxy(
  834. auth.smtp_server,
  835. auth.smtp_port,
  836. auth.proxy_host,
  837. auth.proxy_port,
  838. auth.proxy_username,
  839. auth.proxy_password,
  840. )
  841. try:
  842. mail.login(auth.email, auth.authorization_code)
  843. recipients = bcc_list if bcc_list else [msg["To"]]
  844. mail.send_message(
  845. msg,
  846. from_addr=auth.email,
  847. to_addrs=recipients
  848. )
  849. logger.info(f"[DEBUG] 开始同步到已发送文件夹...")
  850. EmailAuthorizationService._append_to_sent(auth, msg)
  851. finally:
  852. mail.quit()
  853. @staticmethod
  854. def _extract_body(msg, only_text: bool = True) -> str:
  855. text_parts = []
  856. image_parts = []
  857. # 统一处理 multipart 和单体邮件
  858. parts = msg.walk() if msg.is_multipart() else [msg]
  859. for part in parts:
  860. # 安全获取属性,转为小写方便判断
  861. ctype = str(part.get_content_type()).lower()
  862. disposition = str(part.get("Content-Disposition")).lower()
  863. content_id = str(part.get("Content-ID")).lower()
  864. filename = str(part.get_filename() or "").lower()
  865. # 1. 提取文字内容 (同时允许 text/plain 和 text/html,防止 VFS 这种只有 HTML 的邮件导致内容为空)
  866. if ctype in ["text/plain", "text/html"]:
  867. # 忽略明确被标记为真正附件的文本
  868. if "attachment" in disposition:
  869. continue
  870. charset = part.get_content_charset() or "utf-8"
  871. try:
  872. payload = part.get_payload(decode=True)
  873. if payload:
  874. text = payload.decode(charset, errors="ignore")
  875. text_parts.append(text)
  876. except Exception:
  877. continue
  878. # 2. 提取图片内容 (当 onlyText=False 时触发)
  879. elif not only_text:
  880. # 兼容 VFS 等发件系统的恶心格式:判断是否为图片
  881. # a) 标准的 image/ 开头
  882. # b) 带有 Content-ID (如 cid:otp_image) 且不是文字
  883. # c) 文件名以图片格式结尾
  884. is_image = (
  885. ctype.startswith("image/") or
  886. (content_id and not ctype.startswith("text/")) or
  887. filename.endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp'))
  888. )
  889. if is_image:
  890. try:
  891. payload = part.get_payload(decode=True)
  892. if payload:
  893. # 如果 ctype 不规范 (比如 application/octet-stream),强制转为 image/png 供大模型使用
  894. img_type = ctype if ctype.startswith("image/") else "image/png"
  895. b64_image = base64.b64encode(payload).decode("utf-8")
  896. image_data_uri = f"data:{img_type};base64,{b64_image}"
  897. image_parts.append(image_data_uri)
  898. except Exception:
  899. continue
  900. # 3. 处理文本部分(保留你的原始清理逻辑)
  901. raw_text = "\n".join(text_parts)
  902. cleaned_text = re.sub(r"\s+", " ", raw_text.strip())
  903. # 4. 合并文本和图片,用换行符切分
  904. final_parts = []
  905. if cleaned_text:
  906. final_parts.append(cleaned_text)
  907. if image_parts:
  908. final_parts.extend(image_parts)
  909. # 最终返回一个字符串:文本在前,图片数据在后
  910. return "\n".join(final_parts)