<?php declare(strict_types=1); namespace Lib\Mail; use Lib\Mail\Body; /** * imap协议直连服务器无需 imap扩展 * @time 2022/8/5 14:17 * Class Imap * @package App\Mail\lib\socket */ class Imap { /** * @var resource */ private $socket; /** * ssl://imap.qq.com:993 * @var string */ private $host; /** 邮箱 * @var */ private $username; /** * 邮箱密码,单独设置的imap密码 * @var */ private $password; /** * 请求时增加的tag * @var int */ private $tagNum = 0; /** * 标签名称 * @var string */ private $tagName = 'A'; /** * imap服务器设置id 邮箱邮件服务器是必须的 * @var string[] */ private $imapId = [ 'name' => 'global so Client', 'version' => '1', 'os' => 'global so', 'os-version' => '1.0' ]; /** * 超时时间 * @var int */ private $timeout = 30; /** * 搜索字段,标题,邮件主题 */ const SEARCH_FILED_SUBJECT = 'SUBJECT'; /** * 搜索字段,发件人 */ const SEARCH_FILED_FROM = 'FROM'; /** * 搜索字段,收件人 */ const SEARCH_FILED_TO = 'TO'; // const SEARCH_FILED_SUBJECT = 'RFC822.TEXT'; /** * 邮件便签 * @var string[] */ public $flags = [ 'SEEN' => '\\Seen', // 已读 'DELETED' => '\\Deleted', //删除 'ANSWERED' => '\\Answered',//已回复 'DRAFT' => '\\Draft',//草稿 'FLAGGED' => '\\Flagged',//星标 'FORWARDED' => '$Forwarded', 'MDNSENT' => '$MDNSent', '*' => '\\*', ]; /** * 已读 标记 */ const FLAGS_SEEN = 'SEEN'; /** * 删除 标记 */ const FLAGS_DELETED = 'DELETED'; /** * 已回复 标记 */ const FLAGS_ANSWERED = 'ANSWERED'; /** * 草稿 标记 */ const FLAGS_DRAFT = 'DRAFT'; /** * 星标 标记 */ const FLAGS_FLAGGED = 'FLAGGED'; /** * 添加邮件标签 */ const FLAGS_APPEND = '+'; /** * 删除邮件标签 */ const FLAGS_REMOVE = '-'; /** * 是否自动关闭 * @var bool */ private $auto_colse = true; /** * 调试模式 * @var bool */ private $debug = false; /** * 调试,记录日志的目录 * @var string */ private $debugWritePath = ''; /** * 登录imap服务器 * @param string $host ssl://imap.qq.com:993 imap.qq.com:143 * @param string $username 邮箱地址 xxxxx@xx.com * @param string $password 密码,此密码非邮箱登录密码,是imap服务器单独设置的密码 * @param bool $readOnly 是否以只读模式打开邮箱 * @return bool * @throws \Exception * @author:dc * @time 2022/11/25 11:06 */ public function login(string $host,string $username,string $password,bool $readOnly=false){ $this->host = $host; $this->username = $username; $this->password = $password; // 查看服务器所支持的功能列表 // $this->request('CAPABILITY'); // 设置id,必须在登录前,163是强制的 $strId = ''; foreach ($this->imapId as $k=>$str){ if(!is_array($str)){ $strId .= '"'.str_replace(['"',"'"],'',$k).'" "'.str_replace(['"',"'"],'',$str).'" '; } } // "name" "测试本地 Client" "version" "1" "os" "测试本地" "os-version" "1.0" $this->request('ID ('.trim($strId).')');// 这里就不处理命令返回的信箱了 // 登录 $result = $this->request("LOGIN {$username} {$password}"); //解析登录数据每个服务商返回的登录结果不一样,很难兼容 if($result[0] != 'ok'){ throw new \Exception('IMAP Login Error:'.end($result[1]),403); } // 是否是只读模式 // 只读模式不可操作邮箱任何内容,只可查看 if($readOnly){ $this->request('EXAMINE '.$username); } return true; } /** * 退出当前操作的邮箱 * 一般情况不需要 * @return bool * @throws \Exception * @author:dc * @time 2022/11/25 10:46 */ public function loginOut():bool { return $this->request('LOGOUT')[0] == 'ok'; } /** * @param resource $socket */ public function setSocket($socket,$auto_colse=true): void { $this->socket = $socket; // 是否自动关闭 $this->auto_colse = $auto_colse; } /** * 删除带有delete标签的邮件,删除的邮件不可恢复。 * 类似清空 * @return bool * @throws \Exception * @author:dc * @time 2022/11/25 10:55 */ public function clearDelete():bool { return $this->request('EXPUNGE')[0] == 'ok'; } /** * [ [EXISTS] => 67 总邮件数 [RECENT] => 1 最新的邮件 [UIDVALIDITY] => 1 [FLAGS] => Array 此文件夹可以操控的标签 ( [0] => \Answered 回复 [1] => \Seen 已读 [2] => \Deleted 删除 [3] => \Draft 草稿 [4] => \Flagged 星标 ) [PERMANENTFLAGS] => Array ( [0] => \Answered [1] => \Seen [2] => \Deleted [3] => \Draft [4] => \Flagged ) ] * 选择文件夹 * @param string $folder * @return array * @throws \Exception * @author:dc * @time 2022/11/22 16:28 */ public function selectFolder($folder='INBOX'){ // 选择文件夹 $result = $this->request("SELECT \"{$folder}\""); if($result[0] != 'ok'){ throw new \Exception('select folder error:'.end($result[1])); } $list = []; foreach ($result[1] as $item){ $item = trim($item); // 总数量 if(preg_match("/^\* (\d+) EXISTS$/i",$item,$m)){ $list['EXISTS'] = $m[1]; } // 最近的 elseif (preg_match("/^\* (\d+) RECENT$/i",$item,$m)){ $list['RECENT'] = $m[1]; } // 未读 elseif (preg_match("/^\*.*\[UNSEEN (\d+)\]/i",$item,$m)){ $list['UNSEEN'] = $m[1]; } // tag elseif (preg_match("/^\* FLAGS \((.*)\)$/i",$item,$m)){ $list['FLAGS'] = explode(' ',$m[1]); } // 其他 elseif (preg_match("/^\*.*\[(.*)\]/i",$item,$m)){ // 是否有(),有小扩号就是数组 // * OK [PERMANENTFLAGS (\* \Answered \Flagged \Deleted \Draft \Seen)] Permanent flags if(preg_match("/\((.*)\)/i",$item,$m2)){ $em = explode(' ',$m[1]); $list[$em[0]] = explode(' ',$m2[1]); }else{ $em = explode(' ',$m[1]); $list[$em[0]] = $em[1]; } } } return $list; } /** * 关闭当前打开的文件夹, * 使用selectFolder后才可以使用此函数 * @return bool * @throws \Exception * @author:dc * @time 2022/11/25 10:40 */ public function closeFolder():bool{ return $this->request('CLOSE')['0'] == 'ok'; } /** * 搜索邮件,如果要获得列表,需要配合fetch使用 * @param array $criteria 搜索[key=>value,...] * @param bool $return_uid 是否返回uid,默认返回邮件编号 * @return array 返回的是搜索出来的uid,每个文件夹下面uid是唯一的 * @throws \Exception * @author:dc * @time 2022/11/24 15:46 */ public function search(array $criteria,$return_uid = false):array{ // 命令,是否返回uid $cmd = ($return_uid ? 'UID ' : '').'SEARCH'; foreach ($criteria as $k=>$v){ $v = addslashes($v); $cmd .= " {$k} \"{$v}\""; } // 获取搜索到的 uid $result = $this->request($cmd); if($result[0] != 'ok'){ throw new \Exception('search error:'.end($result[1])); } $uids = []; foreach ($result[1] as $item){ // 匹配uid if(preg_match("/^\* SEARCH ([0-9\s]{1,})$/i",$item,$m)){ $uids = array_merge($uids,explode(' ',$m[1])); } } return $uids; } /** * 设置标签,删除标签 * @param string|int|array $data 邮件的编号或者uid * @param array $flag 标记属性 [Imap::FLAGS_SEEN,..] * @param string $mod 删除或者添加 Imap::FLAGS_APPEND|Imap::FLAGS_REMOVE * @param bool $is_uid 是否使用uid进行标记 * @return bool * @throws \Exception * @author:dc * @time 2022/11/24 16:58 */ public function flags($data,array $flag,string $mod = self::FLAGS_APPEND,bool $is_uid = false):bool { if(is_array($data)){ $data = implode(',',$data); } // flags 标记 foreach ($flag as $k=>$item){ if(!isset($this->flags[$item])){ unset($flag[$k]); }else{ $flag[$k] = $this->flags[$item]; } } // 是否是规定中的值 if(!in_array($mod,[self::FLAGS_APPEND,self::FLAGS_REMOVE])){ throw new \Exception('set flags error: mod'); } // 请求标记 $result = $this->request(($is_uid?'UID ':'')."STORE {$data} {$mod}FLAGS.SILENT (".implode(' ',$flag).")"); return $result[0] == 'ok'; } /** * 获取邮箱所有文件夹 * @return array * @throws \Exception * @author:dc * @time 2022/11/24 17:20 */ public function getFolder():array { // 获取数据 $result = $this->request('LIST "" *'); $folder = []; foreach ($result[1] as $item){ // 解析源数据 if(preg_match('/^\* LIST \(([\\a-z\s]{0,})\) "(.*)" "(.*)"/Ui',$item,$m)){ $folder[] = [ 'parent' => $m[2], // 源文件夹名称,在进行 select的时候必须用未解析的文件夹名称 'folder' => $m[3], // 解析过的文件夹名称 'parseFolder' => mb_convert_encoding($m[3], 'UTF-8', 'UTF7-IMAP'), // 是否可选择 'isSelect' => strpos($m[1],'NoSelect')===false, ]; } } return $folder; } /** * 创建文件夹 * $folder 目录 创建二级目录用/ a/b * @param string $folder * @return bool * @throws \Exception * @author:dc * @time 2022/11/24 23:35 */ public function folderCreate(string $folder):bool { // 需要转码 $folder = mb_convert_encoding($folder,'UTF7-IMAP','UTF-8'); // A003 CREATE owatagusiam 顶级 // A003 CREATE owatagusiam/owatagusiam2 有上下级关系的文件夹 $res = $this->request('CREATE '.$folder); if ($res[0] == 'ok'){ return true; } throw new \Exception('create folder error:'.end($res[1])); } /** * 修改文件夹名称 * @param string $oldFolder 久文件夹名称 * @param string $newFolder 新文件夹名称 * @return bool * @throws \Exception * @author:dc * @time 2022/11/25 10:24 */ public function folderRename(string $oldFolder, string $newFolder){ // 需要转码 $newFolder = mb_convert_encoding($newFolder,'UTF7-IMAP','UTF-8'); // RENAME oldfolder newfolder $res = $this->request("RENAME {$oldFolder} {$newFolder}"); if ($res[0] == 'ok'){ return true; } throw new \Exception('rename folder error:'.end($res[1])); } /** * 删除文件夹 * @param string $folder * @return bool * @throws \Exception * @author:dc * @time 2022/11/25 10:25 */ public function folderDelete(string $folder):bool{ // A683 DELETE blurdybloop/test $res = $this->request('DELETE '.$folder); if ($res[0] == 'ok'){ return true; } throw new \Exception('rename folder error:'.end($res[1])); } /** * 获取邮件头,并解析 * @param $data * @param false $is_uid * @return array * @throws \Exception * @author:dc * @time 2022/11/23 14:25 */ public function fetchHeader($data,$is_uid = false){ $result = $this->fetch($data,'header',$is_uid); // 解析header字段 foreach ($result as $key=>$item){ $result[$key]['HEADER.FIELDS'] = $this->parseHeader($item['HEADER.FIELDS']); } return $result; } /** * 解析header字段 * @param $header * @return array * @author:dc * @time 2022/11/23 14:54 */ public function parseHeader($header){ /****** 把数据中的 : 转换一下 *******/ $header = explode("\n",$header); foreach ($header as $k=>$str){ $first_str = ''; if(substr_count($str,':') > 1){ $str = explode(':',$str); $first_str = $str[0]; if($first_str){ unset($str[0]); } $str = implode('_(_(_(_0_)_)_)_',$str); if($first_str) $str = $first_str.':'.$str; } $header[$k] = empty($first_str) ? $str : ltrim($str); } $header = implode("\n",$header); /******end*******/ preg_match_all( "/^([^\r\n:]+)\s*[:]\s*([^\r\n:]+(?:[\r]?[\n][ \t][^\r\n]+)*)/m", $header, $matches, PREG_SET_ORDER ); $headers = []; foreach($matches as $match){ $match[2] = str_replace(['_(_(_(_0_)_)_)_'],[':'],$match[2]); if(strpos($match[2],'=?')!==false){ $match2=iconv_mime_decode($match[2],ICONV_MIME_DECODE_CONTINUE_ON_ERROR,'utf-8'); $match[2] = $match2; } if(isset($headers[$match[1]]) && is_array($headers[$match[1]])){ $headers[$match[1]][]=$match[2]; }elseif(isset($headers[$match[1]])){ $headers[$match[1]]=array($headers[$match[1]],$match[2]); }else{ $headers[$match[1]]=$match[2]; } } return $headers; } /** * 获取邮件内容,包括附件 * @param mixed $data 1,1:10,[1,2,3] * @param string $saveFilePath 保存附件的目录 * @param bool $is_uid 是否是UID查询 * @throws \Exception * @author:dc * @time 2022/11/23 17:01 */ public function fetchBody($data,$saveFilePath=__DIR__,$is_uid = false){ // 取得body $result = $this->fetch($data,'body',$is_uid); foreach ($result as &$item){ if (!empty($item['RFC822.TEXT'])){ // 解析 $item['RFC822.TEXT'] = (new Body($item['RFC822.TEXT'],$saveFilePath))->getItem(); } } return $result; } /** * @param $data * @param false $is_uid * @return array * @throws \Exception * @author:dc * @time 2022/11/23 10:46 */ public function fetch($data,$header2Body='header',$is_uid = false):array{ // string 必须是 0:99 开始:结束 if(is_numeric($data)){ $data = intval($data); } elseif(is_string($data)){ if(!preg_match("/^\d+:\d+$/",$data)){ $data = ''; } }elseif (is_array($data)){ foreach ($data as $k=>$v){ if(!is_numeric($v)){ unset($data[$k]); } } $data = array_unique(array_values($data)); $data = implode(',',$data); } if(empty($data)){ throw new \Exception('fetch data error'); } // (UID RFC822.SIZE RFC822.HEADER FLAGS INTERNALDATE BODYSTRUCTURE BODY.PEEK[HEADER.FIELDS (DATE FROM TO SUBJECT CONTENT-TYPE CC REPLY-TO LIST-POST DISPOSITION-NOTIFICATION-TO X-PRIORITY CONTENT-TRANSFER-ENCODING BCC IN-REPLY-TO MAIL-FOLLOWUP-TO MAIL-REPLY-TO MESSAGE-ID REFERENCES RESENT-BCC RETURN-PATH SENDER X-DRAFT-INFO)]) // 指定字段 BODY.PEEK[HEADER.FIELDS (DATE FROM TO ...)] // 所有header字段 RFC822.HEADER // RFC822.TEXT 内容包括附件 // body字段必须放最后 if($header2Body=='header'){ // BODY.peek必须放最后 $filed = 'UID FLAGS INTERNALDATE RFC822.SIZE BODYSTRUCTURE BODY.PEEK[HEADER.FIELDS (DATE FROM TO SUBJECT CONTENT-TYPE CC REPLY-TO LIST-POST DISPOSITION-NOTIFICATION-TO X-PRIORITY CONTENT-TRANSFER-ENCODING BCC IN-REPLY-TO MAIL-FOLLOWUP-TO MAIL-REPLY-TO MESSAGE-ID REFERENCES RESENT-BCC RETURN-PATH SENDER X-DRAFT-INFO)]'; }elseif($header2Body=='body'){ $filed = 'RFC822.TEXT'; }else{ $filed = 'UID FLAGS INTERNALDATE'; } // 读取数据 $result = $this->request(($is_uid?'UID ':'')."FETCH {$data} ($filed)"); if($result[0] != 'ok'){ throw new \Exception('fetch error:'.end($result[1])); } // 开始解析数据 $list = []; foreach ($result[1] as $item){ $item = trim($item); // 匹配出id和数据 if(preg_match("/\* (\d+) FETCH \(([\w\s\d\r\n\W]{1,})\)$/i",$item,$line)){ $list[$line[1]] = $this->parseFetch($line[2]); } } // 返回结果 return $list; } /** * 解析头部信息,只能解析固定字段的属性 * @param string $line * @return array * @author:dc * @time 2022/11/22 18:06 */ public function parseFetch($line):array{ $result = []; $line = ltrim($line); $header = explode(' ',$line); do{ switch ($header[0]){ case 'UID':{ $result['UID'] = $header[1]; array_splice($header,0,2); break; } case 'FLAGS':{ // 标记,标签 $result['FLAGS'] = $this->pregFiled( $header, "/^\(([\\a-z\s]{0,})\)/Ui" ); $result['FLAGS'] = $result['FLAGS'] ? explode(' ',$result['FLAGS']) : []; break; } case 'INTERNALDATE':{ // 内部时间 $result['INTERNALDATE'] = $this->pregFiled( $header, "/^\"([\\a-z\d\s]{0,})\"/Ui" ); break; } case 'RFC822.SIZE':{ $result['RFC822.SIZE'] = $header[1]; array_splice($header,0,2); break; } case 'BODYSTRUCTURE':{ array_shift($header); $result['BODYSTRUCTURE'] = ''; $leftNum = $RightNum = 0; foreach ($header as $key=>$str){ // 还有括号不对等的情况,无语了 if($str == 'BODY[HEADER.FIELDS'){ break; } // 找出现的次数 $leftNum += substr_count($str,'('); $RightNum += substr_count($str,')'); // 拼接起来 $result['BODYSTRUCTURE'] .= ' '.$str; unset($header[$key]); // 说明找到结束了 if($leftNum===$RightNum){ break; } } $header = array_values($header); break; } // RFC822.TEXT不能和 BODY[HEADER.FIELDS 同时存在,否则解析失败 case 'RFC822.TEXT':{ array_shift($header); $result['RFC822.TEXT'] = implode(' ',$header); $header = []; break; } case 'RFC822.HEADER':{ array_shift($header); $result['RFC822.HEADER'] = implode(' ',$header); $header = []; break; } case 'BODY[HEADER.FIELDS':{ // header $header = implode(' ',$header); preg_match("/^BODY\[HEADER.FIELDS.*\]/Ui",$header,$m); // 结果,提出掉字段字符 $result['HEADER.FIELDS'] = ltrim(mb_substr($header,mb_strlen($m[0]),mb_strlen($header))); $header = [];//表示结束了 break; } default:{ array_shift($header); break; } } }while($header); return $result; } /** * 匹配字段中的值 * @param $header * @param $preg * @return mixed|string * @author:dc * @time 2022/11/23 14:38 */ private function pregFiled(&$header,$preg){ array_shift($header); // 组成字符串 $header = implode(' ',$header); // 找flags,非贪婪模式 if(preg_match($preg,$header,$m)){ $header = mb_substr($header,mb_strlen($m[0]),mb_strlen($header)); } // 打散成数组 $header = explode(' ',$header); return $m[1]??''; } /** * imap服务器设置的id * @param array|string $id * @author:dc * @time 2022/11/22 11:53 */ public function setImapId($id){ if(is_array($id)){ $this->imapId = array_merge($this->imapId,$id); }else{ $this->imapId['name'] = $id; } } /** * 打开socket链接 * @param int $timeout * @throws \Exception * @author:dc * @time 2022/11/22 14:13 */ private function socketOpen(int $timeout = 30){ if(!is_resource($this->socket)){ $this->timeout = $timeout ? : 30; // 链接服务器 $this->socket = stream_socket_client($this->host, $errno, $error, $this->timeout); if($error){ throw new \Exception("socket error: {$error}"); } if (!$this->socket) { throw new \Exception("{$this->host} connection fail."); } stream_set_timeout($this->socket, $this->timeout); }else{ // 是否超时,超时关闭 $meta = stream_get_meta_data($this->socket); if (isset($meta['timed_out']) && $meta['timed_out'] !== false) { $this->socketClose(); // 重新链接 $this->socketOpen(); } } } /** * 最后一次执行时候的tag * @return string * @author:dc * @time 2022/11/22 13:54 */ protected function getLastTag(){ return sprintf('%s%d', $this->tagName, $this->tagNum); } /** * 获取下一次执行的tag * @return string * @author:dc * @time 2022/11/22 13:52 */ protected function getNextTag(){ $this->tagNum += 1; return sprintf('%s%d', $this->tagName, $this->tagNum); } /** * 向服务器发送一个noop消息,表示什么也不做, * 只是用来保持通信,避免长久没有操作,丢失掉连接 * @throws \Exception * @author:dc * @time 2022/11/25 11:15 */ public function noop():bool { $status = $this->request('NOOP'); return $status[0] == 'ok'; } /** * debug模式 * @param bool $debug * @param string $logPath * @author:dc * @time 2022/12/6 11:10 */ public function debug($debug=true,$logPath=''){ $this->debug = $debug; $this->debugWritePath = $logPath ? $logPath : storage_path('logs'); } /** * 记录日志 * @param string $message * @author:dc * @time 2022/12/6 11:13 */ private function log(string $message){ if($this->debug){ if(!is_dir($this->debugWritePath)){ @mkdir($this->debugWritePath,0775,true); } @file_put_contents($this->debugWritePath.'/imap.log',date('Y-m-d H:i:s ').$message.PHP_EOL,FILE_APPEND); } } /** * 获取数据集 * @param $cmd * @return array * @throws \Exception * @author:dc * @time 2022/11/22 11:41 */ public function request($cmd){ // 链接 $this->socketOpen(); $tag = $this->getNextTag(); // 命令行必须要结束换行 $cmd = trim($tag.' '.$cmd); // 记录日志 $this->log($cmd); // 写入数据 $r = fwrite($this->socket, $cmd."\r\n"); if (!$r) { $this->socketClose(); throw new \Exception("request error write"); } // 读取数据 $result = []; // 返回数据 $status = 'no'; // 状态 $i = 1; while ($i) { $append = false;// 是否追加到上一个元素值里面 // 这里不指定长度,读取一行 $line = @fgets($this->socket); if ($line === false || !mb_strlen($line)) { $end = true; }else{ // 是否有其他的固定串 preg_match("/\{(\d+)\}\r\n$/",$line,$number); if($number[1]??0){ // 获取到固定字符串 $appendLine = fread($this->socket, intval($number[1])); // 替换 {123} $line = str_replace($number[0],$appendLine,$line); } // 记录日志 $this->log($line); // 是否结束了,到了最后一行 if(strpos($line,$tag.' ') === 0){ $end = true; $status = strtolower(explode(' ',$line)[1]??''); // 脚本异常 if($status == 'bad'){ throw new \Exception('request bad:'.$line); } }else{ if(strpos($line,'* ') !==0 ){ $append = true; } } // 结果数组 if($append){ $result[$i-1] .= $line; }else{ $result[$i] = $line; $i++; } } // 结束了 if(!empty($end)){ $i = false; } } // print_r($result); return [$status,$result]; } /** * 是否读取结束 * @return bool * @author:dc * @time 2022/11/22 14:14 */ protected function socketEof() { if (!is_resource($this->socket)) { return true; } // If a connection opened by fsockopen() wasn't closed // by the server, feof() will hang. $start = microtime(true); if (feof($this->socket) || ($this->timeout && (microtime(true) - $start > $this->timeout)) ) { $this->socketClose(); return true; } return false; } /** * 关闭socket链接 * @author:dc * @time 2022/11/22 10:45 */ protected function socketClose(){ if(is_resource($this->socket)){ fclose($this->socket); } $this->socket = false; } public function __construct() { } public function __destruct() { if($this->auto_colse){ // echo '关闭'; // TODO: Implement __destruct() method. $this->socketClose(); } } }