HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/hi.naniguide.com/app/Services/OutreachSendService.php
<?php

namespace App\Services;

use App\Models\DeliveryServer;
use App\Models\DeliveryServerLog;
use App\Models\OutreachCampaign;
use App\Models\OutreachLead;
use App\Models\OutreachSequence;
use App\Support\EmailFooter;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Throwable;

/**
 * Drives the cold-email outreach send pipeline.
 *
 * The send model is per-lead, not per-campaign:
 *   - Each lead has its own `current_step` and `next_send_at`.
 *   - When `next_send_at <= now()`, the next step (sequences[current_step]) is sent.
 *   - After a successful send the lead's `current_step` is incremented and
 *     `next_send_at` is set to `now() + next_step_delay`. If there is no next
 *     step the lead's `sequence_completed_at` is recorded.
 *
 * This means leads added today start at step 1 even if other leads in the same
 * campaign are already on step 2/3/etc — exactly the behaviour requested by
 * the user.
 */
class OutreachSendService
{
    /**
     * Statuses that indicate the lead should NOT receive any further messages.
     */
    private const TERMINAL_STATUSES = ['replied', 'bounced', 'unsubscribed'];

    public function __construct(
        protected DeliveryServerService $deliveryServerService,
    ) {
    }

    /**
     * Process every campaign that is currently active and has due leads.
     *
     * @return array{leads_processed: int, leads_sent: int, leads_failed: int}
     */
    public function processAll(): array
    {
        $stats = ['leads_processed' => 0, 'leads_sent' => 0, 'leads_failed' => 0];
        $startTime = time();
        $maxRuntime = 50; // seconds — leave headroom before next minute tick

        OutreachCampaign::query()
            ->where('status', 'active')
            ->cursor()
            ->each(function (OutreachCampaign $campaign) use (&$stats, $startTime, $maxRuntime) {
                if (time() - $startTime > $maxRuntime) {
                    Log::warning('[Outreach] processAll runtime budget exceeded, skipping remaining campaigns');
                    return false; // break cursor iteration
                }

                try {
                    $campaignStats = $this->processCampaign($campaign);
                    $stats['leads_processed'] += $campaignStats['leads_processed'];
                    $stats['leads_sent']      += $campaignStats['leads_sent'];
                    $stats['leads_failed']    += $campaignStats['leads_failed'];
                } catch (\Throwable $e) {
                    Log::error('[Outreach] Campaign processing failed, continuing to next', [
                        'campaign_id' => $campaign->id,
                        'error'       => $e->getMessage(),
                    ]);
                }
            });

        return $stats;
    }

