Skip to content

[Mime] [Email] Add encoders to text and html parts #54196

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: 7.4
Choose a base branch
from

Conversation

kumulo
Copy link

@kumulo kumulo commented Mar 7, 2024

Q A
Branch 7.1
Bug fix no
New feature yes
Deprecations no
Issues n/a
License MIT

Add encoders to text and html parts of Email object.

$email = new Email();
$email->text('My text content', 'utf-8', 'base64');
$email->html('<div>My HTML content</div>', 'utf-8', 'base64');

Each part of the sent mail will be encoded in Base 64 with defined charset in Content-Type header and base64 in Content-Transfer-Encoding header.

@carsonbot carsonbot added this to the 7.1 milestone Mar 7, 2024
@carsonbot carsonbot changed the title [Mime][Email] Add encoders to text and html parts [Mime] [Email] Add encoders to text and html parts Mar 7, 2024
@jprivet-dev
Copy link

jprivet-dev commented Mar 7, 2024

Hi @kumulo, and thank you for your PR! 😄
It's an excellent idea, but we risk having limits as it is.

I created a controller as in the example at https://symfony.com/doc/current/mailer.html#creating-sending-messages:

class MailerController extends AbstractController
{
    #[Route('/text_html', name: 'text_html')]
    public function textHtml(MailerInterface $mailer): Response
    {
        $email = (new Email())
            ->from('hello@example.com')
            ->to('you@example.com')
            ->subject('Time for Symfony Mailer!')
            ->text('Sending emails is fun again!', 'utf-8', 'base64')  // <-- encoding option !
            ->html('<p>See Twig integration for better HTML integration!</p>', 'utf-8', 'base64');   // <-- encoding option !

        $mailer->send($email);

        return new Response('text_html');
    }
}

It's good! In the Symfony Profiler, I can see the following raw message (with base64 encoding):

From: hello@example.com
To: you@example.com
Subject: Time for Symfony Mailer!
MIME-Version: 1.0
Date: Thu, 07 Mar 2024 21:14:48 +0000
Message-ID: <0e85489e793ddbe547fd9cd7509ef72f@example.com>
Content-Type: multipart/alternative; boundary=RxGlMsDY

--RxGlMsDY
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: base64

U2VuZGluZyBlbWFpbHMgaXMgZnVuIGFnYWluIQ== // <-- base64 encoding !!!
--RxGlMsDY
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: base64

PHA+U2VlIFR3aWcgaW50ZWdyYXRpb24gZm9yIGJldHRlciBIVE1MIGludGVncmF0aW9uITwvcD4=  // <-- base64 encoding !!!
--RxGlMsDY--

But, if I want to use TemplatedEmail() as in the example at https://symfony.com/doc/current/mailer.html#html-content, it's not possible to specify base64 encoding:

class MailerController extends AbstractController
{
    #[Route('/templated_email', name: 'templated_email')]
    public function templatedEmail(MailerInterface $mailer): Response
    {
        $email = (new TemplatedEmail())
            ->from('fabien@example.com')
            ->to(new Address('ryan@example.com'))
            ->subject('Thanks for signing up!')
            ->htmlTemplate('emails/signup.html.twig') // <-- no encoding option
            ->locale('de')
            ->context([
                'expiration_date' => new \DateTime('+7 days'),
                'username' => 'foo',
            ]);

        $mailer->send($email);

        return new Response('templated_email');
    }
}

In this case, rendering is performed in this following method BodyRenderer::render() :

 //src/Symfony/Bridge/Twig/Mime/BodyRenderer.php
            ...
            if ($template = $message->getTextTemplate()) {
                $message->text($this->twig->render($template, $vars)); // <-- no encoding option
            }

            if ($template = $message->getHtmlTemplate()) {
                $message->html($this->twig->render($template, $vars)); // <-- no encoding option
            }
            ...

Another little detail in Email(): text, textCharset, html and htmlCharset properties have accessors. If we follow this formalism, we can create accessors fortextEncoding and htmlEncoding. For Example:

// src/Symfony/Component/Mime/Email.php
    ...
    public function getHtmlEncoding(): ?string
    {
        return $this->htmlEncoding;
    }
    ...

I haven't yet found all the places where the base64 encoding option would be needed.

What are your thoughts on this?
Do you have the time and opportunity to take your idea further? 😀

