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', '

This is a test email from your journal system.

'); 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 = '
' . htmlspecialchars($text) . '
'; } return ['html' => $html, 'text' => $text]; } private function decodeBody($body, $encoding) { switch ($encoding) { case 3: return base64_decode($body); case 4: return quoted_printable_decode($body); default: return $body; } } private function getCharset($struct) { if (isset($struct->parameters)) { foreach ($struct->parameters as $param) { if (strtolower($param->attribute) === 'charset') { return $param->value; } } } return 'UTF-8'; } private function convertToUtf8($str, $charset) { $charset = strtoupper(trim($charset)); if ($charset === 'UTF-8' || $charset === 'UTF8' || empty($charset)) { return $str; } $converted = @iconv($charset, 'UTF-8//IGNORE', $str); return $converted !== false ? $converted : $str; } private function decodeMimeStr($str) { $elements = imap_mime_header_decode($str); $decoded = ''; foreach ($elements as $el) { $charset = ($el->charset === 'default') ? 'ASCII' : $el->charset; $decoded .= $this->convertToUtf8($el->text, $charset); } return $decoded; } private function parseAddress($addressStr) { $decoded = $this->decodeMimeStr($addressStr); $email = ''; $name = ''; if (preg_match('/?/', $decoded, $m)) { $email = strtolower($m[1]); } if (preg_match('/^"?([^"<]+)"?\s* $email, 'name' => $name]; } private function checkAttachment($struct) { if (!isset($struct->parts)) { return false; } foreach ($struct->parts as $part) { $disposition = ''; if (isset($part->disposition)) { $disposition = strtolower($part->disposition); } if ($disposition === 'attachment') { return true; } if (isset($part->ifdparameters) && $part->ifdparameters) { foreach ($part->dparameters as $dp) { if (strtolower($dp->attribute) === 'filename') { return true; } } } } return false; } private function listAttachments($struct, $prefix = '') { $attachments = []; if (!isset($struct->parts)) { return $attachments; } foreach ($struct->parts as $index => $part) { $partNum = $prefix ? ($prefix . '.' . ($index + 1)) : (string) ($index + 1); $filename = ''; if (isset($part->dparameters)) { foreach ($part->dparameters as $dp) { if (strtolower($dp->attribute) === 'filename') { $filename = $this->decodeMimeStr($dp->value); } } } if (empty($filename) && isset($part->parameters)) { foreach ($part->parameters as $p) { if (strtolower($p->attribute) === 'name') { $filename = $this->decodeMimeStr($p->value); } } } if (!empty($filename)) { $attachments[] = [ 'filename' => $filename, 'part_number' => $partNum, 'encoding' => $part->encoding ?? 0, 'size' => $part->bytes ?? 0, ]; } if (isset($part->parts)) { $sub = $this->listAttachments($part, $partNum); $attachments = array_merge($attachments, $sub); } } return $attachments; } // ==================== Bounce Detection ==================== private function detectBounce($fromEmail, $subject, $header) { $fromEmail = strtolower($fromEmail); if (strpos($fromEmail, 'mailer-daemon') !== false || strpos($fromEmail, 'postmaster') !== false) { return true; } $subjectLower = strtolower($subject); $bounceKeywords = ['undelivered', 'delivery status', 'delivery failed', 'mail delivery failed', 'returned mail', 'undeliverable', 'failure notice']; foreach ($bounceKeywords as $kw) { if (strpos($subjectLower, $kw) !== false) { return true; } } if (stripos($header, 'report-type=delivery-status') !== false) { return true; } return false; } private function extractBounceRecipient($bodyText, $header) { if (preg_match('/Final-Recipient:.*?;\s*?/i', $header . "\n" . $bodyText, $m)) { return strtolower($m[1]); } if (preg_match('/Original-Recipient:.*?;\s*?/i', $header . "\n" . $bodyText, $m)) { return strtolower($m[1]); } if (preg_match('/?\s+was not delivered/i', $bodyText, $m)) { return strtolower($m[1]); } if (preg_match('/delivery to.*??.*?failed/i', $bodyText, $m)) { return strtolower($m[1]); } return ''; } // ==================== 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) */ 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', 30)); $sendStartHour = intval($this->request->param('send_start_hour', 8)); $sendEndHour = intval($this->request->param('send_end_hour', 22)); 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)) { $ids = array_map('intval', explode(',', $expertIds)); $experts = Db::name('expert')->where('expert_id', 'in', $ids)->where('state', 0)->select(); } else { $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); if ($field) { $query->where('ef.field', 'like', '%' . $field . '%'); } if ($majorId) { $query->where('ef.major_id', $majorId); } $experts = $query->field('e.*')->group('e.expert_id')->select(); } if ($noRepeatDays > 0) { $cutoff = time() - ($noRepeatDays * 86400); $experts = array_filter($experts, function ($e) use ($cutoff) { return intval($e['ltime']) < $cutoff; }); $experts = array_values($experts); } if (empty($experts)) { return jsonError('No eligible experts found (all may have been promoted recently)'); } $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, '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); return jsonSuccess([ 'task_id' => $taskId, 'total_count' => count($experts), 'state' => 0, 'msg' => 'Task created, call startTask to begin sending', ]); } /** * 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'); } 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]]; } }