mailops 0.2.4__tar.gz → 0.2.5__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.4 → mailops-0.2.5}/PKG-INFO +3 -2
  2. {mailops-0.2.4 → mailops-0.2.5}/README.md +2 -1
  3. {mailops-0.2.4 → mailops-0.2.5}/pyproject.toml +1 -1
  4. {mailops-0.2.4 → mailops-0.2.5}/src/mailops.egg-info/PKG-INFO +3 -2
  5. {mailops-0.2.4 → mailops-0.2.5}/src/mailops.egg-info/SOURCES.txt +1 -0
  6. {mailops-0.2.4 → mailops-0.2.5}/src/mailops.egg-info/top_level.txt +1 -0
  7. {mailops-0.2.4 → mailops-0.2.5}/src/sync163.py +37 -32
  8. mailops-0.2.5/src/sync_account.py +240 -0
  9. {mailops-0.2.4 → mailops-0.2.5}/setup.cfg +0 -0
  10. {mailops-0.2.4 → mailops-0.2.5}/src/maildl.py +0 -0
  11. {mailops-0.2.4 → mailops-0.2.5}/src/mailfwd.py +0 -0
  12. {mailops-0.2.4 → mailops-0.2.5}/src/mailops/__init__.py +0 -0
  13. {mailops-0.2.4 → mailops-0.2.5}/src/mailops/backend/__init__.py +0 -0
  14. {mailops-0.2.4 → mailops-0.2.5}/src/mailops/backend/imap.py +0 -0
  15. {mailops-0.2.4 → mailops-0.2.5}/src/mailops/backend/maildir.py +0 -0
  16. {mailops-0.2.4 → mailops-0.2.5}/src/mailops/cli.py +0 -0
  17. {mailops-0.2.4 → mailops-0.2.5}/src/mailops/config.py +0 -0
  18. {mailops-0.2.4 → mailops-0.2.5}/src/mailops/downloader.py +0 -0
  19. {mailops-0.2.4 → mailops-0.2.5}/src/mailops/forwarder.py +0 -0
  20. {mailops-0.2.4 → mailops-0.2.5}/src/mailops/operations.py +0 -0
  21. {mailops-0.2.4 → mailops-0.2.5}/src/mailops/organizer.py +0 -0
  22. {mailops-0.2.4 → mailops-0.2.5}/src/mailops/sync.py +0 -0
  23. {mailops-0.2.4 → mailops-0.2.5}/src/mailops/workflow.py +0 -0
  24. {mailops-0.2.4 → mailops-0.2.5}/src/mailops.egg-info/dependency_links.txt +0 -0
  25. {mailops-0.2.4 → mailops-0.2.5}/src/mailops.egg-info/entry_points.txt +0 -0
  26. {mailops-0.2.4 → mailops-0.2.5}/src/mailops.egg-info/requires.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailops
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: 邮件操作工具箱 — 下载、统计、搜索、转发、附件管理、发送
5
5
  Author: Song Wupei
6
6
  License-Expression: MIT
@@ -186,7 +186,8 @@ accounts:
186
186
  |--------|--------|
187
187
  | `maildl.py` | `mailops download-docs` |
188
188
  | `mailfwd.py` | `mailops forward` |
189
- | `sync163.py` | `mailops fetch` (IMAP → Maildir) + `mailops organize` (分类) |
189
+ | `sync163.py` | `sync_account.py 163` (IMAP → Maildir,索引去重) + `mailops organize` |
190
+ | `mbsync` | `sync_account.py QQ` (IMAP → Maildir,索引去重,本地删除不重下) |
190
191
  | `sync163.py` | `mailops fetch -m 163` |
191
192
  | `run-wf.py` | `mailops workflow` |
192
193
  | `mailx` (系统命令) | `mailops send` |
@@ -165,7 +165,8 @@ accounts:
165
165
  |--------|--------|
166
166
  | `maildl.py` | `mailops download-docs` |
