Initial commit
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user