Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 14 additions & 19 deletions src/Illuminate/Mail/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use Illuminate\Contracts\Mail\Attachable;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\Support\Traits\ForwardsCalls;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
Expand Down Expand Up @@ -344,31 +343,27 @@ public function embed($file)
if ($file instanceof Attachment) {
return $file->attachWith(
function ($path) use ($file) {
$cid = $file->as ?? Str::random();
$part = (new DataPart(new File($path), $file->as, $file->mime))->asInline();

$this->message->addPart(
(new DataPart(new File($path), $cid, $file->mime))->asInline()
);
$this->message->addPart($part);

return "cid:{$cid}";
return "cid:{$part->getContentId()}";
},
function ($data) use ($file) {
$this->message->addPart(
(new DataPart($data(), $file->as, $file->mime))->asInline()
);
$part = (new DataPart($data(), $file->as, $file->mime))->asInline();
$this->message->addPart($part);

return "cid:{$file->as}";
return "cid:{$part->getContentId()}";
}
);
}

$cid = Str::random(10);
$fileObject = new File($file);
$part = (new DataPart($fileObject, $fileObject->getFilename()))->asInline();

$this->message->addPart(
(new DataPart(new File($file), $cid))->asInline()
);
$this->message->addPart($part);

return "cid:$cid";
return "cid:{$part->getContentId()}";
}

/**
Expand All @@ -381,11 +376,11 @@ function ($data) use ($file) {
*/
public function embedData($data, $name, $contentType = null)
{
$this->message->addPart(
(new DataPart($data, $name, $contentType))->asInline()
);
$part = (new DataPart($data, $name, $contentType))->asInline();

$this->message->addPart($part);

return "cid:$name";
return "cid:{$part->getContentId()}";
}

/**
Expand Down
1 change: 1 addition & 0 deletions tests/Integration/Mail/Fixtures/embed-image.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Embedded image: <img src="{{ $message->embed($image) }}" alt="Embedded test image" />
2 changes: 2 additions & 0 deletions tests/Integration/Mail/Fixtures/embed.blade.php
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
Embed file: {{ basename(__FILE__) }}

Embed content: {{ $message->embed(__FILE__) }}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
81 changes: 74 additions & 7 deletions tests/Integration/Mail/SendingMarkdownMailTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,23 +52,28 @@ public function testEmbed()
$email = app('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage()->toString();

$cid = explode(' cid:', (new Stringable($email))->explode("\r\n")
->filter(fn ($line) => str_contains($line, 'Embed content: cid:'))
->filter(fn ($line) => str_contains($line, ' content: cid:'))
->first())[1];

$filename = explode('Embed file: ', (new Stringable($email))->explode("\r\n")
->filter(fn ($line) => str_contains($line, ' file:'))
->first())[1];

$this->assertStringContainsString(<<<EOT
Content-Type: application/x-php; name=$cid\r
Content-Type: application/x-php; name=$filename\r
Content-Transfer-Encoding: base64\r
Content-Disposition: inline; name=$cid; filename=$cid\r
Content-Disposition: inline; name=$filename;\r
filename=$filename\r
Content-ID: <$cid>\r
EOT, $email);
}

public function testEmbedData()
{
Mail::to('test@mail.com')->send($mailable = new EmbedDataMailable());

$mailable->assertSeeInHtml('Embed data content: cid:foo.jpg');
$mailable->assertSeeInText('Embed data content: ');
$mailable->assertDontSeeInText('Embed data content: cid:foo.jpg');
$mailable->assertSeeInHtml('Embed data content: cid:');

$email = app('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage()->toString();

Expand All @@ -87,8 +92,7 @@ public function testEmbedMultilineImage()

$this->assertStringContainsString('Embed multiline content: <img', $html);
$this->assertStringContainsString('alt="multiline image"', $html);
$this->assertStringContainsString('data:image/png;base64', $html);
$this->assertStringNotContainsString('cid:foo.jpg', $html);
$this->assertStringContainsString('<img src="cid:', $html);
}

public function testMessageAsPublicPropertyMayBeDefinedAsViewData()
Expand Down Expand Up @@ -128,6 +132,49 @@ public function testTheme()
Mail::to('test@mail.com')->send(new BasicMailable());
$this->assertSame('default', app(Markdown::class)->getTheme());
}

public function testEmbeddedImageContentIdConsistencyAcrossMailerFailoverClones()
{
Mail::to('test@mail.com')->send($mailable = new EmbedImageMailable);

/** @var \Symfony\Component\Mime\Email $originalEmail */
$originalEmail = app('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage();
$expectedContentId = $originalEmail->getAttachments()[0]->getContentId();

// Simulate failover mailer scenario where email is cloned for retry.
// After shallow clone, the CID in HTML and attachment Content-ID header should remain consistent.
$firstClonedEmail = quoted_printable_decode((clone $originalEmail)->toString());
[$htmlCid, $attachmentContentId] = $this->extractContentIdsFromEmail($firstClonedEmail);

$this->assertEquals($htmlCid, $attachmentContentId, 'HTML img src CID should match attachment Content-ID header');
$this->assertEquals($expectedContentId, $htmlCid, 'Cloned email CID should match original attachment CID');

// Verify consistency is maintained across multiple clone operations (e.g., multiple retries).
$secondClonedEmail = quoted_printable_decode((clone $originalEmail)->toString());
[$htmlCid, $attachmentContentId] = $this->extractContentIdsFromEmail($secondClonedEmail);

$this->assertEquals($htmlCid, $attachmentContentId, 'HTML img src CID should match attachment Content-ID header on subsequent clone');
$this->assertEquals($expectedContentId, $htmlCid, 'Multiple clones should preserve original CID');
}