167
167
  | `mailfwd.py` | `mailops forward` |
168
- | `sync163.py` | `mailops fetch` (IMAP → Maildir) + `mailops organize` (分类) |
168
+ | `sync163.py` | `sync_account.py 163` (IMAP → Maildir,索引去重) + `mailops organize` |
169
+ | `mbsync` | `sync_account.py QQ` (IMAP → Maildir,索引去重,本地删除不重下) |
169
170
  | `sync163.py` | `mailops fetch -m 163` |
170
171
  | `run-wf.py` | `mailops workflow` |
171
172
  | `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.4"
7
+ version = "0.2.5"
8
8
  description = "邮件操作工具箱 — 下载、统计、搜索、转发、附件管理、发送"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailops
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: 邮件操作工具箱 — 下载、统计、搜索、转发、附件管理、发送
5
5
  Author: Song Wupei
6
6
  License-Expression: MIT
@@ -186,7 +186,8 @@ accounts:
186
186
  |--------|--------|
187
187
  | `maildl.py` | `mailops download-docs` |
188
188
  | `mailfwd.py` | `mailops forward` |
189
- | `sync163.py` | `mailops fetch` (IMAP → Maildir) + `mailops organize` (分类) |
189
+ | `sync163.py` | `sync_account.py 163` (IMAP → Maildir,索引去重) + `mailops organize` |
190
+ | `mbsync` | `sync_account.py QQ` (IMAP → Maildir,索引去重,本地删除不重下) |
190
191
  | `sync163.py` | `mailops fetch -m 163` |
191
192
  | `run-wf.py` | `mailops workflow` |
192
193
  | `mailx` (系统命令) | `mailops send` |
@@ -3,6 +3,7 @@ pyproject.toml
3
3
  src/maildl.py
4
4
  src/mailfwd.py
5
5
  src/sync163.py
6
+ src/sync_account.py
6
7
  src/mailops/__init__.py
7
8
  src/mailops/cli.py
8
9
  src/mailops/config.py
@@ -2,3 +2,4 @@ maildl
2
2
  mailfwd
3
3
  mailops
4
4
  sync163
5
+ sync_account
@@ -22,19 +22,23 @@ PASS_ENV = "MAIL163_SMTP_PASS_AERC"
22
22
 
23
23
 
24
24
  def load_index():
25
- """加载已知 Message-ID → 文件名 的索引。"""
25
+ """加载已知 Message-ID 集合。"""
26
26
  if INDEX_FILE.exists():
27
27
  try:
28
- return json.loads(INDEX_FILE.read_text())
28
+ data = json.loads(INDEX_FILE.read_text())
29
+ # 兼容旧格式(dict → 取 keys;新格式是 list)
30
+ if isinstance(data, dict):
31
+ return set(data.keys())
32
+ return set(data)
29
33
  except (json.JSONDecodeError, OSError):
30
34
  pass
31
- return {}
35
+ return set()
32
36
 
33
37
 
34
38
  def save_index(idx):
35
- """保存索引到磁盘。"""
39
+ """保存索引到磁盘(Message-ID 集合)。"""
36
40
  INDEX_FILE.parent.mkdir(parents=True, exist_ok=True)
37
- INDEX_FILE.write_text(json.dumps(idx))
41
+ INDEX_FILE.write_text(json.dumps(sorted(idx)))
38
42
 
39
43
 
40
44
  def extract_message_id(msg_bytes):
@@ -55,23 +59,30 @@ def extract_message_id(msg_bytes):
55
59
 
56
60
 
57
61
  def backfill_index(known):
58
- """首次运行:从已有 Maildir 文件重建索引,避免重复下载。"""
62
+ """首次运行:从已有 Maildir 文件重建索引(递归扫描所有子文件夹)。
63
+
64
+ 不仅扫描 INBOX,也扫描 organize 移入的 GitHub/、Archive/ 等子文件夹。
65
+ """
59
66
  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():
67
+ maildir_root = MAILDIR.parent # ~/Mail/163/
68
+ for maildir_root, dirs, files in os.walk(maildir_root):
69
+ # 跳过 tmp/ 目录
70
+ dirs[:] = [d for d in dirs if d != "tmp"]
71
+ for sub in ["cur", "new"]:
72
+ folder = Path(maildir_root) / sub
73
+ if not folder.is_dir():
66
74
  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
+ for fpath in folder.iterdir():
76
+ if not fpath.is_file() or fpath.name.startswith("."):
77
+ continue
78
+ try:
79
+ # 只读前 8KB 足以包含所有头部
80
+ mid = extract_message_id(fpath.read_bytes()[:8192])
81
+ if mid and mid not in known:
82
+ known.add(mid)
83
+ existing += 1
84
+ except OSError:
85
+ pass
75
86
  if existing:
76
87
  print(f"从本地 Maildir 重建索引: +{existing} 封")
77
88
  return known
@@ -130,9 +141,9 @@ def sync(limit=None):
130
141
 
131
142
  # ---- 先通过只下载 HEADER 来获取 Message-ID(轻量级) ----
132
143
  # 批量获取所有邮件的 Message-ID,用于判断哪些是新的
133
- ids = list(reversed(all_ids))
144
+ ids = all_ids # 保持原始顺序(升序)
134
145
  if limit:
135
- ids = ids[-limit:]
146
+ ids = ids[-limit:] # 取最后 N 封(最新)
136
147
 
137
148
  # 分批处理,每批 200 封
138
149
  BATCH = 200
@@ -141,7 +152,9 @@ def sync(limit=None):
141
152
 
142
153
  for i in range(0, len(ids), BATCH):
143
154
  batch = ids[i : i + BATCH]
144
- id_range = f"{batch[0].decode()}:{batch[-1].decode()}"
155
+ # 确保范围升序(min:max),修复倒序范围导致 163 返回异常
156
+ nums = sorted(int(x) for x in batch)
157
+ id_range = f"{nums[0]}:{nums[-1]}"
145
158
  status, hdr_data = conn.fetch(
146
159
  id_range, "(BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])"
147
160
  )
@@ -190,7 +203,7 @@ def sync(limit=None):
190
203
  fpath.write_bytes(msg_bytes)
191
204
 
192
205
  # 更新索引
193
- known[msg_id] = fname
206
+ known.add(msg_id)
194
207
 
195
208
  new_count += 1
196
209
  # 快速预览主题
@@ -208,14 +221,6 @@ def sync(limit=None):
208
221
  # ---- 保存索引 ----
209
222
  save_index(known)
210
223
 
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
224
  print(f"\n同步完成,新增 {new_count} 封 → {MAILDIR}")
220
225
 
221
226
 
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env python3
2
+ """IMAP → Maildir 同步(通用版,索引去重,支持 163/QQ 等)。
3
+
4
+ 用法: python3 sync_account.py 163
5
+ python3 sync_account.py QQ --limit 50
6
+ python3 sync_account.py 163 --limit 10
7
+ """
8
+
9
+ import imaplib
10
+ import json
11
+ import os
12
+ import re
13
+ import ssl
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+
18
+ # ---- 账号配置 ----
19
+ ACCOUNTS = {
20
+ "163": {
21
+ "host": "imap.163.com",
22
+ "user": "songwupei@163.com",
23
+ "pass_file": Path.home() / ".config" / "aerc" / "163-pass",
24
+ "maildir": Path.home() / "Mail" / "163" / "INBOX",
25
+ "need_id": True, # 163 需要 IMAP ID 命令
26
+ },
27
+ "QQ": {
28
+ "host": "imap.qq.com",
29
+ "user": "songwupei@qq.com",
30
+ "pass_file": Path.home() / ".config" / "aerc" / "qq-pass",
31
+ "maildir": Path.home() / "Mail" / "QQ" / "INBOX",
32
+ "need_id": False,
33
+ },
34
+ }
35
+
36
+ CACHE_DIR = Path.home() / ".cache" / "aerc"
37
+
38
+
39
+ def index_path(account: str) -> Path:
40
+ return CACHE_DIR / f"sync-{account}-index.json"
41
+
42
+
43
+ def load_index(account: str) -> set:
44
+ path = index_path(account)
45
+ if path.exists():
46
+ try:
47
+ data = json.loads(path.read_text())
48
+ if isinstance(data, dict):
49
+ return set(data.keys())
50
+ return set(data)
51
+ except (json.JSONDecodeError, OSError):
52
+ pass
53
+ return set()
54
+
55
+
56
+ def save_index(account: str, idx: set):
57
+ path = index_path(account)
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ path.write_text(json.dumps(sorted(idx)))
60
+
61
+
62
+ def extract_message_id(msg_bytes):
63
+ """从邮件字节中快速提取 Message-ID。"""
64
+ header_end = msg_bytes.find(b"\r\n\r\n")
65
+ if header_end == -1:
66
+ header_end = msg_bytes.find(b"\n\n")
67
+ if header_end == -1:
68
+ header_end = len(msg_bytes)
69
+ header = msg_bytes[:header_end].decode("utf-8", errors="replace")
70
+ m = re.search(r"(?im)^Message-ID:\s*(.+)", header)
71
+ if m:
72
+ return m.group(1).strip().strip("<>")
73
+ return None
74
+
75
+
76
+ def backfill_index(maildir: Path, known: set) -> set:
77
+ """从已有 Maildir 递归重建索引。"""
78
+ existing = 0
79
+ root = maildir.parent # ~/Mail/<account>/
80
+ if not root.is_dir():
81
+ return known
82
+ for walk_root, dirs, files in os.walk(root):
83
+ dirs[:] = [d for d in dirs if d != "tmp"]
84
+ for sub in ["cur", "new"]:
85
+ folder = Path(walk_root) / sub
86
+ if not folder.is_dir():
87
+ continue
88
+ for fpath in folder.iterdir():
89
+ if not fpath.is_file() or fpath.name.startswith("."):
90
+ continue
91
+ try:
92
+ mid = extract_message_id(fpath.read_bytes()[:8192])
93
+ if mid and mid not in known:
94
+ known.add(mid)
95
+ existing += 1
96
+ except OSError:
97
+ pass
98
+ if existing:
99
+ print(f" 从本地 Maildir 重建索引: +{existing} 封")
100
+ return known
101
+
102
+
103
+ def sync(account: str, limit: int = None):
104
+ cfg = ACCOUNTS.get(account)
105
+ if not cfg:
106
+ print(f"错误: 未知账号 '{account}',可用: {', '.join(ACCOUNTS)}")
107
+ sys.exit(1)
108
+
109
+ host = cfg["host"]
110
+ user = cfg["user"]
111
+ maildir = cfg["maildir"]
112
+ pass_file = cfg["pass_file"]
113
+ need_id = cfg.get("need_id", False)
114
+
115
+ # ---- 密码 ----
116
+ if pass_file.exists():
117
+ password = pass_file.read_text().strip()
118
+ else:
119
+ print(f"错误: 密码文件不存在: {pass_file}")
120
+ sys.exit(1)
121
+ if not password:
122
+ print(f"错误: 密码为空: {pass_file}")
123
+ sys.exit(1)
124
+
125
+ # ---- 初始化 Maildir ----
126
+ for sub in ["cur", "new", "tmp"]:
127
+ (maildir / sub).mkdir(parents=True, exist_ok=True)
128
+
129
+ # ---- 加载/重建索引 ----
130
+ known = load_index(account)
131
+ if not known:
132
+ known = backfill_index(maildir, known)
133
+ save_index(account, known)
134
+
135
+ print(f"[{account}] 服务器连接中...")
136
+
137
+ # ---- 连接 ----
138
+ ctx = ssl.create_default_context()
139
+ with imaplib.IMAP4_SSL(host, 993, ssl_context=ctx) as conn:
140
+ conn.login(user, password)
141
+
142
+ # 163.com 必须的 ID 命令
143
+ if need_id:
144
+ try:
145
+ conn.socket().send(
146
+ b'a001 ID ("name" "mail-dl" "version" "1.0")\r\n'
147
+ )
148
+ conn.socket().recv(1024)
149
+ except Exception:
150
+ pass
151
+
152
+ conn.select("INBOX")
153
+ status, data = conn.search(None, "ALL")
154
+ if status != "OK":
155
+ print(" 搜索失败")
156
+ return
157
+
158
+ all_ids = data[0].split()
159
+ if not all_ids:
160
+ print(" 收件箱为空")
161
+ return
162
+
163
+ total = len(all_ids)
164
+ print(f" 服务器 {total} 封,索引 {len(known)} 封")
165
+
166
+ ids = all_ids
167
+ if limit:
168
+ ids = ids[-limit:]
169
+
170
+ BATCH = 200
171
+ to_fetch = []
172
+ seen_msgids = set()
173
+
174
+ for i in range(0, len(ids), BATCH):
175
+ batch = ids[i : i + BATCH]
176
+ nums = sorted(int(x) for x in batch)
177
+ id_range = f"{nums[0]}:{nums[-1]}"
178
+ status, hdr_data = conn.fetch(
179
+ id_range, "(BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])"
180
+ )
181
+ if status != "OK":
182
+ continue
183
+
184
+ for item in hdr_data:
185
+ if isinstance(item, tuple):
186
+ header_bytes = item[1]
187
+ if header_bytes:
188
+ msg_id = extract_message_id(header_bytes)
189
+ if msg_id and msg_id not in known and msg_id not in seen_msgids:
190
+ marker = item[0].decode() if isinstance(
191
+ item[0], bytes
192
+ ) else str(item[0])
193
+ m = re.match(r"(\d+)", marker)
194
+ if m:
195
+ to_fetch.append((m.group(1).encode(), msg_id))
196
+ seen_msgids.add(msg_id)
197
+
198
+ new_count = 0
199
+ for num_bytes, msg_id in to_fetch:
200
+ status, msg_data = conn.fetch(num_bytes, "(RFC822)")
201
+ if status != "OK":
202
+ continue
203
+
204
+ msg_bytes = msg_data[0][1]
205
+ if msg_bytes is None:
206
+ continue
207
+
208
+ ts = int(time.time())
209
+ fname = f"{ts}.{num_bytes.decode()}.{new_count}.{os.uname().nodename}"
210
+ fpath = maildir / "new" / fname
211
+ fpath.write_bytes(msg_bytes)
212
+
213
+ known.add(msg_id)
214
+ new_count += 1
215
+
216
+ subject = "(无主题)"
217
+ try:
218
+ s = re.search(rb"(?im)^Subject:\s*(.+)", msg_bytes[:4096])
219
+ if s:
220
+ subject = s.group(1).decode("utf-8", errors="replace").strip()
221
+ except Exception:
222
+ pass
223
+ print(f" [{new_count}] {subject}")
224
+
225
+ save_index(account, known)
226
+ print(f" 完成,新增 {new_count} 封\n")
227
+
228
+
229
+ if __name__ == "__main__":
230
+ if len(sys.argv) < 2:
231
+ print(f"用法: python3 sync_account.py <163|QQ> [--limit N]")
232
+ print(f"可用账号: {', '.join(ACCOUNTS)}")
233
+ sys.exit(1)
234
+
235
+ account = sys.argv[1]
236
+ limit = None
237
+ if len(sys.argv) > 2 and sys.argv[2] == "--limit":
238
+ limit = int(sys.argv[3]) if len(sys.argv) > 3 else 50
239
+
240
+ sync(account, limit)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes