diff --git a/src/Illuminate/Mail/Markdown.php b/src/Illuminate/Mail/Markdown.php
index d99a232b7f0b..d200160aee17 100644
--- a/src/Illuminate/Mail/Markdown.php
+++ b/src/Illuminate/Mail/Markdown.php
@@ -42,6 +42,13 @@ class Markdown
*/
protected static $withSecuredEncoding = false;
+ /**
+ * The extracted head styles (media queries) from the theme.
+ *
+ * @var string
+ */
+ protected static $headStyles = '';
+
/**
* Create a new Markdown renderer instance.
*
@@ -67,6 +74,20 @@ public function render($view, array $data = [], $inliner = null)
{
$this->view->flushFinderCache();
+ $this->view->replaceNamespace('mail', $this->htmlComponentPaths());
+
+ if ($this->view->exists($customTheme = Str::start($this->theme, 'mail.'))) {
+ $theme = $customTheme;
+ } else {
+ $theme = str_contains($this->theme, '::')
+ ? $this->theme
+ : 'mail::themes.'.$this->theme;
+ }
+
+ $themeCss = $this->view->make($theme, $data)->render();
+
+ [$inlineCss, static::$headStyles] = $this->extractMediaQueries($themeCss);
+
$bladeCompiler = $this->view
->getEngineResolver()
->resolve('blade')
@@ -88,9 +109,7 @@ function () use ($view, $data) {
}
try {
- $contents = $this->view->replaceNamespace(
- 'mail', $this->htmlComponentPaths()
- )->make($view, $data)->render();
+ $contents = $this->view->make($view, $data)->render();
} finally {
EncodedHtmlString::flushState();
}
@@ -99,16 +118,8 @@ function () use ($view, $data) {
}
);
- if ($this->view->exists($customTheme = Str::start($this->theme, 'mail.'))) {
- $theme = $customTheme;
- } else {
- $theme = str_contains($this->theme, '::')
- ? $this->theme
- : 'mail::themes.'.$this->theme;
- }
-
return new HtmlString(($inliner ?: new CssToInlineStyles)->convert(
- str_replace('\[', '[', $contents), $this->view->make($theme, $data)->render()
+ str_replace('\[', '[', $contents), $inlineCss
));
}
@@ -289,5 +300,59 @@ public static function withoutSecuredEncoding()
public static function flushState()
{
static::$withSecuredEncoding = false;
+ static::$headStyles = '';
+ }
+
+ /**
+ * Extract media queries from CSS that cannot be inlined.
+ *
+ * @param string $css
+ * @return array{0: string, 1: string}
+ */
+ protected function extractMediaQueries($css)
+ {
+ $mediaBlocks = '';
+ $inlineCss = '';
+ $offset = 0;
+ $length = strlen($css);
+
+ while (($pos = strpos($css, '@media', $offset)) !== false) {
+ $inlineCss .= substr($css, $offset, $pos - $offset);
+
+ $open = strpos($css, '{', $pos);
+
+ if ($open === false) {
+ break;
+ }
+
+ $braceCount = 1;
+ $i = $open + 1;
+
+ while ($i < $length && $braceCount > 0) {
+ if ($css[$i] === '{') {
+ $braceCount++;
+ } elseif ($css[$i] === '}') {
+ $braceCount--;
+ }
+ $i++;
+ }
+
+ $mediaBlocks .= substr($css, $pos, $i - $pos)."\n";
+ $offset = $i;
+ }
+
+ $inlineCss .= substr($css, $offset);
+
+ return [$inlineCss, $mediaBlocks];
+ }
+
+ /**
+ * Get the extracted head styles (media queries) from the theme.
+ *
+ * @return string
+ */
+ public static function getHeadStyles()
+ {
+ return static::$headStyles;
}
}
diff --git a/src/Illuminate/Mail/resources/views/html/layout.blade.php b/src/Illuminate/Mail/resources/views/html/layout.blade.php
index 0fa6b82f72b2..3217f0db49f5 100644
--- a/src/Illuminate/Mail/resources/views/html/layout.blade.php
+++ b/src/Illuminate/Mail/resources/views/html/layout.blade.php
@@ -4,8 +4,8 @@
{{ config('app.name') }}
-
-
+
+
{!! $head ?? '' !!}
diff --git a/src/Illuminate/Mail/resources/views/html/themes/default.css b/src/Illuminate/Mail/resources/views/html/themes/default.css
index 80465b220e9e..e1431df65b62 100644
--- a/src/Illuminate/Mail/resources/views/html/themes/default.css
+++ b/src/Illuminate/Mail/resources/views/html/themes/default.css
@@ -295,3 +295,94 @@ img {
.break-all {
word-break: break-all;
}
+
+/* Dark Mode */
+
+@media (prefers-color-scheme: dark) {
+ body,
+ .wrapper,
+ .body {
+ background-color: #18181b !important;
+ }
+
+ .inner-body {
+ background-color: #27272a !important;
+ border-color: #3f3f46 !important;
+ }
+
+ p,
+ ul,
+ ol,
+ blockquote,
+ span,
+ td {
+ color: #e4e4e7 !important;
+ }
+
+ a {
+ color: #a5b4fc !important;
+ }
+
+ h1,
+ h2,
+ h3,
+ .header a {
+ color: #fafafa !important;
+ }
+
+ .logo {
+ filter: invert(23%) sepia(5%) saturate(531%) hue-rotate(202deg) brightness(96%) contrast(91%) !important;
+ }
+
+ .button-primary,
+ .button-blue {
+ background-color: #fafafa !important;
+ border-color: #fafafa !important;
+ color: #18181b !important;
+ }
+
+ .button-secondary {
+ background-color: #3f3f46 !important;
+ border-color: #3f3f46 !important;
+ color: #fafafa !important;
+ }
+
+ .button-success,
+ .button-green {
+ background-color: #22c55e !important;
+ border-color: #22c55e !important;
+ color: #fff !important;
+ }
+
+ .button-error,
+ .button-red {
+ background-color: #ef4444 !important;
+ border-color: #ef4444 !important;
+ color: #fff !important;
+ }
+
+ .footer p,
+ .footer a {
+ color: #71717a !important;
+ }
+
+ .panel {
+ border-left-color: #d4d4d8 !important;
+ }
+
+ .panel-content {
+ background-color: #3f3f46 !important;
+ }
+
+ .panel-content p {
+ color: #e4e4e7 !important;
+ }
+
+ .subcopy {
+ border-top-color: #3f3f46 !important;
+ }
+
+ .table th {
+ border-bottom-color: #3f3f46 !important;
+ }
+}
diff --git a/tests/Mail/MailMarkdownTest.php b/tests/Mail/MailMarkdownTest.php
index ed96b332da89..0a754a33c6f9 100644
--- a/tests/Mail/MailMarkdownTest.php
+++ b/tests/Mail/MailMarkdownTest.php
@@ -14,6 +14,7 @@ class MailMarkdownTest extends TestCase
protected function tearDown(): void
{
m::close();
+ Markdown::flushState();
}
public function testRenderFunctionReturnsHtml(): void
@@ -31,9 +32,9 @@ public function testRenderFunctionReturnsHtml(): void
$viewFactory->shouldReceive('flushFinderCache')->once();
$viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf();
$viewFactory->shouldReceive('exists')->with('mail.default')->andReturn(false);
- $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf();
$viewFactory->shouldReceive('make')->with('mail::themes.default', [])->andReturnSelf();
- $viewFactory->shouldReceive('render')->twice()->andReturn('', 'body {}');
+ $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf();
+ $viewFactory->shouldReceive('render')->twice()->andReturn('body {}', '');
$result = $markdown->render('view', []);
@@ -56,9 +57,9 @@ public function testRenderFunctionReturnsHtmlWithCustomTheme(): void
$viewFactory->shouldReceive('flushFinderCache')->once();
$viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf();
$viewFactory->shouldReceive('exists')->with('mail.yaz')->andReturn(true);
- $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf();
$viewFactory->shouldReceive('make')->with('mail.yaz', [])->andReturnSelf();
- $viewFactory->shouldReceive('render')->twice()->andReturn('', 'body {}');
+ $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf();
+ $viewFactory->shouldReceive('render')->twice()->andReturn('body {}', '');
$result = $markdown->render('view', []);
@@ -81,9 +82,9 @@ public function testRenderFunctionReturnsHtmlWithCustomThemeWithMailPrefix(): vo
$viewFactory->shouldReceive('flushFinderCache')->once();
$viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf();
$viewFactory->shouldReceive('exists')->with('mail.yaz')->andReturn(true);
- $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf();
$viewFactory->shouldReceive('make')->with('mail.yaz', [])->andReturnSelf();
- $viewFactory->shouldReceive('render')->twice()->andReturn('', 'body {}');
+ $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf();
+ $viewFactory->shouldReceive('render')->twice()->andReturn('body {}', '');
$result = $markdown->render('view', []);
@@ -113,4 +114,121 @@ public function testParseReturnsParsedMarkdown(): void
$this->assertSame("Something
\n", $result);
}
+
+ public function testMediaQueriesAreExtractedFromThemeCss(): void
+ {
+ $viewFactory = m::mock(Factory::class);
+ $engineResolver = m::mock(EngineResolver::class);
+ $bladeCompiler = m::mock(BladeCompiler::class);
+ $viewFactory->shouldReceive('getEngineResolver')->andReturn($engineResolver);
+ $engineResolver->shouldReceive('resolve->getCompiler')->andReturn($bladeCompiler);
+ $bladeCompiler->shouldReceive('usingEchoFormat')
+ ->with('new \Illuminate\Support\EncodedHtmlString(%s)', m::type('Closure'))
+ ->andReturnUsing(fn ($echoFormat, $callback) => $callback());
+
+ $markdown = new Markdown($viewFactory);
+ $viewFactory->shouldReceive('flushFinderCache')->once();
+ $viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf();
+ $viewFactory->shouldReceive('exists')->with('mail.default')->andReturn(false);
+ $viewFactory->shouldReceive('make')->with('mail::themes.default', [])->andReturnSelf();
+ $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf();
+
+ $cssWithMedia = 'body { color: #000; } @media (prefers-color-scheme: dark) { body { color: #fff; } } .footer { color: gray; }';
+ $viewFactory->shouldReceive('render')->twice()->andReturn($cssWithMedia, '');
+
+ $markdown->render('view', []);
+
+ $headStyles = Markdown::getHeadStyles();
+
+ $this->assertStringContainsString('@media (prefers-color-scheme: dark)', $headStyles);
+ $this->assertStringContainsString('body { color: #fff; }', $headStyles);
+ }
+
+ public function testMediaQueriesAreNotInlinedIntoHtml(): void
+ {
+ $viewFactory = m::mock(Factory::class);
+ $engineResolver = m::mock(EngineResolver::class);
+ $bladeCompiler = m::mock(BladeCompiler::class);
+ $viewFactory->shouldReceive('getEngineResolver')->andReturn($engineResolver);
+ $engineResolver->shouldReceive('resolve->getCompiler')->andReturn($bladeCompiler);
+ $bladeCompiler->shouldReceive('usingEchoFormat')
+ ->with('new \Illuminate\Support\EncodedHtmlString(%s)', m::type('Closure'))
+ ->andReturnUsing(fn ($echoFormat, $callback) => $callback());
+
+ $markdown = new Markdown($viewFactory);
+ $viewFactory->shouldReceive('flushFinderCache')->once();
+ $viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf();
+ $viewFactory->shouldReceive('exists')->with('mail.default')->andReturn(false);
+ $viewFactory->shouldReceive('make')->with('mail::themes.default', [])->andReturnSelf();
+ $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf();
+
+ $cssWithMedia = 'body { color: #000; } @media (prefers-color-scheme: dark) { body { color: #fff; } }';
+ $viewFactory->shouldReceive('render')->twice()->andReturn($cssWithMedia, '');
+
+ $result = $markdown->render('view', []);
+
+ $this->assertStringContainsString('color: #000', $result);
+ $this->assertStringNotContainsString('@media', $result);
+ }
+
+ public function testFlushStateClearsHeadStyles(): void
+ {
+ $viewFactory = m::mock(Factory::class);
+ $engineResolver = m::mock(EngineResolver::class);
+ $bladeCompiler = m::mock(BladeCompiler::class);
+ $viewFactory->shouldReceive('getEngineResolver')->andReturn($engineResolver);
+ $engineResolver->shouldReceive('resolve->getCompiler')->andReturn($bladeCompiler);
+ $bladeCompiler->shouldReceive('usingEchoFormat')
+ ->with('new \Illuminate\Support\EncodedHtmlString(%s)', m::type('Closure'))
+ ->andReturnUsing(fn ($echoFormat, $callback) => $callback());
+
+ $markdown = new Markdown($viewFactory);
+ $viewFactory->shouldReceive('flushFinderCache')->once();
+ $viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf();
+ $viewFactory->shouldReceive('exists')->with('mail.default')->andReturn(false);
+ $viewFactory->shouldReceive('make')->with('mail::themes.default', [])->andReturnSelf();
+ $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf();
+
+ $cssWithMedia = '@media (prefers-color-scheme: dark) { body { color: #fff; } }';
+ $viewFactory->shouldReceive('render')->twice()->andReturn($cssWithMedia, '');
+
+ $markdown->render('view', []);
+
+ $this->assertNotEmpty(Markdown::getHeadStyles());
+
+ Markdown::flushState();
+
+ $this->assertEmpty(Markdown::getHeadStyles());
+ }
+
+ public function testNestedMediaQueriesAreExtractedCorrectly(): void
+ {
+ $viewFactory = m::mock(Factory::class);
+ $engineResolver = m::mock(EngineResolver::class);
+ $bladeCompiler = m::mock(BladeCompiler::class);
+ $viewFactory->shouldReceive('getEngineResolver')->andReturn($engineResolver);
+ $engineResolver->shouldReceive('resolve->getCompiler')->andReturn($bladeCompiler);
+ $bladeCompiler->shouldReceive('usingEchoFormat')
+ ->with('new \Illuminate\Support\EncodedHtmlString(%s)', m::type('Closure'))
+ ->andReturnUsing(fn ($echoFormat, $callback) => $callback());
+
+ $markdown = new Markdown($viewFactory);
+ $viewFactory->shouldReceive('flushFinderCache')->once();
+ $viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf();
+ $viewFactory->shouldReceive('exists')->with('mail.default')->andReturn(false);
+ $viewFactory->shouldReceive('make')->with('mail::themes.default', [])->andReturnSelf();
+ $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf();
+
+ $cssWithMedia = '.base { color: red; } @media (max-width: 600px) { .inner { width: 100%; } } @media (prefers-color-scheme: dark) { body { background: #000; } .panel { background: #333; } } .end { margin: 0; }';
+ $viewFactory->shouldReceive('render')->twice()->andReturn($cssWithMedia, '');
+
+ $markdown->render('view', []);
+
+ $headStyles = Markdown::getHeadStyles();
+
+ $this->assertStringContainsString('@media (max-width: 600px)', $headStyles);
+ $this->assertStringContainsString('@media (prefers-color-scheme: dark)', $headStyles);
+ $this->assertStringContainsString('.inner { width: 100%; }', $headStyles);
+ $this->assertStringContainsString('body { background: #000; }', $headStyles);
+ }
}