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, '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', '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['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]); } // ==================== 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); } }