logFile = ROOT_PATH . 'runtime' . DS . 'promotion_task.log'; } /** * Process the next email in a promotion task (called by queue job) */ public function processNextEmail($taskId) { $task = Db::name('promotion_task')->where('task_id', $taskId)->find(); if (!$task) { return ['done' => true, 'reason' => 'task_not_found']; } if ($task['state'] != 1) { return ['done' => true, 'reason' => 'task_not_running', 'state' => $task['state']]; } $currentHour = intval(date('G')); if ($currentHour < $task['send_start_hour'] || $currentHour >= $task['send_end_hour']) { $this->enqueueNextEmail($taskId, 300); return ['done' => false, 'reason' => 'outside_send_window', 'retry_in' => 300]; } if ($task['sent_count'] > 0 && $task['max_bounce_rate'] > 0) { $bounceRate = ($task['bounce_count'] / $task['sent_count']) * 100; if ($bounceRate >= $task['max_bounce_rate']) { Db::name('promotion_task')->where('task_id', $taskId)->update([ 'state' => 2, 'utime' => time(), ]); $this->log("Task {$taskId} auto-paused: bounce rate {$bounceRate}% >= {$task['max_bounce_rate']}%"); return ['done' => true, 'reason' => 'auto_paused_bounce_rate', 'bounce_rate' => $bounceRate]; } } $logEntry = Db::name('promotion_email_log') ->where('task_id', $taskId) ->where('state', 0) ->order('log_id asc') ->find(); if (!$logEntry) { Db::name('promotion_task')->where('task_id', $taskId)->update([ 'state' => 3, 'utime' => time(), ]); return ['done' => true, 'reason' => 'all_emails_processed']; } $expert = Db::name('expert')->where('expert_id', $logEntry['expert_id'])->find(); if (!$expert || $expert['state'] == 4 || $expert['state'] == 5) { Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([ 'state' => 2, 'error_msg' => 'Expert invalid or deleted (state=' . (isset($expert['state']) ? $expert['state'] : 'null') . ')', 'send_time' => time(), ]); Db::name('promotion_task')->where('task_id', $taskId)->setInc('fail_count'); Db::name('promotion_task')->where('task_id', $taskId)->update(['utime' => time()]); $this->enqueueNextEmail($taskId, 2); return ['done' => false, 'skipped' => $logEntry['email_to'], 'reason' => 'expert_invalid']; } $account = $this->pickSmtpAccountForTask($task); if (!$account) { $this->enqueueNextEmail($taskId, 600); return ['done' => false, 'reason' => 'no_smtp_quota', 'retry_in' => 600]; } $journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find(); $expertVars = $this->buildExpertVars($expert); $journalVars = $this->buildJournalVars($journal); $vars = array_merge($journalVars, $expertVars); $rendered = $this->renderFromTemplate( $task['template_id'], $task['journal_id'], json_encode($vars, JSON_UNESCAPED_UNICODE), $task['style_id'] ); if ($rendered['code'] !== 0) { Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([ 'state' => 2, 'error_msg' => 'Template render failed: ' . $rendered['msg'], 'send_time' => time(), ]); Db::name('promotion_task')->where('task_id', $taskId)->setInc('fail_count'); Db::name('promotion_task')->where('task_id', $taskId)->update(['utime' => time()]); $this->enqueueNextEmail($taskId, 2); return ['done' => false, 'failed' => $logEntry['email_to'], 'reason' => 'template_error']; } $subject = $rendered['data']['subject']; $body = $rendered['data']['body']; $result = $this->doSendEmail($account, $logEntry['email_to'], $subject, $body); $now = time(); if ($result['status'] === 1) { Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([ 'j_email_id' => $account['j_email_id'], 'subject' => mb_substr($subject, 0, 512), 'state' => 1, 'send_time' => $now, ]); 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(['ltime' => $now]); Db::name('promotion_task')->where('task_id', $taskId)->setInc('sent_count'); } else { Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([ 'j_email_id' => $account['j_email_id'], 'subject' => mb_substr($subject, 0, 512), 'state' => 2, 'error_msg' => mb_substr($result['data'], 0, 512), 'send_time' => $now, ]); Db::name('promotion_task')->where('task_id', $taskId)->setInc('fail_count'); } Db::name('promotion_task')->where('task_id', $taskId)->update(['utime' => $now]); $delay = rand(max(5, $task['min_interval']), max($task['min_interval'], $task['max_interval'])); $this->enqueueNextEmail($taskId, $delay); return [ 'done' => false, 'sent' => $result['status'] === 1, 'email' => $logEntry['email_to'], 'next_in' => $delay, ]; } // ==================== Queue ==================== public function enqueueNextEmail($taskId, $delay = 0) { $jobClass = 'app\api\job\PromotionSend@fire'; $data = ['task_id' => $taskId]; if ($delay > 0) { Queue::later($delay, $jobClass, $data, 'promotion'); } else { Queue::push($jobClass, $data, 'promotion'); } } // ==================== SMTP ==================== public function pickSmtpAccountForTask($task) { $journalId = $task['journal_id']; $smtpIds = $task['smtp_ids'] ? array_map('intval', explode(',', $task['smtp_ids'])) : []; $query = Db::name('journal_email') ->where('journal_id', $journalId) ->where('state', 0); if (!empty($smtpIds)) { $query->where('j_email_id', 'in', $smtpIds); } $accounts = $query->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; } public function resetDailyCountIfNeeded(&$account) { $todayDate = date('Y-m-d'); $cacheKey = 'smtp_reset_' . $account['j_email_id']; $lastReset = 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; Cache::set($cacheKey, $todayDate, 86400); } } public 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()]; } } // ==================== Template Rendering ==================== public 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; 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]]; } public function buildExpertVars($expert) { return [ 'name' => $expert['name'] ?? '', 'email' => $expert['email'] ?? '', 'affiliation' => $expert['affiliation'] ?? '', 'field' => $expert['field'] ?? '', ]; } public function buildJournalVars($journal) { if (!$journal) return []; return [ 'journal_title' => $journal['title'] ?? '', 'journal_abbr' => $journal['jabbr'] ?? '', 'journal_url' => $journal['website'] ?? '', ]; } public 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); } // ==================== Logging ==================== public function log($msg) { $line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL; @file_put_contents($this->logFile, $line, FILE_APPEND); } }