first commit

This commit is contained in:
2025-07-18 16:20:14 +07:00
commit 98af45c018
16382 changed files with 3148096 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bridge\Twig\Mime;
use League\HTMLToMarkdown\HtmlConverterInterface;
use Symfony\Component\Mime\BodyRendererInterface;
use Symfony\Component\Mime\Exception\InvalidArgumentException;
use Symfony\Component\Mime\HtmlToTextConverter\DefaultHtmlToTextConverter;
use Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface;
use Symfony\Component\Mime\HtmlToTextConverter\LeagueHtmlToMarkdownConverter;
use Symfony\Component\Mime\Message;
use Symfony\Component\Translation\LocaleSwitcher;
use Twig\Environment;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
final class BodyRenderer implements BodyRendererInterface
{
private Environment $twig;
private array $context;
private HtmlToTextConverterInterface $converter;
private ?LocaleSwitcher $localeSwitcher = null;
public function __construct(Environment $twig, array $context = [], ?HtmlToTextConverterInterface $converter = null, ?LocaleSwitcher $localeSwitcher = null)
{
$this->twig = $twig;
$this->context = $context;
$this->converter = $converter ?: (interface_exists(HtmlConverterInterface::class) ? new LeagueHtmlToMarkdownConverter() : new DefaultHtmlToTextConverter());
$this->localeSwitcher = $localeSwitcher;
}
public function render(Message $message): void
{
if (!$message instanceof TemplatedEmail) {
return;
}
if (null === $message->getTextTemplate() && null === $message->getHtmlTemplate()) {
// email has already been rendered
return;
}
$callback = function () use ($message) {
$messageContext = $message->getContext();
if (isset($messageContext['email'])) {
throw new InvalidArgumentException(sprintf('A "%s" context cannot have an "email" entry as this is a reserved variable.', get_debug_type($message)));
}
$vars = array_merge($this->context, $messageContext, [
'email' => new WrappedTemplatedEmail($this->twig, $message),
]);
if ($template = $message->getTextTemplate()) {
$message->text($this->twig->render($template, $vars));
}
if ($template = $message->getHtmlTemplate()) {
$message->html($this->twig->render($template, $vars));
}
$message->markAsRendered();
// if text body is empty, compute one from the HTML body
if (!$message->getTextBody() && null !== $html = $message->getHtmlBody()) {
$text = $this->converter->convert(\is_resource($html) ? stream_get_contents($html) : $html, $message->getHtmlCharset());
$message->text($text, $message->getHtmlCharset());
}
};
$locale = $message->getLocale();
if ($locale && $this->localeSwitcher) {
$this->localeSwitcher->runWithLocale($locale, $callback);
return;
}
$callback();
}
}

View File

