Skip to content

Commit 538ffae

Browse files
committed
BounceProcessingService
1 parent 7630187 commit 538ffae

File tree

8 files changed

+409
-236
lines changed

8 files changed

+409
-236
lines changed

config/services/commands.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ services:
1111
PhpList\Core\Domain\Identity\Command\:
1212
resource: '../../src/Domain/Identity/Command'
1313
tags: ['console.command']
14+
15+
PhpList\Core\Domain\Messaging\Command\ProcessBouncesCommand:
16+
arguments:
17+
$protocolProcessors: !tagged_iterator 'phplist.bounce_protocol_processor'

config/services/processor.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
services:
2+
_defaults:
3+
autowire: true
4+
autoconfigure: true
5+
public: false
6+
7+
PhpList\Core\Domain\Messaging\Service\Processor\PopBounceProcessor:
8+
tags: ['phplist.bounce_protocol_processor']
9+
10+
PhpList\Core\Domain\Messaging\Service\Processor\MboxBounceProcessor:
11+
tags: ['phplist.bounce_protocol_processor']

src/Domain/Messaging/Command/ProcessBouncesCommand.php

Lines changed: 19 additions & 236 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
use PhpList\Core\Domain\Messaging\Model\Bounce;
1010
use PhpList\Core\Domain\Messaging\Model\UserMessage;
1111
use PhpList\Core\Domain\Messaging\Model\UserMessageBounce;
12-
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
12+
use PhpList\Core\Domain\Messaging\Service\BounceProcessingService;
13+
use PhpList\Core\Domain\Messaging\Service\Processor\BounceProtocolProcessor;
1314
use PhpList\Core\Domain\Messaging\Service\LockService;
1415
use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager;
1516
use PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager;
@@ -48,14 +49,15 @@ protected function configure(): void
4849

4950
public function __construct(
5051
private readonly BounceManager $bounceManager,
51-
private readonly SubscriberRepository $users,
52-
private readonly MessageRepository $messages,
5352
private readonly BounceRuleManager $ruleManager,
5453
private readonly LockService $lockService,
5554
private readonly LoggerInterface $logger,
5655
private readonly SubscriberManager $subscriberManager,
5756
private readonly SubscriberHistoryManager $subscriberHistoryManager,
5857
private readonly SubscriberRepository $subscriberRepository,
58+
private readonly BounceProcessingService $processingService,
59+
/** @var iterable<BounceProtocolProcessor> */
60+
private readonly iterable $protocolProcessors,
5961
) {
6062
parent::__construct();
6163
}
@@ -82,62 +84,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int
8284
try {
8385
$io->title('Processing bounces');
8486
$protocol = (string)$input->getOption('protocol');
85-
$testMode = (bool)$input->getOption('test');
86-
$max = (int)$input->getOption('maximum');
87-
$purgeProcessed = $input->getOption('purge') && !$testMode;
88-
$purgeUnprocessed = $input->getOption('purge-unprocessed') && !$testMode;
8987

9088
$downloadReport = '';
9189

92-
if ($protocol === 'pop') {
93-
$host = (string)$input->getOption('host');
94-
$user = (string)$input->getOption('user');
95-
$password = (string)$input->getOption('password');
96-
$port = (string)$input->getOption('port');
97-
$mailboxes = (string)$input->getOption('mailbox');
98-
99-
if (!$host || !$user || !$password) {
100-
$io->error('POP configuration incomplete: host, user, and password are required.');
101-
102-
return Command::FAILURE;
103-
}
104-
105-
foreach (explode(',', $mailboxes) as $mailboxName) {
106-
$mailboxName = trim($mailboxName);
107-
if ($mailboxName === '') { $mailboxName = 'INBOX'; }
108-
$mailbox = sprintf('{%s:%s}%s', $host, $port, $mailboxName);
109-
$io->section("Connecting to $mailbox");
110-
111-
$link = @imap_open($mailbox, $user, $password);
112-
if (!$link) {
113-
$io->error('Cannot create connection to '.$mailbox.': '.imap_last_error());
114-
115-
return Command::FAILURE;
116-
}
117-
118-
$downloadReport .= $this->processMessages($io, $link, $max, $purgeProcessed, $purgeUnprocessed, $testMode);
119-
}
120-
} elseif ($protocol === 'mbox') {
121-
$file = (string)$input->getOption('mailbox');
122-
if (!$file) {
123-
$io->error('mbox file path must be provided with --mailbox.');
124-
125-
return Command::FAILURE;
90+
$processor = null;
91+
foreach ($this->protocolProcessors as $p) {
92+
if ($p->getProtocol() === $protocol) {
93+
$processor = $p;
94+
break;
12695
}
127-
$io->section("Opening mbox $file");
128-
$link = @imap_open($file, '', '', $testMode ? 0 : CL_EXPUNGE);
129-
if (!$link) {
130-
$io->error('Cannot open mailbox file: '.imap_last_error());
96+
}
13197

132-
return Command::FAILURE;
133-
}
134-
$downloadReport .= $this->processMessages($io, $link, $max, $purgeProcessed, $purgeUnprocessed, $testMode);
135-
} else {
98+
if ($processor === null) {
13699
$io->error('Unsupported protocol: '.$protocol);
137-
138100
return Command::FAILURE;
139101
}
140102

103+
$downloadReport .= $processor->process($input, $io);
104+
141105
// Reprocess unidentified bounces (status = "unidentified bounce")
142106
$this->reprocessUnidentified($io);
143107

@@ -169,144 +133,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int
169133
}
170134
}
171135

172-
private function processMessages(SymfonyStyle $io, $link, int $max, bool $purgeProcessed, bool $purgeUnprocessed, bool $testMode): string
173-
{
174-
$num = imap_num_msg($link);
175-
$io->writeln(sprintf('%d bounces to fetch from the mailbox', $num));
176-
if ($num === 0) {
177-
imap_close($link);
178-
179-
return '';
180-
}
181-
$io->writeln('Please do not interrupt this process');
182-
if ($num > $max) {
183-
$io->writeln(sprintf('Processing first %d bounces', $max));
184-
$num = $max;
185-
}
186-
$io->writeln($testMode ? 'Running in test mode, not deleting messages from mailbox' : 'Processed messages will be deleted from the mailbox');
187-
188-
for ($x = 1; $x <= $num; $x++) {
189-
$header = imap_fetchheader($link, $x);
190-
$processed = $this->processImapBounce($link, $x, $header, $io);
191-
if ($processed) {
192-
if (!$testMode && $purgeProcessed) {
193-
imap_delete($link, (string)$x);
194-
}
195-
} else {
196-
if (!$testMode && $purgeUnprocessed) {
197-
imap_delete($link, (string)$x);
198-
}
199-
}
200-
}
201-
202-
$io->writeln('Closing mailbox, and purging messages');
203-
if (!$testMode) {
204-
imap_close($link, CL_EXPUNGE);
205-
} else {
206-
imap_close($link);
207-
}
208-
209-
return '';
210-
}
211-
212-
private function processImapBounce($link, int $num, string $header, SymfonyStyle $io): bool
213-
{
214-
$headerInfo = imap_headerinfo($link, $num);
215-
$date = $headerInfo->date ?? null;
216-
$bounceDate = $date ? new DateTimeImmutable($date) : new DateTimeImmutable();
217-
$body = imap_body($link, $num);
218-
$body = $this->decodeBody($header, $body);
219-
220-
// Quick hack: ignore MsExchange delayed notices (as in original)
221-
if (preg_match('/Action: delayed\s+Status: 4\.4\.7/im', $body)) {
222-
return true;
223-
}
224-
225-
$msgId = $this->findMessageId($body);
226-
$userId = $this->findUserId($body);
227-
228-
$bounce = $this->bounceManager->create($bounceDate, $header, $body);
229-
230-
return $this->processBounceData($bounce, $msgId, $userId, $bounceDate);
231-
}
232-
233-
private function processBounceData(
234-
Bounce $bounce,
235-
string|int|null $msgId,
236-
?int $userId,
237-
DateTimeImmutable $bounceDate,
238-
): bool {
239-
$msgId = $msgId ?: null;
240-
if ($userId) {
241-
$user = $this->subscriberManager->getSubscriberById($userId);
242-
}
243-
244-
if ($msgId === 'systemmessage' && $userId) {
245-
$this->bounceManager->update(
246-
bounce: $bounce,
247-
status: 'bounced system message',
248-
comment: sprintf('%d marked unconfirmed', $userId))
249-
;
250-
$this->bounceManager->linkUserMessageBounce($bounce,$bounceDate, $userId);
251-
$this->subscriberManager->markUnconfirmed($userId);
252-
$this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]);
253-
$this->subscriberHistoryManager->addHistory(
254-
subscriber: $user,
255-
message: 'Bounced system message',
256-
details: sprintf('User marked unconfirmed. Bounce #%d', $bounce->getId())
257-
);
258-
259-
return true;
260-
}
261-
262-
if ($msgId && $userId) {
263-
if (!$this->bounceManager->existsUserMessageBounce($userId, (int)$msgId)) {
264-
$this->bounceManager->linkUserMessageBounce($bounce, $bounceDate,$userId, (int)$msgId);
265-
$this->bounceManager->update(
266-
bounce: $bounce,
267-
status: sprintf('bounced list message %d', $msgId),
268-
comment: sprintf('%d bouncecount increased', $userId)
269-
);
270-
$this->messages->incrementBounceCount((int)$msgId);
271-
$this->users->incrementBounceCount($userId);
272-
} else {
273-
$this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId, (int)$msgId);
274-
$this->bounceManager->update(
275-
bounce: $bounce,
276-
status: sprintf('duplicate bounce for %d', $userId),
277-
comment: sprintf('duplicate bounce for subscriber %d on message %d', $userId, $msgId)
278-
);
279-
}
280-
return true;
281-
}
282-
283-
if ($userId) {
284-
$this->bounceManager->update(
285-
bounce: $bounce,
286-
status: 'bounced unidentified message',
287-
comment: sprintf('%d bouncecount increased', $userId)
288-
);
289-
$this->users->incrementBounceCount($userId);
290-
return true;
291-
}
292-
293-
if ($msgId === 'systemmessage') {
294-
$this->bounceManager->update($bounce, 'bounced system message', 'unknown user');
295-
$this->logger->info('system message bounced, but unknown user');
296-
return true;
297-
}
298-
299-
if ($msgId) {
300-
$this->bounceManager->update($bounce, sprintf('bounced list message %d', $msgId), 'unknown user');
301-
$this->messages->incrementBounceCount((int)$msgId);
302-
return true;
303-
}
304-
305-
$this->bounceManager->update($bounce, 'unidentified bounce', 'not processed');
306-
307-
return false;
308-
}
309-
310136
private function reprocessUnidentified(SymfonyStyle $io): void
311137
{
312138
$io->section('Reprocessing unidentified bounces');
@@ -319,12 +145,12 @@ private function reprocessUnidentified(SymfonyStyle $io): void
319145
if ($count % 25 === 0) {
320146
$io->writeln(sprintf('%d out of %d processed', $count, $total));
321147
}
322-
$decodedBody = $this->decodeBody($bounce->getHeader(), $bounce->getData());
323-
$userId = $this->findUserId($decodedBody);
324-
$messageId = $this->findMessageId($decodedBody);
148+
$decodedBody = $this->processingService->decodeBody($bounce->getHeader(), $bounce->getData());
149+
$userId = $this->processingService->findUserId($decodedBody);
150+
$messageId = $this->processingService->findMessageId($decodedBody);
325151
if ($userId || $messageId) {
326152
$reparsed++;
327-
if ($this->processBounceData($bounce->getId(), $messageId, $userId, new DateTimeImmutable())) {
153+
if ($this->processingService->processBounceData($bounce, $messageId, $userId, new DateTimeImmutable())) {
328154
$reidentified++;
329155
}
330156
}
@@ -488,7 +314,7 @@ private function handleConsecutiveBounces(SymfonyStyle $io, int $unsubscribeThre
488314
break;
489315
}
490316
}
491-
if ($removed || $msgokay) {
317+
if ($removed) {
492318
break;
493319
}
494320
}
@@ -499,47 +325,4 @@ private function handleConsecutiveBounces(SymfonyStyle $io, int $unsubscribeThre
499325
$io->writeln(sprintf('total of %d subscribers processed', $total));
500326
}
501327

502-
private function decodeBody(string $header, string $body): string
503-
{
504-
$transferEncoding = '';
505-
if (preg_match('/Content-Transfer-Encoding: ([\w-]+)/i', $header, $regs)) {
506-
$transferEncoding = strtolower($regs[1]);
507-
}
508-
return match ($transferEncoding) {
509-
'quoted-printable' => quoted_printable_decode($body),
510-
'base64' => base64_decode($body) ?: '',
511-
default => $body,
512-
};
513-
}
514-
515-
private function findMessageId(string $text): string|int|null
516-
{
517-
if (preg_match('/(?:X-MessageId|X-Message): (.*)\r\n/iU', $text, $match)) {
518-
return trim($match[1]);
519-
}
520-
return null;
521-
}
522-
523-
private function findUserId(string $text): ?int
524-
{
525-
// Try X-ListMember / X-User first
526-
if (preg_match('/(?:X-ListMember|X-User): (.*)\r\n/iU', $text, $match)) {
527-
$user = trim($match[1]);
528-
if (str_contains($user, '@')) {
529-
return $this->users->idByEmail($user);
530-
} elseif (preg_match('/^\d+$/', $user)) {
531-
return (int)$user;
532-
} elseif ($user !== '') {
533-
return $this->users->idByUniqId($user);
534-
}
535-
}
536-
// Fallback: parse any email in the body and see if it is a subscriber
537-
if (preg_match_all('/[._a-zA-Z0-9-]+@[.a-zA-Z0-9-]+/', $text, $regs)) {
538-
foreach ($regs[0] as $email) {
539-
$id = $this->users->idByEmail($email);
540-
if ($id) { return $id; }
541-
}
542-
}
543-
return null;
544-
}
545328
}

0 commit comments

Comments
 (0)