@kumulo
Copy link
Author

kumulo commented Mar 8, 2024

Hi @jprivet-dev , thanks for you feedback !

But, if I want to use TemplatedEmail() as in the example at https://symfony.com/doc/current/mailer.html#html-content, it's not possible to specify base64 encoding:

class MailerController extends AbstractController
{
    #[Route('/templated_email', name: 'templated_email')]
    public function templatedEmail(MailerInterface $mailer): Response
    {
        $email = (new TemplatedEmail())
            ->from('fabien@example.com')
            ->to(new Address('ryan@example.com'))
            ->subject('Thanks for signing up!')
            ->htmlTemplate('emails/signup.html.twig') // <-- no encoding option
            ->locale('de')
            ->context([
                'expiration_date' => new \DateTime('+7 days'),
                'username' => 'foo',
            ]);

        $mailer->send($email);

        return new Response('templated_email');
    }
}

In this case, rendering is performed in this following method BodyRenderer::render() :

 //src/Symfony/Bridge/Twig/Mime/BodyRenderer.php
            ...
            if ($template = $message->getTextTemplate()) {
                $message->text($this->twig->render($template, $vars)); // <-- no encoding option
            }

            if ($template = $message->getHtmlTemplate()) {
                $message->html($this->twig->render($template, $vars)); // <-- no encoding option
            }
            ...

Body rendering is very far from TemplatedEmail creation, so I've made some properties setters, you will be able to do this :

class MailerController extends AbstractController
{
    #[Route('/templated_email', name: 'templated_email')]
    public function templatedEmail(MailerInterface $mailer): Response
    {
        $email = (new TemplatedEmail())
            ->from('fabien@example.com')
            ->to(new Address('ryan@example.com'))
            ->subject('Thanks for signing up!')
            ->htmlTemplate('emails/signup.html.twig')
            ->htmlEncoding('base64') // <= Encoding option :)
            ->locale('de')
            ->context([
                'expiration_date' => new \DateTime('+7 days'),
                'username' => 'foo',
            ]);

        $mailer->send($email);

        return new Response('templated_email');
    }
}

Another little detail in Email(): text, textCharset, html and htmlCharset properties have accessors. If we follow this formalism, we can create accessors fortextEncoding and htmlEncoding. For Example:

// src/Symfony/Component/Mime/Email.php
    ...
    public function getHtmlEncoding(): ?string
    {
        return $this->htmlEncoding;
    }
    ...

Properties accessors are there now :)

@smnandre
Copy link
Member

smnandre commented Mar 8, 2024

Each part of the sent mail will be encoded in Base 64 with defined charset in Content-Type header and base64 in Content-Transfer-Encoding header.

I'm not sure to understand thewill be encoded in Base 64 .

Where is this done / by "who" ?

@kumulo
Copy link
Author

kumulo commented Mar 8, 2024

Each part of the sent mail will be encoded in Base 64 with defined charset in Content-Type header and base64 in Content-Transfer-Encoding header.

I'm not sure to understand thewill be encoded in Base 64 .

Where is this done / by "who" ?

Hi @smnandre,
It's to encode text and html parts of the Email and TemplatedEmail.

Without encoding (by default), raw message is :

From: hello@example.com
To: you@example.com
Subject: Time for Symfony Mailer!
MIME-Version: 1.0
Date: Thu, 07 Mar 2024 21:14:48 +0000
Message-ID: <0e85489e793ddbe547fd9cd7509ef72f@example.com>
Content-Type: multipart/alternative; boundary=RxGlMsDY

--RxGlMsDY
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable

Sending emails is fun again!
--RxGlMsDY
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable

<p>See Twig integration for better HTML integration!</p>
--RxGlMsDY--

With encoding it could be :

From: hello@example.com
To: you@example.com
Subject: Time for Symfony Mailer!
MIME-Version: 1.0
Date: Thu, 07 Mar 2024 21:14:48 +0000
Message-ID: <0e85489e793ddbe547fd9cd7509ef72f@example.com>
Content-Type: multipart/alternative; boundary=RxGlMsDY

--RxGlMsDY
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: base64

U2VuZGluZyBlbWFpbHMgaXMgZnVuIGFnYWluIQ== // <-- base64 encoding !!!
--RxGlMsDY
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: base64

