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.
- himalaya_kit/__init__.py +14 -0
- himalaya_kit/_lib/__init__.py +0 -0
- himalaya_kit/_lib/mail_compose.py +139 -0
- himalaya_kit/_lib/mail_message.py +320 -0
- himalaya_kit/_lib/mail_send.py +399 -0
- himalaya_kit/archive.py +301 -0
- himalaya_kit/cli.py +404 -0
- himalaya_kit/config.py +246 -0
- himalaya_kit/contacts/__init__.py +11 -0
- himalaya_kit/contacts/ai_todo.py +66 -0
- himalaya_kit/contacts/json_file.py +49 -0
- himalaya_kit/contacts/none.py +12 -0
- himalaya_kit/contacts/store.py +85 -0
- himalaya_kit/contacts/types.py +19 -0
- himalaya_kit/export.py +548 -0
- himalaya_kit/forward.py +158 -0
- himalaya_kit/i18n.py +104 -0
- himalaya_kit/locales/en_US.json +191 -0
- himalaya_kit/locales/zh_CN.json +191 -0
- himalaya_kit/reply.py +141 -0
- himalaya_kit/search.py +320 -0
- himalaya_kit/send.py +98 -0
- himalaya_kit/yearly_archive.py +90 -0
- himalaya_kit-0.2.0.dist-info/METADATA +250 -0
- himalaya_kit-0.2.0.dist-info/RECORD +29 -0
- himalaya_kit-0.2.0.dist-info/WHEEL +5 -0
- himalaya_kit-0.2.0.dist-info/entry_points.txt +2 -0
- himalaya_kit-0.2.0.dist-info/licenses/LICENSE +189 -0
- himalaya_kit-0.2.0.dist-info/top_level.txt +1 -0
himalaya_kit/__init__.py
ADDED
|
@@ -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()
|