1775 lines
61 KiB
PHP
1775 lines
61 KiB
PHP
<?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]];
|
||
}
|
||
}
|