Files
fdesk/app/common/utils/email/Emails.php
T
2025-08-04 14:03:57 +08:00

410 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common\utils\email;
use support\Db;
class Emails
{
private $mailbox = '{imap.feishu.cn:993/imap/ssl}INBOX';
private $username = 'info@borderlesscar.com';
private $password = 'ErkhUA629aocm7Bc';
private $limit = 5;
private $per_page = 20;
private $batch_size = 10;
public function __construct($options = [])
{
if ($options) {
$this->mailbox = $options['mailbox'];
$this->username = $options['username'];
$this->password = $options['password'];
$this->limit = $options['limit'];
$this->per_page = $options['per_page'];
$this->batch_size = $options['batch_size'];
}
}
/**
* 将任意字符串清洗为有效 UTF-8,移除或替换无效字节
*/
protected function sanitizeUtf8(string $text): string
{
return iconv('UTF-8', 'UTF-8//IGNORE', $text);
}
/**
* 记录错误到日志
*/
protected function logError(string $msg): void
{
$line = '[' . date('Y-m-d H:i:s') . '] ' . $msg . "\n";
file_put_contents(
runtime_path('logs/email_errors.log'),
$line,
FILE_APPEND | LOCK_EX
);
}
/**
* 按编码解码并保留 "\r\n"
*/
protected function getMailBody($inbox, int $id, bool $isUid = false): string
{
$flags = $isUid ? FT_UID : 0;
$structure = imap_fetchstructure($inbox, $id, $flags);
if (!empty($structure->parts)) {
// HTML 部分
foreach ($structure->parts as $i => $part) {
if (isset($part->subtype) && strtolower($part->subtype) === 'html') {
$data = imap_fetchbody($inbox, $id, (string)($i+1), $flags);
return $this->decodeBody($data, $part->encoding);
}
}
// 文本部分
foreach ($structure->parts as $i => $part) {
if (isset($part->subtype) && strtolower($part->subtype) === 'plain') {
$data = imap_fetchbody($inbox, $id, (string)($i+1), $flags);
return $this->decodeBody($data, $part->encoding);
}
}
// 原始 body
return imap_body($inbox, $id, $flags);
}
return imap_body($inbox, $id, $flags);
}
/**
* 按编码解码
*/
protected function decodeBody(string $body, int $encoding): string
{
switch ($encoding) {
case 3: return imap_base64($body);
case 4: return imap_qprint($body);
default: return $body;
}
}
/**
* 解析 HTML,找不到 <h2>Preview</h2> 则返回 null,
* 否则提取所有 <span>Key:</span> Value 为小写蛇形 key=>value 数组
*/
protected function parseHtml(string $html): ?array
{
$doc = new \DOMDocument();
libxml_use_internal_errors(true);
$doc->loadHTML('<?xml encoding="utf-8"?>' . $html);
libxml_clear_errors();
$xpath = new \DOMXPath($doc);
// 类型检查
if ($xpath->query("//h2[text()='Preview']")->length === 0) {
return null;
}
$nodes = $xpath->query("//div[span]");
$data = [];
/** @var \DOMElement $node */
foreach ($nodes as $node) {
$span = $xpath->query("span", $node)->item(0);
if (! $span) {
continue;
}
// 原始键名
$key = trim(str_replace(':', '', $span->textContent));
// 转成小写蛇形
$col = strtolower(str_replace(' ', '_', $key));
// 获取值
$a = $xpath->query(".//a", $node)->item(0);
if ($a && $a->hasAttribute('href')) {
$value = trim($a->getAttribute('href'));
} else {
$clone = $node->cloneNode(true);
$spanClone = $xpath->query(".//span", $clone)->item(0);
if ($spanClone) {
$spanClone->parentNode->removeChild($spanClone);
}
$value = trim($clone->textContent);
}
$data[$col] = $value;
}
return $data;
}
/**
* 拉取邮件uid
*/
public function uids()
{
try {
$retryTimes = 3;
$retryDelay = 10;
$inbox = false;
$attempt = 0;
$maxUid = Db::table('email_uids')->max('uid');
while ($attempt < $retryTimes) {
$attempt++;
flg("尝试第 {$attempt} 次连接 IMAP...", false);
$inbox = @imap_open($this->mailbox, $this->username, $this->password);
if ($inbox) {
break;
}
$error = imap_last_error();
flg("<comment>第 {$attempt} 次失败:{$error}</comment>");
// 若出现 too many login failures 立即暂停一段时间
if (stripos($error, 'Too many login failures') !== false) {
flg("<error>检测到登录失败次数过多,暂停 60 秒避免封禁</error>");
sleep(60);
} else {
sleep($retryDelay);
}
}
if (!$inbox) {
flg("<error>最终仍然连接失败:" . imap_last_error() . "</error>");
return false;
}
// 拉取 UID 并入库
flg("开始拉取 UID 并入库 …", false);
$uids = imap_search($inbox, 'ALL', SE_UID) ?: [];
$uids = array_filter($uids, function($uid) use ($maxUid) {
return $uid > $maxUid;
});
sort($uids);
$now = time();
$count = 0;
foreach ($uids as $uid) {
try {
Db::table('email_uids')->insert([
'uid' => $uid,
'fetched_at' => $now,
'email' => $this->username
]);
$count++;
flg("UID:".$uid." 已写入数据库", false);
} catch (\Throwable $e) {
flg("<comment>{$e->getMessage()}</comment>");
}
}
imap_close($inbox);
flg("✔ 获取到新询盘邮件 {$count} 条 ...", false);
return true;
} catch (\Throwable $th) {
flg(dpr($th));
return false;
}
}
/**
* 获取邮件详情
*/
public function details()
{
try {
$inbox = @imap_open($this->mailbox, $this->username, $this->password);
if (!$inbox) {
flg("<error>IMAP 连接失败:". imap_last_error() ."</error>");
return false;
}
// 依次拉取详情并写入 emails 表
flg("依次拉取详情并写入 emails 表 …", false);
while (true) {
$pending = Db::table('email_uids')
->where('status', 0)
->orderBy('uid', 'desc')
->limit(2)
->get();
if (count($pending) === 0) {
flg("邮件UID的详情,已拉取完毕。", false);
break;
}
flg("处理下一批 " . count($pending) . " 条 …", false);
foreach ($pending as $row) {
$uid = $row->uid;
// 包裹整行处理在 try/catch 中,任何异常都不会中断循环
try {
flg("---- 开始处理 UID={$uid} ----", false);
// 1. fetch overview
$ov = @imap_fetch_overview($inbox, $uid, FT_UID)[0] ?? null;
if (!$ov) {
$err = imap_last_error() ?: '未知错误';
flg("UID={$uid} overview 失败:{$err}");
continue;
}
$subject = mb_convert_encoding($ov->subject ?? '', 'UTF-8', 'GBK,UTF-8');
$from = $ov->from ?? '';
$date = $ov->date ?? '';
flg("UID={$uid} overview 成功,subject={$subject}", false);
// 2. fetch body
$body = $this->getMailBody($inbox, $uid, true);
flg("UID={$uid} body 长度:" . strlen($body), false);
// 3. sanitize & insert
$body = $this->sanitizeUtf8($body);
$now = time();
try {
Db::table('emails')->insert([
'uid' => $uid,
'subject' => $subject,
'from' => $from,
'date' => $date,
'body' => $body,
'created_at' => $now,
'updated_at' => $now,
]);
flg("UID={$uid} 写入 emails 成功", false);
} catch (\Throwable $e) {
flg("UID={$uid} 写入失败:{$e->getMessage()}");
continue;
}
// 4. 标记已处理
try {
Db::table('email_uids')
->where('uid', $uid)
->update([
'status' => 1,
'processed_at' => $now,
]);
flg("UID={$uid} 标记为已处理", false);
} catch (\Throwable $e) {
flg("UID={$uid} 标记状态失败:{$e->getMessage()}", false);
// 即使更新 status 失败,也继续下一条
}
} catch (\Throwable $e) {
// 捕获任何非预期错误
flg("UID={$uid} 处理异常:{$e->getMessage()}");
continue;
}
}
// 批次结束后稍作休眠,防止服务器过载
sleep(1);
}
imap_close($inbox);
flg("✔ 新邮件详情拉取完成 ...", false);
return true;
} catch (\Throwable $th) {
flg(dpr($th));
return false;
}
}
/**
* 邮件清洗处理
*/
public function process()
{
try {
$page = 0;
// 明确定义 parsed_contacts 表里要插入的所有列(顺序无所谓,但要全部包含)
$fields = [
'email_id',
'date',
'email',
'name',
'tel',
'company',
'country',
'form_url',
'message',
'ip',
'iplocation',
'ilang',
'mailto',
'navigator_language',
'navigator_platform',
'screen_width',
'screen_height',
'ucountry',
];
$maxEmailId = Db::table('email_parsed_result')->max('email_id');
while (true) {
$rows = Db::table('emails')
->where('status', 0)
->where('id', '>', $maxEmailId)
->orderBy('id', 'asc')
->limit($this->batch_size)
->offset($page * $this->batch_size)
->get();
if ($rows->isEmpty()) {
break;
}
$toInsert = [];
foreach ($rows as $row) {
$parsed = $this->parseHtml($row->body);
if ($parsed === null) {
flg(" ↳ [{$row->id}] skip: not a contact-form HTML", false);
continue;
}
// 严格按 $fields 顺序填充每条记录,缺失的字段补 null
$record = [];
foreach ($fields as $col) {
if ($col === 'email_id') {
$record[$col] = $row->id;
} else {
$record[$col] = $parsed[$col] ?? null;
}
}
$toInsert[] = $record;
}
if (!empty($toInsert)) {
Db::table('email_parsed_result')->insert($toInsert);
$emailId = array_column($toInsert, 'email_id');
Db::table('emails')->whereIn('id', $emailId)->update(['status' => 1]);
flg("Inserted batch #" . ($page + 1) . ", count: " . count($toInsert), false);
}
$page++;
}
flg("✔ 邮件清洗完毕,等待分配 ...", false);
return true;
} catch (\Throwable $th) {
flg(dpr($th));
return false;
}
}
}