449 lines
14 KiB
PHP
449 lines
14 KiB
PHP
<?php
|
|
|
|
namespace app\api\controller;
|
|
|
|
use think\Db;
|
|
use think\Env;
|
|
use PHPMailer;
|
|
|
|
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,
|
|
'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', '<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, 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);
|
|
}
|
|
}
|