    /**
     * Process all due leads for a single campaign.
     *
     * @return array{leads_processed: int, leads_sent: int, leads_failed: int}
     */
    public function processCampaign(OutreachCampaign $campaign): array
    {
        $stats = ['leads_processed' => 0, 'leads_sent' => 0, 'leads_failed' => 0];

        // ── Sending-window guard ──────────────────────────────────────────
        // Check whether right now (in the campaign's timezone) falls inside
        // an allowed day + time-block.  If not, skip the entire campaign.
        if (!$this->isInsideSendingWindow($campaign)) {
            Log::debug('[Outreach] Skipped campaign — outside sending window', [
                'campaign_id' => $campaign->id,
                'timezone'    => $campaign->getSetting('timezone', 'UTC'),
                'send_days'   => $campaign->getSetting('send_days', []),
                'time_blocks' => $campaign->getSetting('send_time_blocks', []),
            ]);
            return $stats;
        }

        $sequences = OutreachSequence::where('campaign_id', $campaign->id)
            ->orderBy('sort_order')
            ->get()
            ->values();

        if ($sequences->isEmpty()) {
            Log::debug('[Outreach] Skipped campaign — no sequences', [
                'campaign_id' => $campaign->id,
            ]);
            return $stats;
        }

        $deliveryServers = $this->resolveDeliveryServers($campaign);
        if ($deliveryServers->isEmpty()) {
            Log::warning('[Outreach] No active delivery server for campaign', [
                'campaign_id' => $campaign->id,
                'sender_ids'  => $campaign->getSetting('sender_account_ids', []),
            ]);
            return $stats;
        }

        $now = Carbon::now();
        $minDelayEnabled = (bool) $campaign->getSetting('min_delay_enabled', false);
        $minDelayValue = max(1, (int) $campaign->getSetting('min_delay', 1));
        $minDelayUnit  = (string) $campaign->getSetting('min_delay_unit', 'minutes');
        $minDelaySeconds = match ($minDelayUnit) {
            'seconds' => $minDelayValue,
            'hours'   => $minDelayValue * 3600,
            default   => $minDelayValue * 60, // minutes
        };
        $cacheKey = "outreach:campaign:{$campaign->id}:last_sent_at";

        $totalSteps = $this->scalarTotalSteps($sequences);

        // ── Diagnostic breakdown ────────────────────────────────────────
        $totalLeads          = OutreachLead::where('campaign_id', $campaign->id)->count();
        $nonTerminalLeads    = OutreachLead::where('campaign_id', $campaign->id)->whereNotIn('status', self::TERMINAL_STATUSES)->count();
        $notCompletedLeads   = OutreachLead::where('campaign_id', $campaign->id)->whereNotIn('status', self::TERMINAL_STATUSES)->whereNull('sequence_completed_at')->count();
        $timeDueLeads        = OutreachLead::where('campaign_id', $campaign->id)->whereNotIn('status', self::TERMINAL_STATUSES)->whereNull('sequence_completed_at')->where(function ($q) use ($now) { $q->whereNull('next_send_at')->orWhere('next_send_at', '<=', $now); })->count();
        $stepValidLeads      = OutreachLead::where('campaign_id', $campaign->id)->whereNotIn('status', self::TERMINAL_STATUSES)->whereNull('sequence_completed_at')->where(function ($q) use ($now) { $q->whereNull('next_send_at')->orWhere('next_send_at', '<=', $now); })->where('current_step', '<', $totalSteps)->count();

        Log::debug('[Outreach] Lead funnel breakdown', [
            'campaign_id'         => $campaign->id,
            'total_leads'         => $totalLeads,
            'non_terminal'        => $nonTerminalLeads,
            'not_completed'       => $notCompletedLeads,
            'time_due'            => $timeDueLeads,
            'step_valid'          => $stepValidLeads,
            'total_steps'         => $totalSteps,
            'now'                 => $now->toDateTimeString(),
        ]);

        $dueQuery = OutreachLead::query()
            ->where('campaign_id', $campaign->id)
            ->whereNotIn('status', self::TERMINAL_STATUSES)
            ->whereNull('sequence_completed_at')
            ->where(function ($q) use ($now) {
                $q->whereNull('next_send_at')->orWhere('next_send_at', '<=', $now);
            })
            ->where('current_step', '<', $totalSteps)
            ->orderBy('next_send_at')
            ->limit(500);

        $dueCount = (clone $dueQuery)->count();
        Log::debug('[Outreach] Due leads found', [
            'campaign_id' => $campaign->id,
            'due_count'   => $dueCount,
            'now'         => $now->toDateTimeString(),
        ]);

        $campaignStart = time();
        $maxCampaignTime = 25; // seconds — one broken campaign must not block all others

        $dueQuery->cursor()
            ->each(function (OutreachLead $lead) use ($campaign, $sequences, $deliveryServers, &$stats, $cacheKey, $minDelayEnabled, $minDelaySeconds, $campaignStart, $maxCampaignTime) {
                if (time() - $campaignStart > $maxCampaignTime) {
                    Log::warning('[Outreach] Campaign processing time exceeded, yielding to next campaign', [
                        'campaign_id'  => $campaign->id,
                        'elapsed_sec'  => time() - $campaignStart,
                    ]);
                    return false; // break cursor iteration
                }

                // ── Min-delay guard ──────────────────────────────────────
                if ($minDelayEnabled) {
                    $lastSentTimestamp = Cache::get($cacheKey);
                    if ($lastSentTimestamp && (time() - (int) $lastSentTimestamp) < $minDelaySeconds) {
                        Log::debug('[Outreach] Blocked by min-delay guard', [
                            'campaign_id'      => $campaign->id,
                            'min_delay_sec'    => $minDelaySeconds,
                            'last_sent_at'     => $lastSentTimestamp,
                            'seconds_elapsed'  => time() - (int) $lastSentTimestamp,
                        ]);
                        return false; // Break cursor iteration – min delay not yet elapsed
                    }
                }

                $stats['leads_processed']++;
                if ($this->processLead($campaign, $lead, $sequences, $deliveryServers, $campaign)) {
                    $stats['leads_sent']++;
                    if ($minDelayEnabled) {
                        Cache::put($cacheKey, time(), now()->addDay());
                    }
                } else {
                    $stats['leads_failed']++;
                }
            });

        Log::debug('[Outreach] Campaign processing complete', [
            'campaign_id'      => $campaign->id,
            'leads_processed'  => $stats['leads_processed'],
            'leads_sent'       => $stats['leads_sent'],
            'leads_failed'     => $stats['leads_failed'],
        ]);

        return $stats;
    }

