wneessen / go-mail

📧 Easy to use, yet comprehensive library for sending mails with Go
https://go-mail.dev
MIT License
567 stars 44 forks source link

Bulk email example is not working as expected #251

Open stefansaasen opened 2 months ago

stefansaasen commented 2 months ago

Description

I believe the example on https://go-mail.dev/examples/bulk-mailer/ does not yield the expected result when it comes to alternative content (HTML vs. plain text).

Note: This might be entirely due to my expectations. But it might be beneficial to be explicit in the documentation.

The following snippet is used:

        if err := m.SetBodyHTMLTemplate(htpl, u); err != nil {
            log.Fatalf("failed to set HTML template as HTML body: %s", err)
        }
        if err := m.AddAlternativeTextTemplate(ttpl, u); err != nil {
            log.Fatalf("failed to set text template as alternative body: %s", err)
        }

This sets HTML content and an alternative text content. The naming of the method suggests (at least to me) that the HTML content is the desired content and the text content an alternative (aka fallback content).

At least in Gmail (likely in other clients) the behavior is such, that the plain text content is shown and not the HTML content. This is likely due to the behavior outlined in https://www.rfc-editor.org/rfc/rfc2046#section-5.1.4

Systems should recognize that the content of the various parts are interchangeable. Systems should choose the "best" type based on the local environment and references, in some cases even through user interaction. As with "multipart/mixed", the order of body parts is significant. In this case, the alternatives appear in an order of increasing faithfulness to the original content.
In general, the best choice is the LAST part of a type supported by the recipient system's local environment.

(emphasis mine)

The raw content of the email generated with the code above is:

Content-Type: multipart/alternative; boundary=c7f2352434561d81b8798f234e495dab32a27e11fbaa6046bc23782ff994

--c7f2352434561d81b8798f234e495dab32a27e11fbaa6046bc23782ff994
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=UTF-8

<p>Hi Toni,</p>
<p>we are writing your to let you know that this week we have an amazing of=
fer for you.
…

--c7f2352434561d81b8798f234e495dab32a27e11fbaa6046bc23782ff994
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=UTF-8

Hi Toni,

we are writing your to let you know that this week we have an amazing offer=
 for you.

…

--c7f2352434561d81b8798f234e495dab32a27e11fbaa6046bc23782ff994--

I.e. the plain text part is the last part and thus chosen by Gmail as the content to show.

Given the naming of the methods, one might assume (at least I did) that the preferred content is the HTML content and the text content is the secondary or alternative content.

To Reproduce

  1. Use the example from https://go-mail.dev/examples/bulk-mailer/ and send an email to a Gmail address.
  2. Use the Gmail web client to view the email

Expected behaviour

The email uses the HTML content and only shows the plain text content if HTML cannot be displayed.

Screenshots

Actual email show by Gmail:

image

Desired email shown by Gmail:

image

Attempted Fixes

Swapping the order of calls causes this example to only send the HTML part:

        if err := m.AddAlternativeTextTemplate(ttpl, u); err != nil {
            log.Fatalf("failed to set text template as alternative body: %s", err)
        }
        if err := m.SetBodyHTMLTemplate(htpl, u); err != nil {
            log.Fatalf("failed to set HTML template as HTML body: %s", err)
        }

This produces an email with the following content:

Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable

<p>Hi Toni,</p>
<p>we are writing your to let you know that this week we have an amazing of=
fer for you.
Using the coupon code "<strong>GOMAIL</strong>" you will get a 20% discount=
 on all=20
our products in our online shop.</p>
<p>Check out our latest offer on <a href=3D"https://acme.com" target=3D"_bl=
ank">https://acme.com</a>
and use your discount code today!</p>
<p>Your marketing team<br />
&nbsp;&nbsp;at ACME Inc.</p>

I.e. the text part is entirely missing.

Coincidentally, the following yields the desired result:

    if err := msg.EmbedFromEmbedFS("templates/logo.png", &content); err != nil {
        return err
    }
    msg.AddAlternativeString(mail.TypeTextPlain, bodyText)
    // This needs to come last - see above
    msg.SetBodyString(mail.TypeTextHTML, bodyHTML)

this leads to an email with the following structure:

Content-Type: multipart/related;
boundary=2ec5bf9a105a79805adb55c2a7b60f212984379b2ad4b3a290cad3899f3f

--2ec5bf9a105a79805adb55c2a7b60f212984379b2ad4b3a290cad3899f3f <- multipart/related boundary
Content-Type: multipart/alternative;
boundary=d588d0fdd90dde9ec046a67417962a8e745fe05b9d463b20d52b80159858

--d588d0fdd90dde9ec046a67417962a8e745fe05b9d463b20d52b80159858 <- multipart/alternative boundary
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=UTF-8

--d588d0fdd90dde9ec046a67417962a8e745fe05b9d463b20d52b80159858 <- multipart/alternative boundary
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=UTF-8

--d588d0fdd90dde9ec046a67417962a8e745fe05b9d463b20d52b80159858-- <- multipart/alternative boundary END

--2ec5bf9a105a79805adb55c2a7b60f212984379b2ad4b3a290cad3899f3f <- multipart/related boundary
Content-Disposition: inline; filename="logo.png"
Content-Id: <logo.png>
Content-Transfer-Encoding: base64
Content-Type: image/png; name="logo.png"

--2ec5bf9a105a79805adb55c2a7b60f212984379b2ad4b3a290cad3899f3f-- <- multipart/related boundary END

I.e. it's an email with a multipart/related and multipart/alternative body with the HTML email content being last (and therefore chosen by Gmail).

But this only works if I embed the image, removing the msg.EmbedFromEmbedFS call, leads to the same behavior describer above where the email content is just the HTML content.

Setting the HTML as the alternative content

The following yields the desired result (with or without the embedded image):

    msg.SetBodyString(mail.TypeTextPlain, bodyText)
        // This needs to come last
    msg.AddAlternativeString(mail.TypeTextHTML, bodyHTML)

Here the body is set to text/plain and the alternative to text/html with the latter being chosen by Gmail.

Ultimately this largely boils down to semantics and the meaning of alternativeString but to some degree also to the order of calling the methods. To me the desired behaviour would be for the body string to always come last for it to be chosen as the most appropriate format and for alternative strings to be added in order of the method execution. But simply calling out the behaviour in the documentation might be sufficient.

Additional context

No response

wneessen commented 2 months ago

Thanks for the detailed report @stefansaasen. I'll have a deeper look into it soon.

wneessen commented 2 months ago

Sorry for the late response @stefansaasen. Again thanks for the detailed report, that's really valuable information. I agree that we should respect the RFC and the behaviour you described, yet changing this might break some already rolled solutions, so we need to be careful on how to implement this properly.

As quick "fix" I will update the example on the go-mail website and switch the calls so that they produce the expected output. As long term solution I think it's best to rewrite the logic in the msgwriter code to always follow the correct order and produce results that follow the RFC. That way it's unlikely we break any existing code out there. I'll link the branch to this ticket once I've started work on it.

stefansaasen commented 2 months ago

Thanks for your response. Sounds like a good plan!