Files
gustavoo-me/wp-content/plugins/fluent-crm/app/Services/Libs/Mailer/BaseHandler.php
T
2026-07-23 22:56:30 -03:00

307 lines
11 KiB
PHP

<?php
namespace FluentCrm\App\Services\Libs\Mailer;
use FluentCrm\App\Models\CampaignEmail;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
abstract class BaseHandler
{
protected $runnerTitle = '';
protected $sentCount = 0;
protected $maximumProcessingTime = 50;
protected $calledFrom = 'cron';
protected $startingTimeStamp = null;
protected $optionKey = 'fluentcrm_is_sending_emails';
protected $isMultiThread = false;
protected $sendingChunkNumber = 0;
protected $lastLockRefreshAt = 0;
abstract protected function isTimeUp();
protected function sendEmails($campaignEmails)
{
global $wpdb;
do_action('fluent_crm/sending_emails_starting', $campaignEmails);
if (defined('FLUENTMAIL')) {
add_filter('fluentmail_will_log_email', 'fluentcrm_maybe_disable_fsmtp_log', 10, 2);
}
$failedIds = [];
$this->sendingChunkNumber++;
$sendableStatuses = ['subscribed', 'transactional'];
$table = $wpdb->prefix . 'fc_campaign_emails';
foreach ($campaignEmails as $email) {
// Stop starting new emails once the runtime budget is spent. The
// rate-limit wait below counts against wall-clock, so check every
// iteration; any rows we already claimed but don't reach stay
// 'processing' and are recovered by the stale-row reset.
if ($this->isTimeUp()) {
break;
}
// Check again if the contact is in subscribed status or not
// If not then we will cancel the email
if ($email->subscriber && !in_array($email->subscriber->status, $sendableStatuses, true)) {
$email->status = 'cancelled';
$email->save();
continue;
}
$emailData = $email->data();
// for the same id
if (Helper::wasProcessedByKeyId('mail_' . $email->id . '_' . $email->email_address)) {
continue;
}
// Wait for this email's global rate-limit slot BEFORE marking it
// sent, so a crash/timeout during the wait leaves the row in
// 'processing' (recoverable by the stale-row reset) instead of
// 'sent'-but-never-delivered. Heartbeat the processing lock first
// (time-gated, so it costs an in-memory check not a write per email)
// so a long backpressure sleep can't let the lock expire mid-batch
// and admit a second concurrent sender.
$this->maybeRefreshLock();
GlobalRateLimiter::throttle($emailData);
// Mark as 'sent' and clear email_body BEFORE sending.
// This prevents duplicates on crash — if the process dies after this
// point, the email won't be re-queued. Missing one email is acceptable,
// sending duplicates is not.
//
// The WHERE pins both status='processing' AND the original claim's
// updated_at. If the rate-limit wait above ran long enough that the
// stale-row reset reclaimed this row and another sender re-claimed it
// (even one that is mid-send right now, with status back at
// 'processing'), that re-claim rewrote updated_at — so our UPDATE
// matches 0 rows and we skip. This closes the duplicate-send window
// independently of the lock TTL, so it holds even for slow SMTP
// transports where a single wp_mail() can hang past the lock. (Same
// UPDATE, one extra WHERE column — no added query.)
//
// Use the RAW updated_at string, not $email->updated_at: the model
// casts that column to a DateTime, and $wpdb binds a DateTime object
// as an empty string (it does not call __toString), which would make
// the WHERE `updated_at = ''`, match 0 rows, and strand EVERY email in
// 'processing' forever. The raw attribute is the exact stored string.
$claimToken = Arr::get($email->getAttributes(), 'updated_at');
$claimed = $wpdb->update($table, [
'status' => 'sent',
'scheduled_at' => current_time('mysql'),
'email_body' => '',
'is_parsed' => 1,
], ['id' => $email->id, 'status' => 'processing', 'updated_at' => $claimToken]);
if ($claimed === false) {
Helper::debugLog('DB Error at ' . $this->runnerTitle, $wpdb->last_error, 'error');
return new \WP_Error('db_error', $wpdb->last_error ?: 'mark-sent update failed');
}
if ($claimed === 0) {
// Row was reclaimed (and likely already sent) by another process
// during our wait. Do not send it again.
continue;
}
$this->sentCount++;
// Already throttled above (before mark-sent); skip the in-Mailer
// reservation so this email isn't rate-limited twice.
$response = Mailer::send($emailData, $email->subscriber, $email, true);
// wp_mail() returns false on failure (not WP_Error) in most cases.
// We must catch both to avoid marking undelivered emails as 'sent'.
// Note: emails are marked 'sent' BEFORE wp_mail() by design to prevent
// duplicate sends on crash. This is intentional — losing one email is
// acceptable, sending duplicates is not.
if (is_wp_error($response) || $response === false) {
$failedIds[] = $email->id;
}
}
$this->updateEmailsStatus($failedIds, 'failed');
if (defined('FLUENTMAIL')) {
remove_filter('fluentmail_will_log_email', 'fluentcrm_maybe_disable_fsmtp_log', 10);
}
do_action('fluentcrm_sending_emails_done', $campaignEmails);
return true;
}
protected function processBatchEmails()
{
if ($this->isTimeUp()) {
return 'time_up';
}
$emails = $this->getNextBatchEmails();
if (!$emails || $emails->isEmpty()) {
return 'empty';
}
$this->refreshLock();
$result = $this->sendEmails($emails);
if (is_wp_error($result)) {
return $result;
}
usleep(10000); // 0.01 seconds sleep
return $this->processBatchEmails();
}
abstract protected function getNextBatchEmails();
protected function logSentCount()
{
if ($this->sentCount) {
Helper::debugLog(sprintf($this->runnerTitle . ': Sent %d', $this->sentCount), sprintf('%d seconds via %s', time() - $this->startingTimeStamp, $this->calledFrom));
}
}
/**
* Memory exceeded
*
* Ensures the batch process never exceeds 90% of the maximum WordPress memory.
*
* Based on WP_Background_Process::memory_exceeded()
*
* @return bool
*/
protected function memoryExceeded()
{
$memory_limit = fluentCrmGetMemoryLimit() * 0.70;
$current_memory = memory_get_usage(true);
$memory_exceeded = $current_memory >= $memory_limit;
return apply_filters('fluentcrm_memory_exceeded', $memory_exceeded, $this);
}
protected function updateEmailsStatus($ids, $status)
{
if (!$ids) {
return false;
}
global $wpdb;
$whereIn = implode(',', array_fill(0, count($ids), '%d'));
$query = "UPDATE {$wpdb->prefix}fc_campaign_emails SET status = %s WHERE id IN ($whereIn)";
$wpdb->query($wpdb->prepare($query, array_merge([$status], $ids)));
return true;
}
protected function handleFailedLog()
{
add_action('wp_mail_failed', function ($error) {
$data = $error->get_error_data();
$to = Arr::get($data, 'to');
if ($to) {
if (is_array($to)) {
$to = $to[0];
}
}
if (!$to || !\is_string($to) || !is_email($to)) {
return;
}
CampaignEmail::where('email_address', $to)
->limit(1)
->whereIn('status', ['processing', 'sent', 'failed'])
->orderBy('updated_at', 'DESC')
->update([
'status' => 'failed',
'note' => $error->get_error_message()
]);
});
}
/**
* Atomically acquire the processing lock.
*
* Replaces the old isProcessing() + processing() two-step pattern
* which had a TOCTOU race condition — two processes could both read
* "not processing" and both start sending emails.
*
* @return bool True if the lock was acquired, false if another process holds it.
*/
protected function acquireLock()
{
// Single atomic conditional UPDATE on wp_options (Helper::acquireDbLock)
// on every environment. We no longer take a wp_cache_add() fast path when
// an external object cache is active: that primitive is only atomic if the
// drop-in implements it against the shared backend, and some do not —
// notably LiteSpeed Object Cache, whose add() checks only the per-process
// in-memory array and then writes unconditionally. Under it, concurrent
// senders all acquired the lock and ran at once, overshooting the provider
// rate limit. The DB row lock has no such gap. See Helper::acquireDbLock().
$lockTimeout = $this->maximumProcessingTime + 30;
return Helper::acquireDbLock($this->optionKey, $lockTimeout);
}
/**
* Refresh the lock timestamp (heartbeat) to prevent stuck-lock detection.
*
* Writes to the same wp_options row acquireLock() claims, so the heartbeat
* and the stale-detection read share one source of truth.
*/
protected function refreshLock()
{
Helper::refreshDbLock($this->optionKey);
$this->lastLockRefreshAt = microtime(true);
}
/**
* Heartbeat the lock only when it's getting close to its TTL, instead of on
* every email. The per-email send loop calls this so a long rate-limit wait
* can't let the lock expire mid-batch — but in normal sending (sub-second
* waits, batch done in seconds) it never actually writes: the guard is just
* an in-memory timestamp compare. It fires ~once per (TTL/3) only when a
* batch runs long under backpressure.
*/
protected function maybeRefreshLock()
{
// Refresh at TTL/4 so the gap between heartbeats, plus one email's
// max wait (GlobalRateLimiter caps a single wait at 15s) plus its
// wp_mail() send, stays under the lock TTL (maximumProcessingTime + 30):
// 20s gap + 15s wait + ~30s send = 65s < 80s. That keeps the lock alive
// across a long backpressure sleep without writing on every email.
$interval = max(8, (int)(($this->maximumProcessingTime + 30) / 4));
if ((microtime(true) - $this->lastLockRefreshAt) >= $interval) {
$this->refreshLock();
}
}
/**
* Release the processing lock so another process can acquire it.
*/
protected function releaseLock()
{
Helper::releaseDbLock($this->optionKey);
}
}