    /**
     * Send the next due step for a single lead and advance the lead's state.
     *
     * Returns true when the email was actually sent.
     */
    public function processLead(
        OutreachCampaign $campaign,
        OutreachLead $lead,
        Collection $sequences,
        Collection $deliveryServers,
        ?OutreachCampaign $campaignForSchedule = null,
    ): bool {
        $stepIndex = (int) ($lead->current_step ?? 0);
        /** @var OutreachSequence|null $step */
        $step = $sequences->get($stepIndex);
        if (!$step) {
            $lead->forceFill([
                'sequence_completed_at' => Carbon::now(),
                'next_send_at'          => null,
            ])->save();
            return false;
        }

        [$subjectTemplate, $bodyTemplate] = $this->pickVariant($step);

        $subject = $lead->renderTokensForCampaign($subjectTemplate, $campaign);
        $body    = $lead->renderTokensForCampaign($bodyTemplate, $campaign);
        $footer  = $lead->renderTokensForCampaign((string) $campaign->getSetting('email_footer', ''), $campaign);
        $body    = $this->appendFooter($body, $footer);

        $groupFooter = EmailFooter::forCustomerId((int) ($campaign->customer_id ?? 0));
        if ($groupFooter !== '') {
            $groupFooter = $lead->renderTokensForCampaign($groupFooter, $campaign);
            $body = $this->appendFooter($body, $groupFooter);
        }

        $body = $this->injectUnsubscribeLink($body, $lead, $campaign);

        $lastError = null;
        $fromEmail = '';
        $fromName  = '';

        foreach ($deliveryServers as $deliveryServer) {
            $attemptBody = $body;
            if ($deliveryServer->type !== 'resend') {
                $attemptBody = $this->injectOpenPixel($attemptBody, $lead, $campaign);
                $attemptBody = $this->rewriteClickLinks($attemptBody, $lead, $campaign);
            }
            $attemptBody = $campaign->getSetting('send_styled_body', true)
                ? $attemptBody
                : $this->convertToBareEmailBody($attemptBody);

            $fromEmail = (string) ($deliveryServer->from_email ?: config('mail.from.address'));
            $fromName  = (string) ($deliveryServer->from_name ?: config('mail.from.name'));

            try {
                $this->deliveryServerService->configureMailFromServer($deliveryServer);

                Mail::send([], [], function ($message) use ($lead, $subject, $attemptBody, $fromEmail, $fromName, $campaign, $deliveryServer) {
                    $message->to($lead->email, trim(($lead->first_name ?? '') . ' ' . ($lead->last_name ?? '')) ?: null)
                        ->subject($subject)
                        ->from($fromEmail, $fromName);

                    if (!empty($attemptBody)) {
                        if (preg_match('/<\s*\w+[^>]*>/', $attemptBody) === 1) {
                            $message->html($attemptBody);
                        } else {
                            $htmlBody = '<div style="white-space: pre-wrap;">' . nl2br(e($attemptBody)) . '</div>';

                            if ($campaign->getSetting('track_opens', true) && $deliveryServer->type !== 'resend') {
                                $htmlBody .= '<img src="' . e(route('outreach.track.open', ['uuid' => $lead->uuid])) . '" alt="" width="1" height="1" style="display:none;border:0;" />';
                            }

                            $message->html($htmlBody);
                            $message->text($attemptBody);
                        }
                    }

                    $headers = $message->getHeaders();
                    $headers->addTextHeader('X-Outreach-Campaign-ID', (string) $campaign->id);
                    $headers->addTextHeader('X-Outreach-Lead-ID', (string) $lead->id);
                    $headers->addTextHeader('X-Outreach-Step', (string) ($lead->current_step + 1));

                    if ($deliveryServer->bounceServer && !empty($deliveryServer->bounceServer->username)) {
                        $message->returnPath($deliveryServer->bounceServer->username);
                    }
                });

                // Success on this server – log and advance lead.
                DeliveryServerLog::create([
                    'delivery_server_id' => $deliveryServer->id,
                    'event' => 'outreach_sent',
                    'to_email' => $lead->email,
                    'status' => 'success',
                    'meta' => [
                        'outreach_campaign_id' => $campaign->id,
                        'outreach_lead_id' => $lead->id,
                        'outreach_step' => $stepIndex + 1,
                        'subject' => $subject,
                        'from' => $fromEmail,
                    ],
                ]);

                $this->advanceLead($lead, $sequences, $stepIndex, $campaignForSchedule ?? $campaign);
                return true;

            } catch (Throwable $e) {
                $lastError = $e->getMessage();
                Log::error('[Outreach] Failed sending step', [
                    'campaign_id' => $campaign->id,
                    'lead_id'     => $lead->id,
                    'step'        => $stepIndex + 1,
                    'server_id'   => $deliveryServer->id,
                    'error'       => $lastError,
                ]);

                DeliveryServerLog::create([
                    'delivery_server_id' => $deliveryServer->id,
                    'event' => 'outreach_failed',
                    'to_email' => $lead->email,
                    'status' => 'failed',
                    'error_message' => mb_substr($lastError, 0, 1000),
                    'diagnostic' => $lastError,
                    'error_category' => DeliveryServerLog::categorizeError($lastError),
                    'meta' => [
                        'outreach_campaign_id' => $campaign->id,
                        'outreach_lead_id' => $lead->id,
                        'outreach_step' => $stepIndex + 1,
                        'subject' => $subject,
                        'from' => $fromEmail,
                    ],
                ]);
            }
        }

        // All servers exhausted – mark lead for retry and surface failure in UI.
        $campaign->appendStatusLog('failed', __('Step :step failed for lead :email: :error', [
            'step' => $stepIndex + 1,
            'email' => $lead->email,
            'error' => $lastError ?? 'Unknown error',
        ]));

        $retryCampaign = $campaignForSchedule ?? $campaign;
        $meta = $lead->meta ?? [];
        $meta['failed_step'] = $stepIndex + 1;
        $meta['failed_error'] = mb_substr((string) $lastError, 0, 500);

        $lead->forceFill([
            'status'           => 'failed',
            'next_send_at'     => $this->clampToSendingWindow(Carbon::now()->addMinutes(1), $retryCampaign),
            'last_activity_at' => Carbon::now(),
            'meta'             => $meta,
        ])->save();

        return false;
    }

