@adelie/csu-mail 0.0.0-stage → 0.4.2
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.
- package/LICENSE +201 -0
- package/icon.svg +7 -0
- package/package.json +17 -3
- package/plugin.json +9 -0
- package/skills/csu-mail/SKILL.md +160 -0
- package/skills/csu-mail/scripts/bootstrap.sh +87 -0
- package/skills/csu-mail/scripts/cas_login.py +151 -0
- package/skills/csu-mail/scripts/create_apppw.mjs +133 -0
- package/skills/csu-mail/scripts/mail.py +195 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="CSU mail">
|
|
2
|
+
<rect x="6" y="14" width="52" height="36" rx="5" fill="#2f6fed"/>
|
|
3
|
+
<path d="M8 18.5 32 36 56 18.5" fill="none" stroke="#eaf1ff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
4
|
+
<path d="M8 46.5 25 33M56 46.5 39 33" fill="none" stroke="#eaf1ff" stroke-width="3.2" stroke-linecap="round"/>
|
|
5
|
+
<circle cx="49" cy="47" r="10" fill="#0b3ea8"/>
|
|
6
|
+
<path d="M44 47h10M49 42v10" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round"/>
|
|
7
|
+
</svg>
|
package/package.json
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adelie/csu-mail",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"description": "Manage a Coremail mailbox (CSU student mail, mail.csu.edu.cn) over IMAP/SMTP with a client app password instead of the unified auth portal — create the app password once, then list, search, read and send mail from the shell, with a copy kept in the Sent folder.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/lmliheng/Adelie.git",
|
|
9
|
+
"directory": "plugins/csu-mail"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"plugin.json",
|
|
13
|
+
"icon.svg",
|
|
14
|
+
"skills",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
}
|
|
6
20
|
}
|
package/plugin.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Manage a Coremail mailbox (CSU student mail, mail.csu.edu.cn) without going through the unified auth portal: create a client app password once, then list, search, read and send mail over IMAP/SMTP from the shell, keeping a copy in the Sent folder.",
|
|
3
|
+
"description_zh": "绕开统一身份认证管理 Coremail 邮箱(中南大学学生邮箱):生成客户端专用密码,再用 IMAP/SMTP 从命令行查信、检索、读信、发信,并在「已发送」留底。",
|
|
4
|
+
"short_description": "Coremail mailbox over IMAP/SMTP with an app password, no CAS.",
|
|
5
|
+
"short_description_zh": "Coremail 邮箱免 CAS 收发信:专用密码 + IMAP/SMTP,发信自动留底。",
|
|
6
|
+
"version": "2026.10.07.1",
|
|
7
|
+
"category": "office-productivity",
|
|
8
|
+
"preinstall": false
|
|
9
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: csu-mail
|
|
3
|
+
description: Manage a Coremail mailbox (CSU student mail, mail.csu.edu.cn) over IMAP/SMTP with an app password instead of the unified auth portal — create the app password once, then list, search, read and send mail from the shell, with a copy kept in the Sent folder. Use whenever the task involves checking, sending or automating email on a CSU mailbox, or when a CAS login must be avoided.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 中南大学邮箱管理(Coremail,绕开统一身份认证)
|
|
7
|
+
|
|
8
|
+
## Before you start
|
|
9
|
+
|
|
10
|
+
如果消息只是点名这个技能(比如「用 csu-mail」)而没有具体的事,先问清楚要做什么——查信、发信,还是
|
|
11
|
+
做一次性引导。有具体任务时先自检凭据:`CSU_MAIL_ADDR` / `CSU_MAIL_AUTHCODE` 在不在环境里;不在就去
|
|
12
|
+
第一节做引导,那是**起点**不是认证失败,别去重试登录。
|
|
13
|
+
|
|
14
|
+
## 结论先行
|
|
15
|
+
|
|
16
|
+
| 事项 | 做法 |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| 环境里没有 `CSU_MAIL_ADDR` / `CSU_MAIL_AUTHCODE` | 这是**起点**不是异常:先做一次性引导(**必须登录一次网页邮箱**),见第一节——`bash scripts/bootstrap.sh` 一条命令搞定 |
|
|
19
|
+
| 收发信 | **客户端专用密码 + IMAP/SMTP**,不要为了邮件去登录统一身份认证(CAS) |
|
|
20
|
+
| 为什么 | 短时间高频登录 CAS 会触发风控、甚至冻结账号(2026-10-02 真实发生过一次) |
|
|
21
|
+
| 留底 | 用 SMTP 发信并 `APPEND` 到「已发送」;网页接口 `mbox:compose` **不留底** |
|
|
22
|
+
|
|
23
|
+
## 一、起点:环境里没有 `CSU_MAIL_ADDR` / `CSU_MAIL_AUTHCODE` 时怎么办
|
|
24
|
+
|
|
25
|
+
**这两个变量不是前提,是引导的产物。** 全新部署、换机器、或者专用密码丢了,环境里当然什么都没有 ——
|
|
26
|
+
这时**必须登录一次网页邮箱**:专用密码只能在登录后的设置页生成,绕不过去。
|
|
27
|
+
|
|
28
|
+
| 路径 | 需要什么 | 中南大学实测 | 怎么做 |
|
|
29
|
+
| --- | --- | --- | --- |
|
|
30
|
+
| ① CAS SSO(默认) | 统一身份认证账号 + 密码 | ✅ 可用 | `cas_login.py --mode cas` 换 sid,再驱动设置页生成专用密码。**只做这一次** |
|
|
31
|
+
| ② 网页直登 | 邮箱**自己的**密码(≠ 统一密码) | ❌ `FA_UNAUTHORIZED`,还会开始要验证码 | `cas_login.py --mode direct`(POST `/coremail/index.jsp?cus=1`,先把表单隐藏字段原样回传)。只有邮箱有独立密码的部署才走得通 |
|
|
32
|
+
| ③ 人工在浏览器里点 | 用户本人(Coremail 二次验证是**短信**,只有真人能收) | ✅ 最稳 | 用户自己登录 → 设置 → 个人信息 → 邮箱密码 → 生成专用密码 → **由用户自己**写进密钥库(别在聊天里贴密码) |
|
|
33
|
+
|
|
34
|
+
自动化这两步用一条命令:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cd <插件目录>/skills/csu-mail/scripts
|
|
38
|
+
CSU_MAIL_ADDR=学号@csu.edu.cn \
|
|
39
|
+
CSU_CAS_USER=学号 CSU_CAS_PASS=密码 \
|
|
40
|
+
PROJECT_ID=<App Data Dir 的最后一段> \
|
|
41
|
+
PLAYWRIGHT_MODULE=/path/to/node_modules/playwright-core CHROME_PATH=/path/to/chrome \
|
|
42
|
+
bash bootstrap.sh
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`bootstrap.sh` 做四件事:登录换 sid → 生成专用密码 → 把 `CSU_MAIL_ADDR` / `CSU_MAIL_AUTHCODE`
|
|
46
|
+
写进**指定项目**的密钥库 → 销毁临时明文。`NO_VAULT=1` 只打印命令不写库;
|
|
47
|
+
`SECRET_OUT=/path` 额外留一份 600 的明文(systemd 这类读不到密钥库的场景要用)。
|
|
48
|
+
|
|
49
|
+
**账号密码从哪来**:问用户要,或放进密钥库(`CSU_CAS_USER` / `CSU_CAS_PASS`)——
|
|
50
|
+
绝不写进代码或仓库;引导完成后这两个变量就该删掉,日常收发信用不到它们。
|
|
51
|
+
|
|
52
|
+
**键名的别名**(2026-10-05 加):脚本同时认 `CSU_CAS_ADDRESS`(= 邮箱地址)与 `CSU_CAS_PASSWORD`
|
|
53
|
+
(= 统一身份认证密码)这两个名字 —— 密钥库里叫什么就用什么,不必为了对齐插件文档重抄一遍明文。
|
|
54
|
+
引导写进密钥库的是插件自己的名字:`CSU_MAIL_ADDR` 与 `CSU_MAIL_AUTHCODE`。
|
|
55
|
+
|
|
56
|
+
**登录只做一次,失败不要重试**:CAS 短时间高频失败会触发风控甚至冻结账号(2026-10-02 真实发生过一次)。
|
|
57
|
+
|
|
58
|
+
## 二、系统事实(实测)
|
|
59
|
+
|
|
60
|
+
| 项 | 值 |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| 邮件系统 | Coremail XT5,网页入口 `https://mail.csu.edu.cn` |
|
|
63
|
+
| 地址 | `学号@csu.edu.cn`,另有别名 `拼音.csu@csu.edu.cn`,两者是同一邮箱 |
|
|
64
|
+
| 二次验证 | 已开启(短信验证),所以第三方客户端只能用**客户端专用密码** |
|
|
65
|
+
| IMAP | `imap.csu.edu.cn:993` SSL(`mail.csu.edu.cn:993` 同样可用) |
|
|
66
|
+
| POP | `pop.csu.edu.cn:995` SSL |
|
|
67
|
+
| SMTP | `smtp.csu.edu.cn:465` SSL;**587 关闭、25 不通** |
|
|
68
|
+
| IMAP 证书 | CN = `mail.csu.edu.cn`,与主机名 `imap.csu.edu.cn` 不匹配,必须跳过证书校验,否则 `SSL: CERTIFICATE_VERIFY_FAILED` |
|
|
69
|
+
|
|
70
|
+
链路实测(2026-10-02,非校园网出口):
|
|
71
|
+
|
|
72
|
+
- 两个端口都可达,**TLS1.3** 握手成功;IMAP 问候 `* OK Coremail System IMap Server Ready(...)`,
|
|
73
|
+
SMTP 问候 `220 csu.edu.cn Anti-spam GT for Coremail System (...)`;`imaplib`/`smtplib` 用跳过校验的 `SSLContext` 可直接建连。
|
|
74
|
+
- 证书严格校验访问 `imap.csu.edu.cn` 必然 `Hostname mismatch`——这不是网络故障,别再往防火墙方向排查。
|
|
75
|
+
|
|
76
|
+
认证的边界(都实测过):
|
|
77
|
+
|
|
78
|
+
- 统一身份认证密码**不能**登录 IMAP → `LOGIN Login error or password error`
|
|
79
|
+
- 网页直登 `/coremail/index.jsp?cus=1`(明文 `uid`+`password`)→ `FA_UNAUTHORIZED`,还会弹验证码
|
|
80
|
+
- 结论:第三方客户端只有**客户端专用密码**这一条路
|
|
81
|
+
|
|
82
|
+
## 三、首次引导:两条路都走完的三步
|
|
83
|
+
|
|
84
|
+
第一节决定「用哪条路登录」,这里是把结果落到密钥库的三步(`bootstrap.sh` 就是自动化了这三步)。
|
|
85
|
+
|
|
86
|
+
1. 网页邮箱 → `设置 → 个人信息 → 邮箱密码` →「客户端专用密码」区块 → 点**虚线方框 “+”** → 填密码名称 →「生成」。
|
|
87
|
+
**不需要短信验证**,但密码串**只在生成时显示一次**;丢了就在同一页面「移除」后重建。
|
|
88
|
+
2. 后端接口(想自动化时用):`user:listAppPwds`(列表)、`user:addAppPwd`(创建,响应里带明文)、
|
|
89
|
+
`user:deleteAppPwds`(`{"ids":[102]}`)。请求走 `/coremail/s`,**`func` 与 `sid` 必须在 query string**。
|
|
90
|
+
3. 本插件的脚本:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# ① CAS 登录换 sid(只做一次)
|
|
94
|
+
CSU_MAIL_ADDR=学号@csu.edu.cn CSU_CAS_USER=学号 CSU_CAS_PASS=密码 \
|
|
95
|
+
python scripts/cas_login.py --out mail_session.json # 产出 600 权限的 sid + cookies
|
|
96
|
+
|
|
97
|
+
# ② 驱动网页邮箱生成专用密码(需要 Playwright)
|
|
98
|
+
PLAYWRIGHT_MODULE=/path/to/node_modules/playwright-core \
|
|
99
|
+
CHROME_PATH=/path/to/chrome \
|
|
100
|
+
node scripts/create_apppw.mjs mail_session.json agent-server --out secret.txt
|
|
101
|
+
|
|
102
|
+
# ③ 存进密钥库并删掉明文
|
|
103
|
+
# 必须显式带 --project-id / --agent-id:CLI 的默认项目是 default_project,
|
|
104
|
+
# 不带就会写进别的项目的密钥库(密钥库里查得到,环境变量里永远没有)。
|
|
105
|
+
# 项目 id = 运行环境 App Data Dir 的最后一段,如 /root/.adelie/data/asass -> asass
|
|
106
|
+
P="--project-id <项目id> --agent-id default_agent"
|
|
107
|
+
penguin config vault set $P --key CSU_MAIL_AUTHCODE --value "$(cat secret.txt)" && shred -u secret.txt
|
|
108
|
+
penguin config vault set $P --key CSU_MAIL_ADDR --value "学号@csu.edu.cn"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
> 写完后**当前会话仍然没有**这两个变量(见踩坑 2);开一个新对话,或等本会话压缩后再用。
|
|
112
|
+
|
|
113
|
+
> 引导用的 CAS 登录**不要重试**:失败就停下来问用户,别连续试密码。
|
|
114
|
+
|
|
115
|
+
## 四、日常收发信:`scripts/mail.py`
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
export CSU_MAIL_ADDR=学号@csu.edu.cn CSU_MAIL_AUTHCODE=专用密码 # 或从密钥库注入
|
|
119
|
+
V=python3
|
|
120
|
+
|
|
121
|
+
$V scripts/mail.py check # IMAP + SMTP 自检
|
|
122
|
+
$V scripts/mail.py list INBOX 10 # 最近 10 封
|
|
123
|
+
$V scripts/mail.py list "Sent Items" 5 # 含空格的文件夹要加引号
|
|
124
|
+
$V scripts/mail.py read 124 # 读一封(正文)
|
|
125
|
+
$V scripts/mail.py search 成绩 # 按标题检索
|
|
126
|
+
$V scripts/mail.py send --to jcc@csu.edu.cn --subject "..." --body-file body.txt
|
|
127
|
+
# 发信,并自动存一份到「已发送」
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
依赖:`requests`(只有 `mail.py` 的核心收发用标准库 `imaplib`/`smtplib`/`email`,无需三方包)。
|
|
131
|
+
|
|
132
|
+
**动手前先自检**:`check` 会依次登录 IMAP 与 SMTP,两条都通过才算通。若报「缺少邮箱地址 / 缺少 CSU_MAIL_AUTHCODE」,
|
|
133
|
+
那是凭据没注入,不是认证失败——去查密钥库写没写错项目(见踩坑 9),别去重试登录。
|
|
134
|
+
|
|
135
|
+
## 五、踩过的坑
|
|
136
|
+
|
|
137
|
+
1. **「生成」按钮初始是 `disabled`**,弹出框里的密码名称输入框必须用**真实键盘事件**输入
|
|
138
|
+
(Playwright 的 `fill()` 不行,要 `click()` + `keyboard.type()`),否则点了没反应、也不报错。
|
|
139
|
+
2. **专用密码只显示一次**:生成后立刻写进密钥库。密钥库变量在**会话开始**时注入,所以当前会话新加的变量当场读不到——
|
|
140
|
+
开一个新对话即可。若**换了会话**还是空的,那不是注入时机问题,是写错了项目(见踩坑 9)。
|
|
141
|
+
验证办法:在当前 shell 里 `echo ${#CSU_MAIL_AUTHCODE}`,非 0 才算注入成功(只输出长度,别打印明文)。
|
|
142
|
+
3. **网页接口发信不留底**:`mbox:compose` 返回 `S_OK`,但「已发送」里查不到,事后无法核实送达。
|
|
143
|
+
要留底就用 SMTP 发送 + `IMAP APPEND` 到 `Sent Items`(本插件的 `mail.py send` 就是这么做的)。
|
|
144
|
+
4. **文件夹名含空格要加引号**(`"Sent Items"`),否则 IMAP 报 `BAD Request not ending with ...`。
|
|
145
|
+
5. **中文 `SEARCH SUBJECT` 命中不稳定**:查不到时改用 `list` 翻页,别下结论说「没有这封邮件」。
|
|
146
|
+
6. 网页 API 的 `func` / `sid` 必须放在 **query string**,其余字段放 form body,否则 202/失败。
|
|
147
|
+
7. IMAP 证书 CN 不匹配,连接时必须 `check_hostname=False; verify_mode=CERT_NONE`。
|
|
148
|
+
8. **不要把账号、密码、专用密码写进代码或提交进仓库**——凭证只放密钥库/环境变量。
|
|
149
|
+
9. **`penguin config vault set/list` 的默认项目是 `default_project`**:不带 `--project-id` 就会把凭据写进另一个项目的密钥库,
|
|
150
|
+
现象是「密钥库里查得到,Agent 的 shell 里却始终没有这个变量」。2026-10-02 真实踩到:专用密码落进了 `default_project`,
|
|
151
|
+
而 Agent 跑在别的项目里,于是 `mail.py check` 只报「缺少邮箱地址」——**别把它当认证失败去重试登录**。
|
|
152
|
+
查证方法:`penguin config vault list --project-id <项目id> --agent-id default_agent`,
|
|
153
|
+
项目 id = 运行环境 App Data Dir 的最后一段(`/root/.adelie/data/<项目id>`),列表里有、`env` 里没有就是这个问题。
|
|
154
|
+
修法:把值重新 set 到正确项目,并清掉写错的那份:`penguin config vault remove --key <KEY> --project-id default_project --agent-id <agent>`。
|
|
155
|
+
|
|
156
|
+
## 六、边界
|
|
157
|
+
|
|
158
|
+
- 本技能只覆盖**邮件**本身。Coremail 的通讯录、日程不在范围内。
|
|
159
|
+
- 代用户发信前先确认收件人与措辞;不要群发、不要发垃圾邮件。
|
|
160
|
+
- 邮箱是学校通知与个人信息的入口,读信时注意隐私,不要把邮件内容贴到无关的地方。
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# csu-mail 引导:从「环境里没有 CSU_MAIL_ADDR / CSU_MAIL_AUTHCODE」到「两个变量都在密钥库里」。
|
|
3
|
+
#
|
|
4
|
+
# 这一步**必须登录一次网页邮箱**——专用密码只能在登录后的设置页生成,没有别的办法。
|
|
5
|
+
# 走的是 CAS SSO(Coremail 开着二次验证时唯一可靠的路);登录一次之后就再也不碰统一身份认证了。
|
|
6
|
+
#
|
|
7
|
+
# 用法:
|
|
8
|
+
# CSU_MAIL_ADDR=学号@csu.edu.cn \
|
|
9
|
+
# CSU_CAS_USER=学号 CSU_CAS_PASS=密码 \
|
|
10
|
+
# PROJECT_ID=<App Data Dir 的最后一段> \
|
|
11
|
+
# bash scripts/bootstrap.sh
|
|
12
|
+
#
|
|
13
|
+
# 可选环境变量:
|
|
14
|
+
# AGENT_ID 默认 default_agent
|
|
15
|
+
# PASSWORD_NAME 专用密码名称,默认 agent-server
|
|
16
|
+
# PYTHON python 解释器,默认 python3(需要 requests / bs4 / pycryptodome)
|
|
17
|
+
# PLAYWRIGHT_MODULE / CHROME_PATH Playwright 与本机 Chromium 的路径
|
|
18
|
+
# SECRET_OUT 额外把专用密码写到该文件(600,不删除)——给 systemd 这类读不了密钥库的场景用
|
|
19
|
+
# NO_VAULT=1 不写密钥库,只打印该执行的两条命令
|
|
20
|
+
# CSU_LOGIN_MODE cas(默认)| direct(邮箱有自己的密码时才用;中南大学实测走不通)
|
|
21
|
+
set -euo pipefail
|
|
22
|
+
|
|
23
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
24
|
+
PYTHON="${PYTHON:-python3}"
|
|
25
|
+
AGENT_ID="${AGENT_ID:-default_agent}"
|
|
26
|
+
PASSWORD_NAME="${PASSWORD_NAME:-agent-server}"
|
|
27
|
+
MODE="${CSU_LOGIN_MODE:-cas}"
|
|
28
|
+
|
|
29
|
+
# 密钥库里的键名以实际写进去的为准,这里两套都认、一律折成插件原本的名字。2026-10-05 用户写的是
|
|
30
|
+
# CSU_CAS_PASSWORD / CSU_CAS_ADDRESS;插件文档一直写的是 CSU_CAS_PASS / CSU_MAIL_ADDR。重命名要
|
|
31
|
+
# 重新抄一遍明文,不值得 —— 兼容读一遍即可。
|
|
32
|
+
CSU_MAIL_ADDR="${CSU_MAIL_ADDR:-${CSU_CAS_ADDRESS:-}}"
|
|
33
|
+
CSU_CAS_PASS="${CSU_CAS_PASS:-${CSU_CAS_PASSWORD:-}}"
|
|
34
|
+
|
|
35
|
+
die() { echo "✗ $*" >&2; exit 1; }
|
|
36
|
+
|
|
37
|
+
[ -n "${CSU_MAIL_ADDR:-}" ] || die "请设置 CSU_MAIL_ADDR(或 CSU_CAS_ADDRESS):邮箱地址,如 学号@csu.edu.cn"
|
|
38
|
+
[ -n "${CSU_CAS_USER:-}" ] || die "请设置 CSU_CAS_USER(统一身份认证账号,通常是学号)"
|
|
39
|
+
[ -n "${CSU_CAS_PASS:-}" ] || die "请设置 CSU_CAS_PASS(或 CSU_CAS_PASSWORD):统一身份认证密码"
|
|
40
|
+
|
|
41
|
+
[ -n "${CSU_MAIL_AUTHCODE:-}" ] && { echo "✓ 环境里已经有 CSU_MAIL_AUTHCODE(长度 ${#CSU_MAIL_AUTHCODE}),无需引导。"; echo " 直接自检即可:$PYTHON $HERE/mail.py check"; exit 0; }
|
|
42
|
+
|
|
43
|
+
# 先验依赖:python 侧 requests/bs4/pycryptodome,node 侧供 create_apppw.mjs 用
|
|
44
|
+
"$PYTHON" -c 'import requests, bs4, Crypto' 2>/dev/null \
|
|
45
|
+
|| die "$PYTHON 缺依赖,先装:$PYTHON -m pip install requests beautifulsoup4 pycryptodome"
|
|
46
|
+
command -v node >/dev/null || die "找不到 node(第②步生成专用密码要它)"
|
|
47
|
+
|
|
48
|
+
TMP="$(mktemp -d)"
|
|
49
|
+
cleanup() {
|
|
50
|
+
[ -f "$TMP/secret" ] && { shred -u "$TMP/secret" 2>/dev/null || rm -f "$TMP/secret"; }
|
|
51
|
+
rm -rf "$TMP"
|
|
52
|
+
}
|
|
53
|
+
trap cleanup EXIT
|
|
54
|
+
|
|
55
|
+
echo "→ ① 登录网页邮箱换 sid(模式 $MODE,只这一次)"
|
|
56
|
+
CSU_MAIL_ADDR="$CSU_MAIL_ADDR" "$PYTHON" "$HERE/cas_login.py" --mode "$MODE" --out "$TMP/session.json" \
|
|
57
|
+
|| die "登录失败:不要重试,先找用户确认账号密码或账号状态(CAS 会被风控)。"
|
|
58
|
+
|
|
59
|
+
echo "→ ② 在网页邮箱里生成一条客户端专用密码(名称:$PASSWORD_NAME)"
|
|
60
|
+
PLAYWRIGHT_MODULE="${PLAYWRIGHT_MODULE:-playwright-core}" CHROME_PATH="${CHROME_PATH:-}" \
|
|
61
|
+
node "$HERE/create_apppw.mjs" "$TMP/session.json" "$PASSWORD_NAME" --out "$TMP/secret" \
|
|
62
|
+
|| die "生成专用密码失败:确认已装 Playwright/Chromium(PLAYWRIGHT_MODULE、CHROME_PATH)。"
|
|
63
|
+
|
|
64
|
+
echo "→ ③ 写进密钥库(长度校验:$(wc -c < "$TMP/secret" | tr -d ' ') 字节)"
|
|
65
|
+
if [ "${NO_VAULT:-0}" = "1" ]; then
|
|
66
|
+
echo " NO_VAULT=1,跳过。请自己执行(注意必须指定项目,否则写进 default_project,环境里永远没有):"
|
|
67
|
+
echo " penguin config vault set --project-id <项目id> --agent-id $AGENT_ID --key CSU_MAIL_ADDR --value '$CSU_MAIL_ADDR'"
|
|
68
|
+
echo " penguin config vault set --project-id <项目id> --agent-id $AGENT_ID --key CSU_MAIL_AUTHCODE --value \"\$(cat 专用密码文件)\""
|
|
69
|
+
[ -n "${SECRET_OUT:-}" ] && { install -m 600 "$TMP/secret" "$SECRET_OUT"; echo " 专用密码也写到了 $SECRET_OUT"; }
|
|
70
|
+
exit 0
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
[ -n "${PROJECT_ID:-}" ] || die "请设置 PROJECT_ID(= 运行环境 App Data Dir 的最后一段,如 /root/.adelie/data/sjaaj -> sjaaj);
|
|
74
|
+
否则凭据会落进 default_project:密钥库里查得到,Agent 的 shell 里却永远没有这个变量。"
|
|
75
|
+
|
|
76
|
+
command -v penguin >/dev/null || die "找不到 penguin CLI:请手动写密钥库,或用 NO_VAULT=1 看命令。"
|
|
77
|
+
|
|
78
|
+
penguin config vault set --project-id "$PROJECT_ID" --agent-id "$AGENT_ID" \
|
|
79
|
+
--key CSU_MAIL_ADDR --value "$CSU_MAIL_ADDR"
|
|
80
|
+
penguin config vault set --project-id "$PROJECT_ID" --agent-id "$AGENT_ID" \
|
|
81
|
+
--key CSU_MAIL_AUTHCODE --value "$(cat "$TMP/secret")"
|
|
82
|
+
|
|
83
|
+
[ -n "${SECRET_OUT:-}" ] && { install -m 600 "$TMP/secret" "$SECRET_OUT"; echo "✓ 专用密码副本写到 $SECRET_OUT(600)"; }
|
|
84
|
+
|
|
85
|
+
echo "✓ 完成。当前会话里还没有这两个变量(注入发生在会话开始),先这么用:"
|
|
86
|
+
echo " CSU_MAIL_ADDR=$CSU_MAIL_ADDR CSU_MAIL_AUTHCODE=<从密钥库读> python $HERE/mail.py check"
|
|
87
|
+
echo " 下一个新会话起,环境里会自动带上 CSU_MAIL_ADDR / CSU_MAIL_AUTHCODE。"
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""登录网页邮箱,换取 Coremail 的 sid —— 只用于首次引导。
|
|
3
|
+
|
|
4
|
+
日常收发信**不需要**这个脚本:有了客户端专用密码就能直接走 IMAP/SMTP(见 mail.py)。
|
|
5
|
+
只有当邮箱里还没有专用密码、又必须进网页邮箱生成一条时才用它。
|
|
6
|
+
|
|
7
|
+
两条登录路径(`--mode`):
|
|
8
|
+
|
|
9
|
+
cas 走统一身份认证 SSO(默认)。Coremail 侧二次验证开着时,这是**唯一可靠**的路。
|
|
10
|
+
direct 直接用 `学号 + 密码` POST 网页登录表单 `/coremail/index.jsp?cus=1`。
|
|
11
|
+
只有当邮箱有自己的密码(≠ 统一身份认证密码)时才会成功。
|
|
12
|
+
中南大学实测:用统一身份认证密码直登返回 `FA_UNAUTHORIZED`,并开始要验证码 —— 走不通。
|
|
13
|
+
|
|
14
|
+
无论哪条路:**不要重试**。失败就停下来问用户,别连续试密码(CAS 会被风控,邮箱会要验证码)。
|
|
15
|
+
|
|
16
|
+
用法:
|
|
17
|
+
CSU_MAIL_ADDR=学号@csu.edu.cn CSU_CAS_USER=学号 CSU_CAS_PASS=密码 \
|
|
18
|
+
python cas_login.py [--mode cas|direct] [--out mail_session.json]
|
|
19
|
+
|
|
20
|
+
产出:JSON(sid + cookies,权限 600),可直接喂给 create_apppw.mjs。
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import base64
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import random
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
import requests
|
|
31
|
+
from bs4 import BeautifulSoup
|
|
32
|
+
from Crypto.Cipher import AES
|
|
33
|
+
|
|
34
|
+
CAS = os.environ.get("CSU_CAS_URL", "https://ca.csu.edu.cn/authserver/login")
|
|
35
|
+
SERVICE = os.environ.get("CSU_MAIL_SSO", "https://mail.csu.edu.cn/coremail/cmcu_addon/sso.jsp")
|
|
36
|
+
MAIL_BASE = os.environ.get("CSU_MAIL_BASE", "https://mail.csu.edu.cn")
|
|
37
|
+
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
38
|
+
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
|
|
39
|
+
CHARS = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _rand(n: int) -> str:
|
|
43
|
+
return "".join(random.choice(CHARS) for _ in range(n))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def encrypt_password(password: str, salt: str) -> str:
|
|
47
|
+
"""与 CAS 页面 static/common/encrypt.js 的 encryptAES 一致:
|
|
48
|
+
明文 = 64 位随机串 + 密码,AES-CBC(key = pwdEncryptSalt,iv = 16 位随机串),PKCS7,Base64。"""
|
|
49
|
+
text = _rand(64) + password
|
|
50
|
+
pad_len = AES.block_size - len(text) % AES.block_size
|
|
51
|
+
text += pad_len * chr(pad_len)
|
|
52
|
+
cipher = AES.new(salt.encode(), AES.MODE_CBC, _rand(16).encode())
|
|
53
|
+
return base64.b64encode(cipher.encrypt(text.encode())).decode()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cas_login(user: str, password: str, service: str = SERVICE) -> requests.Session:
|
|
57
|
+
s = requests.Session()
|
|
58
|
+
s.headers["User-Agent"] = UA
|
|
59
|
+
page = s.get(CAS, params={"service": service}, timeout=20)
|
|
60
|
+
div = BeautifulSoup(page.text, "lxml").find(id="pwdLoginDiv")
|
|
61
|
+
if div is None:
|
|
62
|
+
raise RuntimeError("CAS 页面结构变化,取不到 pwdLoginDiv(可能要求验证码)")
|
|
63
|
+
resp = s.post(CAS, params={"service": service}, timeout=25, data={
|
|
64
|
+
"username": user,
|
|
65
|
+
"password": encrypt_password(password, div.find(id="pwdEncryptSalt")["value"]),
|
|
66
|
+
"passwordText": "", "_eventId": "submit", "cllt": "userNameLogin",
|
|
67
|
+
"dllt": "generalLogin", "lt": "",
|
|
68
|
+
"execution": div.find(id="execution")["value"], "rmShown": "1",
|
|
69
|
+
})
|
|
70
|
+
if "/authserver/login" in resp.url and "ticket" not in resp.url:
|
|
71
|
+
raise RuntimeError("统一身份认证登录失败(账号或密码错误,或触发风控/需要验证码)")
|
|
72
|
+
return s
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def direct_login(user: str, password: str):
|
|
76
|
+
"""网页直登,返回 (session, sid):POST /coremail/index.jsp?cus=1(明文的 uid + password + 表单隐藏字段)。
|
|
77
|
+
|
|
78
|
+
要先把登录页上的隐藏字段原样回传(`loginType`、`locale`、`action:login` 等),
|
|
79
|
+
少了它们会被判非法请求。Coremail 的失败信息在页面的 #warnOrErrDiv 里,
|
|
80
|
+
同时 `CM.config.user.loginResultCode` 会给一个代码(如 FA_UNAUTHORIZED)。
|
|
81
|
+
"""
|
|
82
|
+
s = requests.Session()
|
|
83
|
+
s.headers["User-Agent"] = UA
|
|
84
|
+
page = s.get(MAIL_BASE + "/", timeout=20)
|
|
85
|
+
html = page.text
|
|
86
|
+
fi = html.find('class="j-login-form')
|
|
87
|
+
if fi < 0:
|
|
88
|
+
raise RuntimeError("取不到登录表单:页面结构可能变了")
|
|
89
|
+
seg = html[fi: html.find("</form>", fi)]
|
|
90
|
+
data = {}
|
|
91
|
+
for m in re.finditer(r"<input([^>]*)>", seg):
|
|
92
|
+
attrs = m.group(1)
|
|
93
|
+
n = re.search(r'name="([^"]+)"', attrs)
|
|
94
|
+
v = re.search(r'value="([^"]*)"', attrs)
|
|
95
|
+
if n:
|
|
96
|
+
data[n.group(1)] = v.group(1) if v else ""
|
|
97
|
+
data["uid"] = user
|
|
98
|
+
data["password"] = password
|
|
99
|
+
resp = s.post(MAIL_BASE + "/coremail/index.jsp?cus=1", data=data, timeout=25,
|
|
100
|
+
headers={"Referer": MAIL_BASE + "/"})
|
|
101
|
+
# 注意:失败页面里也会出现 "sid=" 字样(SSO 链接),必须先判错再用 sid 判成功
|
|
102
|
+
err = (re.search(r'id="warnOrErrDiv"[^>]*>\s*<label>([^<]+)', resp.text)
|
|
103
|
+
or re.search(r'class="[^"]*\berrMsg\b[^"]*"[^>]*>\s*<label>([^<]+)', resp.text))
|
|
104
|
+
code = re.search(r"loginResultCode:\s*'([^']+)'", resp.text)
|
|
105
|
+
m = re.search(r"sid=([A-Za-z0-9_\-]+)", resp.url) or re.search(r'sid="([A-Za-z0-9_\-]+)"', resp.text)
|
|
106
|
+
if err or not m:
|
|
107
|
+
detail = err.group(1).strip() if err else "没有返回可用的 sid"
|
|
108
|
+
if code:
|
|
109
|
+
detail += f"(loginResultCode={code.group(1)})"
|
|
110
|
+
raise RuntimeError(f"网页直登失败:{detail} —— 邮箱有自己的密码才走得通,中南大学实测走不通;"
|
|
111
|
+
"请改用默认的 --mode cas,或让用户在浏览器里手动生成专用密码")
|
|
112
|
+
return s, m.group(1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def mail_sid(s: requests.Session, resp) -> str:
|
|
116
|
+
m = re.search(r"sid=([A-Za-z0-9_\-]+)", resp.url)
|
|
117
|
+
if not m:
|
|
118
|
+
r = s.get("https://mail.csu.edu.cn/coremail/XT5/index.jsp", allow_redirects=True, timeout=25)
|
|
119
|
+
m = (re.search(r"sid=([A-Za-z0-9_\-]+)", r.url)
|
|
120
|
+
or re.search(r'name="sid"\s+value="([^"]+)"', r.text))
|
|
121
|
+
if not m:
|
|
122
|
+
raise RuntimeError("SSO 回调里没有 sid")
|
|
123
|
+
return m.group(1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main():
|
|
127
|
+
ap = argparse.ArgumentParser(description="登录网页邮箱换 sid(仅引导时使用)")
|
|
128
|
+
ap.add_argument("--mode", choices=["cas", "direct"], default=os.environ.get("CSU_LOGIN_MODE", "cas"))
|
|
129
|
+
ap.add_argument("--user", default=os.environ.get("CSU_CAS_USER", ""))
|
|
130
|
+
ap.add_argument("--password", default=os.environ.get("CSU_CAS_PASS") or os.environ.get("CSU_CAS_PASSWORD", ""))
|
|
131
|
+
ap.add_argument("--service", default=SERVICE)
|
|
132
|
+
ap.add_argument("--out", default="mail_session.json")
|
|
133
|
+
a = ap.parse_args()
|
|
134
|
+
if not a.user or not a.password:
|
|
135
|
+
sys.exit("需要 CSU_CAS_USER / CSU_CAS_PASS(或 CSU_CAS_PASSWORD;也可用 --user/--password)")
|
|
136
|
+
|
|
137
|
+
if a.mode == "direct":
|
|
138
|
+
s, sid = direct_login(a.user, a.password)
|
|
139
|
+
else:
|
|
140
|
+
s = cas_login(a.user, a.password, a.service)
|
|
141
|
+
resp = s.get(a.service, timeout=25)
|
|
142
|
+
sid = mail_sid(s, resp)
|
|
143
|
+
|
|
144
|
+
with open(a.out, "w", encoding="utf-8") as f:
|
|
145
|
+
json.dump({"sid": sid, "cookies": s.cookies.get_dict()}, f)
|
|
146
|
+
os.chmod(a.out, 0o600)
|
|
147
|
+
print(f"sid 已写入 {a.out}({sid[:4]}…{sid[-4:]},模式 {a.mode}),接下来可用 create_apppw.mjs 生成客户端专用密码")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
main()
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 用 cas_login.py 产出的 mail_session.json 打开网页邮箱,生成一条「客户端专用密码」。
|
|
3
|
+
// 专用密码只在生成时显示一次,生成后请立刻存进密钥库(不要写进代码/仓库)。
|
|
4
|
+
//
|
|
5
|
+
// 用法:
|
|
6
|
+
// PLAYWRIGHT_MODULE=/path/to/node_modules/playwright-core \
|
|
7
|
+
// CHROME_PATH=/path/to/chrome \
|
|
8
|
+
// node create_apppw.mjs mail_session.json <密码名称> [--out secret.txt]
|
|
9
|
+
//
|
|
10
|
+
// 两个坑(本脚本已处理):
|
|
11
|
+
// 1. 弹窗里的「生成」按钮初始带 disabled,输入框必须用**真实键盘事件**输入才会解除,
|
|
12
|
+
// Playwright 的 fill() 不够,要用 click + keyboard.type。
|
|
13
|
+
// 2. 生成结果同时来自 user:addAppPwd 的响应体,正则取 password/pwd 字段最稳。
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
|
|
18
|
+
const PW_MODULE = process.env.PLAYWRIGHT_MODULE || "playwright-core";
|
|
19
|
+
const CHROME = process.env.CHROME_PATH || "";
|
|
20
|
+
// PLAYWRIGHT_MODULE 可以是包名(playwright-core),也可以是本机安装目录的绝对路径
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
let pw;
|
|
23
|
+
try {
|
|
24
|
+
pw = require(PW_MODULE);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
pw = require(PW_MODULE.replace(/\/+$/, "") + "/index.js");
|
|
27
|
+
}
|
|
28
|
+
const chromium = pw.chromium;
|
|
29
|
+
if (!chromium) {
|
|
30
|
+
console.error(
|
|
31
|
+
`没有从 ${PW_MODULE} 拿到 chromium:检查 PLAYWRIGHT_MODULE 是否指向 playwright / playwright-core`,
|
|
32
|
+
);
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const [, , sessionFile, name, ...rest] = process.argv;
|
|
37
|
+
if (!sessionFile || !name) {
|
|
38
|
+
console.error("用法: node create_apppw.mjs <mail_session.json> <密码名称> [--out secret.txt]");
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
const opts = {};
|
|
42
|
+
for (let i = 0; i < rest.length; i += 2) opts[rest[i].replace(/^--/, "")] = rest[i + 1];
|
|
43
|
+
|
|
44
|
+
(async () => {
|
|
45
|
+
const sess = JSON.parse(fs.readFileSync(sessionFile, "utf8"));
|
|
46
|
+
const browser = await chromium.launch({
|
|
47
|
+
executablePath: CHROME || undefined,
|
|
48
|
+
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
49
|
+
});
|
|
50
|
+
const ctx = await browser.newContext({
|
|
51
|
+
userAgent:
|
|
52
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36",
|
|
53
|
+
viewport: { width: 1280, height: 800 },
|
|
54
|
+
});
|
|
55
|
+
await ctx.addCookies(
|
|
56
|
+
Object.entries(sess.cookies || {}).map(([k, v]) => ({
|
|
57
|
+
name: k,
|
|
58
|
+
value: v,
|
|
59
|
+
domain: "mail.csu.edu.cn",
|
|
60
|
+
path: "/",
|
|
61
|
+
})),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const page = await ctx.newPage();
|
|
65
|
+
const bodies = [];
|
|
66
|
+
page.on("response", async (r) => {
|
|
67
|
+
if (!r.url().includes("/coremail/s")) return;
|
|
68
|
+
const func = decodeURIComponent((r.url().match(/func=([^&]+)/) || [])[1] || "");
|
|
69
|
+
if (!/AppPwd/i.test(func)) return;
|
|
70
|
+
try {
|
|
71
|
+
bodies.push({ func, text: await r.text() });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
/* ignore */
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await page.goto(`https://mail.csu.edu.cn/coremail/XT5/index.jsp?sid=${sess.sid}#setting`, {
|
|
78
|
+
waitUntil: "domcontentloaded",
|
|
79
|
+
timeout: 60000,
|
|
80
|
+
});
|
|
81
|
+
await page.waitForTimeout(6000);
|
|
82
|
+
await page.getByText("个人信息", { exact: true }).first().click({ timeout: 10000 });
|
|
83
|
+
await page.waitForTimeout(2000);
|
|
84
|
+
await page.getByText("邮箱密码", { exact: true }).first().click({ timeout: 10000 });
|
|
85
|
+
await page.waitForTimeout(4000);
|
|
86
|
+
|
|
87
|
+
await page.locator("li.j-add-item").first().click({ timeout: 10000 });
|
|
88
|
+
await page.waitForTimeout(2500);
|
|
89
|
+
const input = page.locator("input.j-input-name").first();
|
|
90
|
+
await input.click();
|
|
91
|
+
await page.keyboard.type(name, { delay: 60 }); // 必须真实键入,否则确认键仍是 disabled
|
|
92
|
+
await page.waitForTimeout(500);
|
|
93
|
+
await page
|
|
94
|
+
.locator(".u-dialog-btns button[data-role=confirm]")
|
|
95
|
+
.first()
|
|
96
|
+
.click({ force: true, timeout: 10000 });
|
|
97
|
+
await page.waitForTimeout(7000);
|
|
98
|
+
|
|
99
|
+
let secret = null;
|
|
100
|
+
for (const b of bodies) {
|
|
101
|
+
const m = b.text.match(/"(?:password|pwd|appPwd|altpwd|secret)"\s*:\s*"([^"]{6,64})"/i);
|
|
102
|
+
if (m) {
|
|
103
|
+
secret = m[1];
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!secret) {
|
|
108
|
+
const txt = (await page.innerText("body")).replace(/\s+/g, " ");
|
|
109
|
+
const m = txt.match(/专用密码[^A-Za-z0-9]{0,20}([A-Za-z0-9]{8,32})/);
|
|
110
|
+
if (m) secret = m[1];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!secret) {
|
|
114
|
+
console.error(
|
|
115
|
+
"没拿到专用密码:确认已在网页邮箱里点开了「客户端专用密码」区块,且密码名称未被占用。",
|
|
116
|
+
);
|
|
117
|
+
await page.screenshot({ path: "apppw-failed.png", fullPage: true });
|
|
118
|
+
await browser.close();
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (opts.out) {
|
|
123
|
+
fs.writeFileSync(opts.out, secret, { mode: 0o600 });
|
|
124
|
+
console.log(`专用密码已写入 ${opts.out}(len=${secret.length},请立刻存进密钥库后删除该文件)`);
|
|
125
|
+
} else {
|
|
126
|
+
console.log("专用密码(只显示这一次):", secret);
|
|
127
|
+
}
|
|
128
|
+
console.log("已创建的专用密码名称:", name, "| 后端接口:", bodies.map((b) => b.func).join(", "));
|
|
129
|
+
await browser.close();
|
|
130
|
+
})().catch((e) => {
|
|
131
|
+
console.error("ERR", e.message);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Coremail 邮箱命令行工具 —— 走 IMAP/SMTP + 客户端专用密码,不经过统一身份认证(CAS)。
|
|
2
|
+
|
|
3
|
+
凭证(环境变量):
|
|
4
|
+
CSU_MAIL_ADDR 邮箱地址,如 1234567890@csu.edu.cn ← 必填,不写死在代码里
|
|
5
|
+
CSU_MAIL_AUTHCODE 客户端专用密码(App Password) ← 必填,放在密钥库里
|
|
6
|
+
CSU_MAIL_IMAP_HOST 默认 imap.csu.edu.cn
|
|
7
|
+
CSU_MAIL_SMTP_HOST 默认 smtp.csu.edu.cn
|
|
8
|
+
|
|
9
|
+
没有专用密码就去网页邮箱「设置 → 个人信息 → 邮箱密码 → 客户端专用密码」点虚线 "+" 生成一个
|
|
10
|
+
(密码串只在生成时显示一次,不需要短信验证)。
|
|
11
|
+
|
|
12
|
+
用法:
|
|
13
|
+
python mail.py check # 连通性自检(IMAP + SMTP 登录)
|
|
14
|
+
python mail.py list [文件夹] [条数] # 默认 INBOX 最近 10 封
|
|
15
|
+
python mail.py search <关键词> [文件夹] [条数]
|
|
16
|
+
python mail.py read <序号> [文件夹] # 打印邮件正文
|
|
17
|
+
python mail.py send --to a@b.com --subject 主题 [--body 正文 | --body-file f]
|
|
18
|
+
[--attach 文件] # 发信并留一份到「已发送」
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import email
|
|
22
|
+
import imaplib
|
|
23
|
+
import os
|
|
24
|
+
import ssl
|
|
25
|
+
import smtplib
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
from email.header import decode_header, make_header
|
|
29
|
+
from email.message import EmailMessage
|
|
30
|
+
from email.utils import formatdate
|
|
31
|
+
|
|
32
|
+
IMAP_HOST = os.environ.get("CSU_MAIL_IMAP_HOST", "imap.csu.edu.cn")
|
|
33
|
+
IMAP_PORT = int(os.environ.get("CSU_MAIL_IMAP_PORT", "993"))
|
|
34
|
+
SMTP_HOST = os.environ.get("CSU_MAIL_SMTP_HOST", "smtp.csu.edu.cn")
|
|
35
|
+
SMTP_PORT = int(os.environ.get("CSU_MAIL_SMTP_PORT", "465"))
|
|
36
|
+
SENT = os.environ.get("CSU_MAIL_SENT_FOLDER", "Sent Items")
|
|
37
|
+
|
|
38
|
+
_ADDR = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def addr() -> str:
|
|
42
|
+
a = _ADDR or os.environ.get("CSU_MAIL_ADDR", "").strip() or os.environ.get("CSU_CAS_ADDRESS", "").strip()
|
|
43
|
+
if not a:
|
|
44
|
+
sys.exit("缺少邮箱地址:设置环境变量 CSU_MAIL_ADDR(或 CSU_CAS_ADDRESS),或用 --addr 指定")
|
|
45
|
+
return a
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def code() -> str:
|
|
49
|
+
c = os.environ.get("CSU_MAIL_AUTHCODE", "").strip()
|
|
50
|
+
if not c:
|
|
51
|
+
sys.exit("缺少环境变量 CSU_MAIL_AUTHCODE(客户端专用密码)—— 在邮箱「设置 → 个人信息 → 邮箱密码 → 客户端专用密码」生成一条")
|
|
52
|
+
return c
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _ctx() -> ssl.SSLContext:
|
|
56
|
+
# 学校 IMAP 证书的 CN 与主机名不一致,跳过校验(Coremail 自身仍是 TLS 加密)
|
|
57
|
+
ctx = ssl.create_default_context()
|
|
58
|
+
ctx.check_hostname = False
|
|
59
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
60
|
+
return ctx
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def imap():
|
|
64
|
+
m = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=_ctx())
|
|
65
|
+
m.login(addr(), code())
|
|
66
|
+
return m
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def dec(s) -> str:
|
|
70
|
+
if not s:
|
|
71
|
+
return ""
|
|
72
|
+
try:
|
|
73
|
+
return str(make_header(decode_header(s)))
|
|
74
|
+
except Exception:
|
|
75
|
+
return s
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _mb(folder: str) -> str:
|
|
79
|
+
"""IMAP 文件夹名含空格时必须加引号。"""
|
|
80
|
+
if folder.startswith('"') or not any(c in folder for c in " \t"):
|
|
81
|
+
return folder
|
|
82
|
+
return '"%s"' % folder
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def body_text(msg) -> str:
|
|
86
|
+
if msg.is_multipart():
|
|
87
|
+
for part in msg.walk():
|
|
88
|
+
if part.get_content_type() == "text/plain":
|
|
89
|
+
return part.get_payload(decode=True).decode(part.get_content_charset() or "utf-8", "ignore")
|
|
90
|
+
for part in msg.walk():
|
|
91
|
+
if part.get_content_type() == "text/html":
|
|
92
|
+
return part.get_payload(decode=True).decode(part.get_content_charset() or "utf-8", "ignore")
|
|
93
|
+
return ""
|
|
94
|
+
return msg.get_payload(decode=True).decode(msg.get_content_charset() or "utf-8", "ignore")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_check(_):
|
|
98
|
+
m = imap()
|
|
99
|
+
print("IMAP ok:", [x.decode().split(' "/" ')[-1] for x in m.list()[1]][:8])
|
|
100
|
+
typ, d = m.select("INBOX", readonly=True)
|
|
101
|
+
print("INBOX 封数:", d[0].decode())
|
|
102
|
+
m.logout()
|
|
103
|
+
s = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=_ctx(), timeout=30)
|
|
104
|
+
s.login(addr(), code())
|
|
105
|
+
print("SMTP ok (登录成功,未发信)")
|
|
106
|
+
s.quit()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmd_list(a):
|
|
110
|
+
m = imap()
|
|
111
|
+
m.select(_mb(a.folder), readonly=True)
|
|
112
|
+
typ, ids = m.search(None, "ALL")
|
|
113
|
+
ids = ids[0].split()[-a.limit:][::-1]
|
|
114
|
+
for i in ids:
|
|
115
|
+
t, data = m.fetch(i, "(BODY.PEEK[HEADER.FIELDS (DATE FROM SUBJECT)])")
|
|
116
|
+
raw = data[0][1].decode("utf-8", "ignore")
|
|
117
|
+
msg = email.message_from_string(raw)
|
|
118
|
+
print(f"[{i.decode()}] {dec(msg['Date'])} | {dec(msg['From'])} | {dec(msg['Subject'])}")
|
|
119
|
+
m.logout()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_search(a):
|
|
123
|
+
m = imap()
|
|
124
|
+
m.select(_mb(a.folder), readonly=True)
|
|
125
|
+
typ, ids = m.search("UTF-8", b"SUBJECT", a.keyword.encode("utf-8"))
|
|
126
|
+
if typ != "OK":
|
|
127
|
+
print("检索失败:", ids)
|
|
128
|
+
ids = ids[0].split()[-a.limit:][::-1]
|
|
129
|
+
print(f"{a.folder} 命中 {len(ids)} 封")
|
|
130
|
+
for i in ids:
|
|
131
|
+
t, data = m.fetch(i, "(BODY.PEEK[HEADER.FIELDS (DATE FROM TO SUBJECT)])")
|
|
132
|
+
msg = email.message_from_string(data[0][1].decode("utf-8", "ignore"))
|
|
133
|
+
print(f"[{i.decode()}] {dec(msg['Date'])} | {dec(msg['From'])} -> {dec(msg['To'])} | {dec(msg['Subject'])}")
|
|
134
|
+
m.logout()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_read(a):
|
|
138
|
+
m = imap()
|
|
139
|
+
m.select(_mb(a.folder), readonly=True)
|
|
140
|
+
t, data = m.fetch(a.uid, "(RFC822)")
|
|
141
|
+
msg = email.message_from_bytes(data[0][1])
|
|
142
|
+
print("From:", dec(msg["From"]))
|
|
143
|
+
print("To:", dec(msg["To"]))
|
|
144
|
+
print("Date:", dec(msg["Date"]))
|
|
145
|
+
print("Subject:", dec(msg["Subject"]))
|
|
146
|
+
print("-" * 60)
|
|
147
|
+
print(body_text(msg))
|
|
148
|
+
m.logout()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_send(a):
|
|
152
|
+
body = a.body
|
|
153
|
+
if a.body_file:
|
|
154
|
+
body = open(a.body_file, encoding="utf-8").read()
|
|
155
|
+
msg = EmailMessage()
|
|
156
|
+
msg["From"] = addr()
|
|
157
|
+
msg["To"] = ", ".join(a.to)
|
|
158
|
+
msg["Subject"] = a.subject
|
|
159
|
+
msg["Date"] = formatdate(localtime=True)
|
|
160
|
+
msg.set_content(body)
|
|
161
|
+
for f in a.attach or []:
|
|
162
|
+
import mimetypes
|
|
163
|
+
ctype, _ = mimetypes.guess_type(f)
|
|
164
|
+
maintype, subtype = (ctype or "application/octet-stream").split("/", 1)
|
|
165
|
+
msg.add_attachment(open(f, "rb").read(), maintype=maintype, subtype=subtype, filename=os.path.basename(f))
|
|
166
|
+
raw = msg.as_bytes()
|
|
167
|
+
s = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=_ctx(), timeout=60)
|
|
168
|
+
s.login(addr(), code())
|
|
169
|
+
s.send_message(msg)
|
|
170
|
+
s.quit()
|
|
171
|
+
print("SMTP 已投递:", ", ".join(a.to))
|
|
172
|
+
# 自己留一份到「已发送」,否则在邮箱里看不到发送记录
|
|
173
|
+
m = imap()
|
|
174
|
+
m.append(_mb(SENT), "(\\Seen)", imaplib.Time2Internaldate(time.time()), raw)
|
|
175
|
+
m.logout()
|
|
176
|
+
print("已存入「已发送」")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main():
|
|
180
|
+
p = argparse.ArgumentParser(description="Coremail 邮箱 IMAP/SMTP 工具")
|
|
181
|
+
p.add_argument("--addr", default="", help="邮箱地址(默认取环境变量 CSU_MAIL_ADDR)")
|
|
182
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
183
|
+
sub.add_parser("check").set_defaults(fn=cmd_check)
|
|
184
|
+
q = sub.add_parser("list"); q.add_argument("folder", nargs="?", default="INBOX"); q.add_argument("limit", nargs="?", type=int, default=10); q.set_defaults(fn=cmd_list)
|
|
185
|
+
q = sub.add_parser("search"); q.add_argument("keyword"); q.add_argument("folder", nargs="?", default="INBOX"); q.add_argument("limit", nargs="?", type=int, default=10); q.set_defaults(fn=cmd_search)
|
|
186
|
+
q = sub.add_parser("read"); q.add_argument("uid"); q.add_argument("folder", nargs="?", default="INBOX"); q.set_defaults(fn=cmd_read)
|
|
187
|
+
q = sub.add_parser("send"); q.add_argument("--to", action="append", required=True); q.add_argument("--subject", required=True); q.add_argument("--body", default=""); q.add_argument("--body-file"); q.add_argument("--attach", action="append"); q.set_defaults(fn=cmd_send)
|
|
188
|
+
a = p.parse_args()
|
|
189
|
+
global _ADDR
|
|
190
|
+
_ADDR = a.addr
|
|
191
|
+
a.fn(a)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
main()
|
package/README.md
DELETED