作者 邓超

新增 邮件导出页面(cmd/export_mail 的 web 化)

- controller/MailExport.php:密码访问(mailexport)、公司项目搜索、count/start/download
- service/MailExport.php:从 cmd/export_mail.php 抽出,支持 postid/导出条件/数量上限,附带导出清单与 zip 打包
- views/mail_export.html:单页界面(选公司项目 → 选条件 → 导出下载)
- route.php 注册路由;.gitignore 忽略 storage/export

顺带修掉老脚本的问题:中文标题被清洗成空文件名、EML 出现两个 Date 头
@@ -13,6 +13,7 @@ log @@ -13,6 +13,7 @@ log
13 public/.user.ini 13 public/.user.ini
14 public/.well-known 14 public/.well-known
15 public/storage 15 public/storage
  16 +storage/export
16 cmd/*.log 17 cmd/*.log
17 *.log 18 *.log
18 .htaccess 19 .htaccess
  1 +<?php
  2 +
  3 +namespace Controller;
  4 +
  5 +use Service\MailExport as MailExportService;
  6 +
  7 +/**
  8 + * 邮件导出页面
  9 + *
  10 + * 需要一个固定密码才能访问,密码:mailexport
  11 + *
  12 + * 路由:
  13 + * mail-export 页面(GET 展示 / POST 校验密码)
  14 + * mail-export/logout 退出
  15 + * mail-export/projects 公司项目搜索(按名称或 postid)
  16 + * mail-export/count 查询命中的邮件数量
  17 + * mail-export/start 执行导出(同步,导出完成后返回下载地址)
  18 + * mail-export/download 下载导出的 zip
  19 + *
  20 + * @author:dc
  21 + * @time 2026/10/28 11:40
  22 + * Class MailExport
  23 + * @package Controller
  24 + */
  25 +class MailExport extends Base {
  26 +
  27 + /**
  28 + * 访问密码(固定)
  29 + */
  30 + private const PASSWORD = 'mailexport';
  31 +
  32 + /**
  33 + * 登录态 cookie 名称
  34 + */
  35 + private const COOKIE_NAME = 'mail_export_token';
  36 +
  37 + /**
  38 + * 登录态签名盐值,cookie 里不存明文密码
  39 + */
  40 + private const COOKIE_SALT = 'mail_export_2026';
  41 +
  42 + /**
  43 + * 登录态有效期,7 天
  44 + */
  45 + private const COOKIE_TTL = 604800;
  46 +
  47 + /**
  48 + * 页面
  49 + * GET :未登录返回密码框,已登录返回页面
  50 + * POST :校验密码,成功后写入登录态并返回页面
  51 + *
  52 + * @return string
  53 + * @author:dc
  54 + * @time 2026/10/28 11:40
  55 + */
  56 + public function index(){
  57 + if(app()->requestHas('password')){
  58 + $password = (string)app()->request('password');
  59 + if(!hash_equals(self::PASSWORD, $password)){
  60 + logs('mail-export 密码错误 IP:'.($_SERVER['REMOTE_ADDR'] ?? ''));
  61 + return $this->loginHtml('密码不正确');
  62 + }
  63 + $this->setAuthCookie();
  64 + return $this->pageHtml();
  65 + }
  66 +
  67 + if($this->isAuthed()){
  68 + return $this->pageHtml();
  69 + }
  70 +
  71 + return $this->loginHtml('');
  72 + }
  73 +
  74 + /**
  75 + * 退出
  76 + * @return string
  77 + * @author:dc
  78 + * @time 2026/10/28 11:40
  79 + */
  80 + public function logout(){
  81 + setcookie(self::COOKIE_NAME, '', [
  82 + 'expires' => time() - 3600,
  83 + 'path' => '/',
  84 + 'httponly' => true,
  85 + 'samesite' => 'Lax',
  86 + ]);
  87 +
  88 + return $this->loginHtml('已退出');
  89 + }
  90 +
  91 + /**
  92 + * 搜索公司项目
  93 + * 支持按公司名称模糊、按 postid 精确
  94 + * @return array
  95 + * @throws \Lib\Err
  96 + * @author:dc
  97 + * @time 2026/10/28 11:40
  98 + */
  99 + public function projects(){
  100 + $this->needAuth();
  101 +
  102 + $keyword = trim((string)app()->request('keyword', ''));
  103 + if($keyword === ''){
  104 + return ['item' => []];
  105 + }
  106 +
  107 + $db = fob_mysql();
  108 +
  109 + // 名称模糊 + postid 精确
  110 + $where = "`company` like '%".addslashes($keyword)."%'";
  111 + if(ctype_digit($keyword)){
  112 + $where = "(`postid` = ".intval($keyword)." or ".$where.")";
  113 + }
  114 +
  115 + $rows = $db->throw()->all(
  116 + "select `postid`,`company`,`plan` from `projects` where {$where} order by `postid` desc limit 20"
  117 + );
  118 +
  119 + if(!$rows){
  120 + return ['item' => []];
  121 + }
  122 +
  123 + // 已绑定邮箱数量,方便确认选的是哪个项目
  124 + $bindCount = [];
  125 + $postids = array_map('intval', array_column($rows, 'postid'));
  126 + try {
  127 + $counts = $db->throw()->all(
  128 + "select `post_id`,count(*) as `num` from `e_mail_binds` where `post_id` in (".implode(',', $postids).") group by `post_id`"
  129 + );
  130 + $bindCount = array_column($counts, 'num', 'post_id');
  131 + }catch (\Throwable $e){
  132 + logs('mail-export 统计绑定邮箱失败:'.$e->getMessage());
  133 + }
  134 +
  135 + $item = [];
  136 + foreach($rows as $row){
  137 + $item[] = [
  138 + 'postid' => intval($row['postid']),
  139 + 'company' => (string)$row['company'],
  140 + 'plan' => (string)$row['plan'],
  141 + 'bind' => intval($bindCount[$row['postid']] ?? 0),
  142 + ];
  143 + }
  144 +
  145 + return ['item' => $item];
  146 + }
  147 +
  148 + /**
  149 + * 查询命中的邮件数量
  150 + * @return array
  151 + * @throws \Lib\Err
  152 + * @author:dc
  153 + * @time 2026/10/28 11:40
  154 + */
  155 + public function count(){
  156 + $this->needAuth();
  157 +
  158 + $postid = intval(app()->request('postid', 0));
  159 + $scope = $this->scope();
  160 +
  161 + if(!$postid){
  162 + app()->e('请先选择公司项目');
  163 + }
  164 +
  165 + return [
  166 + 'count' => MailExportService::count($postid, $scope),
  167 + 'scope' => $scope,
  168 + ];
  169 + }
  170 +
  171 + /**
  172 + * 执行导出
  173 + * @return array
  174 + * @throws \Lib\Err
  175 + * @author:dc
  176 + * @time 2026/10/28 11:40
  177 + */
  178 + public function start(){
  179 + $this->needAuth();
  180 +
  181 + $postid = intval(app()->request('postid', 0));
  182 + $scope = $this->scope();
  183 + $limit = intval(app()->request('limit', MailExportService::DEFAULT_LIMIT));
  184 +
  185 + if(!$postid){
  186 + app()->e('请先选择公司项目');
  187 + }
  188 +
  189 + $project = fob_mysql()->throw()->first(
  190 + "select `postid`,`company` from `projects` where `postid` = {$postid} limit 1"
  191 + );
  192 + if(!$project){
  193 + app()->e('公司项目不存在,请重新搜索');
  194 + }
  195 +
  196 + // 导出是同步执行的,数量大时需要更长时间
  197 + set_time_limit(0);
  198 + ignore_user_abort(true);
  199 +
  200 + $ret = (new MailExportService())->export($postid, $scope, $limit);
  201 +
  202 + $ret['company'] = (string)$project['company'];
  203 +
  204 + logs('邮件导出 postid:'.$postid.' 范围:'.$scope.' 数量:'.$ret['total']
  205 + .' 成功:'.$ret['success'].' 失败:'.$ret['fail'].' 耗时:'.$ret['cost'].'s IP:'.($_SERVER['REMOTE_ADDR'] ?? ''));
  206 +
  207 + return $ret;
  208 + }
  209 +
  210 + /**
  211 + * 下载导出的 zip
  212 + * @return string
  213 + * @throws \Lib\Err
  214 + * @author:dc
  215 + * @time 2026/10/28 11:40
  216 + */
  217 + public function download(){
  218 + $this->needAuth();
  219 +
  220 + $f = (string)app()->request('f', '');
  221 + // 只允许 目录/文件名.zip 的形式
  222 + if(!preg_match('#^(\d{1,12}_\d{8}_\d{6}_[0-9a-f]{6})/([0-9a-zA-Z_\-\.]+\.zip)$#', $f, $match)){
  223 + app()->e('文件地址不合法');
  224 + }
  225 +
  226 + $path = MailExportService::baseDir().'/'.$match[1].'/'.$match[2];
  227 + if(!is_file($path)){
  228 + app()->e('文件不存在,可能已被清理,请重新导出');
  229 + }
  230 +
  231 + app()->header('Content-Type', 'application/zip');
  232 + app()->header('Content-Disposition', 'attachment; filename="'.$match[2].'"');
  233 + app()->header('Content-Length', filesize($path));
  234 + app()->header('Cache-Control', 'no-store');
  235 +
  236 + return file_get_contents($path);
  237 + }
  238 +
  239 + /**
  240 + * 导出条件
  241 + * @return string
  242 + * @throws \Lib\Err
  243 + * @author:dc
  244 + * @time 2026/10/28 11:40
  245 + */
  246 + private function scope():string {
  247 + $scope = (string)app()->request('scope', '');
  248 + if(!isset(MailExportService::scopeMap()[$scope])){
  249 + app()->e('导出条件不正确');
  250 + }
  251 + return $scope;
  252 + }
  253 +
  254 + /**
  255 + * 未登录直接报错,前端收到后刷新页面
  256 + * @throws \Lib\Err
  257 + * @author:dc
  258 + * @time 2026/10/28 11:40
  259 + */
  260 + private function needAuth(){
  261 + if(!$this->isAuthed()){
  262 + app()->e('登录已失效,请刷新页面重新输入密码', 401);
  263 + }
  264 + }
  265 +
  266 + /**
  267 + * 是否已登录
  268 + * @return bool
  269 + * @author:dc
  270 + * @time 2026/10/28 11:40
  271 + */
  272 + private function isAuthed():bool {
  273 + return hash_equals($this->token(), (string)($_COOKIE[self::COOKIE_NAME] ?? ''));
  274 + }
  275 +
  276 + /**
  277 + * 登录态令牌
  278 + * @return string
  279 + * @author:dc
  280 + * @time 2026/10/28 11:40
  281 + */
  282 + private function token():string {
  283 + return hash_hmac('sha256', self::PASSWORD, self::COOKIE_SALT);
  284 + }
  285 +
  286 + /**
  287 + * 写入登录态
  288 + * @author:dc
  289 + * @time 2026/10/28 11:40
  290 + */
  291 + private function setAuthCookie(){
  292 + setcookie(self::COOKIE_NAME, $this->token(), [
  293 + 'expires' => time() + self::COOKIE_TTL,
  294 + 'path' => '/',
  295 + 'httponly' => true,
  296 + 'samesite' => 'Lax',
  297 + 'secure' => !empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off',
  298 + ]);
  299 + }
  300 +
  301 + /**
  302 + * 页面 html
  303 + * @return string
  304 + * @author:dc
  305 + * @time 2026/10/28 11:40
  306 + */
  307 + private function pageHtml():string {
  308 + $file = ROOT_PATH.'/views/mail_export.html';
  309 + if(!is_file($file)){
  310 + return $this->loginHtml('页面模版不存在:views/mail_export.html');
  311 + }
  312 +
  313 + return str_replace(
  314 + ['<!--{#host#}-->', '<!--{#time#}-->'],
  315 + [APP_HOST, app()->nowDateTime()],
  316 + file_get_contents($file)
  317 + );
  318 + }
  319 +
  320 + /**
  321 + * 密码输入页面
  322 + * @param string $error
  323 + * @return string
  324 + * @author:dc
  325 + * @time 2026/10/28 11:40
  326 + */
  327 + private function loginHtml(string $error = ''):string {
  328 + $errorHtml = $error ? '<p class="error">'.$error.'</p>' : '';
  329 +
  330 + return '<!DOCTYPE html>
  331 +<html lang="zh-CN">
  332 +<head>
  333 +<meta charset="UTF-8">
  334 +<meta name="viewport" content="width=device-width, initial-scale=1">
  335 +<meta name="robots" content="noindex,nofollow">
  336 +<title>邮件导出 · 访问验证</title>
  337 +<style>
  338 + *{box-sizing:border-box;}
  339 + body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;
  340 + background:#eef3fa;font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;color:#243447;}
  341 + .box{width:340px;padding:32px 28px;background:#fff;border-radius:12px;
  342 + box-shadow:0 8px 30px rgba(31,63,107,.12);}
  343 + h1{margin:0 0 6px;font-size:18px;font-weight:600;}
  344 + .sub{margin:0 0 22px;color:#8a97a8;font-size:12px;}
  345 + input{width:100%;height:40px;padding:0 12px;border:1px solid #d5dde8;border-radius:8px;
  346 + font-size:14px;outline:none;transition:.15s;}
  347 + input:focus{border-color:#4a7fd6;box-shadow:0 0 0 3px rgba(74,127,214,.12);}
  348 + button{width:100%;height:40px;margin-top:14px;border:0;border-radius:8px;background:#2f6bd8;
  349 + color:#fff;font-size:14px;cursor:pointer;transition:.15s;}
  350 + button:hover{background:#255cc0;}
  351 + .error{margin:12px 0 0;color:#d0342c;font-size:12px;}
  352 +</style>
  353 +</head>
  354 +<body>
  355 +<form class="box" method="post" action="/mail-export" autocomplete="off">
  356 + <h1>邮件导出</h1>
  357 + <p class="sub">该页面需要密码才可访问</p>
  358 + <input type="password" name="password" placeholder="请输入访问密码" autofocus>
  359 + <button type="submit">进入</button>
  360 + '.$errorHtml.'
  361 +</form>
  362 +</body>
  363 +</html>';
  364 + }
  365 +
  366 +}
@@ -108,6 +108,14 @@ return [ @@ -108,6 +108,14 @@ return [
108 ,'api-doc' => [\Controller\ApiDoc::class,'index'] 108 ,'api-doc' => [\Controller\ApiDoc::class,'index']
109 ,'api-doc/logout' => [\Controller\ApiDoc::class,'logout'] 109 ,'api-doc/logout' => [\Controller\ApiDoc::class,'logout']
110 110
  111 + // 邮件导出(需要密码访问)
  112 + ,'mail-export' => [\Controller\MailExport::class,'index']
  113 + ,'mail-export/logout' => [\Controller\MailExport::class,'logout']
  114 + ,'mail-export/projects' => [\Controller\MailExport::class,'projects']
  115 + ,'mail-export/count' => [\Controller\MailExport::class,'count']
  116 + ,'mail-export/start' => [\Controller\MailExport::class,'start']
  117 + ,'mail-export/download' => [\Controller\MailExport::class,'download']
  118 +
111 119
112 120
113 ]; 121 ];
  1 +<?php
  2 +
  3 +namespace Service;
  4 +
  5 +use Lib\Err;
  6 +use PHPMailer\PHPMailer\PHPMailer;
  7 +
  8 +/**
  9 + * 邮件导出为 eml 文件
  10 + *
  11 + * 逻辑来源 cmd/export_mail.php,这里抽成服务类,支持传入
  12 + * postid(公司项目)、导出范围、数量上限,供页面调用。
  13 + *
  14 + * @author:dc
  15 + * @time 2026/10/28 11:40
  16 + * Class MailExport
  17 + * @package Service
  18 + */
  19 +class MailExport {
  20 +
  21 + /**
  22 + * 星标邮件(所有文件夹)
  23 + */
  24 + public const SCOPE_STARRED = 'starred';
  25 +
  26 + /**
  27 + * 收件箱全部
  28 + */
  29 + public const SCOPE_INBOX = 'inbox';
  30 +
  31 + /**
  32 + * 默认导出数量
  33 + */
  34 + public const DEFAULT_LIMIT = 300;
  35 +
  36 + /**
  37 + * 单次最大导出数量
  38 + */
  39 + public const MAX_LIMIT = 2000;
  40 +
  41 + /**
  42 + * 清单文件名
  43 + */
  44 + private const MANIFEST_NAME = '导出清单.csv';
  45 +
  46 + /**
  47 + * @var int 项目id
  48 + */
  49 + private int $postid = 0;
  50 +
  51 + /**
  52 + * @var string 导出范围
  53 + */
  54 + private string $scope = '';
  55 +
  56 + /**
  57 + * @var int 数量上限
  58 + */
  59 + private int $limit = 0;
  60 +
  61 + /**
  62 + * @var string 本次导出目录
  63 + */
  64 + private string $dir = '';
  65 +
  66 + /**
  67 + * @var array es _id 去重
  68 + */
  69 + private array $ids = [];
  70 +
  71 + /**
  72 + * @var array 已生成的 eml 文件
  73 + */
  74 + private array $files = [];
  75 +
  76 + /**
  77 + * @var array 已使用过的文件名,避免同一批里重名
  78 + */
  79 + private array $usedNames = [];
  80 +
  81 + /**
  82 + * @var array 清单数据
  83 + */
  84 + private array $manifest = [];
  85 +
  86 + /**
  87 + * @var array 失败记录
  88 + */
  89 + private array $errors = [];
  90 +
  91 + /**
  92 + * @var int 成功数量
  93 + */
  94 + private int $success = 0;
  95 +
  96 + /**
  97 + * @var int 失败数量
  98 + */
  99 + private int $fail = 0;
  100 +
  101 + /**
  102 + * 导出范围
  103 + * @return string[]
  104 + * @author:dc
  105 + * @time 2026/10/28 11:40
  106 + */
  107 + public static function scopeMap():array {
  108 + return [
  109 + self::SCOPE_STARRED => '星标邮件(所有文件夹)',
  110 + self::SCOPE_INBOX => '收件箱全部',
  111 + ];
  112 + }
  113 +
  114 + /**
  115 + * 导出文件根目录
  116 + * 说明:不放在 public 下面,只能通过页面鉴权后下载
  117 + * @return string
  118 + * @author:dc
  119 + * @time 2026/10/28 11:40
  120 + */
  121 + public static function baseDir():string {
  122 + return ROOT_PATH.'/storage/export';
  123 + }
  124 +
  125 + /**
  126 + * 查询命中的邮件数量
  127 + * 返回 -1 表示 es 查询异常,与「0 封」区分开
  128 + * @param int $postid
  129 + * @param string $scope
  130 + * @return int
  131 + * @author:dc
  132 + * @time 2026/10/28 11:40
  133 + */
  134 + public static function count(int $postid,string $scope):int {
  135 + try {
  136 + $count = es('email_lists_branch_'.$postid)->count(['query' => self::query($scope)], true);
  137 + return max(0, intval($count));
  138 + }catch (\Throwable $e){
  139 + logs('邮件导出统计失败 postid:'.$postid.' '.$e->getMessage());
  140 + return -1;
  141 + }
  142 + }
  143 +
  144 + /**
  145 + * es 查询条件
  146 + * 与 cmd/export_mail.php 保持一致:只取 ai 邮箱(source=2)、未删除
  147 + * @param string $scope
  148 + * @return array
  149 + * @author:dc
  150 + * @time 2026/10/28 11:40
  151 + */
  152 + public static function query(string $scope):array {
  153 + $must = [
  154 + ['term' => ['source' => 2]],
  155 + ['term' => ['deleted' => 0]],
  156 + ];
  157 +
  158 + if($scope === self::SCOPE_STARRED){
  159 + // 星标:不限文件夹
  160 + $must[] = ['term' => ['flagged' => 1]];
  161 + $must[] = ['terms' => ['folder_as_int' => [1, 2, 3, 4, 5]]];
  162 + }else{
  163 + // 收件箱全部(含星标、含未读已读)
  164 + $must[] = ['term' => ['folder_as_int' => 1]];
  165 + }
  166 +
  167 + return ['constant_score' => ['filter' => ['bool' => ['must' => $must]]]];
  168 + }
  169 +
  170 + /**
  171 + * 文件名净化,保留中文
  172 + * @param string $name
  173 + * @return string
  174 + * @author:dc
  175 + * @time 2026/10/28 11:40
  176 + */
  177 + public static function sanitize(string $name):string {
  178 + $name = preg_replace('/[^\p{L}\p{N}_\-\.\(\)]+/u', '_', $name);
  179 + $name = preg_replace('/_+/u', '_', (string)$name);
  180 + return trim((string)$name, '_ .');
  181 + }
  182 +
  183 + /**
  184 + * 执行导出
  185 + * @param int $postid 项目id
  186 + * @param string $scope 导出范围
  187 + * @param int $limit 数量上限
  188 + * @return array
  189 + * @throws Err
  190 + * @author:dc
  191 + * @time 2026/10/28 11:40
  192 + */
  193 + public function export(int $postid,string $scope,int $limit):array {
  194 + if(!$postid){
  195 + throw new Err('缺少公司项目');
  196 + }
  197 + if(!isset(self::scopeMap()[$scope])){
  198 + throw new Err('导出条件不支持');
  199 + }
  200 +
  201 + $this->postid = $postid;
  202 + $this->scope = $scope;
  203 + $this->limit = max(1, min($limit ?: self::DEFAULT_LIMIT, self::MAX_LIMIT));
  204 +
  205 + $startTime = microtime(true);
  206 +
  207 + // 导出目录,带随机后缀,避免重复
  208 + $this->dir = self::baseDir().'/'.$postid.'_'.date('Ymd_His').'_'.substr(md5(uniqid('', true)), 0, 6);
  209 + if(!is_dir($this->dir) && !@mkdir($this->dir, 0775, true) && !is_dir($this->dir)){
  210 + throw new Err('导出目录创建失败,请检查 storage 目录权限');
  211 + }
  212 +
  213 + $hits = $this->search();
  214 + if(!$hits){
  215 + $this->removeDir($this->dir);
  216 + // 没有数据时再确认一次,区分「es 挂了」和「真的没有邮件」
  217 + if(self::count($this->postid, $this->scope) < 0){
  218 + throw new Err('es 查询失败,请稍后再试(已记录日志)');
  219 + }
  220 + throw new Err('没有命中任何邮件,换个条件再试');
  221 + }
  222 +
  223 + foreach($hits as $hit){
  224 + $this->saveOne($hit);
  225 + }
  226 +
  227 + if(!$this->files){
  228 + $this->removeDir($this->dir);
  229 + $detail = $this->errors ? $this->errors[0]['message'] : '未知原因';
  230 + throw new Err('全部邮件导出失败:'.$detail);
  231 + }
  232 +
  233 + // 清单
  234 + $this->writeManifest();
  235 +
  236 + // 打包
  237 + $zipName = $this->postid.'_'.date('Ymd_His').'.zip';
  238 + $zipFile = $this->dir.'/'.$zipName;
  239 + $this->zip($zipFile);
  240 +
  241 + return [
  242 + 'postid' => $this->postid,
  243 + 'scope' => $this->scope,
  244 + 'scope_label'=> self::scopeMap()[$this->scope],
  245 + 'limit' => $this->limit,
  246 + 'total' => count($hits),
  247 + 'success' => $this->success,
  248 + 'fail' => $this->fail,
  249 + 'errors' => array_slice($this->errors, 0, 20),
  250 + 'dir' => str_replace(ROOT_PATH, '', $this->dir),
  251 + 'zip_name' => $zipName,
  252 + 'zip_size' => is_file($zipFile) ? filesize($zipFile) : 0,
  253 + 'download' => '/mail-export/download?f='.rawurlencode(basename($this->dir).'/'.$zipName),
  254 + 'cost' => round(microtime(true) - $startTime, 1),
  255 + ];
  256 + }
  257 +
  258 + /**
  259 + * 查询 es
  260 + * @return array
  261 + * @author:dc
  262 + * @time 2026/10/28 11:40
  263 + */
  264 + private function search():array {
  265 + $res = es('email_lists_branch_'.$this->postid)->search(
  266 + ['query' => self::query($this->scope)],
  267 + 0,
  268 + $this->limit,
  269 + ['udate' => 'desc']
  270 + );
  271 +
  272 + return $res['hits']['hits'] ?? [];
  273 + }
  274 +
  275 + /**
  276 + * 导出一封
  277 + * @param array $hit
  278 + * @author:dc
  279 + * @time 2026/10/28 11:40
  280 + */
  281 + private function saveOne(array $hit):void {
  282 + $esId = (string)($hit['_id'] ?? '');
  283 + if($esId === '' || isset($this->ids[$esId])){
  284 + return;
  285 + }
  286 + $this->ids[$esId] = 1;
  287 +
  288 + $src = $hit['_source'] ?? [];
  289 + $file = $this->fileName($src);
  290 + $subject = (string)($src['subject'] ?? '');
  291 +
  292 + try {
  293 + $this->saveEml($src, $file);
  294 +
  295 + if(!is_file($file) || filesize($file) <= 0){
  296 + throw new \Exception('eml 文件为空');
  297 + }
  298 +
  299 + $this->files[] = $file;
  300 + $this->success++;
  301 + $this->manifest[] = [
  302 + 'subject' => $subject,
  303 + 'from' => $this->addressText($src['from'] ?? []),
  304 + 'to' => $this->toText($src),
  305 + 'date' => empty($src['udate']) ? '' : date('Y-m-d H:i:s', $src['udate']),
  306 + 'uuid' => (string)($src['uuid'] ?? ''),
  307 + 'file' => basename($file),
  308 + 'status' => '成功',
  309 + ];
  310 + }catch (\Throwable $e){
  311 + // 半成品删掉
  312 + if(is_file($file)){
  313 + @unlink($file);
  314 + }
  315 + $this->fail++;
  316 + $this->errors[] = [
  317 + 'subject' => $subject,
  318 + 'uuid' => (string)($src['uuid'] ?? ''),
  319 + 'message' => $e->getMessage(),
  320 + ];
  321 + logs('邮件导出失败 '.(string)($src['uuid'] ?? '').' : '.$e->getMessage());
  322 + }
  323 + }
  324 +
  325 + /**
  326 + * 生成 eml(逻辑同 cmd/export_mail.php 的 save_eml)
  327 + * @param array $src es 数据
  328 + * @param string $file
  329 + * @throws \Exception
  330 + * @author:dc
  331 + * @time 2026/10/28 11:40
  332 + */
  333 + private function saveEml(array $src,string $file):void {
  334 + $mail = new PHPMailer(true);
  335 +
  336 + $from = $src['from'] ?? [];
  337 + $fromEmail = is_array($from) ? ($from['email'] ?? '') : (string)$from;
  338 + $fromName = is_array($from) ? ($from['name'] ?? '') : '';
  339 + if(!$fromEmail){
  340 + throw new \Exception('发件人为空');
  341 + }
  342 + $mail->setFrom($fromEmail, $fromName);
  343 +
  344 + // 收件人
  345 + $toName = @json_decode((string)($src['to_name'] ?? ''), true);
  346 + if(is_array($toName) && $toName){
  347 + foreach($toName as $item){
  348 + if(empty($item['email'])) continue;
  349 + try {
  350 + $mail->addAddress($item['email'], $item['name'] ?? '');
  351 + }catch (\Throwable $e){}
  352 + }
  353 + }
  354 + if(!$mail->getToAddresses() && !empty($src['to'])){
  355 + foreach(explode(',', (string)$src['to']) as $item){
  356 + $item = trim($item);
  357 + if($item === '') continue;
  358 + try {
  359 + $mail->addAddress($item);
  360 + }catch (\Throwable $e){}
  361 + }
  362 + }
  363 +
  364 + $mail->Subject = (string)($src['subject'] ?? '');
  365 +
  366 + $uuid = (string)($src['uuid'] ?? '');
  367 + // uuid 实际是 lists 表的自增id
  368 + $body = ctype_digit($uuid) ? getMailBody((int)$uuid) : [];
  369 + $html = getBodyHtml($body);
  370 +
  371 + // 没有 body,尝试同步一次
  372 + if(!$html && ctype_digit($uuid) && !empty($src['email_id']) && !empty($src['uid'])){
  373 + try {
  374 + $folder = db()->value(\Model\folderSql::first($src['folder_id'] ?? 0, 'origin_folder'));
  375 + (new SyncMail($src['email_id']))->mail($folder, [(string)$src['uid']], true);
  376 + $body = getMailBody((int)$uuid);
  377 + $html = getBodyHtml($body);
  378 + }catch (\Throwable $e){
  379 + logs('邮件导出补拉正文失败 '.$uuid.' : '.$e->getMessage());
  380 + }
  381 + }
  382 +
  383 + // 正文为空也要能生成 eml(否则 PHPMailer 会报 Message body empty)
  384 + $mail->Body = $html ?: '<div></div>';
  385 + $mail->Encoding = 'quoted-printable';
  386 + $mail->CharSet = 'utf-8';
  387 + // 用 MessageDate 保留原始收发时间(不要用 addCustomHeader,会出两个 Date 头)
  388 + if(!empty($src['udate'])){
  389 + $mail->MessageDate = date('D, d M Y H:i:s O', intval($src['udate']));
  390 + }
  391 + if(!empty($src['uuid'])){
  392 + $mail->addCustomHeader('Uuid', (string)$src['uuid']);
  393 + }
  394 +
  395 + // 附件
  396 + foreach((array)$body as $item){
  397 + if(!empty($item['path']) && is_file($item['path'])){
  398 + try {
  399 + $mail->addAttachment($item['path'], $item['filename'] ?? '');
  400 + }catch (\Throwable $e){}
  401 + }
  402 + }
  403 +
  404 + // 抄送
  405 + foreach($this->addressList($src['cc'] ?? '') as $cc){
  406 + try {
  407 + $mail->addCC($cc['email'], $cc['name']);
  408 + }catch (\Throwable $e){}
  409 + }
  410 + // 密送
  411 + foreach($this->addressList($src['bcc'] ?? '') as $bcc){
  412 + try {
  413 + $mail->addBCC($bcc['email'], $bcc['name']);
  414 + }catch (\Throwable $e){}
  415 + }
  416 +
  417 + $mail->isHTML(true);
  418 + $mail->preSend();
  419 +
  420 + $content = $mail->getSentMIMEMessage();
  421 + if(!$content){
  422 + throw new \Exception('eml 内容生成失败');
  423 + }
  424 +
  425 + if(file_put_contents($file, $content) === false){
  426 + throw new \Exception('eml 写入失败');
  427 + }
  428 + }
  429 +
  430 + /**
  431 + * cc / bcc 解析
  432 + * @param mixed $value
  433 + * @return array
  434 + * @author:dc
  435 + * @time 2026/10/28 11:40
  436 + */
  437 + private function addressList(mixed $value):array {
  438 + if(empty($value)){
  439 + return [];
  440 + }
  441 + $list = is_array($value) ? $value : (@json_decode((string)$value, true) ?: []);
  442 + $ret = [];
  443 + foreach($list as $item){
  444 + if(!is_array($item)){
  445 + $item = ['email' => (string)$item];
  446 + }
  447 + if(empty($item['email'])) continue;
  448 + $ret[] = ['email' => $item['email'], 'name' => $item['name'] ?? ''];
  449 + }
  450 + return $ret;
  451 + }
  452 +
  453 + /**
  454 + * 发件人文本
  455 + * @param mixed $from
  456 + * @return string
  457 + * @author:dc
  458 + * @time 2026/10/28 11:40
  459 + */
  460 + private function addressText(mixed $from):string {
  461 + if(is_array($from)){
  462 + $email = $from['email'] ?? '';
  463 + $name = $from['name'] ?? '';
  464 + return $name ? "{$name} <{$email}>" : (string)$email;
  465 + }
  466 + return (string)$from;
  467 + }
  468 +
  469 + /**
  470 + * 收件人文本
  471 + * @param array $src
  472 + * @return string
  473 + * @author:dc
  474 + * @time 2026/10/28 11:40
  475 + */
  476 + private function toText(array $src):string {
  477 + $toName = @json_decode((string)($src['to_name'] ?? ''), true);
  478 + if(is_array($toName) && $toName){
  479 + $arr = [];
  480 + foreach($toName as $item){
  481 + if(empty($item['email'])) continue;
  482 + $arr[] = empty($item['name']) ? $item['email'] : "{$item['name']} <{$item['email']}>";
  483 + }
  484 + if($arr){
  485 + return implode('; ', $arr);
  486 + }
  487 + }
  488 + return (string)($src['to'] ?? '');
  489 + }
  490 +
  491 + /**
  492 + * 生成不重复的文件名
  493 + * @param array $src
  494 + * @return string
  495 + * @author:dc
  496 + * @time 2026/10/28 11:40
  497 + */
  498 + private function fileName(array $src):string {
  499 + $name = self::sanitize((string)($src['subject'] ?? ''));
  500 + if($name === ''){
  501 + $name = '无主题';
  502 + }
  503 + $name = mb_substr($name, 0, 80);
  504 +
  505 + $date = empty($src['udate']) ? '' : date('Ymd_His', $src['udate']);
  506 + $base = trim(($date ? $date.'_' : '').$name, '_');
  507 +
  508 + $fileName = $base.'.eml';
  509 + if(isset($this->usedNames[$fileName]) || is_file($this->dir.'/'.$fileName)){
  510 + $i = 1;
  511 + while(isset($this->usedNames[$base.'('.$i.').eml']) || is_file($this->dir.'/'.$base.'('.$i.').eml')){
  512 + $i++;
  513 + }
  514 + $fileName = $base.'('.$i.').eml';
  515 + }
  516 +
  517 + $this->usedNames[$fileName] = 1;
  518 +
  519 + return $this->dir.'/'.$fileName;
  520 + }
  521 +
  522 + /**
  523 + * 写导出清单
  524 + * @author:dc
  525 + * @time 2026/10/28 11:40
  526 + */
  527 + private function writeManifest():void {
  528 + $file = $this->dir.'/'.self::MANIFEST_NAME;
  529 + $fp = @fopen($file, 'wb');
  530 + if(!$fp){
  531 + return;
  532 + }
  533 + // excel 打开不乱码
  534 + fwrite($fp, "\xEF\xBB\xBF");
  535 + fputcsv($fp, ['序号', '主题', '发件人', '收件人', '日期', 'UUID', '文件名', '状态']);
  536 +
  537 + $i = 0;
  538 + foreach($this->manifest as $item){
  539 + fputcsv($fp, [
  540 + ++$i,
  541 + $item['subject'],
  542 + $item['from'],
  543 + $item['to'],
  544 + $item['date'],
  545 + $item['uuid'],
  546 + $item['file'],
  547 + $item['status'],
  548 + ]);
  549 + }
  550 + // 失败的也记一下
  551 + foreach($this->errors as $item){
  552 + fputcsv($fp, [++$i, $item['subject'], '', '', '', $item['uuid'], '', '失败:'.$item['message']]);
  553 + }
  554 +
  555 + fclose($fp);
  556 + $this->files[] = $file;
  557 + }
  558 +
  559 + /**
  560 + * 打包
  561 + * @param string $zipFile
  562 + * @throws Err
  563 + * @author:dc
  564 + * @time 2026/10/28 11:40
  565 + */
  566 + private function zip(string $zipFile):void {
  567 + if(class_exists('\ZipArchive')){
  568 + $zip = new \ZipArchive();
  569 + if($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) === true){
  570 + foreach($this->files as $file){
  571 + $zip->addFile($file, basename($file));
  572 + }
  573 + if($zip->close() && is_file($zipFile) && filesize($zipFile) > 0){
  574 + return;
  575 + }
  576 + }
  577 + }
  578 +
  579 + // 没有 zip 扩展时用不压缩的 zip
  580 + $this->storeZip($zipFile);
  581 +
  582 + if(!is_file($zipFile) || filesize($zipFile) <= 0){
  583 + throw new Err('打包失败,请检查 php-zip 扩展或目录权限');
  584 + }
  585 + }
  586 +
  587 + /**
  588 + * 不压缩的 zip(无 php-zip 扩展时的兜底)
  589 + * @param string $zipFile
  590 + * @throws Err
  591 + * @author:dc
  592 + * @time 2026/10/28 11:40
  593 + */
  594 + private function storeZip(string $zipFile):void {
  595 + $fp = @fopen($zipFile, 'wb');
  596 + if(!$fp){
  597 + throw new Err('zip 文件创建失败');
  598 + }
  599 +
  600 + $central = '';
  601 + $offset = 0;
  602 + $count = 0;
  603 +
  604 + foreach($this->files as $file){
  605 + $data = @file_get_contents($file);
  606 + if($data === false){
  607 + continue;
  608 + }
  609 + $name = basename($file);
  610 + $crc = crc32($data);
  611 + $size = strlen($data);
  612 + $nameLen = strlen($name);
  613 + $mtime = @filemtime($file) ?: time();
  614 + $dosTime = (int)((date('H', $mtime) << 11) | (date('i', $mtime) << 5) | (date('s', $mtime) / 2));
  615 + $dosDate = (int)(((date('Y', $mtime) - 1980) << 9) | (date('n', $mtime) << 5) | date('j', $mtime));
  616 +
  617 + // 本地文件头,0x0800 表示文件名是 utf-8
  618 + $local = pack('VvvvvvVVVvv', 0x04034b50, 20, 0x0800, 0, $dosTime, $dosDate, $crc, $size, $size, $nameLen, 0).$name;
  619 + fwrite($fp, $local);
  620 + fwrite($fp, $data);
  621 +
  622 + // 中央目录
  623 + $central .= pack('VvvvvvvVVVvvvvvVV', 0x02014b50, 20, 20, 0x0800, 0, $dosTime, $dosDate, $crc, $size, $size, $nameLen, 0, 0, 0, 0, 0, $offset).$name;
  624 +
  625 + $offset += strlen($local) + $size;
  626 + $count++;
  627 + }
  628 +
  629 + fwrite($fp, $central);
  630 + fwrite($fp, pack('VvvvvVVv', 0x06054b50, 0, 0, $count, $count, strlen($central), $offset, 0));
  631 + fclose($fp);
  632 + }
  633 +
  634 + /**
  635 + * 删除本次导出目录
  636 + * @param string $dir
  637 + * @author:dc
  638 + * @time 2026/10/28 11:40
  639 + */
  640 + private function removeDir(string $dir):void {
  641 + if(!$dir || !is_dir($dir)){
  642 + return;
  643 + }
  644 + foreach((array)@scandir($dir) as $item){
  645 + if($item === '.' || $item === '..' || $item === false){
  646 + continue;
  647 + }
  648 + $path = $dir.'/'.$item;
  649 + if(is_file($path)){
  650 + @unlink($path);
  651 + }
  652 + }
  653 + @rmdir($dir);
  654 + }
  655 +
  656 +}
  1 +<!DOCTYPE html>
  2 +<html lang="zh-CN">
  3 +<head>
  4 +<meta charset="UTF-8">
  5 +<meta name="viewport" content="width=device-width, initial-scale=1">
  6 +<meta name="robots" content="noindex,nofollow">
  7 +<title>邮件导出</title>
  8 +<style>
  9 + *{box-sizing:border-box;margin:0;padding:0;}
  10 + body{background:#f6f8fb;color:#22303f;font:14px/1.7 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;}
  11 + .hide{display:none !important;}
  12 +
  13 + /* 顶栏 */
  14 + .top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:16px;
  15 + height:56px;padding:0 22px;background:#fff;border-bottom:1px solid #e4eaf2;}
  16 + .top .logo{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600;white-space:nowrap;}
  17 + .top .logo i{display:inline-block;width:8px;height:18px;border-radius:3px;background:#2f6bd8;}
  18 + .top .meta{color:#93a1b3;font-size:12px;white-space:nowrap;}
  19 + .top .meta b{color:#5b6b7e;font-weight:500;}
  20 + .top .grow{flex:1;}
  21 + .top .quit{color:#8a97a8;font-size:12px;text-decoration:none;border:1px solid #e4eaf2;
  22 + border-radius:6px;padding:4px 10px;transition:.15s;}
  23 + .top .quit:hover{color:#d0342c;border-color:#f0c9c6;background:#fdf5f4;}
  24 +
  25 + main{max-width:940px;margin:0 auto;padding:24px 22px 60px;}
  26 +
  27 + /* 卡片 */
  28 + .card{background:#fff;border:1px solid #e4eaf2;border-radius:12px;padding:20px 22px;margin-bottom:16px;}
  29 + .hd{display:flex;align-items:center;gap:10px;margin-bottom:16px;flex-wrap:wrap;}
  30 + .hd .num{width:20px;height:20px;border-radius:50%;background:#eaf1fd;color:#2f6bd8;
  31 + font-size:12px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;}
  32 + .hd h2{font-size:15px;font-weight:600;}
  33 + .hd .tip{color:#93a1b3;font-size:12px;}
  34 +
  35 + /* 公司项目搜索 */
  36 + .search{position:relative;}
  37 + .search input{width:100%;height:42px;padding:0 14px;border:1px solid #dde5ef;border-radius:9px;
  38 + font-size:14px;outline:none;transition:.15s;background:#fcfdff;color:#22303f;}
  39 + .search input:focus{border-color:#7ba4e8;box-shadow:0 0 0 3px rgba(47,107,216,.1);background:#fff;}
  40 + .dropdown{position:absolute;left:0;right:0;top:46px;z-index:30;background:#fff;border:1px solid #e4eaf2;
  41 + border-radius:10px;box-shadow:0 12px 30px rgba(31,63,107,.12);max-height:320px;overflow:auto;}
  42 + .dd-item{display:flex;align-items:center;gap:12px;padding:10px 14px;cursor:pointer;border-bottom:1px solid #f2f6fb;}
  43 + .dd-item:last-child{border-bottom:0;}
  44 + .dd-item:hover,.dd-item.on{background:#f4f8ff;}
  45 + .dd-main{flex:1;min-width:0;}
  46 + .dd-company{font-size:13.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
  47 + .dd-sub{color:#93a1b3;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
  48 + .dd-sub span+span:before{content:"·";margin:0 6px;color:#c9d4e2;}
  49 + .dd-bind{color:#8a97a8;font-size:12px;white-space:nowrap;}
  50 + .dd-bind b{color:#2f6bd8;font-weight:600;}
  51 + .dd-empty{padding:14px;color:#93a1b3;font-size:13px;text-align:center;}
  52 +
  53 + .picked{display:flex;align-items:center;gap:14px;padding:12px 14px;margin-top:12px;
  54 + background:#f4f8ff;border:1px solid #dbe7fb;border-radius:10px;}
  55 + .picked .picked-main{flex:1;min-width:0;}
  56 + .picked .company{font-size:14px;font-weight:600;color:#1b3a66;}
  57 + .picked .sub{color:#6b7c90;font-size:12px;margin-top:2px;}
  58 + .picked .sub span+span:before{content:"·";margin:0 6px;color:#c9d4e2;}
  59 + .picked .link{border:0;background:none;color:#2f6bd8;font-size:12.5px;cursor:pointer;padding:4px 2px;}
  60 + .picked .link:hover{text-decoration:underline;}
  61 +
  62 + /* 导出条件 */
  63 + .scopes{display:flex;gap:12px;flex-wrap:wrap;}
  64 + .scope{flex:1;min-width:240px;display:flex;gap:10px;align-items:flex-start;padding:14px 16px;
  65 + border:1px solid #e2e9f3;border-radius:10px;cursor:pointer;transition:.15s;background:#fcfdff;}
  66 + .scope:hover{border-color:#c8d9f3;background:#fafcff;}
  67 + .scope input{margin-top:4px;accent-color:#2f6bd8;}
  68 + .scope b{display:block;font-size:13.5px;font-weight:600;}
  69 + .scope span{display:block;color:#8a97a8;font-size:12px;margin-top:2px;}
  70 + .scope.on{border-color:#7ba4e8;background:#f4f8ff;box-shadow:0 0 0 3px rgba(47,107,216,.08);}
  71 +
  72 + .row{display:flex;align-items:center;gap:18px;margin-top:16px;flex-wrap:wrap;}
  73 + .field{display:flex;align-items:center;gap:8px;font-size:13px;color:#5b6b7e;}
  74 + .field input{width:96px;height:34px;padding:0 10px;border:1px solid #dde5ef;border-radius:8px;
  75 + font-size:13px;outline:none;color:#22303f;}
  76 + .field input:focus{border-color:#7ba4e8;box-shadow:0 0 0 3px rgba(47,107,216,.1);}
  77 + .field em{font-style:normal;color:#93a1b3;}
  78 + .hit{font-size:13px;color:#5b6b7e;}
  79 + .hit b{color:#2f6bd8;font-weight:600;}
  80 + .hit .zero{color:#c07a00;}
  81 +
  82 + /* 操作 */
  83 + .action{display:flex;align-items:center;gap:14px;margin:4px 0 20px;flex-wrap:wrap;}
  84 + .primary{height:42px;padding:0 30px;border:0;border-radius:9px;background:#2f6bd8;color:#fff;
  85 + font-size:14px;font-weight:500;cursor:pointer;transition:.15s;}
  86 + .primary:hover{background:#255cc0;}
  87 + .primary:disabled{background:#c3d2e6;cursor:not-allowed;}
  88 + .hint{color:#93a1b3;font-size:12px;}
  89 +
  90 + /* 结果 */
  91 + .result .ok{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600;color:#1a9c62;}
  92 + .result .ok i{width:20px;height:20px;border-radius:50%;background:#e6f6ee;color:#1a9c62;
  93 + display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-style:normal;}
  94 + .stats{display:flex;gap:26px;margin:16px 0 4px;flex-wrap:wrap;}
  95 + .stat{font-size:12px;color:#8a97a8;}
  96 + .stat b{display:block;font-size:19px;color:#22303f;font-weight:600;line-height:1.4;}
  97 + .stat b.warn{color:#d0342c;}
  98 + .dl{display:inline-flex;align-items:center;gap:8px;height:42px;padding:0 24px;margin-top:16px;
  99 + border-radius:9px;background:#2f6bd8;color:#fff;font-size:14px;font-weight:500;text-decoration:none;
  100 + transition:.15s;}
  101 + .dl:hover{background:#255cc0;}
  102 + .path{margin-top:12px;color:#93a1b3;font-size:12px;word-break:break-all;}
  103 + .path code{color:#5b6b7e;background:#f4f7fb;border-radius:4px;padding:2px 6px;}
  104 + .errbox{margin-top:16px;border-top:1px dashed #e4eaf2;padding-top:14px;}
  105 + .errbox h3{font-size:13px;font-weight:600;color:#d0342c;margin-bottom:8px;}
  106 + .errlist{max-height:220px;overflow:auto;border:1px solid #f0e2e1;border-radius:8px;background:#fffbfb;}
  107 + table{width:100%;border-collapse:collapse;font-size:12.5px;}
  108 + th,td{text-align:left;padding:7px 10px;border-bottom:1px solid #f4ebea;vertical-align:top;}
  109 + th{color:#a08a89;font-weight:500;background:#fdf6f5;position:sticky;top:0;}
  110 + td.sub{color:#5b6b7e;}
  111 +
  112 + /* 遮罩 */
  113 + .mask{position:fixed;inset:0;z-index:50;background:rgba(229,237,247,.72);backdrop-filter:blur(2px);
  114 + display:flex;align-items:center;justify-content:center;}
  115 + .mask-box{background:#fff;border:1px solid #e4eaf2;border-radius:14px;padding:26px 34px;text-align:center;
  116 + box-shadow:0 18px 50px rgba(31,63,107,.16);min-width:260px;}
  117 + .spinner{width:30px;height:30px;margin:0 auto 14px;border:3px solid #e3ecfa;border-top-color:#2f6bd8;
  118 + border-radius:50%;animation:spin .8s linear infinite;}
  119 + @keyframes spin{to{transform:rotate(360deg);}}
  120 + .mask-box p{font-size:13.5px;color:#22303f;}
  121 + .mask-box .mask-sub{margin-top:6px;color:#93a1b3;font-size:12px;}
  122 +
  123 + @media (max-width:640px){
  124 + .top .meta{display:none;}
  125 + main{padding:16px 12px 40px;}
  126 + .card{padding:16px;}
  127 + }
  128 +</style>
  129 +</head>
  130 +<body>
  131 +
  132 +<header class="top">
  133 + <div class="logo"><i></i>邮件导出</div>
  134 + <div class="meta">导出为 eml · 打包 zip 下载</div>
  135 + <div class="grow"></div>
  136 + <div class="meta"><b><!--{#host#}--></b> · <!--{#time#}--></div>
  137 + <a class="quit" href="/mail-export/logout">退出</a>
  138 +</header>
  139 +
  140 +<main>
  141 + <section class="card">
  142 + <div class="hd">
  143 + <span class="num">1</span>
  144 + <h2>选择公司项目</h2>
  145 + <span class="tip">输入公司名称模糊搜索,或直接输入 postid</span>
  146 + </div>
  147 + <div class="search" id="searchWrap">
  148 + <input id="keyword" type="text" placeholder="公司项目名称 / postid" autocomplete="off" spellcheck="false">
  149 + <div class="dropdown hide" id="dropdown"></div>
  150 + </div>
  151 + <div class="picked hide" id="picked">
  152 + <div class="picked-main">
  153 + <div class="company" id="pickedCompany"></div>
  154 + <div class="sub">
  155 + <span id="pickedPostid"></span>
  156 + <span id="pickedPlan"></span>
  157 + <span id="pickedBind"></span>
  158 + </div>
  159 + </div>
  160 + <button type="button" class="link" id="clearProject">重新选择</button>
  161 + </div>
  162 + </section>
  163 +
  164 + <section class="card">
  165 + <div class="hd">
  166 + <span class="num">2</span>
  167 + <h2>导出条件</h2>
  168 + <span class="tip">数据源与命令行脚本一致:仅 AI 邮箱,已排除删除的邮件</span>
  169 + </div>
  170 + <div class="scopes" id="scopes">
  171 + <label class="scope" data-scope="starred">
  172 + <input type="radio" name="scope" value="starred">
  173 + <div>
  174 + <b>导出星标</b>
  175 + <span>所有文件夹里带星标的邮件</span>
  176 + </div>
  177 + </label>
  178 + <label class="scope on" data-scope="inbox">
  179 + <input type="radio" name="scope" value="inbox" checked>
  180 + <div>
  181 + <b>收件箱所有</b>
  182 + <span>收件箱里全部邮件,含星标、未读</span>
  183 + </div>
  184 + </label>
  185 + </div>
  186 + <div class="row">
  187 + <label class="field">
  188 + <span>本次最多导出</span>
  189 + <input id="limit" type="number" min="1" max="2000" value="300">
  190 + <em>封</em>
  191 + </label>
  192 + <div class="hit" id="hit">选择公司项目后可查看命中数量</div>
  193 + </div>
  194 + </section>
  195 +
  196 + <div class="action">
  197 + <button class="primary" id="start" disabled>开始导出</button>
  198 + <span class="hint">同步执行,数量越多越久,期间请不要关闭页面</span>
  199 + </div>
  200 +
  201 + <section class="card result hide" id="result">
  202 + <div class="hd" style="margin-bottom:10px;">
  203 + <span class="num" style="background:#e6f6ee;color:#1a9c62;">✓</span>
  204 + <h2>导出完成</h2>
  205 + <span class="tip" id="resCompany"></span>
  206 + </div>
  207 + <div class="stats">
  208 + <div class="stat">命中邮件<b id="resTotal">0</b></div>
  209 + <div class="stat">成功导出<b id="resSuccess">0</b></div>
  210 + <div class="stat">失败<b id="resFail" class="warn">0</b></div>
  211 + <div class="stat">耗时<b id="resCost">0s</b></div>
  212 + <div class="stat">压缩包<b id="resSize">0</b></div>
  213 + </div>
  214 + <a class="dl" id="resDownload" href="#" download>下载 zip 压缩包</a>
  215 + <div class="path">导出目录:<code id="resDir"></code></div>
  216 + <div class="errbox hide" id="errBox">
  217 + <h3>部分邮件导出失败</h3>
  218 + <div class="errlist">
  219 + <table>
  220 + <thead><tr><th style="width:52%">主题</th><th>UUID</th><th>原因</th></tr></thead>
  221 + <tbody id="errBody"></tbody>
  222 + </table>
  223 + </div>
  224 + </div>
  225 + </section>
  226 +</main>
  227 +
  228 +<div class="mask hide" id="mask">
  229 + <div class="mask-box">
  230 + <div class="spinner"></div>
  231 + <p id="maskText">正在导出…</p>
  232 + <p class="mask-sub" id="maskSub">已用时 0s</p>
  233 + </div>
  234 +</div>
  235 +
  236 +<script>
  237 +(function () {
  238 + 'use strict';
  239 +
  240 + var API = {
  241 + projects: '/mail-export/projects',
  242 + count: '/mail-export/count',
  243 + start: '/mail-export/start'
  244 + };
  245 +
  246 + var el = {
  247 + keyword: document.getElementById('keyword'),
  248 + dropdown: document.getElementById('dropdown'),
  249 + picked: document.getElementById('picked'),
  250 + pickedCompany: document.getElementById('pickedCompany'),
  251 + pickedPostid: document.getElementById('pickedPostid'),
  252 + pickedPlan: document.getElementById('pickedPlan'),
  253 + pickedBind: document.getElementById('pickedBind'),
  254 + clearProject: document.getElementById('clearProject'),
  255 + scopes: document.getElementById('scopes'),
  256 + limit: document.getElementById('limit'),
  257 + hit: document.getElementById('hit'),
  258 + start: document.getElementById('start'),
  259 + result: document.getElementById('result'),
  260 + mask: document.getElementById('mask'),
  261 + maskText: document.getElementById('maskText'),
  262 + maskSub: document.getElementById('maskSub')
  263 + };
  264 +
  265 + var state = {
  266 + project: null,
  267 + scope: 'inbox',
  268 + items: [],
  269 + active: -1,
  270 + seq: 0,
  271 + timer: null,
  272 + running: false
  273 + };
  274 +
  275 + /* ---------- 请求 ---------- */
  276 + function api(url, params, method) {
  277 + method = method || 'GET';
  278 + var opt = {credentials: 'same-origin', headers: {}};
  279 + var target = url;
  280 +
  281 + if (method === 'GET') {
  282 + var qs = new URLSearchParams(params || {}).toString();
  283 + if (qs) target += '?' + qs;
  284 + } else {
  285 + opt.method = 'POST';
  286 + opt.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
  287 + opt.body = new URLSearchParams(params || {}).toString();
  288 + }
  289 +
  290 + return fetch(target, opt).then(function (res) {
  291 + return res.text().then(function (text) {
  292 + var data;
  293 + try {
  294 + data = JSON.parse(text);
  295 + } catch (e) {
  296 + throw new Error('服务返回异常(HTTP ' + res.status + '),请稍后重试');
  297 + }
  298 + if (data && data.status === 401) {
  299 + location.reload();
  300 + throw new Error('登录已失效');
  301 + }
  302 + if (data && data.error_message) {
  303 + throw new Error(data.error_message);
  304 + }
  305 + return data || {};
  306 + });
  307 + });
  308 + }
  309 +
  310 + function esc(str) {
  311 + return String(str == null ? '' : str).replace(/[&<>"']/g, function (c) {
  312 + return {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c];
  313 + });
  314 + }
  315 +
  316 + function sizeText(n) {
  317 + n = Number(n) || 0;
  318 + if (n < 1024) return n + ' B';
  319 + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
  320 + if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
  321 + return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
  322 + }
  323 +
  324 + /* ---------- 公司项目搜索 ---------- */
  325 + var searchTimer = null;
  326 +
  327 + el.keyword.addEventListener('input', function () {
  328 + var v = el.keyword.value.trim();
  329 + clearTimeout(searchTimer);
  330 + if (!v) {
  331 + closeDropdown();
  332 + return;
  333 + }
  334 + searchTimer = setTimeout(function () { doSearch(v); }, 280);
  335 + });
  336 +
  337 + el.keyword.addEventListener('keydown', function (e) {
  338 + if (el.dropdown.classList.contains('hide')) return;
  339 + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
  340 + e.preventDefault();
  341 + var total = state.items.length;
  342 + if (!total) return;
  343 + state.active = e.key === 'ArrowDown'
  344 + ? (state.active + 1) % total
  345 + : (state.active - 1 + total) % total;
  346 + highlight();
  347 + } else if (e.key === 'Enter') {
  348 + e.preventDefault();
  349 + if (state.active >= 0 && state.items[state.active]) {
  350 + pick(state.items[state.active]);
  351 + }
  352 + } else if (e.key === 'Escape') {
  353 + closeDropdown();
  354 + }
  355 + });
  356 +
  357 + el.keyword.addEventListener('focus', function () {
  358 + if (el.keyword.value.trim() && state.items.length) {
  359 + el.dropdown.classList.remove('hide');
  360 + }
  361 + });
  362 +
  363 + document.addEventListener('click', function (e) {
  364 + if (!document.getElementById('searchWrap').contains(e.target)) {
  365 + closeDropdown();
  366 + }
  367 + });
  368 +
  369 + function doSearch(keyword) {
  370 + var seq = ++state.seq;
  371 + el.dropdown.classList.remove('hide');
  372 + el.dropdown.innerHTML = '<div class="dd-empty">搜索中…</div>';
  373 +
  374 + api(API.projects, {keyword: keyword}).then(function (data) {
  375 + if (seq !== state.seq) return;
  376 + state.items = data.item || [];
  377 + state.active = state.items.length ? 0 : -1;
  378 + renderDropdown();
  379 + }).catch(function (err) {
  380 + if (seq !== state.seq) return;
  381 + state.items = [];
  382 + el.dropdown.innerHTML = '<div class="dd-empty">' + esc(err.message) + '</div>';
  383 + });
  384 + }
  385 +
  386 + function renderDropdown() {
  387 + if (!state.items.length) {
  388 + el.dropdown.innerHTML = '<div class="dd-empty">没有匹配的公司项目</div>';
  389 + el.dropdown.classList.remove('hide');
  390 + return;
  391 + }
  392 +
  393 + var html = '';
  394 + state.items.forEach(function (item, i) {
  395 + html += '<div class="dd-item' + (i === state.active ? ' on' : '') + '" data-i="' + i + '">'
  396 + + '<div class="dd-main">'
  397 + + '<div class="dd-company">' + esc(item.company) + '</div>'
  398 + + '<div class="dd-sub"><span>postid ' + item.postid + '</span>'
  399 + + (item.plan ? '<span>' + esc(item.plan) + '</span>' : '')
  400 + + '</div></div>'
  401 + + '<div class="dd-bind">绑定邮箱 <b>' + item.bind + '</b></div>'
  402 + + '</div>';
  403 + });
  404 +
  405 + el.dropdown.innerHTML = html;
  406 + el.dropdown.classList.remove('hide');
  407 +
  408 + [].forEach.call(el.dropdown.querySelectorAll('.dd-item'), function (node) {
  409 + node.addEventListener('click', function () {
  410 + pick(state.items[parseInt(node.getAttribute('data-i'), 10)]);
  411 + });
  412 + });
  413 + }
  414 +
  415 + function highlight() {
  416 + [].forEach.call(el.dropdown.querySelectorAll('.dd-item'), function (node, i) {
  417 + node.classList.toggle('on', i === state.active);
  418 + });
  419 + var on = el.dropdown.querySelector('.dd-item.on');
  420 + if (on && on.scrollIntoView) on.scrollIntoView({block: 'nearest'});
  421 + }
  422 +
  423 + function closeDropdown() {
  424 + el.dropdown.classList.add('hide');
  425 + }
  426 +
  427 + function pick(item) {
  428 + if (!item) return;
  429 + state.project = item;
  430 + closeDropdown();
  431 + el.keyword.value = '';
  432 + el.keyword.blur();
  433 +
  434 + el.picked.classList.remove('hide');
  435 + el.pickedCompany.textContent = item.company;
  436 + el.pickedPostid.textContent = 'postid ' + item.postid;
  437 + el.pickedPlan.textContent = item.plan || '';
  438 + el.pickedPlan.style.display = item.plan ? '' : 'none';
  439 + el.pickedBind.textContent = '绑定邮箱 ' + item.bind;
  440 +
  441 + el.start.disabled = false;
  442 + el.result.classList.add('hide');
  443 + refreshCount();
  444 + }
  445 +
  446 + el.clearProject.addEventListener('click', function () {
  447 + state.project = null;
  448 + el.picked.classList.add('hide');
  449 + el.start.disabled = true;
  450 + el.hit.textContent = '选择公司项目后可查看命中数量';
  451 + el.keyword.focus();
  452 + });
  453 +
  454 + /* ---------- 条件 / 数量 ---------- */
  455 + [].forEach.call(el.scopes.querySelectorAll('.scope'), function (node) {
  456 + node.addEventListener('click', function () {
  457 + [].forEach.call(el.scopes.querySelectorAll('.scope'), function (n) {
  458 + n.classList.remove('on');
  459 + });
  460 + node.classList.add('on');
  461 + state.scope = node.getAttribute('data-scope');
  462 + refreshCount();
  463 + });
  464 + });
  465 +
  466 + el.limit.addEventListener('input', function () {
  467 + if (state.project && state.count) refreshCount(true);
  468 + });
  469 +
  470 + function refreshCount(silent) {
  471 + if (!state.project) return;
  472 + if (!silent) el.hit.textContent = '正在统计命中数量…';
  473 +
  474 + api(API.count, {
  475 + postid: state.project.postid,
  476 + scope: state.scope
  477 + }).then(function (data) {
  478 + state.count = Number(data.count);
  479 + if (isNaN(state.count)) state.count = 0;
  480 + var limit = parseInt(el.limit.value, 10) || 0;
  481 +
  482 + if (state.count < 0) {
  483 + el.hit.innerHTML = '<b class="zero">统计失败</b>,es 查询异常,可直接尝试导出';
  484 + el.start.disabled = false;
  485 + return;
  486 + }
  487 + if (!state.count) {
  488 + el.hit.innerHTML = '<b class="zero">0</b> 封命中,当前条件没有可导出的邮件';
  489 + } else if (limit && state.count > limit) {
  490 + el.hit.innerHTML = '命中 <b>' + state.count + '</b> 封,本次导出最新 <b>' + limit + '</b> 封';
  491 + } else {
  492 + el.hit.innerHTML = '命中 <b>' + state.count + '</b> 封,全部导出';
  493 + }
  494 +
  495 + el.start.disabled = !state.count || state.running;
  496 + }).catch(function (err) {
  497 + el.hit.textContent = '统计失败:' + err.message;
  498 + el.start.disabled = false;
  499 + });
  500 + }
  501 +
  502 + /* ---------- 开始导出 ---------- */
  503 + el.start.addEventListener('click', function () {
  504 + if (!state.project || state.running) return;
  505 +
  506 + var limit = parseInt(el.limit.value, 10);
  507 + if (!limit || limit < 1) {
  508 + alert('请填写本次最多导出的数量');
  509 + return;
  510 + }
  511 + if (limit > 2000) {
  512 + alert('单次最多导出 2000 封,请调小数量或分批导出');
  513 + return;
  514 + }
  515 +
  516 + runExport(state.project, state.scope, limit);
  517 + });
  518 +
  519 + function runExport(project, scope, limit) {
  520 + state.running = true;
  521 + el.start.disabled = true;
  522 + el.result.classList.add('hide');
  523 +
  524 + el.maskText.textContent = '正在导出「' + project.company + '」的邮件…';
  525 + el.mask.classList.remove('hide');
  526 +
  527 + var begin = Date.now();
  528 + state.timer = setInterval(function () {
  529 + el.maskSub.textContent = '已用时 ' + Math.floor((Date.now() - begin) / 1000) + 's';
  530 + }, 1000);
  531 + el.maskSub.textContent = '已用时 0s';
  532 +
  533 + api(API.start, {
  534 + postid: project.postid,
  535 + scope: scope,
  536 + limit: limit
  537 + }, 'POST').then(function (data) {
  538 + showResult(data);
  539 + refreshCount(true);
  540 + }).catch(function (err) {
  541 + alert(err.message);
  542 + }).then(function () {
  543 + stopTimer();
  544 + state.running = false;
  545 + el.start.disabled = !state.count;
  546 + });
  547 + }
  548 +
  549 + function stopTimer() {
  550 + if (state.timer) clearInterval(state.timer);
  551 + state.timer = null;
  552 + el.mask.classList.add('hide');
  553 + }
  554 +
  555 + function showResult(data) {
  556 + document.getElementById('resCompany').textContent = (data.company || '') + ' · ' + (data.scope_label || '');
  557 + document.getElementById('resTotal').textContent = data.total || 0;
  558 + document.getElementById('resSuccess').textContent = data.success || 0;
  559 + document.getElementById('resFail').textContent = data.fail || 0;
  560 + document.getElementById('resCost').textContent = (data.cost || 0) + 's';
  561 + document.getElementById('resSize').textContent = sizeText(data.zip_size);
  562 + document.getElementById('resDir').textContent = data.dir || '';
  563 +
  564 + var dl = document.getElementById('resDownload');
  565 + dl.setAttribute('href', data.download || '#');
  566 + dl.setAttribute('download', data.zip_name || '');
  567 +
  568 + var errors = data.errors || [];
  569 + document.getElementById('errBox').classList.toggle('hide', !errors.length);
  570 +
  571 + var body = document.getElementById('errBody');
  572 + body.innerHTML = '';
  573 + errors.forEach(function (item) {
  574 + var tr = document.createElement('tr');
  575 + tr.innerHTML = '<td>' + esc(item.subject || '(无主题)') + '</td>'
  576 + + '<td class="sub">' + esc(item.uuid || '') + '</td>'
  577 + + '<td class="sub">' + esc(item.message || '') + '</td>';
  578 + body.appendChild(tr);
  579 + });
  580 +
  581 + el.result.classList.remove('hide');
  582 + el.result.scrollIntoView({behavior: 'smooth', block: 'start'});
  583 + }
  584 +
  585 + el.keyword.focus();
  586 +})();
  587 +</script>
  588 +</body>
  589 +</html>