    /**
     * Advance the lead pointer after a successful send.
     */
    private function advanceLead(OutreachLead $lead, Collection $sequences, int $sentStepIndex, OutreachCampaign $campaign): void
    {
        $nextIndex = $sentStepIndex + 1;
        $nextStep  = $sequences->get($nextIndex);

        $update = [
            'current_step'     => $nextIndex,
            'last_activity_at' => Carbon::now(),
        ];

        // Mark as 'sent' the first time, if still pending, or if recovering
        // from a previous failure. Never overwrite stronger engagement
        // signals (opened, clicked, replied) or terminal states.
        if (in_array($lead->status, ['pending', 'failed', null, ''], true)) {
            $update['status'] = 'sent';
        }

        // Clear any transient failure tracking so the UI no longer shows
        // "Failed at step X" once the lead has successfully advanced.
        $meta = $lead->meta ?? [];
        if (isset($meta['failed_step']) || isset($meta['failed_error'])) {
            unset($meta['failed_step'], $meta['failed_error']);
            $update['meta'] = $meta;
        }

        if ($nextStep) {
            $update['next_send_at'] = $this->computeNextSendAt($nextStep, $campaign);
        } else {
            $update['next_send_at']          = null;
            $update['sequence_completed_at'] = Carbon::now();
        }

        $lead->forceFill($update)->save();
    }

