Problem/Motivation

Sending a message that has a file attachment fails. The SendGrid API rejects the request with a 400 and this response:

{ "errors": [ { "message": "If present, text/plain and text/html may only be provided once.", "field": "content" } ] }

The message is never queued, so nothing is delivered.

SendGridMail::mail() builds the request by reading the message's Content-Type header. When the type is multipart/mixed, which is what Mime Mail produces whenever a message carries an attachment, it splits the body on the boundary and loops over every part. For each part it tests whether the text "text/plain" or "text/html" appears anywhere in that part, and if it does, adds the part to the request as content:

  foreach ($body_parts as $body_part) {
    if (strpos($body_part, 'multipart/alternative')) {
      // Splits the nested part and adds text/plain and text/html from it.
    }
    else {
      if (strpos($body_part, 'text/plain')) {
        $sendgrid_message->addContent('text/plain', ...);
      }
      elseif (strpos($body_part, 'text/html')) {
        $sendgrid_message->addContent('text/html', ...);
      }
    }
  }

The test is a plain substring search across the whole part, including its headers and its encoded body, and the loop visits attachment parts as well as message parts. Nothing prevents the same content type being added twice, and the SendGrid API accepts at most one part of each type.

Attachments are added separately, from $message['params']['attachments'], so any content the body parser matches after the first of a given type is surplus regardless of where it came from.

This is present on 8.x-2.3 and on the current 8.x-2.x branch.

Steps to reproduce

1. Install and configure Mail System with Mime Mail as the formatter and SendGrid Integration as the sender.
2. Send any message that includes a file attachment, passed in $message['params']['attachments'].
3. The send fails. /admin/reports/dblog records a sendgrid_integration error containing "Sending emails to Sendgrid service failed with error code 400" and a second entry with the API response quoted above.

Sending the same message without an attachment succeeds, because Mime Mail then produces multipart/alternative or text/html rather than multipart/mixed, and those take different branches that cannot match more than once.

Proposed resolution

Add each content type at most once, keeping the first match.

Introduce a small helper, addContentOnce(), and route all eleven text/plain and text/html additions in SendGridMail::mail() through it, with a local array tracking which types have already been added.

First match wins is the correct precedence: Mime Mail places the multipart/alternative block holding the real message body ahead of the attachment parts, so the genuine content is the one used.

This deliberately does not change how the parser decides which parts are message content and which are attachments. That would be a larger change with more scope to alter behaviour for other senders, and it is not necessary to fix this: because attachments are added from $message['params']['attachments'], anything the body parser matches beyond the first of a type is surplus by definition.

A merge request against 8.x-2.x will follow.

Remaining tasks

- Open a merge request against 8.x-2.x
- Review
- Confirm on a live send with an attachment
- Consider whether a test covering a multipart/mixed message with an attachment is wanted

User interface changes

None.

API changes

Adds one protected method, SendGridMail::addContentOnce(). No existing method signature or behavior changes for callers. Subclasses overriding mail() are unaffected.

Data model changes

None.

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

kbrinner created an issue. See original summary.

kbrinner’s picture

Opened an MR against 8.x-2.x.

It adds a small addContentOnce() helper and routes the eleven text/plain and text/html additions in SendGridMail::mail() through it, keeping a local array of what's already been added. First match wins, which works out right because Mime Mail puts the multipart/alternative block with the actual message body ahead of the attachment parts.

On coding standards: the file already has 35 phpcs findings against Drupal and DrupalPractice. This doesn't add any, and I've left the existing ones alone rather than blow up the diff.

I haven't confirmed it against a real send yet. We hit this on a site when sending broadcast emails with attachments (emails work fine without attachments), and I'll report back here once it's been through an actual SendGrid send on our environment.

kbrinner’s picture

Status: Active » Needs review

kbrinner’s picture

I hit this on a site running sendgrid_integration 8.x-2.3 with Drupal 10.5.10 on PHP 8.1. Any email with a file attached failed to send. The SendGrid API rejects the request because the payload declares the same content type more than once, which is not allowed in v3.

It happens because addContent() is called from several branches in SendGridMail::mail() depending on whether the message has a plain text part, an HTML part, or both, and with attachments more than one of those branches runs. The API then gets two text/plain entries, or a text/plain and a text/html plus a repeat, and returns a 400.

The change adds a small addContentOnce() helper that tracks which content types have already been added and skips repeats. All 12 call sites go through it. Nothing else changes, and messages without attachments build exactly the same payload as before.

I have tested this against the live SendGrid API rather than a mail catcher, since the failure only appears in the API response. Emails with attachments now send and arrive with the attachment intact, and emails without attachments are unaffected. It has been running on our staging environment through a QA cycle.

Happy to adjust the approach if you would rather solve it differently, for example by restructuring the branching so only one path adds content. The helper seemed like the smaller change.

We are in the middle of a Drupal 11 upgrade, so this will also run on Drupal 10.6 and PHP 8.4 shortly. I will come back and confirm here once it has been tested there. The change is in payload construction rather than anything version specific, so I would not expect a difference, but I will report either way.