<?php namespace Service; use Event\Event; use Lib\Imap\Fun; use Lib\Imap\Imap; use Lib\Imap\ImapConfig; use Lib\Imap\ImapPool; use Lib\Imap\ImapSearch; use Lib\Imap\Parse\Body; use Lib\Imap\Parse\Folder\Folder; use Lib\Imap\Parse\MessageItem; use Lib\Log; use Model\bodySql; use Model\emailSql; use Model\folderSql; use Model\listsSql; /** * 同步邮件 * @author:dc * @time 2024/9/26 9:31 * Class SyncMail * @package Service */ class SyncMail { /** * @var \Lib\Db|\Lib\DbPool */ protected $db; /** * @var \Lib\Imap\Imap */ protected $imap; /** * @var array */ protected $email; protected $isStop = false; /** * @var 搜索规则 */ protected $search = null; /** * 是否强制更新 * @var bool */ protected $isForceUpdate = false; /** * 是否输出日志 * @var bool */ protected $echoLog = false; /** * SyncMail constructor. * @param int|string|array $email * @throws \Exception */ public function __construct(int|string|array $email,\Lib\Imap\Imap|null $imap = null) { $this->db = db(); if(!is_array($email)){ $email = $this->db->cache(3600)->first(emailSql::first($email)); if(!$email){ abort('未查询到邮箱'); } } $this->email = $email; // 实例一个imap类 if($imap instanceof \Lib\Imap\Imap){ $this->imap = $imap; }else{ $this->imap = new Imap( (new ImapConfig()) ->setHost($email['imap']) ->setEmail($email['email']) ->setPassword(base64_decode($email['password'])) // ->debug() ); $this->login(); } } public function stop(){ $this->isStop = true; } /** * @param bool $echoLog */ public function setEchoLog() { $this->echoLog = true; return $this; } /** * 输出日志 * @param $msg * @author:dc * @time 2025/3/20 11:03 */ protected function eLog($msg){ if($this->echoLog){ _echo($msg); } } /** * 搜索 * @param ImapSearch $search * @return $this * @author:dc * @time 2024/11/11 17:29 */ public function search(ImapSearch $search){ $this->search = $search; return $this; } // 查找uid之后的数据 public $isUid = 0; /** * @param $uid 2 表示没有数据 则执行下一个条件 1表示没有数据就没有数据 * @return $this * @author:dc * @time 2025/3/7 14:53 */ public function isUidAfter($uid=1){ $this->isUid = $uid; return $this; } protected function emailId(){ return $this->email['id']; } /** * 强制更新本地数据 */ public function forceUpdate(): static { $this->isForceUpdate = true; return $this; } /** * 登录imap * @throws \Exception * @author:dc * @time 2024/9/26 9:58 */ private function login(){ $login = $this->imap->login(); if(!$login->isOk()){ foreach ([ '[ALERT] Invalid credentials (Failure)',// 登录失败 '[AUTHENTICATIONFAILED] Invalid credentials (Failure)',// 登录失败 '[AUTHENTICATIONFAILED] Authentication failed.',// 登录失败 权限 'LOGIN Login error',// 登录失败 'LOGIN auth error',// 登录失败 'ERR.LOGIN.PASSERR',// 登录失败 密码错误 'Login fail.',// 登录失败 'LOGIN failed.', // 登录失败 // 'NO ERR.LOGIN.REQCODE', // 未知错误 '[ALERT] Application-specific password', // 这个错误是没有提供特定的授权码 'password error', 'ERR.LOGIN.REQCODE', 'authentication failure' ] as $em){ if(str_contains($login->getMessage(), $em)){ $this->db->update( \Model\emailSql::$table, ['pwd_error'=>1], dbWhere(['id'=> $this->emailId()]) ); } } abort($login->getMessage()?:'连接服务器异常'); } } /** * @param $folder * @param int $pid * @return array * @throws \Exception * @author:dc * @time 2024/9/26 10:46 */ protected function folder($folder,$pid = 0){ $uuids = []; foreach ($folder as $item){ /** @var Folder $item*/ $uuid = md5($this->emailId().$item->folder); $uuids[$uuid] = $uuid; $folder_name = ''; if($item->flags){ // 有些邮箱是把公共的文件夹标记在flag里面的,识别出来 foreach ($item->flags as $flag){ if(in_array($flag,['Send','Drafts','Junk','Trash'])){ $folder_name = folderAlias($flag); } } } if(!$folder_name){ $fn = explode('/',$item->getParseFolder()); $folder_name = folderAlias(end($fn)); } // 是否存在 $id = $this->db->value(folderSql::has(['email_id'=>$this->emailId(),'uuid'=>$uuid])); $data = [ 'email_id' => $this->emailId(), 'folder' => $folder_name, 'origin_folder' => $item->folder, 'uuid' => $uuid, 'pid' => $pid ]; if ($id){ $this->db->update(folderSql::$table,$data,dbWhere(['id'=>$id]),false); }else{ $id = $this->db->insert(folderSql::$table,$data,false); if(!$id) abort('文件夹写入异常 '.json_encode($data,JSON_UNESCAPED_UNICODE)); } // 是否有子级目录 if($id && $item->getChild()){ $uuids = array_merge($uuids,$this->folder($item->getChild(),$id)); } } return $uuids; } /** * @param bool $syncMail * @return bool|void|array * @throws \Exception * @author:dc * @time 2024/10/18 17:53 */ public function sync($syncMail = true){ $this->isStop = false; /*********************************** 同步文件夹 ***************************************/ // 获取文件夹 $folders = $this->imap->getFolders(); $uuids = $this->folder($folders->getTopFolder()); if($uuids){ // 删除以前的 $this->db->delete(folderSql::$table,['uuid.notin'=>$uuids,'email_id'=>$this->emailId()]); } if (!$syncMail) return true; // _echo($this->emailId().' ===> 文件夹同步成功'); if($this->isStop) return; /********************* 同步邮件 **********************/ $syncNum = []; // 循环文件夹 $startTime = time(); foreach ($folders->all() as $f){ if($this->isStop) return; if($f->isSelect){ // 是否可以选择 只有可以选中的文件夹才有邮件 $folder = $this->imap->folder($f); // 选择文件夹后,有状态 // 是否有邮件 有邮件才继续 if ($folder->getTotal()){ $num = $this->mail($folder); if($num){ $syncNum[$folder->getName()] = $num; // _echo($this->emailId().' ===> '.$folder->getName().' ===> '.$num); } } } } _echo($this->emailId()."=>runTime:".(time()-$startTime).'s => total: '.(array_sum($syncNum))); return $syncNum; } /** * 当前 目录的id * @param string $name * @return mixed|null * @author:dc * @time 2024/10/12 17:44 */ private function getFolderId(string $name){ return $this->db->cache(120)->value(folderSql::first([ 'email_id'=>$this->emailId(), 'uuid' => md5($this->emailId().$name) ],'`id`')); } /** * 过滤uid * @param array $uids * @return array * @author:dc * @time 2024/11/12 14:17 */ private function getFilterUid(array $uids, int $folder_id):array { // 强制更新本地数据,不进行过滤 if($this->isForceUpdate){ return $uids; } foreach ($uids as $k=>$uid){ $num = redis()->get('h_'.$folder_id.'_'.$uid,function () use ($folder_id,$uid){ $num = $this->db->value(listsSql::first(dbWhere(['email_id'=>$this->emailId(),'folder_id'=>$folder_id,'uid'=>$uid]),'count(*) as c')); // 查询lists_hot表 if(!$num){ $num = $this->db->value("select count(*) as c from lists_hot where `email_id` = ".$this->emailId()." and `folder_id` = {$folder_id} and `uid` = {$uid}"); } if($num){ redis()->set('h_'.$folder_id.'_'.$uid,1,86400); } return $num; }); if($num){ unset($uids[$k]); } } return array_values($uids); } /**同步邮件 * * @param string|\Lib\Imap\Request\Folder $folder * @param array $uids 固定的uid * @param false $isBody 是否同时同步body * @author:dc * @time 2024/9/26 11:10 */ public function mail(string|\Lib\Imap\Request\Folder $folder, array $uids = [],$isBody = false):int { $sync_number = 0; if(is_string($folder)){ $folder = $this->imap->folder($folder)->exec(); } $this->eLog("正在同步 ".$folder->getName()); $folder_id = $this->getFolderId($folder->getName()); if($folder->getName() == 'INBOX'){ $this->folder_inbox_id = $folder_id; } if(in_array($folder->getName(),['INBOX','[Gmail]/Important','[Gmail]/Starred','星标邮件','Important'])){ $isBody = true; } // 选择成功 if($folder->isOk()){ $msg = $folder->msg(); if($uids){ $this->saveMail($folder_id,$msg->uid($uids)->get()->all(),$isBody); }else{ $p=1; while (1){ if($this->isStop) return $sync_number; if($this->isUid){ $maxUid = $this->db->value(listsSql::first(dbWhere([ 'email_id'=>$this->emailId(), 'folder_id'=>$folder_id, ]),'max(uid)')); $maxUid = $maxUid?$maxUid:0; if($this->isUid==1&&!$maxUid){ return 0; } if($maxUid){ $this->eLog("找到最大的uid ".$maxUid); $lists = $msg->uid(1)->get(($maxUid+1).':*')->all(); if($lists){ $this->saveMail($folder_id,$lists,$isBody); return count($lists); } } } // 是否搜索 if ($this->search instanceof ImapSearch){ $uids = $msg->search($this->search)->getUids(); }else{ $uids = $msg->forPage($p)->getOriginUids(); } if(!$uids){ break; } $uids = $this->getFilterUid($uids,$folder_id); $p++; if($uids){ $lists = $msg->uid($uids)->get()->all(); $sync_number += count($lists); // 没有数据就跳出 if($lists){ $this->saveMail($folder_id,$lists,$isBody); } } // 只需要执行一次 if ($this->search instanceof ImapSearch){ break; } } } } return $sync_number; } /** * 保存邮件列表 * @param int $folder_id * @param MessageItem[] $lists * @param bool $isBody * @author:dc * @time 2024/9/29 15:14 */ protected function saveMail(int $folder_id, array $lists, bool $isBody=false){ foreach ($lists as $item){ try { $data = [ 'uid' => $item->uid, 'subject' => mb_substr($item->header->getSubject(),0,1000),// 控制下,有的蛋疼,整tm多长 'cc' => $item->header->getCc(true), 'bcc' => $item->header->getBcc(true), 'from' => $item->header->getFrom()->email, 'from_name' => mb_substr($item->header->getFrom()->name,0,200), 'to' => implode(',',array_column($item->header->getTo(true),'email')), 'to_name' => $item->header->getTo(true), // 这个是 邮件的时间 就是header里面带的 一般情况就是发件时间 // 'date' => strtotime($item->header->getDate()), 'udate' => strtotime($item->date), // 有这个时间就够了,内部时间,就是收到邮件的时间 'size' => $item->size, 'recent' => $item->isRecent() ? 1 : 0, 'seen' => $item->isSeen() ? 1 : 0, 'draft' => $item->isDraft() ? 1 : 0, 'flagged' => $item->isFlagged() ? 1 : 0, 'answered' => $item->isAnswered() ? 1 : 0, 'folder_id' => $folder_id, 'email_id' => $this->emailId(), 'is_file' => $item->isAttachment() ? 1: 0 //是否附件 ]; }catch (\Throwable $e){ logs([$e->getMessage(),$folder_id,$this->emailId(),$item->uid]); continue; } if(empty($data['from'])&&empty($data['subject'])){ logs(['邮件没有主题和发件人',[$folder_id,$item->uid],$data??[],$item->header->getRaw()]); } $data['from'] = mb_substr($data['from'],0,120); // 不知道为什么 有些邮件标题有下划线,但是发件那边并没有添加下划线 $data['subject'] = str_replace('_',' ',$data['subject']); // 查询是否存在 $id = $this->db->value(listsSql::first(dbWhere([ 'email_id'=> $data['email_id'], 'folder_id' => $data['folder_id'], 'uid' => $data['uid'] ]),'`id`')); if(!$id){ $this->eLog( sprintf("正在插入数据 eid:%d fid:%d uid:%d subject: %s", $data['email_id'], $data['folder_id'], $data['uid'], $data['subject'], )); // 收件箱直接 未读 不远程为准 if(defined('CLI_AI_SYNC_START') && !empty($this->folder_inbox_id) && $this->folder_inbox_id == $data['folder_id']){ $data['seen'] = 0; } $id = $this->insert($data); if(!$id){ continue; } // 新邮件标记 if($item->getFolderName() == 'INBOX') redis()->incr('have_new_mail_'.$this->emailId(),120); // 执行事件 $data['Aicc-Hot-Mail'] = $item->header->get('Aicc-Hot-Mail'); Event::call('mail_sync_list',$id, $data); }else{ // 非ai邮件才更新 if(!defined('CLI_AI_SYNC_START')){ $this->eLog( sprintf("正在update数据 eid:%d fid:%d uid:%d subject: %s", $data['email_id'], $data['folder_id'], $data['uid'], $data['subject'], )); $this->db->update(listsSql::$table,$data,dbWhere(['id'=> $id])); } if(php_sapi_name()=='cli'){ // 更新的就不需要操作body了 continue; } } //TODO 如果header 头信息里面有2段数据 第二段就作为内容解析 if($item->header->body()){ $parseBody = $item->header->body(); }elseif($isBody && $item->body->getRaw()){ $parseBody = $item->body; }else{ $parseBody = false; } // 是否同步body内容 if($parseBody instanceof Body){ // 记录邮件体 源文件 // Log::put( // ROOT_PATH.'/eml/'.$this->email['email']."/".$item->getFolderName().'/'.$item->uid.'.eml', // $item->header->getRaw()."\r\n\r\n".$parseBody->getRaw() // ); $body = [ 'lists_id' => $id, 'text_html' => [] ]; $body['text_html'][] = [ 'body' => base64_encode($parseBody->getHtml() ? : $parseBody->getText()), 'type' => $parseBody->getHtml()?'text/html':'text/plain', 'charset' => 'utf-8', 'encode' => 'base64', ]; // 处理附件 foreach ($parseBody->getAttachment() as $itemBody){ $tmp = [ 'body' => '', 'type' => $itemBody->getFileType(), 'charset' => 'binary', 'encode' => $itemBody->data->get('content-transfer-encoding'), 'name' => $itemBody->getFilename(), 'filename' => $itemBody->getFilename(), 'path' => $itemBody->save(MAIL_ATTACHMENT_PATH) ]; if(!$tmp){ throw new \Exception('请检查附件是否有写入权限'); } if($itemBody->getContentId()){ $tmp['content-id'] = $itemBody->getContentId(); } if($itemBody->data->get('Content-Disposition')){ $tmp['content-disposition'] = $itemBody->data->get('Content-Disposition'); } $tmp['signName'] = explode('/',$tmp['path']); $tmp['signName'] = end($tmp['signName']); $body['text_html'][] = $tmp; } if($this->db->count(bodySql::has($id))){ $this->db->update(bodySql::$table,$body,'`lists_id` = '.$id,false); }else{ $this->db->insert(bodySql::$table,$body,false); } // 更新描述 $this->db->update(listsSql::$table,[ 'description'=> Fun::mb_convert_encoding(mb_substr($parseBody->getText(),0,150),'utf-8') ],dbWhere(['id'=> $id])); } } } /** * 查询数据 并重试 * @param array $data * @param int $num * @return int * @author:dc * @time 2024/10/12 15:32 */ protected function insert(array $data, int $num = 0){ if($num>2){ return 0; } try { $id = $this->db->throw()->insert(listsSql::$table,$data); }catch (\Throwable $e){ // 字符串编码异常 if(stripos($e->getMessage(),'Incorrect string value:')!==false){ // 编码异常的 字段 preg_match("/for column '([a-z0-9_]{2,})' at/",$e->getMessage(),$filed); if(!empty($filed[1]) && isset($data[$filed[1]])){ // 进行编码转换 大概率会失败 $data[$filed[1]] = Fun::mb_convert_encoding($data[$filed[1]],'UTF-8'); } $id = $this->insert($data,$num+1); } logs([$data,$e->getMessage()]); } return $id??0; } public function __destruct() { // TODO: Implement __destruct() method. ImapPool::release($this->imap); unset($this->imap); } }