MailExport.php
10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
<?php
namespace Controller;
use Service\MailExport as MailExportService;
/**
* 邮件导出页面
*
* 需要一个固定密码才能访问,密码:mailexport
*
* 路由:
* mail-export 页面(GET 展示 / POST 校验密码)
* mail-export/logout 退出
* mail-export/projects 公司项目搜索(按名称或 postid)
* mail-export/count 查询命中的邮件数量
* mail-export/start 执行导出(同步,导出完成后返回下载地址)
* mail-export/download 下载导出的 zip
*
* @author:dc
* @time 2026/10/28 11:40
* Class MailExport
* @package Controller
*/
class MailExport extends Base {
/**
* 访问密码(固定)
*/
private const PASSWORD = 'mailexport';
/**
* 登录态 cookie 名称
*/
private const COOKIE_NAME = 'mail_export_token';
/**
* 登录态签名盐值,cookie 里不存明文密码
*/
private const COOKIE_SALT = 'mail_export_2026';
/**
* 登录态有效期,7 天
*/
private const COOKIE_TTL = 604800;
/**
* 页面
* GET :未登录返回密码框,已登录返回页面
* POST :校验密码,成功后写入登录态并返回页面
*
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
public function index(){
if(app()->requestHas('password')){
$password = (string)app()->request('password');
if(!hash_equals(self::PASSWORD, $password)){
logs('mail-export 密码错误 IP:'.($_SERVER['REMOTE_ADDR'] ?? ''));
return $this->loginHtml('密码不正确');
}
$this->setAuthCookie();
return $this->pageHtml();
}
if($this->isAuthed()){
return $this->pageHtml();
}
return $this->loginHtml('');
}
/**
* 退出
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
public function logout(){
setcookie(self::COOKIE_NAME, '', [
'expires' => time() - 3600,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
]);
return $this->loginHtml('已退出');
}
/**
* 搜索公司项目
* 支持按公司名称模糊、按 postid 精确
* @return array
* @throws \Lib\Err
* @author:dc
* @time 2026/10/28 11:40
*/
public function projects(){
$this->needAuth();
$keyword = trim((string)app()->request('keyword', ''));
if($keyword === ''){
return ['item' => []];
}
$db = fob_mysql();
// 名称模糊 + postid 精确
$where = "`company` like '%".addslashes($keyword)."%'";
if(ctype_digit($keyword)){
$where = "(`postid` = ".intval($keyword)." or ".$where.")";
}
$rows = $db->throw()->all(
"select `postid`,`company`,`plan` from `projects` where {$where} order by `postid` desc limit 20"
);
if(!$rows){
return ['item' => []];
}
// 已绑定邮箱数量,方便确认选的是哪个项目
$bindCount = [];
$postids = array_map('intval', array_column($rows, 'postid'));
try {
$counts = $db->throw()->all(
"select `post_id`,count(*) as `num` from `e_mail_binds` where `post_id` in (".implode(',', $postids).") group by `post_id`"
);
$bindCount = array_column($counts, 'num', 'post_id');
}catch (\Throwable $e){
logs('mail-export 统计绑定邮箱失败:'.$e->getMessage());
}
$item = [];
foreach($rows as $row){
$item[] = [
'postid' => intval($row['postid']),
'company' => (string)$row['company'],
'plan' => (string)$row['plan'],
'bind' => intval($bindCount[$row['postid']] ?? 0),
];
}
return ['item' => $item];
}
/**
* 查询命中的邮件数量
* @return array
* @throws \Lib\Err
* @author:dc
* @time 2026/10/28 11:40
*/
public function count(){
$this->needAuth();
$postid = intval(app()->request('postid', 0));
$scope = $this->scope();
if(!$postid){
app()->e('请先选择公司项目');
}
return [
'count' => MailExportService::count($postid, $scope),
'scope' => $scope,
];
}
/**
* 执行导出
* @return array
* @throws \Lib\Err
* @author:dc
* @time 2026/10/28 11:40
*/
public function start(){
$this->needAuth();
$postid = intval(app()->request('postid', 0));
$scope = $this->scope();
$limit = intval(app()->request('limit', MailExportService::DEFAULT_LIMIT));
if(!$postid){
app()->e('请先选择公司项目');
}
$project = fob_mysql()->throw()->first(
"select `postid`,`company` from `projects` where `postid` = {$postid} limit 1"
);
if(!$project){
app()->e('公司项目不存在,请重新搜索');
}
// 导出是同步执行的,数量大时需要更长时间
set_time_limit(0);
ignore_user_abort(true);
$ret = (new MailExportService())->export($postid, $scope, $limit);
$ret['company'] = (string)$project['company'];
logs('邮件导出 postid:'.$postid.' 范围:'.$scope.' 数量:'.$ret['total']
.' 成功:'.$ret['success'].' 失败:'.$ret['fail'].' 耗时:'.$ret['cost'].'s IP:'.($_SERVER['REMOTE_ADDR'] ?? ''));
return $ret;
}
/**
* 下载导出的 zip
* @return string
* @throws \Lib\Err
* @author:dc
* @time 2026/10/28 11:40
*/
public function download(){
$this->needAuth();
$f = (string)app()->request('f', '');
// 只允许 目录/文件名.zip 的形式
if(!preg_match('#^(\d{1,12}_\d{8}_\d{6}_[0-9a-f]{6})/([0-9a-zA-Z_\-\.]+\.zip)$#', $f, $match)){
app()->e('文件地址不合法');
}
$path = MailExportService::baseDir().'/'.$match[1].'/'.$match[2];
if(!is_file($path)){
app()->e('文件不存在,可能已被清理,请重新导出');
}
app()->header('Content-Type', 'application/zip');
app()->header('Content-Disposition', 'attachment; filename="'.$match[2].'"');
app()->header('Content-Length', filesize($path));
app()->header('Cache-Control', 'no-store');
return file_get_contents($path);
}
/**
* 导出条件
* @return string
* @throws \Lib\Err
* @author:dc
* @time 2026/10/28 11:40
*/
private function scope():string {
$scope = (string)app()->request('scope', '');
if(!isset(MailExportService::scopeMap()[$scope])){
app()->e('导出条件不正确');
}
return $scope;
}
/**
* 未登录直接报错,前端收到后刷新页面
* @throws \Lib\Err
* @author:dc
* @time 2026/10/28 11:40
*/
private function needAuth(){
if(!$this->isAuthed()){
app()->e('登录已失效,请刷新页面重新输入密码', 401);
}
}
/**
* 是否已登录
* @return bool
* @author:dc
* @time 2026/10/28 11:40
*/
private function isAuthed():bool {
return hash_equals($this->token(), (string)($_COOKIE[self::COOKIE_NAME] ?? ''));
}
/**
* 登录态令牌
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
private function token():string {
return hash_hmac('sha256', self::PASSWORD, self::COOKIE_SALT);
}
/**
* 写入登录态
* @author:dc
* @time 2026/10/28 11:40
*/
private function setAuthCookie(){
setcookie(self::COOKIE_NAME, $this->token(), [
'expires' => time() + self::COOKIE_TTL,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
'secure' => !empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off',
]);
}
/**
* 页面 html
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
private function pageHtml():string {
$file = ROOT_PATH.'/views/mail_export.html';
if(!is_file($file)){
return $this->loginHtml('页面模版不存在:views/mail_export.html');
}
return str_replace(
['<!--{#host#}-->', '<!--{#time#}-->'],
[APP_HOST, app()->nowDateTime()],
file_get_contents($file)
);
}
/**
* 密码输入页面
* @param string $error
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
private function loginHtml(string $error = ''):string {
$errorHtml = $error ? '<p class="error">'.$error.'</p>' : '';
return '<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>邮件导出 · 访问验证</title>
<style>
*{box-sizing:border-box;}
body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;
background:#eef3fa;font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;color:#243447;}
.box{width:340px;padding:32px 28px;background:#fff;border-radius:12px;
box-shadow:0 8px 30px rgba(31,63,107,.12);}
h1{margin:0 0 6px;font-size:18px;font-weight:600;}
.sub{margin:0 0 22px;color:#8a97a8;font-size:12px;}
input{width:100%;height:40px;padding:0 12px;border:1px solid #d5dde8;border-radius:8px;
font-size:14px;outline:none;transition:.15s;}
input:focus{border-color:#4a7fd6;box-shadow:0 0 0 3px rgba(74,127,214,.12);}
button{width:100%;height:40px;margin-top:14px;border:0;border-radius:8px;background:#2f6bd8;
color:#fff;font-size:14px;cursor:pointer;transition:.15s;}
button:hover{background:#255cc0;}
.error{margin:12px 0 0;color:#d0342c;font-size:12px;}
</style>
</head>
<body>
<form class="box" method="post" action="/mail-export" autocomplete="off">
<h1>邮件导出</h1>
<p class="sub">该页面需要密码才可访问</p>
<input type="password" name="password" placeholder="请输入访问密码" autofocus>
<button type="submit">进入</button>
'.$errorHtml.'
</form>
</body>
</html>';
}
}