    /**
     * Pick the variant_a or variant_b body+subject for the given step.
     *
     * @return array{0:string,1:string}
     */
    private function pickVariant(OutreachSequence $step): array
    {
        if ($step->has_variant_b && $step->subject_b !== null) {
            $split = max(0, min(100, (int) ($step->variant_split ?? 50)));
            if (random_int(1, 100) > $split) {
                return [(string) $step->subject_b, (string) ($step->body_b ?? '')];
            }
        }

        return [(string) $step->subject_a, (string) ($step->body_a ?? '')];
    }

    private function appendFooter(string $body, string $footer): string
    {
        $footer = trim($footer);

        if ($footer === '') {
            return $body;
        }

        $body = (string) $body;
        if (trim($body) === '') {
            return $footer;
        }

        $bodyIsHtml = preg_match('/<\s*\w+[^>]*>/', $body) === 1;
        $footerIsHtml = preg_match('/<\s*\w+[^>]*>/', $footer) === 1;

        if ($bodyIsHtml || $footerIsHtml) {
            $normalizedFooter = $footerIsHtml
                ? $footer
                : '<div style="white-space: pre-wrap; margin-top: 24px;">' . nl2br(e($footer)) . '</div>';

            if (stripos($body, '</body>') !== false) {
                return str_ireplace('</body>', $normalizedFooter . '</body>', $body);
            }

            return $body . $normalizedFooter;
        }

        return rtrim($body) . "\n\n" . $footer;
    }

    private function convertToBareEmailBody(string $body): string
    {
        $body = trim($body);

        if ($body === '') {
            return $body;
        }

        if (preg_match('/<\s*\w+[^>]*>/', $body) !== 1) {
            return $body;
        }

        $normalized = preg_replace('/\sstyle=("|\').*?\1/i', '', $body);
        $normalized = preg_replace('/\sclass=("|\').*?\1/i', '', (string) $normalized);
        $normalized = preg_replace('/<(span|font)(\s[^>]*)?>/i', '', (string) $normalized);
        $normalized = preg_replace('/<\/(span|font)>/i', '', (string) $normalized);
        $normalized = preg_replace('/<(div|section|article|main|header|footer)(\s[^>]*)?>/i', '<p>', (string) $normalized);
        $normalized = preg_replace('/<\/(div|section|article|main|header|footer)>/i', '</p>', (string) $normalized);
        $normalized = preg_replace('/<(p|h[1-6]|blockquote)(\s[^>]*)?>/i', '<p>', (string) $normalized);
        $normalized = preg_replace('/<\/(p|h[1-6]|blockquote)>/i', '</p>', (string) $normalized);
        $normalized = preg_replace('/<a([^>]*?)>/i', '<a$1>', (string) $normalized);
        $normalized = preg_replace('/<(ol|ul|li)(\s[^>]*)?>/i', '<$1>', (string) $normalized);
        $normalized = preg_replace('/<img([^>]*?)>/i', static function (array $matches): string {
            return Str::contains($matches[1], 'outreach/track/open/') ? '<img' . $matches[1] . '>' : '';
        }, (string) $normalized);

        return trim((string) $normalized);
    }

