-
Notifications
You must be signed in to change notification settings - Fork 30
ref: email sending #388
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
ref: email sending #388
Changes from all commits
2354bdb
d84ad25
8167417
023d26e
0b8d113
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| PHPLIST_DATABASE_DRIVER=pdo_sqlite | ||
| PHPLIST_DATABASE_PATH=:memory: | ||
| SEARCH_TRANSPORT_DSN=sync:// | ||
| ELASTICSEARCH_ENABLED=false | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ | |
| use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; | ||
| use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; | ||
| use PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder; | ||
| use PhpList\Core\Domain\Messaging\Service\DomainRateLimiter; | ||
| use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; | ||
| use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; | ||
| use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; | ||
|
|
@@ -72,13 +73,15 @@ public function __construct( | |
| private readonly EmailBuilder $campaignEmailBuilder, | ||
| private readonly MailSizeChecker $mailSizeChecker, | ||
| private readonly ConfigProvider $configProvider, | ||
| private readonly DomainRateLimiter $domainRateLimiter, | ||
| #[Autowire('%imap_bounce.email%')] private readonly string $bounceEmail, | ||
| #[Autowire('%messaging.use_list_exclude%')] private readonly bool $useListExclude = false, | ||
| ) { | ||
| } | ||
|
|
||
| public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $data): void | ||
| { | ||
| $campaign = $this->messageRepository->findByIdAndStatus($data->getMessageId(), MessageStatus::Submitted); | ||
| $campaign = $this->messageRepository->tryClaimForProcessing($data->getMessageId()); | ||
| if (!$campaign) { | ||
| $this->logger->warning( | ||
| $this->translator->trans('Campaign not found or not in submitted status'), | ||
|
|
@@ -121,32 +124,17 @@ public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $ | |
|
|
||
| $this->handleAdminNotifications($campaign, $loadedMessageData, $data->getMessageId()); | ||
|
|
||
| $this->updateMessageStatus($campaign, MessageStatus::Prepared); | ||
| $subscribers = $this->subscriberProvider->getSubscribersForMessageOrLists($data, $campaign); | ||
| // Campaign was already atomically claimed into Prepared status above. | ||
| $excludeListIds = $this->getExcludeListIds($loadedMessageData); | ||
| $this->markExcludedSubscribers($campaign, $excludeListIds); | ||
| $subscribers = $this->subscriberProvider->getSubscribersForMessageOrLists( | ||
| $data, | ||
| $campaign, | ||
| $excludeListIds | ||
| ); | ||
|
|
||
| $this->updateMessageStatus($campaign, MessageStatus::InProcess); | ||
|
|
||
| // if (USE_LIST_EXCLUDE) { | ||
| // if (VERBOSE) { | ||
| // processQueueOutput(s('looking for users who can be excluded from this mailing')); | ||
| // } | ||
| // if (count($msgdata['excludelist'])) { | ||
| // $query | ||
| // = ' select userid' | ||
| // .' from '.$GLOBALS['tables']['listuser'] | ||
| // .' where listid in ('.implode(',', $msgdata['excludelist']).')'; | ||
| // if (VERBOSE) { | ||
| // processQueueOutput('Exclude query '.$query); | ||
| // } | ||
| // $req = Sql_Query($query); | ||
| // while ($row = Sql_Fetch_Row($req)) { | ||
| // $um = Sql_Query(sprintf('replace into %s (entered,userid,messageid,status) | ||
| // values(now(),%d,%d,"excluded")', | ||
| // $tables['usermessage'], $row[0], $messageid)); | ||
| // } | ||
| // } | ||
| // } | ||
|
|
||
| $stoppedEarly = $this->processSubscribersForCampaign($campaign, $subscribers, $cacheKey); | ||
|
|
||
| if ($stoppedEarly && $this->requeueHandler->handle($campaign)) { | ||
|
|
@@ -157,6 +145,52 @@ public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $ | |
| $this->updateMessageStatus($campaign, MessageStatus::Sent); | ||
| } | ||
|
|
||
| /** | ||
| * Exclude-list IDs are stored via MessageData as an array keyed by list ID e.g. [3 => 1, 7 => 1]. | ||
| * | ||
| * @return int[] | ||
| */ | ||
| private function getExcludeListIds(array $loadedMessageData): array | ||
| { | ||
| if (!$this->useListExclude) { | ||
| return []; | ||
| } | ||
|
|
||
| $excludeList = $loadedMessageData['excludelist'] ?? []; | ||
| if (!is_array($excludeList) || $excludeList === []) { | ||
| return []; | ||
| } | ||
|
|
||
| return array_values(array_filter(array_map( | ||
| static fn (mixed $key): ?int => is_numeric($key) ? (int) $key : null, | ||
| array_keys($excludeList) | ||
| ), static fn (?int $id): bool => $id !== null)); | ||
| } | ||
|
|
||
| /** | ||
| * pre-marking of exclude-list members as "excluded" in usermessage before the main send loop runs, | ||
| * so there's a persisted audit trail for why a subscriber wasn't sent to. Skips | ||
| * subscribers who already have a nontodo UserMessage for this campaign, so a later run | ||
| * can't clobber an already-recorded Sent/NotSent/etc. status from an earlier partial run. | ||
| */ | ||
| private function markExcludedSubscribers(Message $campaign, array $excludeListIds): void | ||
| { | ||
| if ($excludeListIds === []) { | ||
| return; | ||
| } | ||
|
|
||
| foreach ($this->subscriberProvider->getExcludedSubscribers($excludeListIds) as $subscriber) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Mark only excluded campaign recipients.
🤖 Prompt for AI Agents |
||
| $existing = $this->userMessageRepository->findByUserAndMessage($subscriber, $campaign); | ||
| if ($existing && $existing->getStatus() !== UserMessageStatus::Todo) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the declared PHP target and all nullable-status call paths.
fd -a 'composer.json' . -exec sed -n '/"php"/p' {} \;
rg -n -C 3 'private \?string \$status|function getStatus\(|UserMessageStatus::from\(|new UserMessage\(' src testsRepository: phpList/core Length of output: 311 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- handler ---'
sed -n '150,215p' src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php
printf '%s\n' '--- UserMessage and status bindings ---'
rg -n -C 5 'class UserMessage|private \?string \$status|function getStatus\(|UserMessageStatus::from\(|enum UserMessageStatus|status' src/Domain src/Infrastructure tests 2>/dev/null | head -n 240
printf '%s\n' '--- PHP target ---'
find . -name composer.json -maxdepth 3 -print -exec grep -n '"php"' {} \;Repository: phpList/core Length of output: 26200 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '150,215p' src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php
rg -n -C 5 'class UserMessage|private \?string \$status|function getStatus\(|UserMessageStatus::from\(|enum UserMessageStatus' src tests
find . -maxdepth 3 -name composer.json -print -exec grep -n '"php"' {} \;Repository: phpList/core Length of output: 50368 Handle nullable When an existing 🤖 Prompt for AI Agents |
||
| continue; | ||
| } | ||
|
|
||
| $userMessage = $existing ?? new UserMessage($subscriber, $campaign); | ||
| $userMessage->setStatus(UserMessageStatus::Excluded); | ||
| $this->userMessageRepository->save($userMessage); | ||
| } | ||
| } | ||
|
|
||
| private function unconfirmSubscriber(Subscriber $subscriber): void | ||
| { | ||
| if ($subscriber->isConfirmed()) { | ||
|
|
@@ -170,6 +204,9 @@ private function updateMessageStatus(Message $message, MessageStatus $status): v | |
| if ($status === MessageStatus::InProcess && $message->getMetadata()->getSendStart() === null) { | ||
| $message->getMetadata()->setSendStart(new DateTime()); | ||
| } | ||
| if ($status === MessageStatus::Sent) { | ||
| $message->getMetadata()->setSent(new DateTime()); | ||
| } | ||
| $message->getMetadata()->setStatus($status); | ||
| $this->entityManager->flush(); | ||
| } | ||
|
|
@@ -220,6 +257,9 @@ private function handleEmailSending( | |
| htmlPref: $subscriber->hasHtmlEmail(), | ||
| ); | ||
| if ($result === null) { | ||
| $status = $subscriber->isBlacklisted() ? UserMessageStatus::Excluded : UserMessageStatus::NotSent; | ||
| $this->updateUserMessageStatus($userMessage, $status); | ||
|
|
||
| return; | ||
| } | ||
| [$email, $sentAs] = $result; | ||
|
|
@@ -228,7 +268,7 @@ private function handleEmailSending( | |
| $this->rateLimitedCampaignMailer->send($email); | ||
| ($this->mailSizeChecker)($campaign, $email, $subscriber->hasHtmlEmail()); | ||
| $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); | ||
| $campaign->incrementSentCount($sentAs); | ||
| $this->messageRepository->incrementSentCounts($campaign->getId(), $sentAs); | ||
| } catch (MessageSizeLimitExceededException $e) { | ||
| // stop after the first message if size is exceeded | ||
| $this->updateMessageStatus($campaign, MessageStatus::Suspended); | ||
|
|
@@ -330,6 +370,13 @@ private function processSubscribersForCampaign(Message $campaign, array $subscri | |
| continue; | ||
| } | ||
|
|
||
| if (!$this->domainRateLimiter->attemptSend($subscriber->getEmail())->allowed) { | ||
| // Leave no UserMessage record so this subscriber is picked up again on a | ||
| // later run, once their domain's throttle window has passed. | ||
| $stoppedEarly = true; | ||
| continue; | ||
| } | ||
|
|
||
| $userMessage = $existing ?? new UserMessage($subscriber, $campaign); | ||
| $userMessage->setStatus(UserMessageStatus::Active); | ||
| $this->userMessageRepository->save($userMessage); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace PhpList\Core\Domain\Messaging\Model; | ||
|
|
||
| use Doctrine\ORM\Mapping as ORM; | ||
| use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; | ||
| use PhpList\Core\Domain\Messaging\Repository\DomainThrottleStateRepository; | ||
|
|
||
| /** | ||
| * Per-domain send counters for a single fixed throttle window, persisted so that | ||
| * DomainRateLimiter enforces DOMAIN_BATCH_SIZE/DOMAIN_BATCH_PERIOD consistently across | ||
| * concurrent queue-processing workers instead of each worker keeping its own count. | ||
| * Rows are read/written exclusively via DomainThrottleStateRepository's atomic | ||
| * UPDATE/INSERT statements, not through the entity manager's persist/flush. | ||
| */ | ||
| #[ORM\Entity(repositoryClass: DomainThrottleStateRepository::class)] | ||
| #[ORM\Table(name: 'domain_throttle')] | ||
| class DomainThrottleState implements DomainModel | ||
| { | ||
| #[ORM\Id] | ||
| #[ORM\Column(name: 'domain', type: 'string', length: 255)] | ||
| private string $domain; | ||
|
|
||
| #[ORM\Column(name: 'window_start', type: 'integer')] | ||
| private int $windowStart; | ||
|
|
||
| #[ORM\Column(name: 'sent_count', type: 'integer')] | ||
| private int $sentCount; | ||
|
|
||
| #[ORM\Column(name: 'blocked_count', type: 'integer')] | ||
| private int $blockedCount; | ||
|
|
||
| public function __construct(string $domain, int $windowStart, int $sentCount = 0, int $blockedCount = 0) | ||
| { | ||
| $this->domain = $domain; | ||
| $this->windowStart = $windowStart; | ||
| $this->sentCount = $sentCount; | ||
| $this->blockedCount = $blockedCount; | ||
| } | ||
|
|
||
| public function getDomain(): string | ||
| { | ||
| return $this->domain; | ||
| } | ||
|
|
||
| public function getWindowStart(): int | ||
| { | ||
| return $this->windowStart; | ||
| } | ||
|
|
||
| public function getSentCount(): int | ||
| { | ||
| return $this->sentCount; | ||
| } | ||
|
|
||
| public function getBlockedCount(): int | ||
| { | ||
| return $this->blockedCount; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace PhpList\Core\Domain\Messaging\Model\Dto; | ||
|
|
||
| /** | ||
| * Outcome of an atomic slot reservation attempt in DomainThrottleStateRepository. | ||
| */ | ||
| final class DomainThrottleReservation | ||
| { | ||
| public function __construct( | ||
| public readonly bool $allowed, | ||
| public readonly int $blockedAttempts = 0, | ||
| ) { | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace PhpList\Core\Domain\Messaging\Model\Dto; | ||
|
|
||
| /** | ||
| * Outcome of DomainRateLimiter::attemptSend() for a single recipient. | ||
| */ | ||
| final class DomainThrottleResult | ||
| { | ||
| public function __construct( | ||
| public readonly bool $allowed, | ||
| public readonly ?string $domain, | ||
| public readonly int $blockedAttempts = 0, | ||
| public readonly bool $backoffApplied = false, | ||
| public readonly int $backoffSeconds = 0, | ||
| ) { | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.