Files
tougao/application/api/controller/EmailClient.php
wangjinlei d2a6c3ff30 1
2026-04-20 10:56:25 +08:00

2657 lines
93 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\api\controller;
use think\Db;
use think\Env;
use think\Cache;
use think\Queue;
use PHPMailer\PHPMailer\PHPMailer;
use think\Validate;
use app\common\PromotionService;
class EmailClient extends Base
{
// ==================== SMTP Account Management ====================
/**
* Get SMTP accounts for a journal
* Params: journal_id
*/
public function getAccounts()
{
$journalId = intval($this->request->param('journal_id', 0));
if (!$journalId) {
return jsonError('journal_id is required');
}
$list = Db::name('journal_email')
->where('journal_id', $journalId)
->order('state asc, j_email_id asc')
->select();
foreach ($list as &$item) {
// $item['smtp_password'] = '******';
$item['remaining_today'] = max(0, $item['daily_limit'] - $item['today_sent']);
}
return jsonSuccess($list);
}
public function getAccountsAll(){
$list = Db::name('journal_email')
->where("state",0)
->order('state asc, j_email_id asc')
->select();
return jsonSuccess($list);
}
/**
* Add SMTP account
*/
public function addAccount()
{
$data = $this->request->post();
if (empty($data['journal_id']) || empty($data['smtp_host']) || empty($data['smtp_user']) || empty($data['smtp_password'])) {
return jsonError('journal_id, smtp_host, smtp_user, smtp_password are required');
}
$count = Db::name('journal_email')
->where('journal_id', intval($data['journal_id']))
->where('state', 0)
->count();
if ($count >= 4) {
return jsonError('Each journal allows at most 4 SMTP accounts');
}
$insert = [
'journal_id' => intval($data['journal_id']),
'smtp_host' => trim($data['smtp_host']),
'smtp_port' => intval($data['smtp_port'] ?? 465),
'smtp_user' => trim($data['smtp_user']),
'smtp_password' => trim($data['smtp_password']),
'smtp_encryption' => in_array($data['smtp_encryption'] ?? 'ssl', ['ssl', 'tls']) ? $data['smtp_encryption'] : 'ssl',
'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,
];
$id = Db::name('journal_email')->insertGetId($insert);
return jsonSuccess(['j_email_id' => $id]);
}
/**
* Update SMTP account
*/
public function updateAccount()
{
$data = $this->request->post();
$id = intval($data['j_email_id'] ?? 0);
if (!$id) {
return jsonError('j_email_id is required');
}
$account = Db::name('journal_email')->where('j_email_id', $id)->find();
if (!$account) {
return jsonError('Account not found');
}
$update = [];
$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];
}
}
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']);
}
if (isset($update['state'])) {
$update['state'] = intval($update['state']);
}
if (isset($update['smtp_encryption']) && !in_array($update['smtp_encryption'], ['ssl', 'tls'])) {
$update['smtp_encryption'] = 'ssl';
}
if (empty($update)) {
return jsonError('No fields to update');
}
Db::name('journal_email')->where('j_email_id', $id)->update($update);
return jsonSuccess(['j_email_id' => $id]);
}
/**
* Delete SMTP account
*/
public function deleteAccount()
{
$id = intval($this->request->param('j_email_id', 0));
if (!$id) {
return jsonError('j_email_id is required');
}
Db::name('journal_email')->where('j_email_id', $id)->delete();
return jsonSuccess(['deleted' => $id]);
}
/**
* Test SMTP connection
*/
public function testAccount()
{
$id = intval($this->request->param('j_email_id', 0));
$testEmail = trim($this->request->param('test_email', ''));
if (!$id) {
return jsonError('j_email_id is required');
}
if (empty($testEmail)) {
return jsonError('test_email is required');
}
$account = Db::name('journal_email')->where('j_email_id', $id)->find();
if (!$account) {
return jsonError('Account not found');
}
$result = $this->doSendEmail($account, $testEmail, 'SMTP Test', '<p>This is a test email from your journal system.</p>');
if ($result['status'] === 1) {
return jsonSuccess(['msg' => 'Test email sent successfully']);
} else {
return jsonError('Send failed: ' . $result['data']);
}
}
// ==================== Email Sending ====================
/**
* Send a single email using a specific journal's SMTP
* Params:
* - journal_id, to_email
* - subject/content OR template_id+vars_json
* - optional: style_id (mail_style) and j_email_id (specific SMTP account)
*/
public function sendOne()
{
$journalId = intval($this->request->param('journal_id', 0));
$toEmail = trim($this->request->param('to_email', ''));
$subject = trim($this->request->param('subject', ''));
$content = $this->request->param('content', '');
$templateId = intval($this->request->param('template_id', 0));
$varsJson = $this->request->param('vars_json', '');
$styleId = intval($this->request->param('style_id', 0));
$accountId = intval($this->request->param('j_email_id', 0));
if (!$journalId || empty($toEmail)) {
return jsonError('journal_id and to_email are required');
}
if ($templateId) {
$rendered = $this->renderFromTemplate($templateId, $journalId, $varsJson, $styleId);
if ($rendered['code'] !== 0) {
return jsonError($rendered['msg']);
}
$subject = $rendered['data']['subject'];
$content = $rendered['data']['body'];
} else {
if (empty($subject) || empty($content)) {
return jsonError('subject and content are required when template_id is not provided');
}
}
if ($accountId) {
$account = Db::name('journal_email')
->where('j_email_id', $accountId)
->where('journal_id', $journalId)
->where('state', 0)
->find();
} else {
$account = $this->pickSmtpAccount($journalId);
}
if (!$account) {
return jsonError('No available SMTP account (all disabled or daily limit reached)');
}
$this->resetDailyCountIfNeeded($account);
if ($account['today_sent'] >= $account['daily_limit']) {
$account = $this->pickSmtpAccount($journalId);
if (!$account) {
return jsonError('All SMTP accounts have reached daily limit');
}
}
$result = $this->doSendEmail($account, $toEmail, $subject, $content);
if ($result['status'] === 1) {
Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->setInc('today_sent');
return jsonSuccess([
'msg' => 'Email sent',
'from' => $account['smtp_user'],
'to' => $toEmail,
'j_email_id' => $account['j_email_id'],
]);
} else {
return jsonError('Send failed: ' . $result['data']);
}
}
/**
* 测试邮件:按指定 mail_template + mail_style 渲染后发送一封到指定邮箱。
* 渲染与正式推广任务一致(使用 PromotionService::renderFromTemplate变量含 expert_* / journal_*)。
*
* Params:
* journal_id (必填)
* template_id (必填)
* to_email (必填)
* style_id (选填,默认 0 表示不套 style 包裹)
* expert_id (选填,有则从库取专家填充变量;无则使用占位假数据)
* vars_json (选填JSON 对象,合并覆盖上述变量)
* j_email_id (选填,指定 SMTP否则自动选有余额的账号)
* skip_quota_count (选填,传 1 发送成功时不增加 today_sent避免测试占额度)
*/
public function sendTemplateStyleTest()
{
$journalId = intval($this->request->param('journal_id', 0));
$templateId = intval($this->request->param('template_id', 0));
$styleId = intval($this->request->param('style_id', 0));
$toEmail = trim($this->request->param('to_email', ''));
$expertId = intval($this->request->param('expert_id', 0));
$varsJson = $this->request->param('vars_json', '');
$accountId = intval($this->request->param('j_email_id', 0));
$skipQuota = intval($this->request->param('skip_quota_count', 0));
if (!$journalId || !$templateId || $toEmail === '') {
return jsonError('journal_id, template_id and to_email are required');
}
$journal = Db::name('journal')->where('journal_id', $journalId)->find();
if (!$journal) {
return jsonError('Journal not found');
}
$service = new PromotionService();
$journalVars = $service->buildJournalVars($journal);
if ($expertId > 0) {
$expert = Db::name('expert')->where('expert_id', $expertId)->find();
if (!$expert) {
return jsonError('expert not found');
}else{
$expert_fields = Db::name('expert_fields')->where('expert_id', $expert['expert_id'])->select();
$field_str = '';
foreach ($expert_fields as $field){
if($field_str != ''){
$field_str .= ','.$field['field_name'];
}else{
$field_str = $field['field_name'];
}
}
$expert['fields'] = $field_str;
}
$expertVars = $service->buildExpertVars($expert);
} else {
$expertVars = [
'expert_title' => 'Dr.',
'expert_name' => 'Test Recipient',
'expert_email' => $toEmail,
'expert_affiliation' => 'Example University / Hospital',
'expert_field' => 'Clinical Research',
];
}
$vars = array_merge($journalVars, $expertVars);
if ($varsJson !== '' && $varsJson !== null) {
$extra = json_decode($varsJson, true);
if (is_array($extra)) {
$vars = array_merge($vars, $extra);
}
}
$rendered = $service->renderFromTemplate(
$templateId,
$journalId,
json_encode($vars, JSON_UNESCAPED_UNICODE),
$styleId
);
if ($rendered['code'] !== 0) {
return jsonError($rendered['msg']);
}
$subject = $rendered['data']['subject'];
$content = $rendered['data']['body'];
if ($accountId) {
$account = Db::name('journal_email')
->where('j_email_id', $accountId)
->where('journal_id', $journalId)
->where('state', 0)
->find();
} else {
$account = $this->pickSmtpAccount($journalId);
}
if (!$account) {
return jsonError('No available SMTP account (all disabled or daily limit reached)');
}
$this->resetDailyCountIfNeeded($account);
if (!$skipQuota && $account['today_sent'] >= $account['daily_limit']) {
$account = $this->pickSmtpAccount($journalId);
if (!$account) {
return jsonError('All SMTP accounts have reached daily limit');
}
}
$result = $this->doSendEmail($account, $toEmail, $subject, $content);
if ($result['status'] === 1) {
if (!$skipQuota) {
Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->setInc('today_sent');
}
return jsonSuccess([
'msg' => 'Test email sent',
'from' => $account['smtp_user'],
'to' => $toEmail,
'j_email_id' => $account['j_email_id'],
'subject_sent' => $subject,
]);
}
return jsonError('Send failed: ' . $result['data']);
}
/**
* Send batch emails to multiple experts
* Params: journal_id, expert_ids (comma separated),
* - either subject + content
* - or template_id (recommended), with optional style_id
*
* Supported variables in subject/content or template:
* {name}/{email}/{affiliation}/{field} and {{name}}/{{email}}/... (both styles)
*/
public function sendBatch()
{
$journalId = intval($this->request->param('journal_id', 0));
$expertIds = trim($this->request->param('expert_ids', ''));
$subject = trim($this->request->param('subject', ''));
$content = $this->request->param('content', '');
$templateId = intval($this->request->param('template_id', 0));
$styleId = intval($this->request->param('style_id', 0));
if (!$journalId || empty($expertIds)) {
return jsonError('journal_id and expert_ids are required');
}
if (!$templateId && (empty($subject) || empty($content))) {
return jsonError('subject and content are required when template_id is not provided');
}
$ids = array_map('intval', explode(',', $expertIds));
$experts = Db::name('expert')->where('expert_id', 'in', $ids)->where('state', 0)->select();
if (empty($experts)) {
return jsonError('No valid experts found (state must be 0)');
}
$sent = 0;
$failed = 0;
$skipped = 0;
$errors = [];
$journal = Db::name('journal')->where('journal_id', $journalId)->find();
$journalVars = $this->buildJournalVars($journal);
foreach ($experts as $expert) {
$account = $this->pickSmtpAccount($journalId);
if (!$account) {
$skipped += (count($experts) - $sent - $failed);
$errors[] = 'All SMTP accounts reached daily limit, stopped at #' . ($sent + $failed + 1);
break;
}
$expertVars = $this->buildExpertVars($expert);
$vars = array_merge($journalVars, $expertVars);
if ($templateId) {
$rendered = $this->renderFromTemplate($templateId, $journalId, json_encode($vars, JSON_UNESCAPED_UNICODE), $styleId);
if ($rendered['code'] !== 0) {
$failed++;
$errors[] = $expert['email'] . ': ' . $rendered['msg'];
continue;
}
$personalSubject = $rendered['data']['subject'];
$personalContent = $rendered['data']['body'];
} else {
$personalContent = $this->renderVars($content, $vars);
$personalSubject = $this->renderVars($subject, $vars);
}
$result = $this->doSendEmail($account, $expert['email'], $personalSubject, $personalContent);
if ($result['status'] === 1) {
Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->setInc('today_sent');
Db::name('expert')->where('expert_id', $expert['expert_id'])->update(['state' => 1, 'ltime' => time()]);
$sent++;
} else {
$failed++;
$errors[] = $expert['email'] . ': ' . $result['data'];
}
$delay = rand(30, 60);
sleep($delay);
}
return jsonSuccess([
'sent' => $sent,
'failed' => $failed,
'skipped' => $skipped,
'errors' => $errors,
]);
}
/**
* Get sending stats for a journal
*/
public function getStats()
{
$journalId = intval($this->request->param('journal_id', 0));
if (!$journalId) {
return jsonError('journal_id is required');
}
$accounts = Db::name('journal_email')
->where('journal_id', $journalId)
->where('state', 0)
->select();
$totalLimit = 0;
$totalSent = 0;
$details = [];
foreach ($accounts as $acc) {
$this->resetDailyCountIfNeeded($acc);
$totalLimit += $acc['daily_limit'];
$totalSent += $acc['today_sent'];
$details[] = [
'j_email_id' => $acc['j_email_id'],
'smtp_user' => $acc['smtp_user'],
'daily_limit' => $acc['daily_limit'],
'today_sent' => $acc['today_sent'],
'remaining' => max(0, $acc['daily_limit'] - $acc['today_sent']),
];
}
return jsonSuccess([
'total_daily_limit' => $totalLimit,
'total_today_sent' => $totalSent,
'total_remaining' => max(0, $totalLimit - $totalSent),
'accounts' => $details,
]);
}
/**
* Reset daily sent count (called by cron or manually)
*/
public function resetDailyCount()
{
$journalId = intval($this->request->param('journal_id', 0));
if ($journalId) {
$count = Db::name('journal_email')
->where('journal_id', $journalId)
->update(['today_sent' => 0]);
} else {
$count = Db::name('journal_email')->update(['today_sent' => 0]);
}
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 = [];
$accountUpdates = [];
foreach ($accounts as $account) {
$one = $this->doSyncAccount($account);
$report[] = $one;
$aid = intval($account['j_email_id']);
if (!isset($accountUpdates[$aid])) {
$accountUpdates[$aid] = [
'synced' => 0,
'bounced' => 0,
'journal_id' => intval($account['journal_id']),
];
}
$accountUpdates[$aid]['synced'] += intval(isset($one['synced']) ? $one['synced'] : 0);
$accountUpdates[$aid]['bounced'] += intval(isset($one['bounced']) ? $one['bounced'] : 0);
}
// 发布 SSE 事件:按 j_email_id 维度通知;前端收到后自行刷新列表
foreach ($accountUpdates as $aid => $sum) {
$this->publishInboxUpdatedEvent($aid, [
'type' => 'inbox_updated',
'j_email_id' => intval($aid),
'journal_id' => intval($sum['journal_id']),
'synced' => intval($sum['synced']),
'bounced' => intval($sum['bounced']),
'time' => date('Y-m-d H:i:s'),
]);
}
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 {
// 仅拉取最近 7 天邮件(避免首次全量导致超时)
$since = date('d-M-Y', strtotime('-7 days'));
$uids = imap_search($imap, 'SINCE "' . $since . '"', SE_UID);
if (!$uids) {
$uids = [];
}
sort($uids);
foreach ($uids as $uid) {
$uid = intval($uid);
if ($uid <= $lastUid) continue;
// 每封邮件单独取 overviewFT_UID 模式下传 uid
$ovArr = imap_fetch_overview($imap, (string)$uid, FT_UID);
$overview = (!empty($ovArr) && isset($ovArr[0])) ? $ovArr[0] : null;
if (!$overview) {
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' => mb_substr($subject, 0, 512)=="failure notice"?1: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,
]);
}
/**
* SSE: 订阅收件箱更新事件(按 j_email_id 维度)。
* 前端建议按 j_email_id 建立连接,收到 inbox_updated 后自行请求 getInboxList/getUnreadCount。
*
* URL: /api/email_client/inboxSse?j_email_id=3
*/
public function inboxSse()
{
$accountId = intval($this->request->param('j_email_id', 0));
if (!$accountId) {
return jsonError('j_email_id is required');
}
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
while (ob_get_level() > 0) {
ob_end_flush();
}
@ob_implicit_flush(1);
@set_time_limit(0);
$eventFile = $this->getInboxSseEventFile($accountId);
$lastMtime = is_file($eventFile) ? filemtime($eventFile) : 0;//记录初始时间
echo "retry: 3000\n";
echo "event: connected\n";
echo "data: " . json_encode([
'msg' => 'SSE connected',
'j_email_id' => $accountId,
'time' => date('Y-m-d H:i:s'),
], JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
// 单连接保持 1.5 分钟,随后由前端自动重连
$maxLoop = 90;
for ($i = 0; $i < $maxLoop; $i++) {
if (connection_aborted()) {
break;
}
clearstatcache(true, $eventFile);
$mtime = is_file($eventFile) ? filemtime($eventFile) : 0;//新时间
if ($mtime > $lastMtime) {
$lastMtime = $mtime;
$content = @file_get_contents($eventFile);
$payload = json_decode($content, true);
if (!is_array($payload)) {
$payload = [
'type' => 'inbox_updated',
'j_email_id' => $accountId,
'time' => date('Y-m-d H:i:s'),
];
}
echo "event: inbox_updated\n";
echo "id: " . $mtime . "\n";
echo "data: " . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
} else {
// 心跳,避免中间层断开空闲 SSE 连接
echo ": ping " . time() . "\n\n";
flush();
}
sleep(1);
}
echo "event: disconnected\n";
exit;
}
/**
* SSE demo: fixed message stream for Vue EventSource testing.
* URL: /api/email_client/sseDemo
*/
public function sseDemo()
{
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
while (ob_get_level() > 0) {
ob_end_flush();
}
@ob_implicit_flush(1);
echo "retry: 3000\n";
echo "event: connected\n";
echo "data: " . json_encode([
'msg' => 'SSE connected',
'time' => date('Y-m-d H:i:s'),
], JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
for ($i = 1; $i <= 5; $i++) {
if (connection_aborted()) {
break;
}
echo "event: message\n";
echo "id: " . $i . "\n";
echo "data: " . json_encode([
'msg' => 'This is fixed SSE demo content',
'index' => $i,
'time' => date('Y-m-d H:i:s'),
], JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
sleep(2);
}
echo "event: done\n";
echo "data: " . json_encode(['msg' => 'SSE demo finished'], JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
exit;
}
// ==================== 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 = '<pre>' . htmlspecialchars($text) . '</pre>';
}
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('/<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?/', $decoded, $m)) {
$email = strtolower($m[1]);
}
if (preg_match('/^"?([^"<]+)"?\s*</', $decoded, $m)) {
$name = trim($m[1]);
}
return ['email' => $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*<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?/i', $header . "\n" . $bodyText, $m)) {
return strtolower($m[1]);
}
if (preg_match('/Original-Recipient:.*?;\s*<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?/i', $header . "\n" . $bodyText, $m)) {
return strtolower($m[1]);
}
if (preg_match('/<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?\s+was not delivered/i', $bodyText, $m)) {
return strtolower($m[1]);
}
if (preg_match('/delivery to.*?<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?.*?failed/i', $bodyText, $m)) {
return strtolower($m[1]);
}
return '';
}
// ==================== Promotion Tasks ====================
/**
* Create a promotion sending task
* Params:
* - journal_id, template_id, style_id, scene, task_name
* - expert_ids (comma separated) OR field + major_id (auto-query from DB)
* - smtp_ids (comma separated, optional: restrict to specific SMTP accounts)
* - min_interval, max_interval (seconds between emails)
* - max_bounce_rate (%), no_repeat_days
* - send_start_hour, send_end_hour (UTC, default 8-22)
* - send_date (Y-m-d计划发送日期有则「今日准备明日发」由定时任务处理无则创建后需手动 startTask)
*/
public function createTask()
{
$journalId = intval($this->request->param('journal_id', 0));
$templateId = intval($this->request->param('template_id', 0));
$styleId = intval($this->request->param('style_id', 0));
$scene = trim($this->request->param('scene', ''));
$taskName = trim($this->request->param('task_name', ''));
$expertIds = trim($this->request->param('expert_ids', ''));
$field = trim($this->request->param('field', ''));
$fetchIds = trim($this->request->param('fetch_ids', ''));
$smtpIds = trim($this->request->param('smtp_ids', ''));
$minInterval = intval($this->request->param('min_interval', 30));
$maxInterval = intval($this->request->param('max_interval', 60));
$maxBounceRate = intval($this->request->param('max_bounce_rate', 5));
$noRepeatDays = intval($this->request->param('no_repeat_days', 7));
$sendStartHour = intval($this->request->param('send_start_hour', 8));
$sendEndHour = intval($this->request->param('send_end_hour', 22));
$sendDate = trim($this->request->param('send_date', date("Y-m-d",strtotime('+1 day'))));
$targetPartitions = trim($this->request->param('target_partitions', ''));
$targetCountryIds = trim($this->request->param('target_country_ids', ''));
$type = intval($this->request->param('type', 0));
$expertType = intval($this->request->param('expert_type', 5));
if (!$journalId) {
return jsonError('journal_id is required');
}
$journal_info = $this->journal_obj->where("journal_id",$journalId)->find();
$templateId = ($templateId == 0) ? intval($journal_info['default_template_id']) : $templateId;
$styleId = ($styleId == 0) ? intval(isset($journal_info['default_style_id']) ? $journal_info['default_style_id'] : 0) : $styleId;
if($templateId==0){
return jsonError("template is not set!");
}
$tpl = Db::name('mail_template')
->where('template_id', $templateId)
->where('journal_id', $journalId)
->where('state', 0)
->find();
if (!$tpl) {
return jsonError('Template not found for this journal');
}
if (empty($scene)) {
$scene = $tpl['scene'];
}
$experts = [];
if (!empty($expertIds)) {
$ids = array_map('intval', explode(',', $expertIds));
$experts = Db::name('expert')
->where('expert_id', 'in', $ids)
->where('state', 0)
->select();
} else {
// 领域来源优先级:前端传 field > 前端传 fetch_ids > journal_promotion_field
$fields = [];
if ($field !== '') {
$fields = [$field];
} elseif ($fetchIds !== '') {
$fields = $this->resolveFieldsByFetchIds($fetchIds);
} else {
$fields = $this->resolveFieldsByJournal($journalId);
}
$experts = $this->findEligibleExperts($fields, $noRepeatDays, 100, $targetPartitions, $targetCountryIds);
}
if (empty($experts)) {
return jsonError('No eligible experts found (all may have been promoted recently)');
}
$sendDateVal = null;
if ($sendDate !== '') {
$ts = strtotime($sendDate);
if ($ts !== false) {
$sendDateVal = date('Y-m-d', $ts);
}
}
$now = time();
$taskId = Db::name('promotion_task')->insertGetId([
'journal_id' => $journalId,
'factory_id' => 0,
'template_id' => $templateId,
'style_id' => $styleId,
'scene' => $scene,
'type' => $type,
'expert_type' => $expertType,
'task_name' => $taskName ?: ('Task ' . date('Y-m-d H:i')),
'total_count' => count($experts),
'sent_count' => 0,
'fail_count' => 0,
'bounce_count' => 0,
'state' => 0,
'smtp_ids' => $smtpIds,
'min_interval' => max(5, $minInterval),
'max_interval' => max($minInterval, $maxInterval),
'max_bounce_rate' => $maxBounceRate,
'no_repeat_days' => $noRepeatDays,
'send_start_hour' => $sendStartHour,
'send_end_hour' => $sendEndHour,
'send_date' => $sendDateVal,
'target_partitions' => $targetPartitions,
'target_country_ids' => $targetCountryIds,
'ctime' => $now,
'utime' => $now,
]);
$logs = [];
foreach ($experts as $expert) {
$logs[] = [
'task_id' => $taskId,
'expert_id' => intval($expert['expert_id']),
'j_email_id' => 0,
'email_to' => $expert['email'],
'subject' => '',
'state' => 0,
'error_msg' => '',
'send_time' => 0,
'ctime' => $now,
];
}
Db::name('promotion_email_log')->insertAll($logs);
$msg = 'Task created, call startTask to begin sending';
if ($sendDateVal) {
$msg = 'Task created for send_date=' . $sendDateVal . ', will be prepared by cron and triggered on that day';
}
return jsonSuccess([
'task_id' => $taskId,
'total_count' => count($experts),
'state' => 0,
'send_date' => $sendDateVal,
'msg' => $msg,
]);
}
/**设置期刊默认
* @return \think\response\Json
* @throws \think\Exception
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\ModelNotFoundException
* @throws \think\exception\DbException
* @throws \think\exception\PDOException
*/
public function setDefaultPromotion(){
$data = $this->request->post();
$rule = new Validate([
"journal_id"=>"require",
"default_template_id"=>"require",
"default_style_id"=>"require",
"start_promotion"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$update['default_template_id']=$data['default_template_id'];
$update['default_style_id'] = $data['default_style_id'];
$update['start_promotion'] = $data['start_promotion'];
$this->journal_obj->where("journal_id",$data['journal_id'])->update($update);
$info = $this->journal_obj->where("journal_id",$data['journal_id'])->find();
return jsonSuccess(['journal'=>$info]);
}
public function getPromotionJournalList(){
$data = $this->request->post();
$rule = new Validate([
"user_id"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$journals = $this->journal_obj
->field("t_journal.journal_id,t_journal.title,t_journal.issn,t_journal.default_template_id,t_journal.default_style_id,t_journal.start_promotion")
->where("editor_id",$data['user_id'])->where("state",0)->select();
//丰富期刊内容
return jsonSuccess(["list"=>$journals]);
}
public function getPromotionJournalDetail(){
$data = $this->request->post();
$rule = new Validate([
"journal_id"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$info = $this->journal_obj->where("journal_id",$data['journal_id'])->find();
$template_info = [];
if($info['default_style_id']!==0){
$template_info = Db::name("mail_template")->where("template_id",$info['default_template_id'])->find();
}
$style_info = [];
if($info['default_style_id']!==0){
$style_info = Db::name("mail_style")->where("style_id",$info['default_style_id'])->find();
}
$re['journal'] = $info;
$re['template'] = $template_info;
$re['style'] = $style_info;
return jsonSuccess($re);
}
// ==================== Journal Promotion Field Mapping ====================
/**
* 获取某期刊已选的推广领域
* 参数: journal_id
*/
public function getJournalPromotionFields()
{
$journalId = intval($this->request->param('journal_id', 0));
if (!$journalId) {
return jsonError('journal_id is required');
}
$list = Db::name('journal_promotion_field')
->alias('jpf')
->join('t_expert_fetch ef', 'ef.expert_fetch_id = jpf.expert_fetch_id', 'left')
->where('jpf.journal_id', $journalId)
->where('jpf.state', 0)
->field('jpf.jpf_id, jpf.expert_fetch_id, ef.field, ef.source, ef.state as fetch_state, jpf.ctime')
->order('jpf.jpf_id asc')
->select();
foreach ($list as &$item) {
$item['ctime_text'] = $item['ctime'] ? date('Y-m-d H:i:s', $item['ctime']) : '';
}
return jsonSuccess($list);
}
/**
* 设置/更新期刊的推广领域(全量覆盖)
* 参数: journal_id, fetch_ids (逗号分隔的 expert_fetch_id 列表,如 "1,3,5")
*/
public function setJournalPromotionFields()
{
$data = $this->request->post();
$journalId = intval(isset($data['journal_id']) ? $data['journal_id'] : 0);
$fetchIds = trim(isset($data['fetch_ids']) ? $data['fetch_ids'] : '');
if (!$journalId) {
return jsonError('journal_id is required');
}
$newIds = [];
if ($fetchIds !== '') {
$newIds = array_unique(array_map('intval', explode(',', $fetchIds)));
$newIds = array_filter($newIds, function ($v) { return $v > 0; });
}
$currentIds = Db::name('journal_promotion_field')
->where('journal_id', $journalId)
->where('state', 0)
->column('expert_fetch_id');
$toAdd = array_diff($newIds, $currentIds);
$toRemove = array_diff($currentIds, $newIds);
if (!empty($toRemove)) {
Db::name('journal_promotion_field')
->where('journal_id', $journalId)
->where('expert_fetch_id', 'in', $toRemove)
->where('state', 0)
->update(['state' => 1]);
}
$now = time();
foreach ($toAdd as $fetchId) {
$exists = Db::name('journal_promotion_field')
->where('journal_id', $journalId)
->where('expert_fetch_id', $fetchId)
->find();
if ($exists) {
if ($exists['state'] == 1) {
Db::name('journal_promotion_field')
->where('jpf_id', $exists['jpf_id'])
->update(['state' => 0]);
}
} else {
Db::name('journal_promotion_field')->insert([
'journal_id' => $journalId,
'expert_fetch_id' => $fetchId,
'state' => 0,
'ctime' => $now,
]);
}
}
$finalList = Db::name('journal_promotion_field')
->alias('jpf')
->join('t_expert_fetch ef', 'ef.expert_fetch_id = jpf.expert_fetch_id', 'left')
->where('jpf.journal_id', $journalId)
->where('jpf.state', 0)
->field('jpf.jpf_id, jpf.expert_fetch_id, ef.field')
->select();
return jsonSuccess([
'added' => count($toAdd),
'removed' => count($toRemove),
'current' => $finalList,
]);
}
/**
* 获取所有可选的抓取领域(供期刊选择时的下拉列表)
* 返回 t_expert_fetch 中 state=0 的所有记录
*/
public function getAvailableFields()
{
$list = Db::name('expert_fetch')
->where('state', 0)
->order('field asc')
->select();
foreach ($list as &$item) {
$item['last_time_text'] = $item['last_time'] ? date('Y-m-d H:i:s', $item['last_time']) : '';
}
return jsonSuccess($list);
}
/**
* 为单个任务预生成邮件(可手动或测试用)
* Params: task_id
*/
public function prepareTask()
{
$taskId = intval($this->request->param('task_id', 0));
if (!$taskId) {
return jsonError('task_id is required');
}
$service = new PromotionService();
$result = $service->prepareTask($taskId);
if ($result['error']) {
return jsonError($result['error']);
}
return jsonSuccess($result);
}
/**
* 定时任务:为指定日期的任务预生成邮件(默认明天)
* 建议每天 22:00 执行curl .../EmailClient/prepareTasksForDate 或 prepareTasksForDate?date=2026-03-12
*/
public function prepareTasksForDate()
{
$date = trim($this->request->param('date', ''));
if ($date === '') {
$date = date('Y-m-d', strtotime('+1 day'));
} else {
$ts = strtotime($date);
if ($ts === false) {
return jsonError('date invalid, use Y-m-d');
}
$date = date('Y-m-d', $ts);
}
$service = new PromotionService();
$result = $service->prepareTasksForDate($date);
return jsonSuccess($result);
}
/**
* 定时任务:触发指定日期的已准备任务开始发送(默认今天)
* 建议每天 8:00 执行curl .../EmailClient/triggerTasksForDate 或 triggerTasksForDate?date=2026-03-12
*/
public function triggerTasksForDate()
{
$date = trim($this->request->param('date', ''));
if ($date === '') {
$date = date('Y-m-d');
} else {
$ts = strtotime($date);
if ($ts === false) {
return jsonError('date invalid, use Y-m-d');
}
$date = date('Y-m-d', $ts);
}
$service = new PromotionService();
$result = $service->startTasksForDate($date);
return jsonSuccess($result);
}
/**
* Start or resume a promotion task
*/
public function startTask()
{
$taskId = intval($this->request->param('task_id', 0));
if (!$taskId) {
return jsonError('task_id is required');
}
$task = Db::name('promotion_task')->where('task_id', $taskId)->find();
if (!$task) {
return jsonError('Task not found');
}
if ($task['state'] == 3) {
return jsonError('Task already completed');
}
if ($task['state'] == 4) {
return jsonError('Task has been cancelled');
}
if ($task['state'] == 1) {
return jsonError('Task is already running');
}
// state=0 草稿 或 state=5 已准备 均可启动
if ($task['state'] != 0 && $task['state'] != 5) {
return jsonError('Task can only be started when state is draft(0) or prepared(5), current: ' . $task['state']);
}
Db::name('promotion_task')->where('task_id', $taskId)->update([
'state' => 1,
'utime' => time(),
]);
(new PromotionService())->enqueueNextEmail($taskId, 0);
return jsonSuccess([
'task_id' => $taskId,
'state' => 1,
'msg' => 'Task started, emails will be sent via queue',
]);
}
/**
* Pause a running task
*/
public function pauseTask()
{
$taskId = intval($this->request->param('task_id', 0));
if (!$taskId) {
return jsonError('task_id is required');
}
$task = Db::name('promotion_task')->where('task_id', $taskId)->find();
if (!$task) {
return jsonError('Task not found');
}
if ($task['state'] != 1) {
return jsonError('Can only pause a running task (current state: ' . $task['state'] . ')');
}
Db::name('promotion_task')->where('task_id', $taskId)->update([
'state' => 2,
'utime' => time(),
]);
return jsonSuccess(['task_id' => $taskId, 'state' => 2]);
}
/**
* Cancel a task (cannot be resumed)
*/
public function cancelTask()
{
$taskId = intval($this->request->param('task_id', 0));
if (!$taskId) {
return jsonError('task_id is required');
}
$task = Db::name('promotion_task')->where('task_id', $taskId)->find();
if (!$task) {
return jsonError('Task not found');
}
if ($task['state'] == 3 || $task['state'] == 4) {
return jsonError('Task already finished/cancelled');
}
Db::name('promotion_task')->where('task_id', $taskId)->update([
'state' => 4,
'utime' => time(),
]);
Db::name('promotion_email_log')
->where('task_id', $taskId)
->where('state', 0)
->update(['state' => 4, 'error_msg' => 'Task cancelled']);
return jsonSuccess(['task_id' => $taskId, 'state' => 4]);
}
/**
* Get task list for a journal
*/
public function getTaskList()
{
$journalId = intval($this->request->param('journal_id', 0));
$state = $this->request->param('state', '-1');
$page = max(1, intval($this->request->param('page', 1)));
$perPage = max(1, min(intval($this->request->param('per_page', 20)), 100));
$where = [];
if ($journalId) {
$where['journal_id'] = $journalId;
}
if ($state !== '-1' && $state !== '') {
$where['state'] = intval($state);
}
$total = Db::name('promotion_task')->where($where)->count();
$list = Db::name('promotion_task')
->where($where)
->order('task_id desc')
->page($page, $perPage)
->select();
foreach ($list as &$item) {
$pending = Db::name('promotion_email_log')
->where('task_id', $item['task_id'])
->where('state', 0)
->count();
$item['pending_count'] = $pending;
$item['progress'] = $item['total_count'] > 0
? round(($item['sent_count'] + $item['fail_count']) / $item['total_count'] * 100, 1)
: 0;
$tp = isset($item['target_partitions']) ? trim((string)$item['target_partitions']) : '';
$tc = isset($item['target_country_ids']) ? trim((string)$item['target_country_ids']) : '';
if ($tp === '' && $tc === '') {
$item['country_scope_label'] = '全部国家';
} else {
$parts = [];
if ($tp !== '') {
$parts[] = '分区' . $tp;
}
if ($tc !== '') {
$cids = array_map('intval', explode(',', $tc));
$names = Db::name('country')->where('country_id', 'in', $cids)->column('zh_name');
if (!empty($names)) {
$parts[] = implode(',', $names);
}
}
$item['country_scope_label'] = implode(' + ', $parts);
}
$fid = isset($item['factory_id']) ? intval($item['factory_id']) : 0;
if ($fid > 0) {
$fRow = Db::name('promotion_factory')->where('promotion_factory_id', $fid)->field('promotion_factory_id, type, fetch_ids')->find();
$item['factory_name'] = $fRow ? ('工厂#' . $fid) : '已删除';
} else {
$item['factory_name'] = '手动创建';
}
}
return jsonSuccess([
'list' => $list,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'total_pages' => $total > 0 ? ceil($total / $perPage) : 0,
]);
}
/**
* Get task detail with stats
*/
public function getTaskDetail()
{
$taskId = intval($this->request->param('task_id', 0));
if (!$taskId) {
return jsonError('task_id is required');
}
$task = Db::name('promotion_task')->where('task_id', $taskId)->find();
if (!$task) {
return jsonError('Task not found');
}
$stats = Db::name('promotion_email_log')
->field('state, count(*) as cnt')
->where('task_id', $taskId)
->group('state')
->select();
$stateMap = ['pending' => 0, 'sent' => 0, 'failed' => 0, 'bounce' => 0, 'cancelled' => 0];
foreach ($stats as $s) {
switch ($s['state']) {
case 0: $stateMap['pending'] = $s['cnt']; break;
case 1: $stateMap['sent'] = $s['cnt']; break;
case 2: $stateMap['failed'] = $s['cnt']; break;
case 3: $stateMap['bounce'] = $s['cnt']; break;
case 4: $stateMap['cancelled'] = $s['cnt']; break;
}
}
$task['log_stats'] = $stateMap;
$task['progress'] = $task['total_count'] > 0
? round(($task['sent_count'] + $task['fail_count']) / $task['total_count'] * 100, 1)
: 0;
$task['bounce_rate'] = ($task['sent_count'] > 0)
? round($task['bounce_count'] / $task['sent_count'] * 100, 1)
: 0;
return jsonSuccess($task);
}
/**
* Get sending logs for a task
*/
public function getTaskLogs()
{
$taskId = intval($this->request->param('task_id', 0));
$state = $this->request->param('state', '-1');
$page = max(1, intval($this->request->param('page', 1)));
$perPage = max(1, min(intval($this->request->param('per_page', 50)), 200));
if (!$taskId) {
return jsonError('task_id is required');
}
$where = ['l.task_id' => $taskId];
if ($state !== '-1' && $state !== '') {
$where['l.state'] = intval($state);
}
$total = Db::name('promotion_email_log')->alias('l')->where($where)->count();
$list = Db::name('promotion_email_log')->alias('l')
->join('t_expert e', 'l.expert_id = e.expert_id', 'LEFT')
->where($where)
->field('l.*, e.name as expert_name, e.affiliation')
->order('l.log_id asc')
->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 getTaskLogDetail(){
$data = $this->request->post();
$rule = new Validate([
"log_id"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$info = Db::name("promotion_email_log")->where("log_id",$data['log_id'])->find();
$expert_info = Db::name("expert")->where("expert_id",$info['expert_id'])->find();
$expert_info['fields'] = Db::name("expert_field")->where("expert_id",$info['expert_id'])->where("state",0)->select();
return jsonSuccess(['log'=>$info,'expert'=>$expert_info]);
}
public function cancelTaskLog(){
$data = $this->request->post();
$rule = new Validate([
"log_id"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
Db::name("promotion_email_log")->where("log_id",$data['log_id'])->update(['state'=>4]);
return jsonSuccess();
}
public function updateTaskLog(){
$data = $this->request->post();
$rule = new Validate([
"log_id"=>"require",
"subject_prepared"=>"require",
"body_prepared"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$update['subject_prepared']=$data['subject_prepared'];
$update['body_prepared']=$data['body_prepared'];
Db::name("promotion_email_log")->where("log_id",$data['log_id'])->update($update);
$info = Db::name("promotion_email_log")->where("log_id",$data['log_id'])->find();
return jsonSuccess(['log'=>$info]);
}
/**
* Cron: check bounce emails and update promotion logs accordingly
* Should be called periodically after syncInbox runs.
*/
public function syncBounceToLogs()
{
$journalId = intval($this->request->param('journal_id', 0));
$where = ['is_bounce' => 1, 'state' => 0];
if ($journalId) {
$where['journal_id'] = $journalId;
}
$bounces = Db::name('email_inbox')
->where($where)
->where('bounce_email', '<>', '')
->select();
$updated = 0;
foreach ($bounces as $bounce) {
$affected = Db::name('promotion_email_log')
->where('email_to', strtolower($bounce['bounce_email']))
->where('state', 1)
->update(['state' => 3]);
if ($affected > 0) {
$updated += $affected;
$taskIds = Db::name('promotion_email_log')
->where('email_to', strtolower($bounce['bounce_email']))
->where('state', 3)
->column('task_id');
$taskIds = array_unique($taskIds);
foreach ($taskIds as $tid) {
$bounceCount = Db::name('promotion_email_log')
->where('task_id', $tid)
->where('state', 3)
->count();
Db::name('promotion_task')
->where('task_id', $tid)
->update(['bounce_count' => $bounceCount, 'utime' => time()]);
}
}
}
return jsonSuccess(['bounce_logs_updated' => $updated]);
}
// ==================== Cron: Auto Generate Daily Tasks ====================
/**
* 每日自动生成推广任务(由 Linux crontab 调用)
*
* 逻辑:
* 1. 查询所有 state=0 的任务工厂(当前仅处理 expert_type=5
* 2. JOIN journal 确认期刊有效state=0, start_promotion=1
* 3. 按 factory_id + send_date 检查去重
* 4. template/style: 工厂 > 0 用工厂的,否则用期刊默认
* 5. 用工厂的 fetch_ids 查领域,用工厂的 target_partitions/target_country_ids 做国家过滤
* 6. 生成 promotion_task + promotion_email_log
*
* crontab 示例每天凌晨1点执行
* 0 1 * * * curl -s "https://your-domain.com/api/email_client/cronDailyCreateTasks" >> /var/log/promotion_cron.log 2>&1
*/
public function cronDailyCreateTasks()
{
set_time_limit(120);
$sendDate = date('Y-m-d', strtotime('+1 day'));
$noRepeatDays = 30;
$factories = Db::name('promotion_factory')
->alias('f')
->join('t_journal j', 'j.journal_id = f.journal_id', 'inner')
->where('f.state', 0)
->where('f.expert_type', 5)
->where('j.state', 0)
->where('j.start_promotion', 1)
->field('f.*, j.title as journal_title, j.default_template_id, j.default_style_id')
->select();
if (empty($factories)) {
return jsonSuccess(['msg' => 'No active factories found', 'created' => 0]);
}
$created = 0;
$skipped = 0;
$errors = [];
$details = [];
foreach ($factories as $factory) {
$factoryId = intval($factory['promotion_factory_id']);
$journalId = intval($factory['journal_id']);
$existTask = Db::name('promotion_task')
->where('factory_id', $factoryId)
->where('send_date', $sendDate)
->where('state', 'in', [0, 1, 5])
->find();
if ($existTask) {
$skipped++;
continue;
}
$templateId = intval($factory['template_id']) > 0
? intval($factory['template_id'])
: intval($factory['default_template_id']);
$styleId = intval($factory['style_id']) > 0
? intval($factory['style_id'])
: intval(isset($factory['default_style_id']) ? $factory['default_style_id'] : 0);
if ($templateId <= 0) {
$errors[] = 'factory_id=' . $factoryId . ': no template_id configured';
continue;
}
$tpl = Db::name('mail_template')
->where('template_id', $templateId)
->where('journal_id', $journalId)
->where('state', 0)
->find();
if (!$tpl) {
$errors[] = 'factory_id=' . $factoryId . ': template_id=' . $templateId . ' not found';
continue;
}
$smtpIds = trim((string)$factory['email_ids']);
if ($smtpIds === '') {
$smtpCount = Db::name('journal_email')
->where('journal_id', $journalId)
->where('state', 0)
->count();
if ($smtpCount == 0) {
$errors[] = 'factory_id=' . $factoryId . ': no active SMTP account';
continue;
}
}
$fields = $this->resolveFieldsByFetchIds($factory['fetch_ids']);
if (empty($fields)) {
$errors[] = 'factory_id=' . $factoryId . ': no valid fields from fetch_ids';
continue;
}
$targetPartitions = trim((string)$factory['target_partitions']);
$targetCountryIds = trim((string)$factory['target_country_ids']);
$dailyLimit = max(1, intval($factory['send_count']));
$experts = $this->findEligibleExperts($fields, $noRepeatDays, $dailyLimit, $targetPartitions, $targetCountryIds);
if (empty($experts)) {
$details[] = [
'factory_id' => $factoryId,
'journal_id' => $journalId,
'title' => $factory['journal_title'],
'status' => 'no_experts',
];
continue;
}
$now = time();
$scene = $tpl['scene'] ?? 'promotion';
$taskId = Db::name('promotion_task')->insertGetId([
'journal_id' => $journalId,
'factory_id' => $factoryId,
'template_id' => $templateId,
'style_id' => $styleId,
'scene' => $scene,
'type' => intval($factory['type']),
'expert_type' => intval($factory['expert_type']),
'task_name' => 'Auto-' . ($factory['journal_title'] ?? $journalId) . '-F' . $factoryId . '-' . $sendDate,
'total_count' => count($experts),
'sent_count' => 0,
'fail_count' => 0,
'bounce_count' => 0,
'state' => 0,
'smtp_ids' => $smtpIds,
'min_interval' => 30,
'max_interval' => 60,
'max_bounce_rate' => 5,
'no_repeat_days' => $noRepeatDays,
'send_start_hour' => 8,
'send_end_hour' => 22,
'send_date' => $sendDate,
'target_partitions' => $targetPartitions,
'target_country_ids' => $targetCountryIds,
'ctime' => $now,
'utime' => $now,
]);
$logs = [];
foreach ($experts as $expert) {
$logs[] = [
'task_id' => $taskId,
'expert_id' => intval($expert['expert_id']),
'j_email_id' => 0,
'email_to' => $expert['email'],
'subject' => '',
'state' => 0,
'error_msg' => '',
'send_time' => 0,
'ctime' => $now,
];
}
Db::name('promotion_email_log')->insertAll($logs);
$created++;
$details[] = [
'factory_id' => $factoryId,
'journal_id' => $journalId,
'title' => $factory['journal_title'],
'task_id' => $taskId,
'expert_count' => count($experts),
'send_date' => $sendDate,
];
}
return jsonSuccess([
'send_date' => $sendDate,
'created' => $created,
'skipped' => $skipped,
'errors' => $errors,
'details' => $details,
]);
}
/**
* 每日自动 prepare预生成 subject/body供 crontab 调用
*
* 默认准备明天 send_date 的任务;可传 date=Y-m-d
* 建议每天 22:00 执行curl .../EmailClient/cronDailyPrepareTasks
*/
public function cronDailyPrepareTasks()
{
$date = trim($this->request->param('date', ''));
if ($date === '') {
$date = date('Y-m-d', strtotime('+1 day'));
} else {
$ts = strtotime($date);
if ($ts === false) {
return jsonError('date invalid, use Y-m-d');
}
$date = date('Y-m-d', $ts);
}
$service = new PromotionService();
$result = $service->prepareTasksForDate($date);
return jsonSuccess($result);
}
/**
* 每日自动触发发送(把 prepared 的任务启动),供 crontab 调用
*
* 默认触发今天 send_date 的任务;可传 date=Y-m-d
* 会先对当天 state=0 的任务做一次补 prepare再启动所有 state=5 的任务
* 建议每天 08:00 执行curl .../EmailClient/cronDailyTriggerTasks
*/
public function cronDailyTriggerTasks()
{
$date = trim($this->request->param('date', ''));
if ($date === '') {
$date = date('Y-m-d');
} else {
$ts = strtotime($date);
if ($ts === false) {
return jsonError('date invalid, use Y-m-d');
}
$date = date('Y-m-d', $ts);
}
$service = new PromotionService();
$result = $service->startTasksForDate($date);
return jsonSuccess($result);
}
public function act_task(){
$service = new PromotionService();
$res = $service->processNextEmail(2);
return jsonSuccess(['rr'=>$res]);
}
/**
* 根据 fetch_ids逗号分隔的 expert_fetch_id查出 field 名称数组
*/
private function resolveFieldsByFetchIds($fetchIds)
{
$fetchIds = trim((string)$fetchIds);
if ($fetchIds === '') {
return [];
}
$ids = array_map('intval', explode(',', $fetchIds));
$ids = array_filter($ids);
if (empty($ids)) {
return [];
}
$fields = Db::name('expert_fetch')
->where('expert_fetch_id', 'in', $ids)
->where('state', 0)
->column('field');
return array_values(array_unique(array_filter(array_map('trim', $fields))));
}
/**
* 根据 journal_promotion_field 查出期刊绑定的领域名称数组(旧逻辑兼容)
*/
private function resolveFieldsByJournal($journalId)
{
$fields = Db::name('journal_promotion_field')
->alias('jpf')
->join('t_expert_fetch ef_fetch', 'ef_fetch.expert_fetch_id = jpf.expert_fetch_id', 'inner')
->where('jpf.journal_id', $journalId)
->where('jpf.state', 0)
->where('ef_fetch.state', 0)
->column('ef_fetch.field');
return array_values(array_unique(array_filter(array_map('trim', $fields))));
}
/**
* 根据 target_partitions + target_country_ids 解析出最终要筛选的 country_id 列表
* 两者取并集;都为空则返回空数组,表示不做国家过滤
*/
private function resolveCountryIds($targetPartitions, $targetCountryIds)
{
$ids = [];
if ($targetPartitions !== '') {
$parts = array_map('intval', explode(',', $targetPartitions));
$parts = array_filter($parts);
if (!empty($parts)) {
$partIds = Db::name('country')
->where('partition', 'in', $parts)
->column('country_id');
$ids = array_merge($ids, $partIds);
}
}
if ($targetCountryIds !== '') {
$extra = array_map('intval', explode(',', $targetCountryIds));
$ids = array_merge($ids, $extra);
}
return array_values(array_unique(array_filter($ids)));
}
/**
* 根据领域列表 + 国家筛选 + noRepeatDays 筛选合适的专家
*
* @param array $fields 领域名称数组(由调用方从 factory.fetch_ids 或 journal_promotion_field 查好传入)
* @param int $noRepeatDays 同一专家N天内不重复
* @param int $limit 最大返回数
* @param string $targetPartitions 国家分区,逗号分隔
* @param string $targetCountryIds 单独指定的 country_id逗号分隔
* @return array
*/
private function findEligibleExperts($fields, $noRepeatDays, $limit, $targetPartitions = '', $targetCountryIds = '')
{
$fields = array_values(array_unique(array_filter(array_map('trim', $fields))));
if (empty($fields)) {
return [];
}
$query = Db::name('expert')->alias('e')
->join('t_expert_field ef', 'e.expert_id = ef.expert_id', 'inner')
->where('e.state', 0)
->where('ef.state', 0);
$query->where(function ($q) use ($fields) {
foreach ($fields as $idx => $fieldName) {
if ($idx === 0) {
$q->where('ef.field', 'like', '%' . $fieldName . '%');
} else {
$q->whereOr('ef.field', 'like', '%' . $fieldName . '%');
}
}
});
if ($noRepeatDays > 0) {
$cutoff = time() - ($noRepeatDays * 86400);
$query->where(function ($q) use ($cutoff) {
$q->where('e.ltime', 0)->whereOr('e.ltime', '<', $cutoff);
});
}
$countryIds = $this->resolveCountryIds($targetPartitions, $targetCountryIds);
if (!empty($countryIds)) {
$query->where('e.country_id', 'in', $countryIds);
}
return $query
->field('e.*')
->group('e.expert_id')
->limit($limit)
->select();
}
private function publishInboxUpdatedEvent($accountId, $payload)
{
$file = $this->getInboxSseEventFile($accountId);
$dir = dirname($file);
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
@file_put_contents($file, json_encode($payload, JSON_UNESCAPED_UNICODE));
}
private function getInboxSseEventFile($accountId)
{
return ROOT_PATH . 'runtime' . DS . 'sse' . DS . 'inbox_email_' . intval($accountId) . '.json';
}
// ==================== Internal Methods ====================
/**
* Pick the best available SMTP account for a journal
* Strategy: choose the account with the most remaining quota
*/
private function pickSmtpAccount($journalId)
{
$accounts = Db::name('journal_email')
->where('journal_id', $journalId)
->where('state', 0)
->select();
if (empty($accounts)) {
return null;
}
$best = null;
$bestRemaining = -1;
foreach ($accounts as $acc) {
$this->resetDailyCountIfNeeded($acc);
$remaining = $acc['daily_limit'] - $acc['today_sent'];
if ($remaining > 0 && $remaining > $bestRemaining) {
$best = $acc;
$bestRemaining = $remaining;
}
}
return $best;
}
/**
* Reset today_sent if the date has changed
*/
private function resetDailyCountIfNeeded(&$account)
{
$todayDate = date('Y-m-d');
$cacheKey = 'smtp_reset_' . $account['j_email_id'];
$lastReset = \think\Cache::get($cacheKey);
if ($lastReset !== $todayDate) {
Db::name('journal_email')
->where('j_email_id', $account['j_email_id'])
->update(['today_sent' => 0]);
$account['today_sent'] = 0;
\think\Cache::set($cacheKey, $todayDate, 86400);
}
}
/**
* Actually send an email using PHPMailer with given SMTP config
*/
private function doSendEmail($account, $toEmail, $subject, $htmlContent)
{
try {
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->SMTPDebug = 0;
$mail->CharSet = 'UTF-8';
$mail->Host = $account['smtp_host'];
$mail->Port = intval($account['smtp_port']);
$mail->SMTPAuth = true;
$mail->Username = $account['smtp_user'];
$mail->Password = $account['smtp_password'];
if ($account['smtp_encryption'] === 'ssl') {
$mail->SMTPSecure = 'ssl';
} elseif ($account['smtp_encryption'] === 'tls') {
$mail->SMTPSecure = 'tls';
} else {
$mail->SMTPSecure = false;
$mail->SMTPAutoTLS = false;
}
$fromName = !empty($account['smtp_from_name']) ? $account['smtp_from_name'] : $account['smtp_user'];
$mail->setFrom($account['smtp_user'], $fromName);
$mail->addReplyTo($account['smtp_user'], $fromName);
$mail->addAddress($toEmail);
$mail->isHTML(true);
$mail->Subject = $subject;
$mail->Body = $htmlContent;
$mail->AltBody = strip_tags($htmlContent);
$mail->send();
return ['status' => 1, 'data' => 'success'];
} catch (\Exception $e) {
return ['status' => 0, 'data' => $e->getMessage()];
}
}
/**
* Replace template variables with expert data
*/
private function replaceVariables($template, $expert)
{
$vars = [
'{name}' => $expert['name'] ?? '',
'{email}' => $expert['email'] ?? '',
'{affiliation}' => $expert['affiliation'] ?? '',
'{field}' => $expert['field'] ?? '',
];
return str_replace(array_keys($vars), array_values($vars), $template);
}
private function buildExpertVars($expert)
{
return [
'name' => $expert['name'] ?? '',
'email' => $expert['email'] ?? '',
'affiliation' => $expert['affiliation'] ?? '',
'field' => $expert['field'] ?? '',
];
}
private function buildJournalVars($journal)
{
if (!$journal) return [];
return [
'journal_title' => $journal['title'] ?? '',
'journal_abbr' => $journal['jabbr'] ?? '',
'journal_url' => $journal['website'] ?? '',
];
}
private function renderVars($tpl, $vars)
{
if (!is_string($tpl) || $tpl === '') return '';
if (!is_array($vars) || empty($vars)) return $tpl;
$replace = [];
foreach ($vars as $k => $v) {
$key = trim((string)$k);
if ($key === '') continue;
$replace['{{' . $key . '}}'] = (string)$v;
$replace['{' . $key . '}'] = (string)$v;
}
return str_replace(array_keys($replace), array_values($replace), $tpl);
}
private function renderFromTemplate($templateId, $journalId, $varsJson, $styleId = 0)
{
$tpl = Db::name('mail_template')->where('template_id', $templateId)->where('journal_id', $journalId)->where('state', 0)->find();
if (!$tpl) {
return ['code' => 1, 'msg' => 'Template not found'];
}
$vars = [];
if ($varsJson) {
$decoded = json_decode($varsJson, true);
if (is_array($decoded)) $vars = $decoded;
}
$subject = $this->renderVars($tpl['subject'], $vars);
$body = $this->renderVars($tpl['body_html'], $vars);
$finalBody = $body;
// 新版 style 表:只使用 header_html / footer_html 作为整体风格包裹
if ($styleId) {
$style = Db::name('mail_style')->where('style_id', $styleId)->where('state', 0)->find();
if ($style) {
$header = $style['header_html'] ?? '';
$footer = $style['footer_html'] ?? '';
$finalBody = $header . $body . $footer;
}
}
return ['code' => 0, 'msg' => 'success', 'data' => ['subject' => $subject, 'body' => $finalBody]];
}
}