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, j_email_id(optional, auto-select if empty) */ 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', ''); $accountId = intval($this->request->param('j_email_id', 0)); if (!$journalId || empty($toEmail) || empty($subject) || empty($content)) { return jsonError('journal_id, to_email, subject, content are required'); } 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), subject, content * Content supports variables: {name}, {email}, {affiliation} */ 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', ''); if (!$journalId || empty($expertIds) || empty($subject) || empty($content)) { return jsonError('journal_id, expert_ids, subject, content are required'); } $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 = []; 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; } $personalContent = $this->replaceVariables($content, $expert); $personalSubject = $this->replaceVariables($subject, $expert); $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]); $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 ''; } // ==================== 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\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); } }