/**
* Extract Content IDs from email for embedded image validation.
*
* @param string $rawEmail
* @return array{0: string|null, 1: string|null} [HTML image CID, attachment Content-ID]
*/
private function extractContentIdsFromEmail(string $rawEmail): array
{
// Extract CID from HTML <img src="cid:..."> tag.
preg_match('/<img[^>]+src="cid:([^"]+)"/', $rawEmail, $htmlMatches);
$htmlImageCid = $htmlMatches[1] ?? null;

// Extract CID from MIME attachment Content-ID header.
preg_match('/Content-ID:\s*<([^>]+)>/', $rawEmail, $headerMatches);
$attachmentContentId = $headerMatches[1] ?? null;

return [$htmlImageCid, $attachmentContentId];
}
}

class BasicMailable extends Mailable
Expand Down Expand Up @@ -236,6 +283,26 @@ public function content()
}
}

class EmbedImageMailable extends Mailable
{
public function envelope()
{
return new Envelope(
subject: 'My basic title',
);
}

public function content()
{
return new Content(
markdown: 'embed-image',
with: [
'image' => __DIR__.DIRECTORY_SEPARATOR.'Fixtures'.DIRECTORY_SEPARATOR.'empty_image.jpg',
]
);
}
}

class MessageAsPublicPropertyMailable extends Mailable
{
public $message = 'My message';
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
Embed file: {{ basename(__FILE__) }}
Embed content: {{ $message->embed(__FILE__) }}
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,24 @@ public function testMarkdownNotification()

$user->notify(new MarkdownNotification());

$email = app('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage()->toString();
$message = app('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage();
$email = $message->toString();
$textBody = $message->getTextBody();

$cid = explode(' cid:', (new Stringable($email))->explode("\r\n")
$cid = explode(' cid:', (new Stringable($textBody))->explode("\n")
->filter(fn ($line) => str_contains($line, 'Embed content: cid:'))
->first())[1];

$filename = explode(' file: ', (new Stringable($textBody))->explode("\n")
->filter(fn ($line) => str_contains($line, 'Embed file: '))
->first())[1];

$this->assertStringContainsString(<<<EOT
Content-Type: application/x-php; name=$cid\r
Content-Type: application/x-php; name=$filename\r
Content-Transfer-Encoding: base64\r
Content-Disposition: inline; name=$cid; filename=$cid\r
Content-Disposition: inline; name=$filename;\r
filename=$filename\r
Content-ID: <$cid>\r
EOT, $email);
}

Expand Down
23 changes: 14 additions & 9 deletions tests/Mail/MailMessageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,14 @@ public function testEmbedPath(): void
$cid = $this->message->embed($path);

$this->assertStringStartsWith('cid:', $cid);
$name = Str::after($cid, 'cid:');
$contentId = Str::after($cid, 'cid:');
$attachment = $this->message->getSymfonyMessage()->getAttachments()[0];
$headers = $attachment->getPreparedHeaders()->toArray();
$this->assertSame('bar', $attachment->getBody());
$this->assertSame("Content-Type: image/jpeg; name={$name}", $headers[0]);
$this->assertSame($contentId, $attachment->getContentId());
$this->assertStringContainsString('Content-Type: image/jpeg', $headers[0]);
$this->assertSame('Content-Transfer-Encoding: base64', $headers[1]);
$this->assertSame("Content-Disposition: inline; name={$name}; filename={$name}", $headers[2]);
$this->assertStringContainsString('Content-Disposition: inline', $headers[2]);

unlink($path);
}
Expand All @@ -182,7 +183,9 @@ public function testDataEmbed(): void

$attachment = $this->message->getSymfonyMessage()->getAttachments()[0];
$headers = $attachment->getPreparedHeaders()->toArray();
$this->assertSame('cid:foo.jpg', $cid);
$this->assertStringStartsWith('cid:', $cid);
$contentId = Str::after($cid, 'cid:');
$this->assertSame($contentId, $attachment->getContentId());
$this->assertSame('bar', $attachment->getBody());
$this->assertSame('Content-Type: image/png; name=foo.jpg', $headers[0]);
$this->assertSame('Content-Transfer-Encoding: base64', $headers[1]);
Expand All @@ -201,9 +204,11 @@ public function toMailAttachment()
}
});

$this->assertSame('cid:baz', $cid);
$this->assertStringStartsWith('cid:', $cid);
$contentId = Str::after($cid, 'cid:');
$attachment = $this->message->getSymfonyMessage()->getAttachments()[0];
$headers = $attachment->getPreparedHeaders()->toArray();
$this->assertSame($contentId, $attachment->getContentId());
$this->assertSame('bar', $attachment->getBody());
$this->assertSame('Content-Type: image/png; name=baz', $headers[0]);
$this->assertSame('Content-Transfer-Encoding: base64', $headers[1]);
Expand All @@ -225,14 +230,14 @@ public function toMailAttachment()
});

$this->assertStringStartsWith('cid:', $cid);
$name = Str::after($cid, 'cid:');
$this->assertSame(16, mb_strlen($name));
$contentId = Str::after($cid, 'cid:');
$attachment = $this->message->getSymfonyMessage()->getAttachments()[0];
$this->assertSame($contentId, $attachment->getContentId());
$headers = $attachment->getPreparedHeaders()->toArray();
$this->assertSame('bar', $attachment->getBody());
$this->assertSame("Content-Type: image/jpeg; name={$name}", $headers[0]);
$this->assertStringContainsString('Content-Type: image/jpeg', $headers[0]);
$this->assertSame('Content-Transfer-Encoding: base64', $headers[1]);
$this->assertSame("Content-Disposition: inline; name={$name};\r\n filename={$name}", $headers[2]);
$this->assertStringContainsString('Content-Disposition: inline', $headers[2]);

unlink($path);
}
Expand Down