    /**
     * Compute when a step should fire based on its delay configuration.
     * The resulting timestamp is clamped into the campaign's next valid
     * sending window (respecting timezone, allowed days, and time blocks).
     */
    private function computeNextSendAt(OutreachSequence $step, OutreachCampaign $campaign): CarbonInterface
    {
        $delay = max(0, (int) ($step->delay_days ?? 0));
        $unit  = (string) ($step->delay_type ?? 'days');

        $now = Carbon::now();

        $candidate = match ($unit) {
            'minutes' => $now->copy()->addMinutes($delay),
            'hours'   => $now->copy()->addHours($delay),
            'weeks'   => $now->copy()->addWeeks($delay),
            'months'  => $now->copy()->addMonths($delay),
            default   => $now->copy()->addDays($delay),
        };

        return $this->clampToSendingWindow($candidate, $campaign);
    }

    /**
     * Check whether the current moment (in campaign timezone) is inside an
     * allowed sending day AND at least one configured time block.
     */
    private function isInsideSendingWindow(OutreachCampaign $campaign): bool
    {
        $tz  = $campaign->getSetting('timezone', 'UTC');
        if (!in_array($tz, timezone_identifiers_list(), true)) {
            Log::warning('[Outreach] Invalid timezone on campaign, falling back to UTC', [
                'campaign_id' => $campaign->id,
                'timezone'    => $tz,
            ]);
            $tz = 'UTC';
        }
        $now = Carbon::now($tz);

        // ── Day check ─────────────────────────────────────────────────────
        $allowedDays = array_map('strtolower', (array) $campaign->getSetting('send_days', []));
        if (!empty($allowedDays)) {
            $todayShort = strtolower($now->format('D')); // Mon→mon, Tue→tue …
            // Also accept full day name and 3-letter abbreviation
            $todayFull  = strtolower($now->format('l'));  // monday, tuesday …
            if (!in_array($todayShort, $allowedDays, true) && !in_array($todayFull, $allowedDays, true)) {
                return false;
            }
        }

        // ── Time-block check ──────────────────────────────────────────────
        $timeBlocks = (array) $campaign->getSetting('send_time_blocks', []);

        // Fallback: use legacy single start/end pair
        if (empty($timeBlocks)) {
            $start = $campaign->getSetting('send_hours_start', '00:00');
            $end   = $campaign->getSetting('send_hours_end', '23:59');
            $timeBlocks = [['start' => $start, 'end' => $end]];
        }

        $currentTime = $now->format('H:i');

        foreach ($timeBlocks as $block) {
            $start = $block['start'] ?? '00:00';
            $end   = $block['end']   ?? '23:59';
            if ($currentTime >= $start && $currentTime <= $end) {
                return true;
            }
        }

        return false;
    }

