Skip to content

Commit 615e021

Browse files
authored
Merge pull request #115 from APaikens/MailAttachmentReset-patch
Mail attachment reset patch
2 parents 1fae747 + 7b0d3ae commit 615e021

3 files changed

Lines changed: 142 additions & 1 deletion

File tree

src/Email.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ private function generateBody(): AbstractPart
297297
}
298298
}
299299

300-
return $part ?? new TextPart($this->text, $this->textCharset);
300+
return $part ?? new TextPart((string) $this->text, $this->textCharset);
301301
}
302302

303303
private function prepareParts(): array

src/Service/MailService.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ public function send(): ResultInterface
7070
//trigger error event
7171
$this->getEventManager()->triggerEvent($this->createMailEvent(MailEvent::EVENT_MAIL_SEND_ERROR, $result));
7272
throw new MailException($result->getMessage());
73+
} finally {
74+
// Reset the Symfony Message body after every send attempt (success or failure).
75+
// attachFiles() embeds DataParts into Message::$body; without this reset those
76+
// DataParts survive a failed send and get wrapped into the next email's body,
77+
// causing attachments from a failed send to leak into subsequent emails.
78+
$this->getMessage()->setBody(null);
7379
}
7480

7581
if ($result->isValid()) {

test/Service/MailServiceTest.php

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@
1717
use PHPUnit\Framework\MockObject\Exception;
1818
use PHPUnit\Framework\MockObject\MockObject;
1919
use PHPUnit\Framework\TestCase;
20+
use ReflectionClass;
2021
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
2122
use Symfony\Component\Mailer\Transport\TransportInterface;
23+
use Symfony\Component\Mime\Message;
24+
use Symfony\Component\Mime\Part\AbstractPart;
2225
use Symfony\Component\Mime\Part\DataPart;
2326
use Symfony\Component\Mime\Part\Multipart\MixedPart;
2427
use Symfony\Component\Mime\Part\TextPart;
@@ -292,4 +295,136 @@ public function testAttachFilesNoNestedMixedParts(): void
292295
$this->assertNotInstanceOf(MixedPart::class, $part);
293296
}
294297
}
298+
299+
/**
300+
* Found an attachment leak where DataParts
301+
* embedded into Message::$body by attachFiles() survived a failed send
302+
* and were wrapped into the next email. The fix must live in dot-mail:
303+
* after send() (success OR failure) the underlying Message::$body must
304+
* be reset so the next send rebuilds the body from scratch.
305+
*/
306+
public function testMessageBodyIsResetAfterFailedSendToPreventAttachmentLeak(): void
307+
{
308+
$this->mailService->setSubject('First');
309+
$this->message->html('First body');
310+
$this->mailService->addAttachment(
311+
$this->fileSystem->url() . '/data/mail/attachments/testPdfAttachment.pdf'
312+
);
313+
314+
$this->transportInterface
315+
->method('send')
316+
->willThrowException(new RuntimeException('SMTP failure'));
317+
318+
try {
319+
$this->mailService->send();
320+
$this->fail('Expected MailException was not thrown');
321+
} catch (MailException) {
322+
// expected
323+
}
324+
325+
$this->assertNull(
326+
$this->readPrivateMessageBody($this->message),
327+
'After a failed send the Symfony Message::$body must be reset; '
328+
. 'otherwise attachFiles() will wrap the leaked MixedPart into the next email.'
329+
);
330+
}
331+
332+
/**
333+
* End-to-end regression for the same leak: after a failed send with
334+
* attachments, configure a fresh email with no attachments, and verify
335+
* the message handed to the transport contains no DataPart from the
336+
* previous send.
337+
*/
338+
public function testAttachmentsDoNotLeakIntoSubsequentSendAfterFailure(): void
339+
{
340+
$this->mailService->setSubject('First');
341+
$this->message->html('First email');
342+
$this->mailService->addAttachment(
343+
$this->fileSystem->url() . '/data/mail/attachments/testPdfAttachment.pdf'
344+
);
345+
346+
$capturedSecondBody = null;
347+
$callCount = 0;
348+
$this->transportInterface
349+
->method('send')
350+
->willReturnCallback(function ($email) use (&$capturedSecondBody, &$callCount) {
351+
$callCount++;
352+
if ($callCount === 1) {
353+
throw new RuntimeException('first send fails');
354+
}
355+
$capturedSecondBody = $email->getBody();
356+
return null;
357+
});
358+
359+
try {
360+
$this->mailService->send();
361+
} catch (MailException) {
362+
// expected
363+
}
364+
365+
// Reconfigure: brand new email with NO attachments.
366+
$this->mailService->setAttachments([]);
367+
$this->mailService->setSubject('Second');
368+
$this->message->html('Second email - must not carry first email\'s attachment');
369+
370+
$this->mailService->send();
371+
372+
$this->assertInstanceOf(
373+
AbstractPart::class,
374+
$capturedSecondBody,
375+
'Transport did not receive a second message body'
376+
);
377+
$this->assertFalse(
378+
$this->bodyContainsDataPart($capturedSecondBody),
379+
'Second email leaked a DataPart from the failed first send'
380+
);
381+
}
382+
383+
/**
384+
* Confirms the success path also resets the body, so a future refactor
385+
* does not silently regress the success case.
386+
*/
387+
public function testMessageBodyIsResetAfterSuccessfulSend(): void
388+
{
389+
$this->mailService->setSubject('Hello');
390+
$this->message->html('Body');
391+
$this->mailService->addAttachment(
392+
$this->fileSystem->url() . '/data/mail/attachments/testPdfAttachment.pdf'
393+
);
394+
395+
$this->transportInterface->expects($this->once())->method('send');
396+
397+
$this->mailService->send();
398+
399+
$this->assertNull(
400+
$this->readPrivateMessageBody($this->message),
401+
'Message body should be null after a successful send so the next email starts clean.'
402+
);
403+
}
404+
405+
private function readPrivateMessageBody(Message $message): ?AbstractPart
406+
{
407+
$reflection = new ReflectionClass(Message::class);
408+
$property = $reflection->getProperty('body');
409+
$property->setAccessible(true);
410+
411+
return $property->getValue($message);
412+
}
413+
414+
private function bodyContainsDataPart(AbstractPart $part): bool
415+
{
416+
if ($part instanceof DataPart) {
417+
return true;
418+
}
419+
420+
if ($part instanceof MixedPart) {
421+
foreach ($part->getParts() as $child) {
422+
if ($this->bodyContainsDataPart($child)) {
423+
return true;
424+
}
425+
}
426+
}
427+
428+
return false;
429+
}
295430
}

0 commit comments

Comments
 (0)