@@ -0,0 +1,280 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bridge\Twig\Mime;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
use Symfony\Component\Mime\Header\Headers;
use Symfony\Component\Mime\Part\AbstractPart;
use Symfony\Component\Mime\Part\DataPart;
use Twig\Extra\CssInliner\CssInlinerExtension;
use Twig\Extra\Inky\InkyExtension;
use Twig\Extra\Markdown\MarkdownExtension;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class NotificationEmail extends TemplatedEmail
{
public const IMPORTANCE_URGENT = 'urgent';
public const IMPORTANCE_HIGH = 'high';
public const IMPORTANCE_MEDIUM = 'medium';
public const IMPORTANCE_LOW = 'low';
private string $theme = 'default';
private array $context = [
'importance' => self::IMPORTANCE_LOW,
'content' => '',
'exception' => false,
'action_text' => null,
'action_url' => null,
'markdown' => false,
'raw' => false,
'footer_text' => 'Notification email sent by Symfony',
];
private bool $rendered = false;
public function __construct(?Headers $headers = null, ?AbstractPart $body = null)
{
$missingPackages = [];
if (!class_exists(CssInlinerExtension::class)) {
$missingPackages['twig/cssinliner-extra'] = 'CSS Inliner';
}
if (!class_exists(InkyExtension::class)) {
$missingPackages['twig/inky-extra'] = 'Inky';
}
if ($missingPackages) {
throw new \LogicException(sprintf('You cannot use "%s" if the "%s" Twig extension%s not available. Try running "%s".', static::class, implode('" and "', $missingPackages), \count($missingPackages) > 1 ? 's are' : ' is', 'composer require '.implode(' ', array_keys($missingPackages))));
}
parent::__construct($headers, $body);
}
/**
* Creates a NotificationEmail instance that is appropriate to send to normal (non-admin) users.
*/
public static function asPublicEmail(?Headers $headers = null, ?AbstractPart $body = null): self
{
$email = new static($headers, $body);
$email->markAsPublic();
return $email;
}
/**
* @return $this
*/
public function markAsPublic(): static
{
$this->context['importance'] = null;
$this->context['footer_text'] = null;
return $this;
}
/**
* @return $this
*/
public function markdown(string $content): static
{
if (!class_exists(MarkdownExtension::class)) {
throw new \LogicException(sprintf('You cannot use "%s" if the Markdown Twig extension is not available. Try running "composer require twig/markdown-extra".', __METHOD__));
}
$this->context['markdown'] = true;
return $this->content($content);
}
/**
* @return $this
*/
public function content(string $content, bool $raw = false): static
{
$this->context['content'] = $content;
$this->context['raw'] = $raw;
return $this;
}
/**
* @return $this
*/
public function action(string $text, string $url): static
{
$this->context['action_text'] = $text;
$this->context['action_url'] = $url;
return $this;
}
/**
* @return $this
*/
public function importance(string $importance): static
{
$this->context['importance'] = $importance;
return $this;
}
/**
* @return $this
*/
public function exception(\Throwable|FlattenException $exception): static
{
$exceptionAsString = $this->getExceptionAsString($exception);
$this->context['exception'] = true;
$this->addPart(new DataPart($exceptionAsString, 'exception.txt', 'text/plain'));
$this->importance(self::IMPORTANCE_URGENT);
if (!$this->getSubject()) {
$this->subject($exception->getMessage());
}
return $this;
}
/**
* @return $this
*/
public function theme(string $theme): static
{
$this->theme = $theme;
return $this;
}
public function getTextTemplate(): ?string
{
if ($template = parent::getTextTemplate()) {
return $template;
}
return '@email/'.$this->theme.'/notification/body.txt.twig';
}
public function getHtmlTemplate(): ?string
{
if ($template = parent::getHtmlTemplate()) {
return $template;
}
return '@email/'.$this->theme.'/notification/body.html.twig';
}
/**
* @return $this
*/
public function context(array $context): static
{
$parentContext = [];
foreach ($context as $key => $value) {
if (\array_key_exists($key, $this->context)) {
$this->context[$key] = $value;
} else {
$parentContext[$key] = $value;
}
}
parent::context($parentContext);
return $this;
}
public function getContext(): array
{
return array_merge($this->context, parent::getContext());
}
public function isRendered(): bool
{
return $this->rendered;
}
public function markAsRendered(): void
{
parent::markAsRendered();
$this->rendered = true;
}
public function getPreparedHeaders(): Headers
{
$headers = parent::getPreparedHeaders();
$importance = $this->context['importance'] ?? self::IMPORTANCE_LOW;
$this->priority($this->determinePriority($importance));
if ($this->context['importance']) {
$headers->setHeaderBody('Text', 'Subject', sprintf('[%s] %s', strtoupper($importance), $this->getSubject()));
}
return $headers;
}
private function determinePriority(string $importance): int
{
return match ($importance) {
self::IMPORTANCE_URGENT => self::PRIORITY_HIGHEST,
self::IMPORTANCE_HIGH => self::PRIORITY_HIGH,
self::IMPORTANCE_MEDIUM => self::PRIORITY_NORMAL,
default => self::PRIORITY_LOW,
};
}
private function getExceptionAsString(\Throwable|FlattenException $exception): string
{
if (class_exists(FlattenException::class)) {
$exception = $exception instanceof FlattenException ? $exception : FlattenException::createFromThrowable($exception);
return $exception->getAsString();
}
$message = $exception::class;
if ('' !== $exception->getMessage()) {
$message .= ': '.$exception->getMessage();
}
$message .= ' in '.$exception->getFile().':'.$exception->getLine()."\n";
$message .= "Stack trace:\n".$exception->getTraceAsString()."\n\n";
return rtrim($message);
}
/**
* @internal
*/
public function __serialize(): array
{
return [$this->context, $this->theme, $this->rendered, parent::__serialize()];
}
/**
* @internal
*/
public function __unserialize(array $data): void
{
if (4 === \count($data)) {
[$this->context, $this->theme, $this->rendered, $parentData] = $data;
} elseif (3 === \count($data)) {
[$this->context, $this->theme, $parentData] = $data;
} else {
// Backwards compatibility for deserializing data structures that were serialized without the theme
[$this->context, $parentData] = $data;
}
parent::__unserialize($parentData);
}
}

View File