PHA+U2VlIFR3aWcgaW50ZWdyYXRpb24gZm9yIGJldHRlciBIVE1MIGludGVncmF0aW9uITwvcD4=  // <-- base64 encoding !!!
--RxGlMsDY--

Those two parts are TextPart objects, TextPart allows you to set the encoding of the part :

class TextPart extends AbstractPart
{
    public function __construct($body, ?string $charset = 'utf-8', string $subtype = 'plain', ?string $encoding = null)
    {
        //...
    }
}

For Email object, those parts are constructed by generateBody method for text and prepareParts method for html.
Email class allows you to modify charset of parts but not encoding, this PR adds this possibility.

@jprivet-dev
Copy link

jprivet-dev commented Mar 8, 2024

Stop all! It's all so unnecessary! 😄

After some investigation and a lot of help from @smnandre, it turns out that the base64 encoding option is already available in TextPart::chooseEncoding(), if the charset is null :

    private function chooseEncoding(): string
    {
        if (null === $this->charset) {
            return 'base64';
        }

        return 'quoted-printable';
    }

And that's where the problem lies: you can't get a null charset using Email::text() or Email::html() :

class Email extends Message
{
    ...
    private ?string $textCharset = null; // <-- textCharset can be null
    private ?string $htmlCharset = null; // <-- htmlCharset can be null
    ...

    public function text($body, string $charset = 'utf-8'): static
    {
        ...
        $this->textCharset = $charset; // <-- Impossible to set textCharset to null: default value is 'utf-8'
        ...
    }
    
    public function html($body, string $charset = 'utf-8'): static 
    {
        ...
        $this->htmlCharset = $charset; // <-- Impossible to set htmlCharset to null: default value is 'utf-8'
        ...
    }
}

In this context, the following code:

class MailerController extends AbstractController
{
    #[Route('/']
    public function textHtml(MailerInterface $mailer): Response
    {
        $email = (new Email())
            ->from('hello@example.com')
            ->to('you@example.com')
            ->subject('Time for Symfony Mailer!')
            ->text('Sending emails is fun again!', null) // <-- Set charset to null to access base64 encoding
            ->html('<p>See Twig integration for better HTML integration!</p>', null); // <-- Set charset to null to access base64 encoding

        $mailer->send($email);

        return new Response('text_html');
    }
    
}

Returns errors :

Symfony\Component\Mime\Email::text(): Argument #2 ($charset) must be of type string, null given
Symfony\Component\Mime\Email::html(): Argument #2 ($charset) must be of type string, null given

If the following changes are made:

class Email extends Message
{
    public function text($body, ?string $charset = 'utf-8'): static // <-- add "?"
    {
        ...
    }
    
    public function html($body, ?string $charset = 'utf-8'): static // <-- add "?"
    {
        ...
    }
}

Then the code in the controller will be able to run in base64 encoding 🥳 :

From: hello@example.com
To: you@example.com
Subject: Time for Symfony Mailer!
MIME-Version: 1.0
Date: Fri, 08 Mar 2024 17:27:28 +0000
Message-ID: <9b270dfaf5fa259606fae37ecc0acecd@example.com>
Content-Type: multipart/alternative; boundary=htGvt9zJ

--htGvt9zJ
Content-Type: text/plain
Content-Transfer-Encoding: base64

U2VuZGluZyBlbWFpbHMgaXMgZnVuIGFnYWluIQ== // <-- base64 encoding
--htGvt9zJ
Content-Type: text/html
Content-Transfer-Encoding: base64

PHA+U2VlIFR3aWcgaW50ZWdyYXRpb24gZm9yIGJldHRlciBIVE1MIGludGVncmF0aW9uITwvcD4= // <-- base64 encoding
--htGvt9zJ--

However, the problem remains the same with TemplatedEmail(): how to indicate the use of base64 encoding?

class MailerController extends AbstractController
{    
   #[Route('/')]
    public function templatedEmail(MailerInterface $mailer): Response
    {
        $email = (new TemplatedEmail())
            ->from('fabien@example.com')
            ->to(new Address('ryan@example.com'))
            ->subject('Thanks for signing up!')
            ->htmlTemplate('emails/signup.html.twig') // <-- no base64 encoding option
            ->locale('de')
            ->context([
                'expiration_date' => new \DateTime('+7 days'),
                'username' => 'foo',
            ]);

        $mailer->send($email);

        return new Response('templated_email');
    }
}