    /**
     * Given a candidate UTC timestamp, ensure it falls inside the campaign's
     * sending window. If not, push it forward to the start of the next valid
     * time block on a valid day (up to 14 days ahead to prevent infinite loops).
     */
    private function clampToSendingWindow(CarbonInterface $candidate, OutreachCampaign $campaign): CarbonInterface
    {
        $tz         = $campaign->getSetting('timezone', 'UTC');
        if (!in_array($tz, timezone_identifiers_list(), true)) {
            $tz = 'UTC';
        }
        $allowedDays = array_map('strtolower', (array) $campaign->getSetting('send_days', []));
        $timeBlocks  = (array) $campaign->getSetting('send_time_blocks', []);

        if (empty($timeBlocks)) {
            $start = $campaign->getSetting('send_hours_start', '00:00');
            $end   = $campaign->getSetting('send_hours_end', '23:59');
            $timeBlocks = [['start' => $start, 'end' => $end]];
        }

        // Sort time blocks by start time
        usort($timeBlocks, fn ($a, $b) => ($a['start'] ?? '00:00') <=> ($b['start'] ?? '00:00'));

        // Work in campaign timezone
        $local = $candidate->copy()->setTimezone($tz);

        // Try up to 14 days (to find a valid day + time block)
        for ($dayOffset = 0; $dayOffset < 14; $dayOffset++) {
            $checkDay = $local->copy()->addDays($dayOffset);

            // ── Day check ─────────────────────────────────────────────────
            if (!empty($allowedDays)) {
                $dayShort = strtolower($checkDay->format('D'));
                $dayFull  = strtolower($checkDay->format('l'));
                if (!in_array($dayShort, $allowedDays, true) && !in_array($dayFull, $allowedDays, true)) {
                    continue;
                }
            }

            // ── Time-block check ──────────────────────────────────────────
            foreach ($timeBlocks as $block) {
                $blockStart = $block['start'] ?? '00:00';
                $blockEnd   = $block['end']   ?? '23:59';

                // Build start/end Carbon instances for this block on $checkDay
                [$sh, $sm] = array_map('intval', explode(':', $blockStart));
                [$eh, $em] = array_map('intval', explode(':', $blockEnd));

                $windowStart = $checkDay->copy()->setTime($sh, $sm, 0);
                $windowEnd   = $checkDay->copy()->setTime($eh, $em, 59);

                if ($dayOffset === 0) {
                    // Same day: candidate might already be inside this block
                    if ($local->lte($windowEnd) && $local->gte($windowStart)) {
                        // Already inside — return as-is (converted back to UTC)
                        return $candidate;
                    }
                    // Candidate is before this block's start on the same day
                    if ($local->lt($windowStart)) {
                        return $windowStart->setTimezone('UTC');
                    }
                    // Candidate is past this block — try next block or next day
                } else {
                    // Different day: snap to block start
                    return $windowStart->setTimezone('UTC');
                }
            }
        }

        // Fallback: return candidate as-is (should not happen with <= 14 days)
        return $candidate;
    }

    /**
     * Return ALL active delivery servers for the campaign in priority order.
     */
    private function resolveDeliveryServers(OutreachCampaign $campaign): Collection
    {
        $senderIds = (array) ($campaign->getSetting('sender_account_ids', []) ?? []);
        $servers   = collect();

        foreach ($senderIds as $id) {
            $server = DeliveryServer::where('customer_id', $campaign->customer_id)
                ->where('id', (int) $id)
                ->where('status', 'active')
                ->first();
            if ($server) {
                $servers->push($server);
            }
        }

        if ($servers->isEmpty()) {
            $fallback = DeliveryServer::where('customer_id', $campaign->customer_id)
                ->where('status', 'active')
                ->orderByDesc('is_primary')
                ->get();
            $servers = $servers->concat($fallback);
        }

        return $servers->unique('id')->values();
    }

    private function scalarTotalSteps(Collection $sequences): int
    {
        return $sequences->count();
    }

