Files
tougao/application/api/controller/EmailClient.php
2026-03-18 14:37:23 +08:00

1775 lines
61 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);
}
/**
* 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']);
}
}
/**
* 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 = [];
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 = '<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', ''));
$majorId = intval($this->request->param('major_id', 0));
$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'))));
if (!$journalId || !$templateId) {
return jsonError('journal_id and template_id are required');
}
// if (empty($expertIds) && empty($field)) {
// return jsonError('expert_ids or field is required');
// }
$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)) {
// 显式点名的专家,只按 state 过滤ltime 由外部自行控制
$ids = array_map('intval', explode(',', $expertIds));
$experts = Db::name('expert')
->where('expert_id', 'in', $ids)
->where('state', 0)
->select();
} else {
// 一般情况:不传 expert_ids根据期刊实际领域自动选择专家
$journal = Db::name('journal')->where('journal_id', $journalId)->find();
if (!$journal || empty($journal['issn'])) {
return jsonError('Journal or ISSN not found');
}
// 期刊绑定的领域major_title与 dailyFetchAll 保持一致
$majors = Db::name('major_to_journal')
->alias('mtj')
->join('t_major m', 'm.major_id = mtj.major_id', 'left')
->where('mtj.journal_issn', $journal['issn'])
->where('mtj.mtj_state', 0)
->where('m.major_state', 0)
->column('m.major_title');
$majors = array_unique(array_filter($majors));
$query = Db::name('expert')->alias('e')
->join('t_expert_field ef', 'e.expert_id = ef.expert_id')
->where('e.state', 0)
->where('ef.state', 0);
// 领域条件:
// 1) 若前端显式传了 field/major_id则优先按传入条件过滤
// 2) 否则按期刊绑定领域major_title在 ef.field 中模糊匹配
$query->where(function ($q) use ($field, $majorId, $majors) {
if ($field !== '') {
$q->where('ef.field', 'like', '%' . $field . '%');
} elseif ($majorId > 0) {
$q->where('ef.major_id', $majorId);
} elseif (!empty($majors)) {
$q->where(function ($qq) use ($majors) {
foreach ($majors as $idx => $title) {
$title = trim($title);
if ($title === '') {
continue;
}
if ($idx === 0) {
$qq->where('ef.field', 'like', '%' . $title . '%');
} else {
$qq->whereOr('ef.field', 'like', '%' . $title . '%');
}
}
});
}
});
// 不频繁发送:在 SQL 中直接使用 ltime + no_repeat_days 过滤
if ($noRepeatDays > 0) {
$cutoff = time() - ($noRepeatDays * 86400);
$query->where(function ($q) use ($cutoff) {
$q->where('e.ltime', 0)->whereOr('e.ltime', '<', $cutoff);
});
}
// 去重同一个专家
$experts = $query
->field('e.*')
->group('e.expert_id')
->limit(100)
->select();
}
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,
'template_id' => $templateId,
'style_id' => $styleId,
'scene' => $scene,
'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,
'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,
]);
}
/**
* 为单个任务预生成邮件(可手动或测试用)
* 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;
}
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,
]);
}
/**
* 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]);
}
// ==================== 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]];
}
}