Solution under investigation...

@kumulo
Copy link
Author

kumulo commented Mar 8, 2024

That was my first investigation but, IMO, charset and encoding are 2 different things and encoded content can be iso or utf.
And indeed, in this case, accentuated characters (like éàè) are broken at display because utf8 is not specified.

@smnandre
Copy link
Member

smnandre commented Mar 8, 2024

It's to encode text and html parts of the Email and TemplatedEmail.

Does the charset represent the "current" charset ?

Here, $email->getEncoding() would returns 'base64' .. even if the content is not yet base64 encoded, right ?

So how would you do if someone wanted to pass some already base64-encoded text (genuine question, not saying this is a every-day-scenario either) ?

@jprivet-dev
Copy link

Rapid test with accentuated characters:

    #[Route('/')]
    public function textHtml(MailerInterface $mailer): Response
    {
        $email = (new Email())
            ->from('hello@example.com')
            ->to('you@example.com')
            ->subject('Time for Symfony Mailer!')
            ->text('éàèéàèéàèéàèéàèéàè', null)
            ->html('<p>éàèéàèéàèéàèéàèéàè</p>', null);

        $mailer->send($email);

        return new Response('text_html');
    }

Results:

From: hello@example.com
To: you@example.com
Subject: Time for Symfony Mailer!
MIME-Version: 1.0
Date: Fri, 08 Mar 2024 22:22:29 +0000
Message-ID: <e36a379cd34fa138a402e55fdccf160b@example.com>
Content-Type: multipart/alternative; boundary=M0Ye1N1G

--M0Ye1N1G
Content-Type: text/plain
Content-Transfer-Encoding: base64

w6nDoMOow6nDoMOow6nDoMOow6nDoMOow6nDoMOow6nDoMOo
--M0Ye1N1G
Content-Type: text/html
Content-Transfer-Encoding: base64

PHA+w6nDoMOow6nDoMOow6nDoMOow6nDoMOow6nDoMOow6nDoMOoPC9wPg==
--M0Ye1N1G--

Screenshot:

image

Everything looks good 🤔

  • However, I don't know in what context (Windows?) would an iso charset be necessary?
  • Have you had problems encoding accented characters?

@kumulo
Copy link
Author

kumulo commented Mar 11, 2024

For me, charset can depends of the part :
https://www.rfc-editor.org/rfc/rfc9110#section-8.3.2

Or maybe I misunderstood something in the RFC.

@jprivet-dev in your last exemple, is argument $charset can really be null ?

public function text($body, string $charset = 'utf-8'): static

Maybe signature has to be update as :

public function text($body, ?string $charset = 'utf-8'): static

@kumulo
Copy link
Author

kumulo commented Mar 11, 2024

And I think, charset reading depends of client :
image

image

@jprivet-dev
Copy link

For me, charset can depends of the part : https://www.rfc-editor.org/rfc/rfc9110#section-8.3.2

Or maybe I misunderstood something in the RFC.

Perhaps this is a subject that should be explored in greater depth to get closer to the RFC?

@jprivet-dev in your last exemple, is argument $charset can really be null ?

public function text($body, string $charset = 'utf-8'): static

Maybe signature has to be update as :

public function text($body, ?string $charset = 'utf-8'): static

Yes, that's exactly what I was suggesting and testing in my previous message 😄 :

class Email extends Message
{
    public function text($body, ?string $charset = 'utf-8'): static // <-- add "?"
    {
        ...
    }
    
    public function html($body, ?string $charset = 'utf-8'): static // <-- add "?"
    {
        ...
    }
}

However, the problem remains the same with TemplatedEmail(): how to indicate the use of base64 encoding?

@kumulo
Copy link
Author

kumulo commented Mar 15, 2024

However, the problem remains the same with TemplatedEmail(): how to indicate the use of base64 encoding?

I have add textEncoding() and htmlEncoding() methods in Email parent class, to have encoding setters and be able to do it with TemplatedEmail

@xabbuh xabbuh added the Feature label May 15, 2024
@xabbuh xabbuh modified the milestones: 7.1, 7.2 May 15, 2024
@fabpot fabpot modified the milestones: 7.2, 7.3 Nov 20, 2024
@fabpot fabpot modified the milestones: 7.3, 7.4 May 26, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants