mailops 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {mailops-0.2.2 → mailops-0.2.4}/PKG-INFO +32 -1
  2. {mailops-0.2.2 → mailops-0.2.4}/README.md +31 -0
  3. {mailops-0.2.2 → mailops-0.2.4}/pyproject.toml +1 -1
  4. {mailops-0.2.2 → mailops-0.2.4}/src/mailops/cli.py +44 -0
  5. mailops-0.2.4/src/mailops/organizer.py +318 -0
  6. {mailops-0.2.2 → mailops-0.2.4}/src/mailops.egg-info/PKG-INFO +32 -1
  7. {mailops-0.2.2 → mailops-0.2.4}/src/mailops.egg-info/SOURCES.txt +1 -0
  8. mailops-0.2.4/src/sync163.py +226 -0
  9. mailops-0.2.2/src/sync163.py +0 -104
  10. {mailops-0.2.2 → mailops-0.2.4}/setup.cfg +0 -0
  11. {mailops-0.2.2 → mailops-0.2.4}/src/maildl.py +0 -0
  12. {mailops-0.2.2 → mailops-0.2.4}/src/mailfwd.py +0 -0
  13. {mailops-0.2.2 → mailops-0.2.4}/src/mailops/__init__.py +0 -0
  14. {mailops-0.2.2 → mailops-0.2.4}/src/mailops/backend/__init__.py +0 -0
  15. {mailops-0.2.2 → mailops-0.2.4}/src/mailops/backend/imap.py +0 -0
  16. {mailops-0.2.2 → mailops-0.2.4}/src/mailops/backend/maildir.py +0 -0
  17. {mailops-0.2.2 → mailops-0.2.4}/src/mailops/config.py +0 -0
  18. {mailops-0.2.2 → mailops-0.2.4}/src/mailops/downloader.py +0 -0
  19. {mailops-0.2.2 → mailops-0.2.4}/src/mailops/forwarder.py +0 -0
  20. {mailops-0.2.2 → mailops-0.2.4}/src/mailops/operations.py +0 -0
  21. {mailops-0.2.2 → mailops-0.2.4}/src/mailops/sync.py +0 -0
  22. {mailops-0.2.2 → mailops-0.2.4}/src/mailops/workflow.py +0 -0
  23. {mailops-0.2.2 → mailops-0.2.4}/src/mailops.egg-info/dependency_links.txt +0 -0
  24. {mailops-0.2.2 → mailops-0.2.4}/src/mailops.egg-info/entry_points.txt +0 -0
  25. {mailops-0.2.2 → mailops-0.2.4}/src/mailops.egg-info/requires.txt +0 -0
  26. {mailops-0.2.2 → mailops-0.2.4}/src/mailops.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailops
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: 邮件操作工具箱 — 下载、统计、搜索、转发、附件管理、发送
5
5
  Author: Song Wupei
6
6
  License-Expression: MIT
@@ -71,6 +71,10 @@ mailops send -t me@163.com -s "日报" -b "详见附件" -a report.pdf
71
71
  # 执行工作流
72
72
  mailops workflow run 163-invoice-to-qq
73
73
 
74
+ # 邮件自动分类归档
75
+ mailops organize --dry-run # 预览
76
+ mailops organize -a 163 # 执行
77
+
74
78
  # 指定邮箱
75
79
  mailops -m qq list
76
80
  mailops -m 163 list
@@ -131,6 +135,31 @@ smtp:
131
135
 
132
136
  用于 `mailops workflow` 命令,定义自动化的同步→下载→转发流程。
133
137
 
138
+ ### 邮件分类规则 (`~/.config/aerc/mail-rules.yml`)
139
+
140
+ 用于 `mailops organize` 命令,按规则自动分类/归档邮件。示例:
141
+
142
+ ```yaml
143
+ accounts:
144
+ "163":
145
+ maildir: ~/Mail/163
146
+ rules:
147
+ - name: github
148
+ match:
149
+ from: "(?i)@github\\.com"
150
+ list_id: "(?i)github"
151
+ match_any: true
152
+ action:
153
+ move: GitHub
154
+ - name: archive-old
155
+ match:
156
+ older_than: 7d
157
+ action:
158
+ move: "Archive/{year}/{month}"
159
+ ```
160
+
161
+ 参考 `config/mail-rules.example.yml`。
162
+
134
163
  ## 命令一览
135
164
 
136
165
  | 命令 | 说明 |
@@ -142,6 +171,7 @@ smtp:
142
171
  | `stats` | 邮件统计(总量、发件人排行、附件分布) |
143
172
  | `download` | 下载附件和正文(本地扫描,不先同步) |
144
173
  | `download-docs` | 按关键词搜索 PDF 下载(原 maildl.py) |
174
+ | `organize` | 📬 按规则自动分类归档(GitHub → GitHub/, 旧邮件 → Archive/) |
145
175
  | `send` | 发送邮件(支持多附件、抄送) |
146
176
  | `forward` | 转发已下载文档 |
147
177
  | `fetch` | 同步 IMAP → Maildir |
@@ -156,6 +186,7 @@ smtp:
156
186
  |--------|--------|
157
187
  | `maildl.py` | `mailops download-docs` |
158
188
  | `mailfwd.py` | `mailops forward` |
189
+ | `sync163.py` | `mailops fetch` (IMAP → Maildir) + `mailops organize` (分类) |
159
190
  | `sync163.py` | `mailops fetch -m 163` |
160
191
  | `run-wf.py` | `mailops workflow` |
161
192
  | `mailx` (系统命令) | `mailops send` |
@@ -50,6 +50,10 @@ mailops send -t me@163.com -s "日报" -b "详见附件" -a report.pdf
50
50
  # 执行工作流
51
51
  mailops workflow run 163-invoice-to-qq
52
52
 
53
+ # 邮件自动分类归档
54
+ mailops organize --dry-run # 预览
55
+ mailops organize -a 163 # 执行
56
+
53
57
  # 指定邮箱
54
58
  mailops -m qq list
55
59
  mailops -m 163 list
@@ -110,6 +114,31 @@ smtp:
110
114
 
111
115
  用于 `mailops workflow` 命令,定义自动化的同步→下载→转发流程。
112
116
 
117
+ ### 邮件分类规则 (`~/.config/aerc/mail-rules.yml`)
118
+
119
+ 用于 `mailops organize` 命令,按规则自动分类/归档邮件。示例:
120
+
121
+ ```yaml
122
+ accounts:
123
+ "163":
124
+ maildir: ~/Mail/163
125
+ rules:
126
+ - name: github
127
+ match:
128
+ from: "(?i)@github\\.com"
129
+ list_id: "(?i)github"
130
+ match_any: true
131
+ action:
132
+ move: GitHub
133
+ - name: archive-old
134
+ match:
135
+ older_than: 7d
136
+ action:
137
+ move: "Archive/{year}/{month}"
138
+ ```
139
+
140
+ 参考 `config/mail-rules.example.yml`。
141
+
113
142
  ## 命令一览
114
143
 
115
144
  | 命令 | 说明 |
@@ -121,6 +150,7 @@ smtp:
121
150
  | `stats` | 邮件统计(总量、发件人排行、附件分布) |
122
151
  | `download` | 下载附件和正文(本地扫描,不先同步) |
123
152
  | `download-docs` | 按关键词搜索 PDF 下载(原 maildl.py) |
153
+ | `organize` | 📬 按规则自动分类归档(GitHub → GitHub/, 旧邮件 → Archive/) |
124
154
  | `send` | 发送邮件(支持多附件、抄送) |
125
155
  | `forward` | 转发已下载文档 |
126
156
  | `fetch` | 同步 IMAP → Maildir |
@@ -135,6 +165,7 @@ smtp:
135
165
  |--------|--------|
136
166
  | `maildl.py` | `mailops download-docs` |
137
167
  | `mailfwd.py` | `mailops forward` |
168
+ | `sync163.py` | `mailops fetch` (IMAP → Maildir) + `mailops organize` (分类) |
138
169
  | `sync163.py` | `mailops fetch -m 163` |
139
170
  | `run-wf.py` | `mailops workflow` |
140
171
  | `mailx` (系统命令) | `mailops send` |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mailops"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "邮件操作工具箱 — 下载、统计、搜索、转发、附件管理、发送"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -526,6 +526,50 @@ def config():
526
526
  print(f"\n SMTP 默认: {smtp.get('default', '未配置')}")
527
527
  print(f"\n 输出目录: {get_output_dir()}")
528
528
 
529
+ # ═══════════════════════════════════════════════════════════════
530
+ # organize — 邮件自动分类归档
531
+ # ═══════════════════════════════════════════════════════════════
532
+
533
+ @cli.command()
534
+ @click.option("-a", "--account", "account_name", default=None,
535
+ help="指定账号(默认所有)")
536
+ @click.option("--dry-run", is_flag=True, help="预览模式,不实际移动")
537
+ @click.option("--rules", "rules_path", default=None,
538
+ type=click.Path(exists=True), help="规则文件路径")
539
+ @click.pass_context
540
+ def organize(ctx, account_name, dry_run, rules_path):
541
+ """📬 按规则自动分类/归档邮件。
542
+
543
+ 根据 ~/.config/aerc/mail-rules.yml 中定义的规则,
544
+ 将邮件移动到对应文件夹(如 GitHub → GitHub/, 旧邮件 → Archive/)。
545
+
546
+ 示例:
547
+ mailops organize --dry-run # 预览所有账号
548
+ mailops organize -a 163 # 只处理 163
549
+ mailops organize -a 163 --dry-run # 预览 163 的分类结果
550
+ """
551
+ from .organizer import organize as run_organize, load_rules
552
+
553
+ rp = Path(rules_path) if rules_path else None
554
+
555
+ if account_name:
556
+ result = run_organize(account_name, rules_path=rp, dry_run=dry_run)
557
+ if dry_run:
558
+ print(f"\n📋 预览完成,共 {result['moved']} 封将被移动")
559
+ else:
560
+ print(f"\n✅ 完成,移动 {result['moved']} 封邮件")
561
+ else:
562
+ all_rules = load_rules(rp)
563
+ total_moved = 0
564
+ for name in all_rules:
565
+ result = run_organize(name, rules_path=rp, dry_run=dry_run)
566
+ total_moved += result["moved"]
567
+ if dry_run:
568
+ print(f"\n📋 预览完成,共 {total_moved} 封将被移动")
569
+ else:
570
+ print(f"\n✅ 完成,移动 {total_moved} 封邮件")
571
+
572
+
529
573
  # ═══════════════════════════════════════════════════════════════
530
574
  # 入口
531
575
  # ═══════════════════════════════════════════════════════════════
@@ -0,0 +1,318 @@
1
+ """Maildir 邮件自动分类引擎。
2
+
3
+ 根据 YAML 规则配置,对本地 Maildir 中的邮件进行自动分类/归档。
4
+
5
+ 用法:
6
+ from mailops.organizer import organize
7
+ organize("163", dry_run=True) # 预览
8
+ organize("163") # 执行
9
+ """
10
+
11
+ import email
12
+ import os
13
+ import re
14
+ import shutil
15
+ from datetime import datetime, timedelta
16
+ from email.policy import default
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+
21
+ DEFAULT_RULES_PATH = Path.home() / ".config" / "aerc" / "mail-rules.yml"
22
+
23
+
24
+ def _parse_date(date_str: str) -> Optional[datetime]:
25
+ """尽量从 Date 头解析时间。"""
26
+ if not date_str:
27
+ return None
28
+ try:
29
+ # email.utils.parsedate_to_datetime 是标准方法
30
+ from email.utils import parsedate_to_datetime
31
+ return parsedate_to_datetime(date_str)
32
+ except Exception:
33
+ pass
34
+ # 尝试常见格式
35
+ for fmt in [
36
+ "%a, %d %b %Y %H:%M:%S %z",
37
+ "%d %b %Y %H:%M:%S %z",
38
+ "%a, %d %b %Y %H:%M:%S",
39
+ "%d %b %Y %H:%M:%S",
40
+ ]:
41
+ try:
42
+ return datetime.strptime(date_str.strip(), fmt)
43
+ except ValueError:
44
+ continue
45
+ return None
46
+
47
+
48
+ def _parse_older_than(spec: str) -> timedelta:
49
+ """解析 '7d', '2w', '1m' 等时间字符串。"""
50
+ spec = spec.strip().lower()
51
+ m = re.match(r"(\d+)\s*(d|w|m)", spec)
52
+ if not m:
53
+ raise ValueError(f"无法解析 older_than 值: {spec!r},格式如 7d, 2w, 1m")
54
+ n = int(m.group(1))
55
+ unit = m.group(2)
56
+ if unit == "d":
57
+ return timedelta(days=n)
58
+ elif unit == "w":
59
+ return timedelta(weeks=n)
60
+ elif unit == "m":
61
+ return timedelta(days=n * 30)
62
+ return timedelta(days=n)
63
+
64
+
65
+ def load_rules(config_path: Optional[Path] = None) -> dict:
66
+ """加载邮件分类规则配置。
67
+
68
+ 返回格式: {"account_name": {"maildir": ..., "rules": [...]}}
69
+ """
70
+ import yaml
71
+
72
+ path = Path(config_path) if config_path else DEFAULT_RULES_PATH
73
+ if not path.exists():
74
+ raise FileNotFoundError(
75
+ f"规则配置文件不存在: {path}\n"
76
+ f"请从示例创建: mailops/config/mail-rules.example.yml"
77
+ )
78
+ with open(path) as f:
79
+ cfg = yaml.safe_load(f)
80
+ return cfg.get("accounts", {})
81
+
82
+
83
+ def _parse_mail_headers(filepath: Path) -> Optional[dict]:
84
+ """快速解析邮件头部(只读前 8KB)。
85
+
86
+ 返回包含 uid, from_addr, subject, date, list_id, message_id 的字典。
87
+ """
88
+ try:
89
+ with open(filepath, "rb") as f:
90
+ raw = f.read(8192)
91
+ except OSError:
92
+ return None
93
+
94
+ try:
95
+ msg = email.message_from_bytes(raw, policy=default)
96
+ except Exception:
97
+ return None
98
+
99
+ fname = filepath.name
100
+ uid = fname.split(":2,")[0] if ":2," in fname else fname
101
+
102
+ return {
103
+ "uid": uid,
104
+ "fname": fname,
105
+ "filepath": str(filepath),
106
+ "from_addr": str(msg.get("From", "")),
107
+ "to_addr": str(msg.get("To", "")),
108
+ "subject": str(msg.get("Subject", "")),
109
+ "date": str(msg.get("Date", "")),
110
+ "list_id": str(msg.get("List-ID", "")),
111
+ "message_id": str(msg.get("Message-ID", "")).strip("<>"),
112
+ "parsed_date": _parse_date(str(msg.get("Date", ""))),
113
+ }
114
+
115
+
116
+ def _match_rule(headers: dict, rule: dict) -> bool:
117
+ """检查一封邮件是否匹配某条规则。"""
118
+ match = rule.get("match", {})
119
+ if not match:
120
+ return False
121
+
122
+ match_any = match.get("match_any", False)
123
+
124
+ # 收集各字段匹配结果
125
+ results = {}
126
+
127
+ # From 匹配
128
+ from_pat = match.get("from")
129
+ if from_pat:
130
+ results["from"] = bool(
131
+ re.search(from_pat, headers.get("from_addr", ""))
132
+ )
133
+
134
+ # List-ID 匹配
135
+ lid_pat = match.get("list_id")
136
+ if lid_pat:
137
+ results["list_id"] = bool(
138
+ re.search(lid_pat, headers.get("list_id", ""), re.IGNORECASE)
139
+ )
140
+
141
+ # Subject 匹配
142
+ subj_pat = match.get("subject")
143
+ if subj_pat:
144
+ results["subject"] = bool(
145
+ re.search(subj_pat, headers.get("subject", ""), re.IGNORECASE)
146
+ )
147
+
148
+ # older_than: 邮件日期早于 N 天前
149
+ older_than = match.get("older_than")
150
+ if older_than:
151
+ parsed = headers.get("parsed_date")
152
+ if parsed:
153
+ delta = _parse_older_than(str(older_than))
154
+ results["older_than"] = parsed < (datetime.now().astimezone() - delta)
155
+ else:
156
+ results["older_than"] = False
157
+
158
+ if not results:
159
+ return False
160
+
161
+ if match_any:
162
+ return any(results.values())
163
+ else:
164
+ return all(results.values())
165
+
166
+
167
+ def _expand_path(template: str, headers: dict) -> str:
168
+ """展开路径模板中的变量。
169
+
170
+ 支持: {year}, {month}, {day}, {week} 等。
171
+ """
172
+ dt = headers.get("parsed_date") or datetime.now()
173
+ return template.format(
174
+ year=dt.year,
175
+ month=f"{dt.month:02d}",
176
+ day=f"{dt.day:02d}",
177
+ week=f"W{dt.isocalendar()[1]:02d}",
178
+ )
179
+
180
+
181
+ def move_message(filepath: Path, maildir_base: Path, dest: str) -> Optional[str]:
182
+ """将邮件移动到目标 Maildir 文件夹。
183
+
184
+ Args:
185
+ filepath: 源邮件路径
186
+ maildir_base: Maildir 根目录 (如 ~/Mail/163)
187
+ dest: 目标文件夹名 (如 "GitHub" 或 "Archive/2026/07")
188
+
189
+ Returns:
190
+ 目标文件路径,失败返回 None
191
+ """
192
+ dest_dir = (maildir_base / dest).expanduser().resolve()
193
+ dest_new = dest_dir / "new"
194
+ dest_cur = dest_dir / "cur"
195
+ dest_tmp = dest_dir / "tmp"
196
+
197
+ for d in (dest_new, dest_cur, dest_tmp):
198
+ d.mkdir(parents=True, exist_ok=True)
199
+
200
+ # 保持原文件名移入 new/
201
+ target = dest_new / filepath.name
202
+
203
+ try:
204
+ shutil.move(str(filepath), str(target))
205
+ return str(target)
206
+ except OSError:
207
+ return None
208
+
209
+
210
+ def ensure_folders(maildir_base: Path, rules: list):
211
+ """根据规则预创建目标文件夹,确保 aerc 侧边栏可见。
212
+
213
+ 对静态路径(如 GitHub)直接创建完整的 Maildir 结构;
214
+ 对模板路径(如 Archive/{year}/{month})创建根目录。
215
+ """
216
+ for rule in rules:
217
+ dest = rule.get("action", {}).get("move", "")
218
+ if not dest:
219
+ continue
220
+ # 取模板路径的根目录(第一个 / 之前的部分)
221
+ root_folder = dest.split("/")[0].split("{")[0].rstrip("/")
222
+ if not root_folder:
223
+ continue
224
+ target = (maildir_base / root_folder).expanduser().resolve()
225
+ for sub in ("cur", "new", "tmp"):
226
+ (target / sub).mkdir(parents=True, exist_ok=True)
227
+
228
+
229
+ def organize(account_name: str, rules_path: Optional[Path] = None,
230
+ dry_run: bool = False) -> dict:
231
+ """对指定账号执行邮件分类。
232
+
233
+ Returns:
234
+ {"account": str, "scanned": int, "moved": int, "moves": [...]}
235
+ """
236
+ all_rules = load_rules(rules_path)
237
+
238
+ if account_name not in all_rules:
239
+ known = ", ".join(all_rules.keys())
240
+ raise KeyError(f"未找到账号 '{account_name}',可用: {known}")
241
+
242
+ acct_cfg = all_rules[account_name]
243
+ maildir_str = acct_cfg.get("maildir", f"~/Mail/{account_name}")
244
+ maildir_base = Path(maildir_str).expanduser().resolve()
245
+ rules = acct_cfg.get("rules", [])
246
+
247
+ if not rules:
248
+ print(f" [{account_name}] 无分类规则,跳过")
249
+ return {"account": account_name, "scanned": 0, "moved": 0, "moves": []}
250
+
251
+ # 预创建目标文件夹(确保 aerc 侧边栏可见)
252
+ ensure_folders(maildir_base, rules)
253
+
254
+ inbox = maildir_base / "INBOX"
255
+ if not inbox.is_dir():
256
+ inbox = maildir_base # 可能 INBOX 就是根目录
257
+
258
+ moves = []
259
+ scanned = 0
260
+
261
+ for sub in ("new", "cur"):
262
+ scan_dir = inbox / sub
263
+ if not scan_dir.is_dir():
264
+ continue
265
+ for f in sorted(scan_dir.iterdir()):
266
+ if not f.is_file() or f.name.startswith("."):
267
+ continue
268
+
269
+ scanned += 1
270
+ headers = _parse_mail_headers(f)
271
+ if headers is None:
272
+ continue
273
+
274
+ for rule in rules:
275
+ if _match_rule(headers, rule):
276
+ action = rule.get("action", {})
277
+ dest_template = action.get("move", "")
278
+ if not dest_template:
279
+ continue
280
+
281
+ dest = _expand_path(dest_template, headers)
282
+
283
+ if dry_run:
284
+ moves.append({
285
+ "uid": headers["uid"],
286
+ "from": str(f),
287
+ "to": str((maildir_base / dest / "new" / f.name).expanduser()),
288
+ "rule": rule.get("name", ""),
289
+ "subject": headers["subject"][:80],
290
+ })
291
+ else:
292
+ result = move_message(f, maildir_base, dest)
293
+ if result:
294
+ moves.append({
295
+ "uid": headers["uid"],
296
+ "from": str(f),
297
+ "to": result,
298
+ "rule": rule.get("name", ""),
299
+ "subject": headers["subject"][:80],
300
+ })
301
+ break # 命中第一条规则后停止
302
+
303
+ prefix = "[DRY RUN] " if dry_run else ""
304
+ if moves:
305
+ print(f"{prefix}[{account_name}] 扫描 {scanned} 封,匹配 {len(moves)} 封:")
306
+ for m in moves:
307
+ print(f" [{m['rule']}] {m['subject']}")
308
+ if not dry_run:
309
+ print(f" → {m['to']}")
310
+ else:
311
+ print(f"{prefix}[{account_name}] 扫描 {scanned} 封,无匹配")
312
+
313
+ return {
314
+ "account": account_name,
315
+ "scanned": scanned,
316
+ "moved": len(moves),
317
+ "moves": moves,
318
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailops
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: 邮件操作工具箱 — 下载、统计、搜索、转发、附件管理、发送
5
5
  Author: Song Wupei
6
6
  License-Expression: MIT
@@ -71,6 +71,10 @@ mailops send -t me@163.com -s "日报" -b "详见附件" -a report.pdf
71
71
  # 执行工作流
72
72
  mailops workflow run 163-invoice-to-qq
73
73
 
74
+ # 邮件自动分类归档
75
+ mailops organize --dry-run # 预览
76
+ mailops organize -a 163 # 执行
77
+
74
78
  # 指定邮箱
75
79
  mailops -m qq list
76
80
  mailops -m 163 list
@@ -131,6 +135,31 @@ smtp:
131
135
 
132
136
  用于 `mailops workflow` 命令,定义自动化的同步→下载→转发流程。
133
137
 
138
+ ### 邮件分类规则 (`~/.config/aerc/mail-rules.yml`)
139
+
140
+ 用于 `mailops organize` 命令,按规则自动分类/归档邮件。示例:
141
+
142
+ ```yaml
143
+ accounts:
144
+ "163":
145
+ maildir: ~/Mail/163
146
+ rules:
147
+ - name: github
148
+ match:
149
+ from: "(?i)@github\\.com"
150
+ list_id: "(?i)github"
151
+ match_any: true
152
+ action:
153
+ move: GitHub
154
+ - name: archive-old
155
+ match:
156
+ older_than: 7d
157
+ action:
158
+ move: "Archive/{year}/{month}"
159
+ ```
160
+
161
+ 参考 `config/mail-rules.example.yml`。
162
+
134
163
  ## 命令一览
135
164
 
136
165
  | 命令 | 说明 |
@@ -142,6 +171,7 @@ smtp:
142
171
  | `stats` | 邮件统计(总量、发件人排行、附件分布) |
143
172
  | `download` | 下载附件和正文(本地扫描,不先同步) |
144
173
  | `download-docs` | 按关键词搜索 PDF 下载(原 maildl.py) |
174
+ | `organize` | 📬 按规则自动分类归档(GitHub → GitHub/, 旧邮件 → Archive/) |
145
175
  | `send` | 发送邮件(支持多附件、抄送) |
146
176
  | `forward` | 转发已下载文档 |
147
177
  | `fetch` | 同步 IMAP → Maildir |
@@ -156,6 +186,7 @@ smtp:
156
186
  |--------|--------|
157
187
  | `maildl.py` | `mailops download-docs` |
158
188
  | `mailfwd.py` | `mailops forward` |
189
+ | `sync163.py` | `mailops fetch` (IMAP → Maildir) + `mailops organize` (分类) |
159
190
  | `sync163.py` | `mailops fetch -m 163` |
160
191
  | `run-wf.py` | `mailops workflow` |
161
192
  | `mailx` (系统命令) | `mailops send` |
@@ -9,6 +9,7 @@ src/mailops/config.py
9
9
  src/mailops/downloader.py
10
10
  src/mailops/forwarder.py
11
11
  src/mailops/operations.py
12
+ src/mailops/organizer.py
12
13
  src/mailops/sync.py
13
14
  src/mailops/workflow.py
14
15
  src/mailops.egg-info/PKG-INFO
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env python3
2
+ """163 邮箱 → 本地 Maildir 同步(快速版,使用本地索引避免重复下载)。
3
+
4
+ 用法: python3 sync163.py
5
+ python3 sync163.py --limit 50 # 只同步最近 50 封
6
+ """
7
+
8
+ import imaplib
9
+ import json
10
+ import os
11
+ import re
12
+ import ssl
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+
17
+ HOST = "imap.163.com"
18
+ USER = "songwupei@163.com"
19
+ MAILDIR = Path.home() / "Mail" / "163" / "INBOX"
20
+ INDEX_FILE = Path.home() / ".cache" / "aerc" / "sync163-index.json"
21
+ PASS_ENV = "MAIL163_SMTP_PASS_AERC"
22
+
23
+
24
+ def load_index():
25
+ """加载已知 Message-ID → 文件名 的索引。"""
26
+ if INDEX_FILE.exists():
27
+ try:
28
+ return json.loads(INDEX_FILE.read_text())
29
+ except (json.JSONDecodeError, OSError):
30
+ pass
31
+ return {}
32
+
33
+
34
+ def save_index(idx):
35
+ """保存索引到磁盘。"""
36
+ INDEX_FILE.parent.mkdir(parents=True, exist_ok=True)
37
+ INDEX_FILE.write_text(json.dumps(idx))
38
+
39
+
40
+ def extract_message_id(msg_bytes):
41
+ """从邮件字节中快速提取 Message-ID(只读头部,不解码整个邮件)。"""
42
+ # 只取头部(到第一个空行)
43
+ header_end = msg_bytes.find(b"\r\n\r\n")
44
+ if header_end == -1:
45
+ header_end = msg_bytes.find(b"\n\n")
46
+ if header_end == -1:
47
+ header_end = len(msg_bytes)
48
+ header = msg_bytes[:header_end].decode("utf-8", errors="replace")
49
+
50
+ # 提取 Message-ID
51
+ m = re.search(r"(?im)^Message-ID:\s*(.+)", header)
52
+ if m:
53
+ return m.group(1).strip().strip("<>")
54
+ return None
55
+
56
+
57
+ def backfill_index(known):
58
+ """首次运行:从已有 Maildir 文件重建索引,避免重复下载。"""
59
+ existing = 0
60
+ for sub in ["cur", "new"]:
61
+ folder = MAILDIR / sub
62
+ if not folder.exists():
63
+ continue
64
+ for fpath in folder.iterdir():
65
+ if not fpath.is_file():
66
+ continue
67
+ try:
68
+ # 只读前 8KB 足以包含所有头部
69
+ mid = extract_message_id(fpath.read_bytes()[:8192])
70
+ if mid and mid not in known:
71
+ known[mid] = fpath.name
72
+ existing += 1
73
+ except OSError:
74
+ pass
75
+ if existing:
76
+ print(f"从本地 Maildir 重建索引: +{existing} 封")
77
+ return known
78
+
79
+
80
+ def sync(limit=None):
81
+ # ---- 密码 ----
82
+ pass_file = Path.home() / ".config" / "aerc" / "163-pass"
83
+ if pass_file.exists():
84
+ password = pass_file.read_text().strip()
85
+ else:
86
+ password = os.environ.get(PASS_ENV, "")
87
+ if not password:
88
+ print(f"错误: 未找到密码(环境变量 {PASS_ENV} 未设置,且 {pass_file} 不存在)")
89
+ return
90
+
91
+ # ---- 初始化 Maildir ----
92
+ for sub in ["cur", "new", "tmp"]:
93
+ (MAILDIR / sub).mkdir(parents=True, exist_ok=True)
94
+
95
+ # ---- 加载索引(首次运行自动从已有文件重建) ----
96
+ known = load_index()
97
+ if not known:
98
+ known = backfill_index(known)
99
+ save_index(known)
100
+
101
+ # ---- 连接 ----
102
+ ctx = ssl.create_default_context()
103
+ with imaplib.IMAP4_SSL(HOST, 993, ssl_context=ctx) as conn:
104
+ conn.login(USER, password)
105
+
106
+ # 163.com 必须的 ID 命令
107
+ try:
108
+ conn.socket().send(
109
+ b'a001 ID ("name" "mail-dl" "version" "1.0")\r\n'
110
+ )
111
+ conn.socket().recv(1024)
112
+ except Exception:
113
+ pass
114
+
115
+ conn.select("INBOX")
116
+
117
+ # ---- 搜索所有邮件 ----
118
+ status, data = conn.search(None, "ALL")
119
+ if status != "OK":
120
+ print("搜索失败")
121
+ return
122
+
123
+ all_ids = data[0].split()
124
+ if not all_ids:
125
+ print("收件箱为空")
126
+ return
127
+
128
+ total = len(all_ids)
129
+ print(f"服务器共 {total} 封邮件,索引中已记录 {len(known)} 封")
130
+
131
+ # ---- 先通过只下载 HEADER 来获取 Message-ID(轻量级) ----
132
+ # 批量获取所有邮件的 Message-ID,用于判断哪些是新的
133
+ ids = list(reversed(all_ids))
134
+ if limit:
135
+ ids = ids[-limit:]
136
+
137
+ # 分批处理,每批 200 封
138
+ BATCH = 200
139
+ to_fetch = [] # (num, msg_id) pairs that we need to download
140
+ seen_msgids = set()
141
+
142
+ for i in range(0, len(ids), BATCH):
143
+ batch = ids[i : i + BATCH]
144
+ id_range = f"{batch[0].decode()}:{batch[-1].decode()}"
145
+ status, hdr_data = conn.fetch(
146
+ id_range, "(BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])"
147
+ )
148
+ if status != "OK":
149
+ continue
150
+
151
+ # 解析每个 HEADER 响应
152
+ current_num = None
153
+ for item in hdr_data:
154
+ if isinstance(item, tuple):
155
+ # item[0] 是 "1 (BODY[HEADER...]" 之类的标记
156
+ # item[1] 是头部内容
157
+ header_bytes = item[1]
158
+ if header_bytes:
159
+ msg_id = extract_message_id(header_bytes)
160
+ if msg_id and msg_id not in known and msg_id not in seen_msgids:
161
+ # 从 item[0] 提取序列号
162
+ marker = item[0].decode() if isinstance(
163
+ item[0], bytes
164
+ ) else str(item[0])
165
+ m = re.match(r"(\d+)", marker)
166
+ if m:
167
+ to_fetch.append((m.group(1).encode(), msg_id))
168
+ seen_msgids.add(msg_id)
169
+
170
+ # ---- 只下载新邮件 ----
171
+ # 对 IMAP 重新 select(因为上面的 fetch 可能改变了状态)
172
+ # 实际上不需要,IMAP sequence numbers 在一次 select 内是稳定的
173
+
174
+ new_count = 0
175
+ for num_bytes, msg_id in to_fetch:
176
+ status, msg_data = conn.fetch(num_bytes, "(RFC822)")
177
+ if status != "OK":
178
+ continue
179
+
180
+ msg_bytes = msg_data[0][1]
181
+ if msg_bytes is None:
182
+ continue
183
+
184
+ # 写入 Maildir
185
+ ts = int(time.time())
186
+ fname = (
187
+ f"{ts}.{num_bytes.decode()}.{new_count}.{os.uname().nodename}"
188
+ )
189
+ fpath = MAILDIR / "new" / fname
190
+ fpath.write_bytes(msg_bytes)
191
+
192
+ # 更新索引
193
+ known[msg_id] = fname
194
+
195
+ new_count += 1
196
+ # 快速预览主题
197
+ subject = "(无主题)"
198
+ try:
199
+ s = re.search(
200
+ rb"(?im)^Subject:\s*(.+)", msg_bytes[:4096]
201
+ )
202
+ if s:
203
+ subject = s.group(1).decode("utf-8", errors="replace").strip()
204
+ except Exception:
205
+ pass
206
+ print(f" [{new_count}] {subject}")
207
+
208
+ # ---- 保存索引 ----
209
+ save_index(known)
210
+
211
+ # ---- 清理索引中已经不存在的文件 ----
212
+ stale = [mid for mid, fname in known.items() if not (MAILDIR / "new" / fname).exists() and not (MAILDIR / "cur" / fname).exists()]
213
+ if stale:
214
+ for mid in stale:
215
+ del known[mid]
216
+ save_index(known)
217
+ print(f" (清理了 {len(stale)} 条过期索引记录)")
218
+
219
+ print(f"\n同步完成,新增 {new_count} 封 → {MAILDIR}")
220
+
221
+
222
+ if __name__ == "__main__":
223
+ limit = None
224
+ if len(sys.argv) > 1 and sys.argv[1] == "--limit":
225
+ limit = int(sys.argv[2]) if len(sys.argv) > 2 else 50
226
+ sync(limit)
@@ -1,104 +0,0 @@
1
- #!/usr/bin/env python3
2
- """163 邮箱 → 本地 Maildir 同步(Python 版 mbsync,解决 IMAP ID 问题)。
3
-
4
- 用法: python3 sync163.py
5
- python3 sync163.py --limit 50 # 只同步最近 50 封
6
- """
7
-
8
- import email
9
- import imaplib
10
- import os
11
- import ssl
12
- import sys
13
- import time
14
- from pathlib import Path
15
-
16
- HOST = "imap.163.com"
17
- USER = "songwupei@163.com"
18
- MAILDIR = Path.home() / "Mail" / "163" / "INBOX"
19
- PASS_ENV = "MAIL163_SMTP_PASS_AERC"
20
-
21
-
22
- def sync(limit=None):
23
- pass_file = Path.home() / ".config" / "aerc" / "163-pass"
24
- if pass_file.exists():
25
- password = pass_file.read_text().strip()
26
- else:
27
- password = os.environ.get(PASS_ENV, "")
28
- if not password:
29
- print(f"错误: 未找到密码(环境变量 {PASS_ENV} 未设置,且 {pass_file} 不存在)")
30
- return
31
-
32
- for sub in ["cur", "new", "tmp"]:
33
- (MAILDIR / sub).mkdir(parents=True, exist_ok=True)
34
-
35
- ctx = ssl.create_default_context()
36
- with imaplib.IMAP4_SSL(HOST, 993, ssl_context=ctx) as conn:
37
- conn.login(USER, password)
38
- # 163.com 必须的 ID 命令
39
- try:
40
- conn.socket().send(
41
- b'a001 ID ("name" "mail-dl" "version" "1.0")\r\n')
42
- conn.socket().recv(1024)
43
- except Exception:
44
- pass
45
- conn.select("INBOX")
46
- status, data = conn.search(None, "ALL")
47
- if status != "OK":
48
- print("搜索失败")
49
- return
50
-
51
- all_ids = data[0].split()
52
- if not all_ids:
53
- print("收件箱为空")
54
- return
55
-
56
- ids = list(reversed(all_ids))
57
- if limit:
58
- ids = ids[-limit:]
59
-
60
- new_count = 0
61
- for num in ids:
62
- status, msg_data = conn.fetch(num, "(RFC822 FLAGS)")
63
- if status != "OK":
64
- continue
65
-
66
- msg_bytes = msg_data[0][1]
67
- if msg_bytes is None:
68
- continue
69
-
70
- msg = email.message_from_bytes(msg_bytes)
71
- msg_id = msg.get("Message-ID")
72
- if msg_id is None:
73
- msg_id = f"{int(time.time())}.{int(num)}@unknown"
74
- # strip brackets from Message-ID if present
75
- mid = str(msg_id).strip("<>").encode()
76
-
77
- # 用 Message-ID 去重
78
- existing = False
79
- for sub in ["cur", "new"]:
80
- for f in (MAILDIR / sub).iterdir():
81
- if f.is_file() and mid in f.read_bytes():
82
- existing = True
83
- break
84
- if existing:
85
- break
86
- if existing:
87
- continue
88
-
89
- # 写入 Maildir new/
90
- ts = int(time.time())
91
- fname = f"{ts}.{num}.{os.uname().nodename}"
92
- fpath = MAILDIR / "new" / fname
93
- fpath.write_bytes(msg_bytes)
94
- new_count += 1
95
- print(f" [{new_count}] {msg.get('Subject', '(无主题)')}")
96
-
97
- print(f"\n同步完成,新增 {new_count} 封 → {MAILDIR}")
98
-
99
-
100
- if __name__ == "__main__":
101
- limit = None
102
- if len(sys.argv) > 1 and sys.argv[1] == "--limit":
103
- limit = int(sys.argv[2]) if len(sys.argv) > 2 else 50
104
- sync(limit)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes