diff --git a/application/api/controller/EmailClient.php b/application/api/controller/EmailClient.php index 5bb0a3c..01712a5 100644 --- a/application/api/controller/EmailClient.php +++ b/application/api/controller/EmailClient.php @@ -4,7 +4,8 @@ namespace app\api\controller; use think\Db; use think\Env; -use PHPMailer; +use PHPMailer\PHPMailer; +use think\Validate; class EmailClient extends Base { @@ -63,6 +64,9 @@ class EmailClient extends Base 'smtp_from_name' => trim($data['smtp_from_name'] ?? ''), 'daily_limit' => intval($data['daily_limit'] ?? 100), 'today_sent' => 0, + 'imap_host' => trim($data['imap_host'] ?? ''), + 'imap_port' => intval($data['imap_port'] ?? 993), + 'last_uid' => 0, 'state' => 0, ]; @@ -89,7 +93,7 @@ class EmailClient extends Base } $update = []; - $fields = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'smtp_encryption', 'smtp_from_name', 'daily_limit', 'state']; + $fields = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'smtp_encryption', 'smtp_from_name', 'daily_limit', 'imap_host', 'imap_port', 'state']; foreach ($fields as $field) { if (isset($data[$field])) { $update[$field] = $data[$field]; @@ -99,6 +103,9 @@ class EmailClient extends Base if (isset($update['smtp_port'])) { $update['smtp_port'] = intval($update['smtp_port']); } + if (isset($update['imap_port'])) { + $update['imap_port'] = intval($update['imap_port']); + } if (isset($update['daily_limit'])) { $update['daily_limit'] = intval($update['daily_limit']); } @@ -338,6 +345,684 @@ class EmailClient extends Base return jsonSuccess(['reset_count' => $count]); } + // ==================== Inbox - Sync ==================== + + /** + * Sync emails from IMAP to local database + * Params: j_email_id (single account) or journal_id (all accounts) + */ + public function syncInbox() + { + $accountId = intval($this->request->param('j_email_id', 0)); + $journalId = intval($this->request->param('journal_id', 0)); + + if (!$accountId && !$journalId) { + return jsonError('j_email_id or journal_id is required'); + } + + set_time_limit(300); + + if ($accountId) { + $accounts = Db::name('journal_email')->where('j_email_id', $accountId)->where('state', 0)->select(); + } else { + $accounts = Db::name('journal_email')->where('journal_id', $journalId)->where('state', 0)->select(); + } + + $report = []; + foreach ($accounts as $account) { + $report[] = $this->doSyncAccount($account); + } + + return jsonSuccess(['report' => $report]); + } + + private function doSyncAccount($account) + { + if (empty($account['imap_host'])) { + return ['j_email_id' => $account['j_email_id'], 'error' => 'IMAP not configured']; + } + + $imap = $this->connectImap($account); + if (!$imap) { + return ['j_email_id' => $account['j_email_id'], 'error' => 'IMAP connection failed']; + } + + $lastUid = intval($account['last_uid']); + $synced = 0; + $bounced = 0; + $maxUid = $lastUid; + + try { + if ($lastUid > 0) { + $searchRange = ($lastUid + 1) . ':*'; + $emails = imap_fetch_overview($imap, $searchRange, FT_UID); + } else { + $emails = imap_fetch_overview($imap, '1:*', FT_UID); + } + + if (!$emails) { + $emails = []; + } + + foreach ($emails as $overview) { + $uid = intval($overview->uid); + if ($uid <= $lastUid) { + continue; + } + + $messageId = isset($overview->message_id) ? trim($overview->message_id) : ''; + if ($messageId && Db::name('email_inbox')->where('message_id', $messageId)->find()) { + if ($uid > $maxUid) $maxUid = $uid; + continue; + } + + $header = imap_fetchheader($imap, $uid, FT_UID); + $struct = imap_fetchstructure($imap, $uid, FT_UID); + $body = $this->getEmailBody($imap, $uid, $struct); + + $fromParts = isset($overview->from) ? $this->parseAddress($overview->from) : ['email' => '', 'name' => '']; + $subject = isset($overview->subject) ? $this->decodeMimeStr($overview->subject) : ''; + $toEmail = isset($overview->to) ? $this->parseAddress($overview->to)['email'] : $account['smtp_user']; + $emailDate = isset($overview->date) ? strtotime($overview->date) : time(); + if (!$emailDate) $emailDate = time(); + + $hasAttachment = $this->checkAttachment($struct); + $isBounce = $this->detectBounce($fromParts['email'], $subject, $header); + $bounceEmail = ''; + + if ($isBounce) { + $bounceEmail = $this->extractBounceRecipient($body['text'], $header); + if ($bounceEmail) { + Db::name('expert')->where('email', strtolower($bounceEmail))->update(['state' => 4]); + $bounced++; + } + } + + $insert = [ + 'j_email_id' => $account['j_email_id'], + 'journal_id' => $account['journal_id'], + 'message_id' => mb_substr($messageId, 0, 255), + 'uid' => $uid, + 'from_email' => mb_substr($fromParts['email'], 0, 255), + 'from_name' => mb_substr($fromParts['name'], 0, 255), + 'to_email' => mb_substr($toEmail, 0, 255), + 'subject' => mb_substr($subject, 0, 512), + 'content_html' => $body['html'], + 'content_text' => $body['text'], + 'email_date' => $emailDate, + 'has_attachment' => $hasAttachment ? 1 : 0, + 'is_read' => 0, + 'is_starred' => 0, + 'is_bounce' => $isBounce ? 1 : 0, + 'bounce_email' => mb_substr($bounceEmail, 0, 128), + 'folder' => 'INBOX', + 'ctime' => time(), + 'state' => 0, + ]; + + try { + Db::name('email_inbox')->insert($insert); + $synced++; + } catch (\Exception $e) { + // skip duplicate + } + + if ($uid > $maxUid) { + $maxUid = $uid; + } + } + + if ($maxUid > $lastUid) { + Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->update(['last_uid' => $maxUid]); + } + } catch (\Exception $e) { + imap_close($imap); + return ['j_email_id' => $account['j_email_id'], 'error' => $e->getMessage()]; + } + + imap_close($imap); + + return [ + 'j_email_id' => $account['j_email_id'], + 'smtp_user' => $account['smtp_user'], + 'synced' => $synced, + 'bounced' => $bounced, + 'last_uid' => $maxUid, + ]; + } + + // ==================== Inbox - Read ==================== + + /** + * Get inbox list with pagination and filtering + */ + public function getInboxList() + { + $journalId = intval($this->request->param('journal_id', 0)); + $accountId = intval($this->request->param('j_email_id', 0)); + $isRead = $this->request->param('is_read', '-1'); + $isBounce = $this->request->param('is_bounce', '-1'); + $keyword = trim($this->request->param('keyword', '')); + $folder = trim($this->request->param('folder', 'INBOX')); + $page = max(1, intval($this->request->param('page', 1))); + $perPage = max(1, min(intval($this->request->param('per_page', 20)), 100)); + + $where = ['state' => 0, 'folder' => $folder]; + + if ($accountId) { + $where['j_email_id'] = $accountId; + } elseif ($journalId) { + $where['journal_id'] = $journalId; + } else { + return jsonError('j_email_id or journal_id is required'); + } + + if ($isRead !== '-1' && $isRead !== '') { + $where['is_read'] = intval($isRead); + } + if ($isBounce !== '-1' && $isBounce !== '') { + $where['is_bounce'] = intval($isBounce); + } + + $query = Db::name('email_inbox')->where($where); + if ($keyword !== '') { + $query->where('from_email|from_name|subject', 'like', '%' . $keyword . '%'); + } + + $total = Db::name('email_inbox')->where($where)->count(); + $list = $query + ->field('inbox_id,j_email_id,from_email,from_name,to_email,subject,email_date,has_attachment,is_read,is_starred,is_bounce,bounce_email,ctime') + ->order('email_date desc') + ->page($page, $perPage) + ->select(); + + return jsonSuccess([ + 'list' => $list, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'total_pages' => $total > 0 ? ceil($total / $perPage) : 0, + ]); + } + + public function getOneEmail(){ + $data = $this->request->post(); + $rule = new Validate([ + "j_email_id" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $email_info = DB::name("journal_email")->where("j_email_id",$data['j_email_id'])->find(); + $re['email'] = $email_info; + return jsonSuccess($re); + } + + /** + * Get single email detail + */ + public function getEmailDetail() + { + $inboxId = intval($this->request->param('inbox_id', 0)); + if (!$inboxId) { + return jsonError('inbox_id is required'); + } + + $email = Db::name('email_inbox')->where('inbox_id', $inboxId)->where('state', 0)->find(); + if (!$email) { + return jsonError('Email not found'); + } + + if (!$email['is_read']) { + Db::name('email_inbox')->where('inbox_id', $inboxId)->update(['is_read' => 1]); + $email['is_read'] = 1; + } + + return jsonSuccess($email); + } + + /** + * Get unread count + */ + public function getUnreadCount() + { + $journalId = intval($this->request->param('journal_id', 0)); + $accountId = intval($this->request->param('j_email_id', 0)); + + $where = ['state' => 0, 'is_read' => 0, 'folder' => 'INBOX']; + + if ($accountId) { + $where['j_email_id'] = $accountId; + } elseif ($journalId) { + $where['journal_id'] = $journalId; + } else { + return jsonError('j_email_id or journal_id is required'); + } + + $total = Db::name('email_inbox')->where($where)->count(); + $bounce = Db::name('email_inbox')->where($where)->where('is_bounce', 1)->count(); + + return jsonSuccess([ + 'unread' => $total, + 'unread_bounce' => $bounce, + ]); + } + + // ==================== Inbox - Actions ==================== + + /** + * Mark emails as read/unread + * Params: inbox_ids (comma separated), is_read (0 or 1) + */ + public function markRead() + { + $ids = trim($this->request->param('inbox_ids', '')); + $isRead = intval($this->request->param('is_read', 1)); + + if (empty($ids)) { + return jsonError('inbox_ids is required'); + } + + $idArr = array_map('intval', explode(',', $ids)); + $count = Db::name('email_inbox')->where('inbox_id', 'in', $idArr)->update(['is_read' => $isRead ? 1 : 0]); + + return jsonSuccess(['updated' => $count]); + } + + /** + * Star/unstar emails + */ + public function markStar() + { + $ids = trim($this->request->param('inbox_ids', '')); + $isStarred = intval($this->request->param('is_starred', 1)); + + if (empty($ids)) { + return jsonError('inbox_ids is required'); + } + + $idArr = array_map('intval', explode(',', $ids)); + $count = Db::name('email_inbox')->where('inbox_id', 'in', $idArr)->update(['is_starred' => $isStarred ? 1 : 0]); + + return jsonSuccess(['updated' => $count]); + } + + /** + * Delete emails (soft delete) + */ + public function deleteEmail() + { + $ids = trim($this->request->param('inbox_ids', '')); + if (empty($ids)) { + return jsonError('inbox_ids is required'); + } + + $idArr = array_map('intval', explode(',', $ids)); + $count = Db::name('email_inbox')->where('inbox_id', 'in', $idArr)->update(['state' => 1]); + + return jsonSuccess(['deleted' => $count]); + } + + /** + * Download attachment from IMAP in real time + * Params: inbox_id, part_index (attachment index, starting from 0) + */ + public function getAttachment() + { + $inboxId = intval($this->request->param('inbox_id', 0)); + $partIndex = intval($this->request->param('part_index', 0)); + + if (!$inboxId) { + return jsonError('inbox_id is required'); + } + + $email = Db::name('email_inbox')->where('inbox_id', $inboxId)->where('state', 0)->find(); + if (!$email) { + return jsonError('Email not found'); + } + + $account = Db::name('journal_email')->where('j_email_id', $email['j_email_id'])->find(); + if (!$account || empty($account['imap_host'])) { + return jsonError('IMAP not configured for this account'); + } + + $imap = $this->connectImap($account); + if (!$imap) { + return jsonError('IMAP connection failed'); + } + + $struct = imap_fetchstructure($imap, $email['uid'], FT_UID); + $attachments = $this->listAttachments($struct); + + if (!isset($attachments[$partIndex])) { + imap_close($imap); + return jsonError('Attachment not found at index ' . $partIndex); + } + + $att = $attachments[$partIndex]; + $partNum = $att['part_number']; + $filename = $att['filename']; + $encoding = $att['encoding']; + + $body = imap_fetchbody($imap, $email['uid'], $partNum, FT_UID); + imap_close($imap); + + if ($encoding == 3) { + $body = base64_decode($body); + } elseif ($encoding == 4) { + $body = quoted_printable_decode($body); + } + + $dir = ROOT_PATH . 'public' . DS . 'attachments'; + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + $safeName = time() . '_' . preg_replace('/[^a-zA-Z0-9._\-]/', '_', $filename); + $filepath = $dir . DS . $safeName; + file_put_contents($filepath, $body); + + return jsonSuccess([ + 'filename' => $filename, + 'file_url' => '/attachments/' . $safeName, + 'file_size' => strlen($body), + ]); + } + + /** + * List attachments for an email (without downloading) + */ + public function listEmailAttachments() + { + $inboxId = intval($this->request->param('inbox_id', 0)); + if (!$inboxId) { + return jsonError('inbox_id is required'); + } + + $email = Db::name('email_inbox')->where('inbox_id', $inboxId)->where('state', 0)->find(); + if (!$email) { + return jsonError('Email not found'); + } + + $account = Db::name('journal_email')->where('j_email_id', $email['j_email_id'])->find(); + if (!$account || empty($account['imap_host'])) { + return jsonError('IMAP not configured'); + } + + $imap = $this->connectImap($account); + if (!$imap) { + return jsonError('IMAP connection failed'); + } + + $struct = imap_fetchstructure($imap, $email['uid'], FT_UID); + $attachments = $this->listAttachments($struct); + imap_close($imap); + + $result = []; + foreach ($attachments as $i => $att) { + $result[] = [ + 'index' => $i, + 'filename' => $att['filename'], + 'size' => $att['size'] ?? 0, + ]; + } + + return jsonSuccess($result); + } + + // ==================== IMAP Helpers ==================== + + private function connectImap($account) + { + $host = $account['imap_host']; + $port = intval($account['imap_port'] ?: 993); + $user = $account['smtp_user']; + $password = $account['smtp_password']; + $encryption = ($port == 993) ? 'ssl' : ''; + + $flags = '/imap'; + if ($encryption === 'ssl') { + $flags .= '/ssl'; + } + $flags .= '/novalidate-cert'; + + $mailbox = '{' . $host . ':' . $port . $flags . '}INBOX'; + + $imap = @imap_open($mailbox, $user, $password, 0, 1); + if (!$imap) { + return false; + } + + return $imap; + } + + private function getEmailBody($imap, $uid, $struct) + { + $html = ''; + $text = ''; + + if (!isset($struct->parts) || empty($struct->parts)) { + $body = imap_fetchbody($imap, $uid, '1', FT_UID); + $body = $this->decodeBody($body, $struct->encoding ?? 0); + $charset = $this->getCharset($struct); + $body = $this->convertToUtf8($body, $charset); + + if (isset($struct->subtype) && strtoupper($struct->subtype) === 'HTML') { + $html = $body; + } else { + $text = $body; + } + } else { + foreach ($struct->parts as $index => $part) { + $partNum = ($index + 1); + if (isset($part->subtype)) { + $subtype = strtoupper($part->subtype); + if ($subtype === 'PLAIN' && empty($text)) { + $body = imap_fetchbody($imap, $uid, (string) $partNum, FT_UID); + $text = $this->decodeBody($body, $part->encoding ?? 0); + $text = $this->convertToUtf8($text, $this->getCharset($part)); + } elseif ($subtype === 'HTML' && empty($html)) { + $body = imap_fetchbody($imap, $uid, (string) $partNum, FT_UID); + $html = $this->decodeBody($body, $part->encoding ?? 0); + $html = $this->convertToUtf8($html, $this->getCharset($part)); + } elseif ($subtype === 'ALTERNATIVE' || $subtype === 'MIXED' || $subtype === 'RELATED') { + if (isset($part->parts)) { + foreach ($part->parts as $subIndex => $subPart) { + $subPartNum = $partNum . '.' . ($subIndex + 1); + $sub = strtoupper($subPart->subtype ?? ''); + if ($sub === 'PLAIN' && empty($text)) { + $body = imap_fetchbody($imap, $uid, $subPartNum, FT_UID); + $text = $this->decodeBody($body, $subPart->encoding ?? 0); + $text = $this->convertToUtf8($text, $this->getCharset($subPart)); + } elseif ($sub === 'HTML' && empty($html)) { + $body = imap_fetchbody($imap, $uid, $subPartNum, FT_UID); + $html = $this->decodeBody($body, $subPart->encoding ?? 0); + $html = $this->convertToUtf8($html, $this->getCharset($subPart)); + } + } + } + } + } + } + } + + if (empty($html) && !empty($text)) { + $html = '
' . htmlspecialchars($text) . '
'; + } + + return ['html' => $html, 'text' => $text]; + } + + private function decodeBody($body, $encoding) + { + switch ($encoding) { + case 3: return base64_decode($body); + case 4: return quoted_printable_decode($body); + default: return $body; + } + } + + private function getCharset($struct) + { + if (isset($struct->parameters)) { + foreach ($struct->parameters as $param) { + if (strtolower($param->attribute) === 'charset') { + return $param->value; + } + } + } + return 'UTF-8'; + } + + private function convertToUtf8($str, $charset) + { + $charset = strtoupper(trim($charset)); + if ($charset === 'UTF-8' || $charset === 'UTF8' || empty($charset)) { + return $str; + } + $converted = @iconv($charset, 'UTF-8//IGNORE', $str); + return $converted !== false ? $converted : $str; + } + + private function decodeMimeStr($str) + { + $elements = imap_mime_header_decode($str); + $decoded = ''; + foreach ($elements as $el) { + $charset = ($el->charset === 'default') ? 'ASCII' : $el->charset; + $decoded .= $this->convertToUtf8($el->text, $charset); + } + return $decoded; + } + + private function parseAddress($addressStr) + { + $decoded = $this->decodeMimeStr($addressStr); + $email = ''; + $name = ''; + + if (preg_match('/?/', $decoded, $m)) { + $email = strtolower($m[1]); + } + if (preg_match('/^"?([^"<]+)"?\s* $email, 'name' => $name]; + } + + private function checkAttachment($struct) + { + if (!isset($struct->parts)) { + return false; + } + foreach ($struct->parts as $part) { + $disposition = ''; + if (isset($part->disposition)) { + $disposition = strtolower($part->disposition); + } + if ($disposition === 'attachment') { + return true; + } + if (isset($part->ifdparameters) && $part->ifdparameters) { + foreach ($part->dparameters as $dp) { + if (strtolower($dp->attribute) === 'filename') { + return true; + } + } + } + } + return false; + } + + private function listAttachments($struct, $prefix = '') + { + $attachments = []; + if (!isset($struct->parts)) { + return $attachments; + } + + foreach ($struct->parts as $index => $part) { + $partNum = $prefix ? ($prefix . '.' . ($index + 1)) : (string) ($index + 1); + $filename = ''; + + if (isset($part->dparameters)) { + foreach ($part->dparameters as $dp) { + if (strtolower($dp->attribute) === 'filename') { + $filename = $this->decodeMimeStr($dp->value); + } + } + } + if (empty($filename) && isset($part->parameters)) { + foreach ($part->parameters as $p) { + if (strtolower($p->attribute) === 'name') { + $filename = $this->decodeMimeStr($p->value); + } + } + } + + if (!empty($filename)) { + $attachments[] = [ + 'filename' => $filename, + 'part_number' => $partNum, + 'encoding' => $part->encoding ?? 0, + 'size' => $part->bytes ?? 0, + ]; + } + + if (isset($part->parts)) { + $sub = $this->listAttachments($part, $partNum); + $attachments = array_merge($attachments, $sub); + } + } + + return $attachments; + } + + // ==================== Bounce Detection ==================== + + private function detectBounce($fromEmail, $subject, $header) + { + $fromEmail = strtolower($fromEmail); + if (strpos($fromEmail, 'mailer-daemon') !== false || strpos($fromEmail, 'postmaster') !== false) { + return true; + } + + $subjectLower = strtolower($subject); + $bounceKeywords = ['undelivered', 'delivery status', 'delivery failed', 'mail delivery failed', 'returned mail', 'undeliverable', 'failure notice']; + foreach ($bounceKeywords as $kw) { + if (strpos($subjectLower, $kw) !== false) { + return true; + } + } + + if (stripos($header, 'report-type=delivery-status') !== false) { + return true; + } + + return false; + } + + private function extractBounceRecipient($bodyText, $header) + { + if (preg_match('/Final-Recipient:.*?;\s*?/i', $header . "\n" . $bodyText, $m)) { + return strtolower($m[1]); + } + + if (preg_match('/Original-Recipient:.*?;\s*?/i', $header . "\n" . $bodyText, $m)) { + return strtolower($m[1]); + } + + if (preg_match('/?\s+was not delivered/i', $bodyText, $m)) { + return strtolower($m[1]); + } + + if (preg_match('/delivery to.*??.*?failed/i', $bodyText, $m)) { + return strtolower($m[1]); + } + + return ''; + } + // ==================== Internal Methods ==================== /** @@ -394,7 +1079,7 @@ class EmailClient extends Base private function doSendEmail($account, $toEmail, $subject, $htmlContent) { try { - $mail = new PHPMailer(true); + $mail = new PHPMailer\PHPMailer(true); $mail->isSMTP(); $mail->SMTPDebug = 0; $mail->CharSet = 'UTF-8';