    /**
     * Inject (or replace) the unsubscribe link in the email body when the
     * campaign has unsubscribe link injection enabled.
     *
     * Resolution order:
     *   - `{unsubscribe_url}` / `{{unsubscribe_url}}` placeholders are replaced
     *     with the bare URL.
     *   - Otherwise a small footer link is appended (before `</body>` if
     *     present, or to the end of the body for plain-text content).
     */
    private function injectUnsubscribeLink(string $body, OutreachLead $lead, OutreachCampaign $campaign): string
    {
        if (!$campaign->getSetting('include_unsubscribe_link', true)) {
            return $body;
        }

        $url = route('outreach.unsubscribe', ['uuid' => $lead->uuid]);

        // Substitute placeholders first so users keep full control over
        // placement when they include them in the template.
        if (preg_match('/\{\{?\s*unsubscribe_url\s*\}?\}/i', $body) === 1) {
            return preg_replace('/\{\{?\s*unsubscribe_url\s*\}?\}/i', $url, $body) ?? $body;
        }

        $linkText = (string) $campaign->getSetting(
            'unsubscribe_text',
            'If you no longer wish to receive these emails, click here to unsubscribe.'
        );

        $isHtml = preg_match('/<\s*\w+[^>]*>/', $body) === 1;

        if ($isHtml) {
            $footer = '<p style="font-size:12px;color:#888;margin-top:24px;text-align:center;">'
                . '<a href="' . e($url) . '" style="color:#888;text-decoration:underline;">' . e($linkText) . '</a>'
                . '</p>';

            if (stripos($body, '</body>') !== false) {
                return str_ireplace('</body>', $footer . '</body>', $body);
            }
            return $body . $footer;
        }

        return rtrim($body) . "\n\n" . $linkText . "\n" . $url;
    }

    /**
     * Append a 1×1 open-tracking pixel to the body when `track_opens` is on.
     */
    private function injectOpenPixel(string $body, OutreachLead $lead, OutreachCampaign $campaign): string
    {
        if (!$campaign->getSetting('track_opens', true)) {
            return $body;
        }

        $url = route('outreach.track.open', ['uuid' => $lead->uuid]);
        $pixel = '<img src="' . e($url) . '" alt="" width="1" height="1" style="display:none;border:0;" />';

        // Plain-text body: don't inject HTML — the mailer will wrap the text in
        // a basic HTML version which gets the pixel via the second pass below.
        $isHtml = preg_match('/<\s*\w+[^>]*>/', $body) === 1;
        if (!$isHtml) {
            return $body;
        }

        if (stripos($body, '</body>') !== false) {
            return str_ireplace('</body>', $pixel . '</body>', $body);
        }
        return $body . $pixel;
    }

    /**
     * Rewrite every `<a href="...">` to point at the click-tracking endpoint
     * when `track_clicks` is enabled. Already-rewritten links and the
     * unsubscribe link are skipped.
     */
    private function rewriteClickLinks(string $body, OutreachLead $lead, OutreachCampaign $campaign): string
    {
        if (!$campaign->getSetting('track_clicks', false)) {
            return $body;
        }

        $unsubscribeUrl = route('outreach.unsubscribe', ['uuid' => $lead->uuid]);

        return preg_replace_callback(
            '/<a\s+([^>]*?)href="([^"]+)"([^>]*)>/i',
            static function (array $matches) use ($lead, $unsubscribeUrl) {
                $original = $matches[2];

                if ($original === '' || $original === $unsubscribeUrl) {
                    return $matches[0];
                }

                // Only rewrite http(s) links.
                $scheme = parse_url($original, PHP_URL_SCHEME);
                if (!in_array($scheme, ['http', 'https'], true)) {
                    return $matches[0];
                }

                // Skip already-tracked outreach links.
                if (str_contains($original, '/outreach/track/click/')) {
                    return $matches[0];
                }

                $encoded = rtrim(strtr(base64_encode($original), '+/', '-_'), '=');
                $tracked = route('outreach.track.click', [
                    'uuid' => $lead->uuid,
                    'url'  => $encoded,
                ]);

                return '<a ' . $matches[1] . 'href="' . e($tracked) . '"' . $matches[3] . '>';
            },
            $body
        ) ?? $body;
    }
}