himalaya-kit 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,14 @@
1
+ """himalaya-kit: himalaya 邮件 CLI 的增强工具箱"""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ from himalaya_kit.config import KitConfig, load_kit_config
6
+ from himalaya_kit.contacts import ContactStore, load_contact_store
7
+
8
+ __all__ = [
9
+ "__version__",
10
+ "KitConfig",
11
+ "load_kit_config",
12
+ "ContactStore",
13
+ "load_contact_store",
14
+ ]
File without changes
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """邮件正文解析与签名(send / reply / forward 共用)。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from himalaya_kit._lib.mail_send import MailConfigError, render_markdown_to_html
11
+ from himalaya_kit.i18n import t
12
+
13
+
14
+ def get_body_source_epilog() -> str:
15
+ """获取正文来源说明(支持 i18n)。"""
16
+ return t("body_source_epilog")
17
+
18
+
19
+ # 保持向后兼容
20
+ BODY_SOURCE_EPILOG = get_body_source_epilog()
21
+
22
+
23
+ def process_body(body: str) -> str:
24
+ return body.replace("\\n", "\n").replace("\\t", "\t")
25
+
26
+
27
+ def read_stdin_body() -> str | None:
28
+ if sys.stdin.isatty():
29
+ return None
30
+ data = sys.stdin.read()
31
+ return data if data else None
32
+
33
+
34
+ def resolve_body_content(args: argparse.Namespace, *, allow_empty: bool = False) -> tuple[str, bool]:
35
+ """解析正文,返回 (body, is_html)。优先级: --html > --file > -b/--body/--note > stdin。"""
36
+ body_text = getattr(args, "body", None)
37
+ if body_text is None and getattr(args, "note", None):
38
+ body_text = args.note
39
+
40
+ if args.html:
41
+ if args.markdown:
42
+ print("警告: --markdown 与 --html 同用时忽略 --markdown", file=sys.stderr)
43
+ return args.html, True
44
+
45
+ source: str | None = None
46
+ is_html = False
47
+
48
+ if args.file:
49
+ path = Path(args.file)
50
+ if not path.is_file():
51
+ raise FileNotFoundError(f"正文文件不存在: {path}")
52
+ source = path.read_text(encoding="utf-8")
53
+ if path.suffix.lower() in (".html", ".htm"):
54
+ is_html = True
55
+ elif body_text is not None:
56
+ source = process_body(body_text)
57
+ else:
58
+ source = read_stdin_body()
59
+ if source is None:
60
+ if allow_empty:
61
+ return "", False
62
+ raise MailConfigError(
63
+ "必须指定正文来源之一: -b / --html / --file,或通过管道传入 stdin\n"
64
+ "示例: cat note.md | email-reply.py <id> --markdown"
65
+ )
66
+
67
+ if args.markdown and not is_html:
68
+ source = render_markdown_to_html(source)
69
+ is_html = True
70
+
71
+ return source, is_html
72
+
73
+
74
+ def append_signature(
75
+ body: str,
76
+ is_html: bool,
77
+ no_sig: bool,
78
+ signature_text: str = "",
79
+ signature_html: str = "",
80
+ ) -> str:
81
+ """追加签名到正文。签名内容由调用方从 KitConfig 传入。"""
82
+ if no_sig:
83
+ return body
84
+ sig = signature_html if is_html else signature_text
85
+ if not sig:
86
+ return body
87
+ return body + sig
88
+
89
+
90
+ def add_body_arguments(parser: argparse.ArgumentParser, *, allow_empty: bool = False) -> None:
91
+ if allow_empty:
92
+ parser.add_argument("-b", "--body", default=None, help=t("arg.send.body_optional"))
93
+ else:
94
+ parser.add_argument("-b", "--body", default=None, help=t("arg.send.body"))
95
+ parser.add_argument("--html", default=None, help=t("arg.send.html"))
96
+ parser.add_argument("--file", default=None, help=t("arg.send.file"))
97
+ parser.add_argument(
98
+ "--markdown",
99
+ action="store_true",
100
+ help=t("arg.send.markdown"),
101
+ )
102
+ parser.add_argument(
103
+ "--attach",
104
+ action="append",
105
+ default=[],
106
+ help=t("arg.send.attach"),
107
+ )
108
+ parser.add_argument("--no-sig", action="store_true", help=t("arg.send.no_sig"))
109
+
110
+
111
+ def build_email_content(
112
+ from_addr: str,
113
+ to: str,
114
+ subject: str,
115
+ body: str,
116
+ cc: str | None,
117
+ is_html: bool,
118
+ attachments: list[str],
119
+ ) -> str:
120
+ from himalaya_kit._lib.mail_send import build_multipart_email, build_simple_email
121
+
122
+ if attachments:
123
+ return build_multipart_email(
124
+ from_addr=from_addr,
125
+ to=to,
126
+ subject=subject,
127
+ body=body,
128
+ cc=cc,
129
+ is_html=is_html,
130
+ attachments=attachments,
131
+ )
132
+ return build_simple_email(
133
+ from_addr=from_addr,
134
+ to=to,
135
+ subject=subject,
136
+ body=body,
137
+ cc=cc,
138
+ is_html=is_html,
139
+ )
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env python3
2
+ """邮件读/解析公共库:导出、解码、正文提取(供 reply / forward 共用)。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import html as html_module
7
+ import email
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ from email.header import decode_header
13
+ from email.utils import getaddresses, parseaddr, parsedate_to_datetime
14
+ from pathlib import Path
15
+
16
+ from himalaya_kit.i18n import t
17
+
18
+
19
+ def run(cmd: list[str]) -> subprocess.CompletedProcess:
20
+ return subprocess.run(cmd, capture_output=True, text=True)
21
+
22
+
23
+ def export_message(msg_id: str, account: str | None = None) -> str:
24
+ tmp = tempfile.NamedTemporaryFile(suffix=".eml", delete=False)
25
+ tmp.close()
26
+ cmd = ["himalaya", "message", "export", msg_id, "--full", "-d", tmp.name]
27
+ if account:
28
+ cmd.extend(["-a", account])
29
+ result = run(cmd)
30
+ if result.returncode != 0:
31
+ print(t("error.export_failed", stderr=result.stderr), file=sys.stderr)
32
+ raise SystemExit(1)
33
+ return tmp.name
34
+
35
+
36
+ def decode_mime_header(raw: str) -> str:
37
+ parts = decode_header(raw or "")
38
+ decoded: list[str] = []
39
+ for part, charset in parts:
40
+ if isinstance(part, bytes):
41
+ decoded.append(part.decode(charset or "utf-8", errors="replace"))
42
+ else:
43
+ decoded.append(part)
44
+ return "".join(decoded)
45
+
46
+
47
+ def extract_text_body(msg: email.message.Message) -> str:
48
+ if msg.is_multipart():
49
+ for part in msg.walk():
50
+ if part.get_content_type() == "text/plain":
51
+ payload = part.get_payload(decode=True)
52
+ charset = part.get_content_charset() or "utf-8"
53
+ return payload.decode(charset, errors="replace")
54
+ for part in msg.walk():
55
+ if part.get_content_type() == "text/html":
56
+ payload = part.get_payload(decode=True)
57
+ charset = part.get_content_charset() or "utf-8"
58
+ html = payload.decode(charset, errors="replace")
59
+ return re.sub(r"<[^>]+>", "", html).strip()
60
+ else:
61
+ payload = msg.get_payload(decode=True)
62
+ if payload:
63
+ charset = msg.get_content_charset() or "utf-8"
64
+ return payload.decode(charset, errors="replace")
65
+ return ""
66
+
67
+
68
+ def parse_original_email(eml_path: str) -> dict:
69
+ with open(eml_path, "r", encoding="utf-8", errors="replace") as fp:
70
+ msg = email.message_from_file(fp)
71
+
72
+ raw_subject = msg.get("Subject", t("output.no_subject"))
73
+ from_raw = msg.get("From", "")
74
+ to_raw = msg.get("To", "")
75
+ cc_raw = msg.get("Cc", "")
76
+ date_str = msg.get("Date", "")
77
+
78
+ try:
79
+ dt = parsedate_to_datetime(date_str)
80
+ formatted_date = dt.strftime("%Y年%m月%d日 %H:%M")
81
+ except Exception:
82
+ formatted_date = date_str
83
+
84
+ return {
85
+ "subject": decode_mime_header(raw_subject),
86
+ "from": decode_mime_header(from_raw),
87
+ "from_raw": from_raw,
88
+ "to": decode_mime_header(to_raw),
89
+ "to_raw": to_raw,
90
+ "cc": decode_mime_header(cc_raw),
91
+ "cc_raw": cc_raw,
92
+ "date": formatted_date,
93
+ "body": extract_text_body(msg).strip(),
94
+ }
95
+
96
+
97
+ def reply_subject(subject: str) -> str:
98
+ s = subject.strip()
99
+ lowered = s.lower()
100
+ if lowered.startswith("re:") or s.startswith("回复:") or s.startswith("答复:"):
101
+ return s
102
+ return f"Re: {s}"
103
+
104
+
105
+ def _normalize_email(email_addr: str) -> str:
106
+ return email_addr.strip().lower()
107
+
108
+
109
+ def primary_from_address(from_header: str) -> str:
110
+ """从 From 头提取首选回复地址(姓名 <email> 或纯 email)。"""
111
+ addrs = getaddresses([from_header])
112
+ for _name, addr in addrs:
113
+ if addr:
114
+ return addr
115
+ _name, addr = parseaddr(from_header)
116
+ return addr
117
+
118
+
119
+ def format_address_list(addrs: list[tuple[str, str]]) -> str:
120
+ parts = []
121
+ for name, addr in addrs:
122
+ if not addr:
123
+ continue
124
+ if name:
125
+ parts.append(f"{name} <{addr}>")
126
+ else:
127
+ parts.append(addr)
128
+ return ", ".join(parts)
129
+
130
+
131
+ def resolve_reply_recipients(
132
+ orig: dict,
133
+ own_email: str,
134
+ reply_all: bool = False,
135
+ to_override: str | None = None,
136
+ cc_override: str | None = None,
137
+ ) -> tuple[str, str | None]:
138
+ """计算回复收件人。返回 (to, cc)。"""
139
+ if to_override:
140
+ to_addr = to_override
141
+ else:
142
+ to_addr = primary_from_address(orig["from_raw"])
143
+ if not to_addr:
144
+ raise ValueError(t("error.no_reply_address"))
145
+
146
+ if cc_override is not None:
147
+ return to_addr, cc_override or None
148
+
149
+ if not reply_all:
150
+ return to_addr, None
151
+
152
+ own = _normalize_email(own_email)
153
+ reply_to = _normalize_email(to_addr)
154
+ seen = {reply_to, own}
155
+ cc_addrs: list[tuple[str, str]] = []
156
+
157
+ for raw in (orig.get("to_raw") or "", orig.get("cc_raw") or ""):
158
+ for name, addr in getaddresses([raw]):
159
+ if not addr or _normalize_email(addr) in seen:
160
+ continue
161
+ seen.add(_normalize_email(addr))
162
+ cc_addrs.append((name, addr))
163
+
164
+ cc = format_address_list(cc_addrs) if cc_addrs else None
165
+ return to_addr, cc
166
+
167
+
168
+ def forward_subject(subject: str) -> str:
169
+ s = subject.strip()
170
+ lowered = s.lower()
171
+ if lowered.startswith("fwd:") or s.startswith("转发:"):
172
+ return s
173
+ return f"Fwd: {s}"
174
+
175
+
176
+ def _quote_block_plain(orig: dict, title: str) -> list[str]:
177
+ if title == t("output.original_mail"):
178
+ return [
179
+ f"---------- {title} ----------",
180
+ t("output.from", from_=orig['from']),
181
+ t("output.send_time", date=orig['date']),
182
+ t("output.to", to=orig['to']),
183
+ t("output.subject_label", subject=orig['subject']),
184
+ "",
185
+ orig["body"],
186
+ ]
187
+ return [
188
+ f"---------- {title} ----------",
189
+ t("output.from", from_=orig['from']),
190
+ t("output.to", to=orig['to']),
191
+ t("output.date", date=orig['date']),
192
+ t("output.subject_label", subject=orig['subject']),
193
+ "",
194
+ orig["body"],
195
+ ]
196
+
197
+
198
+ def _quote_block_html(orig: dict, title: str) -> str:
199
+ esc = html_module.escape
200
+ body = esc(orig["body"])
201
+ if title == t("output.original_mail"):
202
+ meta = (
203
+ f"<p><strong>{t('output.from', from_='')}</strong>{esc(orig['from'])}</p>"
204
+ f"<p><strong>{t('output.send_time', date='')}</strong>{esc(orig['date'])}</p>"
205
+ f"<p><strong>{t('output.to', to='')}</strong>{esc(orig['to'])}</p>"
206
+ f"<p><strong>{t('output.subject_label', subject='')}</strong>{esc(orig['subject'])}</p>"
207
+ )
208
+ else:
209
+ meta = (
210
+ f"<p><strong>{t('output.from', from_='')}</strong>{esc(orig['from'])}</p>"
211
+ f"<p><strong>{t('output.to', to='')}</strong>{esc(orig['to'])}</p>"
212
+ f"<p><strong>{t('output.date', date='')}</strong>{esc(orig['date'])}</p>"
213
+ f"<p><strong>{t('output.subject_label', subject='')}</strong>{esc(orig['subject'])}</p>"
214
+ )
215
+ return (
216
+ '<div style="border-top: 1px solid #ccc; margin-top: 16px; padding-top: 8px; '
217
+ 'color: #666; font-size: 12px;">'
218
+ f"<p><strong>{esc(title)}</strong></p>{meta}"
219
+ f'<pre style="white-space: pre-wrap; font-family: inherit; color: #333;">{body}</pre>'
220
+ "</div>"
221
+ )
222
+
223
+
224
+ def build_reply_body(reply_text: str, orig: dict, is_html: bool = False) -> str:
225
+ if is_html:
226
+ parts = [reply_text] if reply_text else []
227
+ parts.append(_quote_block_html(orig, t("output.original_mail")))
228
+ return "".join(parts)
229
+ lines: list[str] = []
230
+ if reply_text:
231
+ lines.extend([reply_text, ""])
232
+ lines.extend(_quote_block_plain(orig, t("output.original_mail")))
233
+ return "\n".join(lines)
234
+
235
+
236
+ def build_forward_body(note: str, orig: dict, is_html: bool = False) -> str:
237
+ if is_html:
238
+ parts = [note] if note else []
239
+ parts.append(_quote_block_html(orig, t("output.forwarded_mail")))
240
+ return "".join(parts)
241
+ lines: list[str] = []
242
+ if note:
243
+ lines.extend([note, ""])
244
+ lines.extend(_quote_block_plain(orig, t("output.forwarded_mail")))
245
+ return "\n".join(lines)
246
+
247
+
248
+ def preview_outgoing(to: str, cc: str | None, subject: str, body: str, title: str | None = None) -> None:
249
+ if title is None:
250
+ title = t("output.forward_preview_title")
251
+ print("=" * 60)
252
+ print(f"【{title}】")
253
+ print("=" * 60)
254
+ print(f" {t('output.recipient', to=to)}")
255
+ if cc:
256
+ print(f" {t('output.cc', cc=cc)}")
257
+ print(f" {t('output.subject', subject=subject)}")
258
+ print("-" * 60)
259
+ print(body)
260
+ print("=" * 60)
261
+
262
+
263
+ def extract_attachments(eml_path: str) -> tuple[list[tuple[str, str]], str]:
264
+ """从 .eml 文件中提取真正的附件。
265
+
266
+ 返回 (附件列表, 临时目录路径)。附件列表元素为 (临时文件路径, 原始文件名)。
267
+ 调用方负责在完成后删除临时目录。
268
+ """
269
+ attachments: list[tuple[str, str]] = []
270
+ tmp_dir = tempfile.mkdtemp(prefix="email-fwd-attach-")
271
+
272
+ with open(eml_path, "rb") as fp:
273
+ msg = email.message_from_binary_file(fp)
274
+
275
+ for part in msg.walk():
276
+ if part.is_multipart():
277
+ continue
278
+
279
+ content_disposition = part.get("Content-Disposition", "")
280
+ # 仅转发明确标记为 attachment 的部分;跳过 inline 图片等内嵌资源
281
+ if "attachment" not in content_disposition.lower():
282
+ continue
283
+
284
+ filename = part.get_filename()
285
+ if not filename:
286
+ continue
287
+
288
+ payload = part.get_payload(decode=True)
289
+ if not payload:
290
+ continue
291
+
292
+ safe_name = re.sub(r'[\\/:*?"<>|]', "_", filename)
293
+ tmp_path = Path(tmp_dir) / safe_name
294
+ counter = 1
295
+ stem = tmp_path.stem
296
+ suffix = tmp_path.suffix
297
+ while tmp_path.exists():
298
+ tmp_path = Path(tmp_dir) / f"{stem}_{counter}{suffix}"
299
+ counter += 1
300
+
301
+ tmp_path.write_bytes(payload)
302
+ attachments.append((str(tmp_path), filename))
303
+
304
+ return attachments, tmp_dir
305
+
306
+
307
+ def confirm_send() -> bool:
308
+ try:
309
+ answer = input(f"\n{t('output.confirm_send')}").strip().lower()
310
+ return answer == "y"
311
+ except (EOFError, KeyboardInterrupt):
312
+ return False
313
+
314
+
315
+ def confirm_forward_with_attachments(attachments: list[tuple[str, str]]) -> bool:
316
+ """转发含附件邮件时的确认:先列清单,再询问是否发送。"""
317
+ print(f"\n{t('output.forward_attach_confirm')}")
318
+ for _path, filename in attachments:
319
+ print(f" • {filename}")
320
+ return confirm_send()