99use PhpList \Core \Domain \Messaging \Model \Bounce ;
1010use PhpList \Core \Domain \Messaging \Model \UserMessage ;
1111use 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 ;
1314use PhpList \Core \Domain \Messaging \Service \LockService ;
1415use PhpList \Core \Domain \Messaging \Service \Manager \BounceManager ;
1516use 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