@@ -0,0 +1,116 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bridge\Twig\Mime;
use Symfony\Component\Mime\Email;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class TemplatedEmail extends Email
{
private ?string $htmlTemplate = null;
private ?string $textTemplate = null;
private ?string $locale = null;
private array $context = [];
/**
* @return $this
*/
public function textTemplate(?string $template): static
{
$this->textTemplate = $template;
return $this;
}
/**
* @return $this
*/
public function htmlTemplate(?string $template): static
{
$this->htmlTemplate = $template;
return $this;
}
/**
* @return $this
*/
public function locale(?string $locale): static
{
$this->locale = $locale;
return $this;
}
public function getTextTemplate(): ?string
{
return $this->textTemplate;
}
public function getHtmlTemplate(): ?string
{
return $this->htmlTemplate;
}
public function getLocale(): ?string
{
return $this->locale;
}
/**
* @return $this
*/
public function context(array $context): static
{
$this->context = $context;
return $this;
}
public function getContext(): array
{
return $this->context;
}
public function isRendered(): bool
{
return null === $this->htmlTemplate && null === $this->textTemplate;
}
public function markAsRendered(): void
{
$this->textTemplate = null;
$this->htmlTemplate = null;
$this->context = [];
}
/**
* @internal
*/
public function __serialize(): array
{
return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize(), $this->locale];
}
/**
* @internal
*/
public function __unserialize(array $data): void
{
[$this->htmlTemplate, $this->textTemplate, $this->context, $parentData] = $data;
$this->locale = $data[4] ?? null;
parent::__unserialize($parentData);
}
}

View File

@@ -0,0 +1,203 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bridge\Twig\Mime;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\File;
use Twig\Environment;
/**
* @internal
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class WrappedTemplatedEmail
{
private Environment $twig;
private TemplatedEmail $message;
public function __construct(Environment $twig, TemplatedEmail $message)
{
$this->twig = $twig;
$this->message = $message;
}
public function toName(): string
{
return $this->message->getTo()[0]->getName();
}
/**
* @param string $image A Twig path to the image file. It's recommended to define
* some Twig namespace for email images (e.g. '@email/images/logo.png').
* @param string|null $contentType The media type (i.e. MIME type) of the image file (e.g. 'image/png').
* Some email clients require this to display embedded images.
*/
public function image(string $image, ?string $contentType = null): string
{
$file = $this->twig->getLoader()->getSourceContext($image);
$body = $file->getPath() ? new File($file->getPath()) : $file->getCode();
$this->message->addPart((new DataPart($body, $image, $contentType))->asInline());
return 'cid:'.$image;
}
/**
* @param string $file A Twig path to the file. It's recommended to define
* some Twig namespace for email files (e.g. '@email/files/contract.pdf').
* @param string|null $name A custom file name that overrides the original name of the attached file
* @param string|null $contentType The media type (i.e. MIME type) of the file (e.g. 'application/pdf').
* Some email clients require this to display attached files.
*/
public function attach(string $file, ?string $name = null, ?string $contentType = null): void
{
$file = $this->twig->getLoader()->getSourceContext($file);
$body = $file->getPath() ? new File($file->getPath()) : $file->getCode();
$this->message->addPart(new DataPart($body, $name, $contentType));
}
/**
* @return $this
*/
public function setSubject(string $subject): static
{
$this->message->subject($subject);
return $this;
}
public function getSubject(): ?string
{
return $this->message->getSubject();
}
/**
* @return $this
*/
public function setReturnPath(string $address): static
{
$this->message->returnPath($address);
return $this;
}
public function getReturnPath(): string
{
return $this->message->getReturnPath();
}
/**
* @return $this
*/
public function addFrom(string $address, string $name = ''): static
{
$this->message->addFrom(new Address($address, $name));
return $this;
}
/**
* @return Address[]
*/
public function getFrom(): array
{
return $this->message->getFrom();
}
/**
* @return $this
*/
public function addReplyTo(string $address): static
{
$this->message->addReplyTo($address);
return $this;
}
/**
* @return Address[]
*/
public function getReplyTo(): array
{
return $this->message->getReplyTo();
}
/**
* @return $this
*/
public function addTo(string $address, string $name = ''): static
{
$this->message->addTo(new Address($address, $name));
return $this;
}
/**
* @return Address[]
*/
public function getTo(): array
{
return $this->message->getTo();
}
/**
* @return $this
*/
public function addCc(string $address, string $name = ''): static
{
$this->message->addCc(new Address($address, $name));
return $this;
}
/**
* @return Address[]
*/
public function getCc(): array
{
return $this->message->getCc();
}
/**
* @return $this
*/
public function addBcc(string $address, string $name = ''): static
{
$this->message->addBcc(new Address($address, $name));
return $this;
}
/**
* @return Address[]
*/
public function getBcc(): array
{
return $this->message->getBcc();
}
/**
* @return $this
*/
public function setPriority(int $priority): static
{
$this->message->priority($priority);
return $this;
}
public function getPriority(): int
{
return $this->message->getPriority();
}
}