161

When creating email messages you are supposed to set the Content-Type to multipart/alternative when sending HTML and TEXT or multipart/mixed when sending TEXT and attachments.

So what do you do if you want to send HTML, Text, and attachments? Use both?

guettli
  • 26,461
  • 53
  • 224
  • 476
Xeoncross
  • 50,836
  • 73
  • 238
  • 351
  • 2
    I'm not sure what the 'correct' way to do this is. I've certainly seen mp/alt messages that had a mp/text part and a mp/mixed part containing HTML and attachment ... but that meant that the attachment was only visible when viewing HTML not when viewing TEXT so it 'smells' wrong. You could try mp/mixed with an mp/alt part containing both message formats and a second part to contain the attachment, but I don't know what clients would make of it. – dajames Oct 14 '10 at 11:45
  • @Iain Your answer is very special for being the only one to contain the (very weird) structure that gmail expects. I'll award bounty to it. – PascalVKooten Jun 19 '15 at 18:54
  • Here is a nice ascii art: http://stackoverflow.com/a/40420648/633961 – guettli Nov 04 '16 at 10:52

9 Answers9

161

I hit this challenge today and I found these answers useful but not quite explicit enough for me.

Edit: Just found the Apache Commons Email that wraps this up nicely, meaning you don't need to know below.

If your requirement is an email with:

  1. text and html versions
  2. html version has embedded (inline) images
  3. attachments

The only structure I found that works with Gmail/Outlook/iPad is:

  • mixed
    • alternative
      • text
      • related
        • html
        • inline image
        • inline image
    • attachment
    • attachment

And the code is:

import javax.activation.DataHandler;
import javax.activation.DataSource;
import javax.activation.URLDataSource;
import javax.mail.BodyPart;
import javax.mail.MessagingException;
import javax.mail.Multipart;
import javax.mail.internet.MimeBodyPart;
import javax.mail.internet.MimeMultipart;
import java.net.URL;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Created by StrongMan on 25/05/14.
 */
public class MailContentBuilder {

    private static final Pattern COMPILED_PATTERN_SRC_URL_SINGLE = Pattern.compile("src='([^']*)'",  Pattern.CASE_INSENSITIVE);
    private static final Pattern COMPILED_PATTERN_SRC_URL_DOUBLE = Pattern.compile("src=\"([^\"]*)\"",  Pattern.CASE_INSENSITIVE);

    /**
     * Build an email message.
     *
     * The HTML may reference the embedded image (messageHtmlInline) using the filename. Any path portion is ignored to make my life easier
     * e.g. If you pass in the image C:\Temp\dog.jpg you can use <img src="dog.jpg"/> or <img src="C:\Temp\dog.jpg"/> and both will work
     *
     * @param messageText
     * @param messageHtml
     * @param messageHtmlInline
     * @param attachments
     * @return
     * @throws MessagingException
     */
    public Multipart build(String messageText, String messageHtml, List<URL> messageHtmlInline, List<URL> attachments) throws MessagingException {
        final Multipart mpMixed = new MimeMultipart("mixed");
        {
            // alternative
            final Multipart mpMixedAlternative = newChild(mpMixed, "alternative");
            {
                // Note: MUST RENDER HTML LAST otherwise iPad mail client only renders the last image and no email
                addTextVersion(mpMixedAlternative,messageText);
                addHtmlVersion(mpMixedAlternative,messageHtml, messageHtmlInline);
            }
            // attachments
            addAttachments(mpMixed,attachments);
        }

        //msg.setText(message, "utf-8");
        //msg.setContent(message,"text/html; charset=utf-8");
        return mpMixed;
    }

    private Multipart newChild(Multipart parent, String alternative) throws MessagingException {
        MimeMultipart child =  new MimeMultipart(alternative);
        final MimeBodyPart mbp = new MimeBodyPart();
        parent.addBodyPart(mbp);
        mbp.setContent(child);
        return child;
    }

    private void addTextVersion(Multipart mpRelatedAlternative, String messageText) throws MessagingException {
        final MimeBodyPart textPart = new MimeBodyPart();
        textPart.setContent(messageText, "text/plain");
        mpRelatedAlternative.addBodyPart(textPart);
    }

    private void addHtmlVersion(Multipart parent, String messageHtml, List<URL> embeded) throws MessagingException {
        // HTML version
        final Multipart mpRelated = newChild(parent,"related");

        // Html
        final MimeBodyPart htmlPart = new MimeBodyPart();
        HashMap<String,String> cids = new HashMap<String, String>();
        htmlPart.setContent(replaceUrlWithCids(messageHtml,cids), "text/html");
        mpRelated.addBodyPart(htmlPart);

        // Inline images
        addImagesInline(mpRelated, embeded, cids);
    }

    private void addImagesInline(Multipart parent, List<URL> embeded, HashMap<String,String> cids) throws MessagingException {
        if (embeded != null)
        {
            for (URL img : embeded)
            {
                final MimeBodyPart htmlPartImg = new MimeBodyPart();
                DataSource htmlPartImgDs = new URLDataSource(img);
                htmlPartImg.setDataHandler(new DataHandler(htmlPartImgDs));
                String fileName = img.getFile();
                fileName = getFileName(fileName);
                String newFileName = cids.get(fileName);
                boolean imageNotReferencedInHtml = newFileName == null;
                if (imageNotReferencedInHtml) continue;
                // Gmail requires the cid have <> around it
                htmlPartImg.setHeader("Content-ID", "<"+newFileName+">");
                htmlPartImg.setDisposition(BodyPart.INLINE);
                parent.addBodyPart(htmlPartImg);
            }
        }
    }

    private void addAttachments(Multipart parent, List<URL> attachments) throws MessagingException {
        if (attachments != null)
        {
            for (URL attachment : attachments)
            {
                final MimeBodyPart mbpAttachment = new MimeBodyPart();
                DataSource htmlPartImgDs = new URLDataSource(attachment);
                mbpAttachment.setDataHandler(new DataHandler(htmlPartImgDs));
                String fileName = attachment.getFile();
                fileName = getFileName(fileName);
                mbpAttachment.setDisposition(BodyPart.ATTACHMENT);
                mbpAttachment.setFileName(fileName);
                parent.addBodyPart(mbpAttachment);
            }
        }
    }

    public String replaceUrlWithCids(String html, HashMap<String,String> cids)
    {
        html = replaceUrlWithCids(html, COMPILED_PATTERN_SRC_URL_SINGLE, "src='cid:@cid'", cids);
        html = replaceUrlWithCids(html, COMPILED_PATTERN_SRC_URL_DOUBLE, "src=\"cid:@cid\"", cids);
        return html;
    }

    private String replaceUrlWithCids(String html, Pattern pattern, String replacement, HashMap<String,String> cids) {
        Matcher matcherCssUrl = pattern.matcher(html);
        StringBuffer sb = new StringBuffer();
        while (matcherCssUrl.find())
        {
            String fileName = matcherCssUrl.group(1);
            // Disregarding file path, so don't clash your filenames!
            fileName = getFileName(fileName);
            // A cid must start with @ and be globally unique
            String cid = "@" + UUID.randomUUID().toString() + "_" + fileName;
            if (cids.containsKey(fileName))
                cid = cids.get(fileName);
            else
                cids.put(fileName,cid);
            matcherCssUrl.appendReplacement(sb,replacement.replace("@cid",cid));
        }
        matcherCssUrl.appendTail(sb);
        html = sb.toString();
        return html;
    }

    private String getFileName(String fileName) {
        if (fileName.contains("/"))
            fileName = fileName.substring(fileName.lastIndexOf("/")+1);
        return fileName;
    }
}

And an example of using it with from Gmail

/**
 * Created by StrongMan on 25/05/14.
 */
import com.sun.mail.smtp.SMTPTransport;

import java.net.URL;
import java.security.Security;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.activation.DataHandler;
import javax.activation.DataSource;
import javax.activation.URLDataSource;
import javax.mail.*;
import javax.mail.internet.*;

/**
 *
 * http://stackoverflow.com/questions/14744197/best-practices-sending-javamail-mime-multipart-emails-and-gmail
 * http://stackoverflow.com/questions/3902455/smtp-multipart-alternative-vs-multipart-mixed
 *
 *
 *
 * @author doraemon
 */
public class GoogleMail {


    private GoogleMail() {
    }

    /**
     * Send email using GMail SMTP server.
     *
     * @param username GMail username
     * @param password GMail password
     * @param recipientEmail TO recipient
     * @param title title of the message
     * @param messageText message to be sent
     * @throws AddressException if the email address parse failed
     * @throws MessagingException if the connection is dead or not in the connected state or if the message is not a MimeMessage
     */
    public static void Send(final String username, final String password, String recipientEmail, String title, String messageText, String messageHtml, List<URL> messageHtmlInline, List<URL> attachments) throws AddressException, MessagingException {
        GoogleMail.Send(username, password, recipientEmail, "", title, messageText, messageHtml, messageHtmlInline,attachments);
    }

    /**
     * Send email using GMail SMTP server.
     *
     * @param username GMail username
     * @param password GMail password
     * @param recipientEmail TO recipient
     * @param ccEmail CC recipient. Can be empty if there is no CC recipient
     * @param title title of the message
     * @param messageText message to be sent
     * @throws AddressException if the email address parse failed
     * @throws MessagingException if the connection is dead or not in the connected state or if the message is not a MimeMessage
     */
    public static void Send(final String username, final String password, String recipientEmail, String ccEmail, String title, String messageText, String messageHtml, List<URL> messageHtmlInline, List<URL> attachments) throws AddressException, MessagingException {
        Security.addProvider(new com.sun.net.ssl.internal.ssl.Provider());
        final String SSL_FACTORY = "javax.net.ssl.SSLSocketFactory";

        // Get a Properties object
        Properties props = System.getProperties();
        props.setProperty("mail.smtps.host", "smtp.gmail.com");
        props.setProperty("mail.smtp.socketFactory.class", SSL_FACTORY);
        props.setProperty("mail.smtp.socketFactory.fallback", "false");
        props.setProperty("mail.smtp.port", "465");
        props.setProperty("mail.smtp.socketFactory.port", "465");
        props.setProperty("mail.smtps.auth", "true");

        /*
        If set to false, the QUIT command is sent and the connection is immediately closed. If set
        to true (the default), causes the transport to wait for the response to the QUIT command.

        ref :   http://java.sun.com/products/javamail/javadocs/com/sun/mail/smtp/package-summary.html
                http://forum.java.sun.com/thread.jspa?threadID=5205249
                smtpsend.java - demo program from javamail
        */
        props.put("mail.smtps.quitwait", "false");

        Session session = Session.getInstance(props, null);

        // -- Create a new message --
        final MimeMessage msg = new MimeMessage(session);

        // -- Set the FROM and TO fields --
        msg.setFrom(new InternetAddress(username + "@gmail.com"));
        msg.setRecipients(Message.RecipientType.TO, InternetAddress.parse(recipientEmail, false));

        if (ccEmail.length() > 0) {
            msg.setRecipients(Message.RecipientType.CC, InternetAddress.parse(ccEmail, false));
        }

        msg.setSubject(title);

        // mixed
        MailContentBuilder mailContentBuilder = new MailContentBuilder();
        final Multipart mpMixed = mailContentBuilder.build(messageText, messageHtml, messageHtmlInline, attachments);
        msg.setContent(mpMixed);
        msg.setSentDate(new Date());

        SMTPTransport t = (SMTPTransport)session.getTransport("smtps");

        t.connect("smtp.gmail.com", username, password);
        t.sendMessage(msg, msg.getAllRecipients());
        t.close();
    }

}
Pang
  • 8,605
  • 144
  • 77
  • 113
Iain
  • 10,034
  • 3
  • 33
  • 29
  • Super helpful. Many clients will correctly interpret various MIME hierarchies, but lain's is the only one I've found that works everywhere (including a bunch of mobile mail apps!). – BringMyCakeBack Jul 01 '14 at 21:27
  • 1
    Could someone comment on how to do this in PHP? – RightHandedMonkey Feb 27 '15 at 20:11
  • 1
    @RightHandedMonkey You might like to ask this as a new question, I cannot speak for php. – Iain Feb 27 '15 at 20:57
  • Hi Iain I followed this structure and this code. Everywhere it is working fine but when email is went to iPhone in Iphone App for mail . In iphone attachments is not shown. Ex. iphone 6. Can you help me with this issue ? – pankaj Jun 09 '16 at 12:53
  • @pankaj The bottom answer by XDevOne suggests some adaptions, and mentions it works on iPhone. I'm not sure if the above code works on iPhone, or only after the adaptions. Best of luck – Iain Aug 14 '16 at 00:29
  • Alternative seems not to be working on Yahoo web client at the moment. So i just stuck the attachment on the mixed, worked fine. – JaredT Aug 17 '16 at 12:41
  • 1
    Thank you for the superb answer @Iain! I'm having a hard time trying to get the right MIME structure for my case, in which I attempt to add an HTML part 'prefix' to the email's body; but some clients get empty body with no attachments, some get the body only in the attachments (empty body) (Outlook on Windows), and some work well (GMail web, Android App, etc.). Please take a look if possible: https://stackoverflow.com/questions/47312409/adding-an-html-prefix-to-email-mime-causes-some-clients-to-show-empty-body – shachar0n Nov 15 '17 at 16:27
  • This answer is legendary. Solved my problem with embedded images being broken in Thunderbird. – cen Feb 14 '19 at 16:53
  • 1
    Amazing answer. Helped fix my issue with not knowing how to send an HTML and text version of an email in one. – dinukadev Dec 17 '19 at 04:01
  • Your Content-IDs violates RFC 2045, section 7 and RFC 5322, section 3.6.4. – Michael-O Mar 17 '20 at 09:50
  • 1
    It is not in form of `""`. – Michael-O Mar 28 '20 at 09:33
119

Use multipart/mixed with the first part as multipart/alternative and subsequent parts for the attachments. In turn, use text/plain and text/html parts within the multipart/alternative part.

A capable email client should then recognise the multipart/alternative part and display the text part or html part as necessary. It should also show all of the subsequent parts as attachment parts.

The important thing to note here is that, in multipart MIME messages, it is perfectly valid to have parts within parts. In theory, that nesting can extend to any depth. Any reasonably capable email client should then be able to recursively process all of the message parts.

MikeMurko
  • 2,039
  • 26
  • 52
Mike Green
  • 2,001
  • 2
  • 18
  • 16
  • 20
    Don't forget to order the subparts of your `multipart/alternative` correctly. The last entry is the best/highest priority part, so you probably want to put the `text/html` part as the last subpart. Per [RFC1341](https://tools.ietf.org/html/rfc1341). – Luna Jun 04 '15 at 11:36
  • 4
    What about `multipart/related`, and when to use this? – Wilt Sep 21 '15 at 09:20
  • 4
    @Wilt: `multipart/alternative` denotes that only one of the included parts should be displayed - e.g. one part is `text/plain` and one part is `text/html`. So the email client should not display both parts but only one. i.e They are *not* related. `multipart/related` indicates that the various subparts, are all part of the main root part, e.g. the main part is `text/html` and the subparts are embedded images. See [here](http://www.indyproject.org/Sockets/Blogs/RLebeau/2005_08_17_A.EN.aspx) for more info. – RaelB Jun 14 '17 at 19:09
  • Just a quick tip - If you have access to a *nix box, you can use the `mutt` CLI client to verify that you've set up your multipart MIME messages correctly. If you press `v` while viewing a message, it will display and allow traversal of the nested tree of MIME parts. – rinogo Jun 11 '18 at 20:33
25

Messages have content. Content can be text, html, a DataHandler or a Multipart, and there can only be one content. Multiparts only have BodyParts but can have more than one. BodyParts, like Messages, can have content which has already been described.

A message with HTML, text and an a attachment can be viewed hierarchically like this:

message
  mainMultipart (content for message, subType="mixed")
    ->htmlAndTextBodyPart (bodyPart1 for mainMultipart)
      ->htmlAndTextMultipart (content for htmlAndTextBodyPart, subType="alternative")
        ->textBodyPart (bodyPart2 for the htmlAndTextMultipart)
          ->text (content for textBodyPart)
        ->htmlBodyPart (bodyPart1 for htmlAndTextMultipart)
          ->html (content for htmlBodyPart)
    ->fileBodyPart1 (bodyPart2 for the mainMultipart)
      ->FileDataHandler (content for fileBodyPart1 )

And the code to build such a message:

    // the parent or main part if you will
    Multipart mainMultipart = new MimeMultipart("mixed");

    // this will hold text and html and tells the client there are 2 versions of the message (html and text). presumably text
    // being the alternative to html
    Multipart htmlAndTextMultipart = new MimeMultipart("alternative");

    // set text
    MimeBodyPart textBodyPart = new MimeBodyPart();
    textBodyPart.setText(text);
    htmlAndTextMultipart.addBodyPart(textBodyPart);

    // set html (set this last per rfc1341 which states last = best)
    MimeBodyPart htmlBodyPart = new MimeBodyPart();
    htmlBodyPart.setContent(html, "text/html; charset=utf-8");
    htmlAndTextMultipart.addBodyPart(htmlBodyPart);

    // stuff the multipart into a bodypart and add the bodyPart to the mainMultipart
    MimeBodyPart htmlAndTextBodyPart = new MimeBodyPart();
    htmlAndTextBodyPart.setContent(htmlAndTextMultipart);
    mainMultipart.addBodyPart(htmlAndTextBodyPart);

    // attach file body parts directly to the mainMultipart
    MimeBodyPart filePart = new MimeBodyPart();
    FileDataSource fds = new FileDataSource("/path/to/some/file.txt");
    filePart.setDataHandler(new DataHandler(fds));
    filePart.setFileName(fds.getName());
    mainMultipart.addBodyPart(filePart);

    // set message content
    message.setContent(mainMultipart);
splashout
  • 400
  • 3
  • 10
12

I hit this issue. This architecture (from Lain's answer) worked for me. Here is the solution in Python.

  • mixed
    • alternative
      • text
      • related
        • html
        • inline image
        • inline image
    • attachment
    • attachment

Here is the main email creation function:

def create_message_with_attachment(
    sender, to, subject, msgHtml, msgPlain, attachmentFile):
    """Create a message for an email.

    Args:
      sender: Email address of the sender.
      to: Email address of the receiver.
      subject: The subject of the email message.
      message_text: The text of the email message.
      file: The path to the file to be attached.

    Returns:
      An object containing a base64url encoded email object.
    """
    message = MIMEMultipart('mixed')
    message['to'] = to
    message['from'] = sender
    message['subject'] = subject

    message_alternative = MIMEMultipart('alternative')
    message_related = MIMEMultipart('related')

    message_related.attach(MIMEText(msgHtml, 'html'))
    message_alternative.attach(MIMEText(msgPlain, 'plain'))
    message_alternative.attach(message_related)

    message.attach(message_alternative)

    print "create_message_with_attachment: file:", attachmentFile
    content_type, encoding = mimetypes.guess_type(attachmentFile)

    if content_type is None or encoding is not None:
        content_type = 'application/octet-stream'
    main_type, sub_type = content_type.split('/', 1)
    if main_type == 'text':
        fp = open(attachmentFile, 'rb')
        msg = MIMEText(fp.read(), _subtype=sub_type)
        fp.close()
    elif main_type == 'image':
        fp = open(attachmentFile, 'rb')
        msg = MIMEImage(fp.read(), _subtype=sub_type)
        fp.close()
    elif main_type == 'audio':
        fp = open(attachmentFile, 'rb')
        msg = MIMEAudio(fp.read(), _subtype=sub_type)
        fp.close()
    else:
        fp = open(attachmentFile, 'rb')
        msg = MIMEBase(main_type, sub_type)
        msg.set_payload(fp.read())
        fp.close()
    filename = os.path.basename(attachmentFile)
    msg.add_header('Content-Disposition', 'attachment', filename=filename)
    message.attach(msg)

    return {'raw': base64.urlsafe_b64encode(message.as_string())}

Here is the full code for sending an email containing html/text/attachment:

import httplib2
import os
import oauth2client
from oauth2client import client, tools
import base64
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from apiclient import errors, discovery
import mimetypes
from email.mime.image import MIMEImage
from email.mime.audio import MIMEAudio
from email.mime.base import MIMEBase

SCOPES = 'https://www.googleapis.com/auth/gmail.send'
CLIENT_SECRET_FILE1 = 'client_secret.json'
location = os.path.realpath(
    os.path.join(os.getcwd(), os.path.dirname(__file__)))
CLIENT_SECRET_FILE = os.path.join(location, CLIENT_SECRET_FILE1)
APPLICATION_NAME = 'Gmail API Python Send Email'

def get_credentials():
    home_dir = os.path.expanduser('~')
    credential_dir = os.path.join(home_dir, '.credentials')
    if not os.path.exists(credential_dir):
        os.makedirs(credential_dir)
    credential_path = os.path.join(credential_dir,
                                   'gmail-python-email-send.json')
    store = oauth2client.file.Storage(credential_path)
    credentials = store.get()
    if not credentials or credentials.invalid:
        flow = client.flow_from_clientsecrets(CLIENT_SECRET_FILE, SCOPES)
        flow.user_agent = APPLICATION_NAME
        credentials = tools.run_flow(flow, store)
        print 'Storing credentials to ' + credential_path
    return credentials

def SendMessageWithAttachment(sender, to, subject, msgHtml, msgPlain, attachmentFile):
    credentials = get_credentials()
    http = credentials.authorize(httplib2.Http())
    service = discovery.build('gmail', 'v1', http=http)
    message1 = create_message_with_attachment(sender, to, subject, msgHtml, msgPlain, attachmentFile)
    SendMessageInternal(service, "me", message1)

def SendMessageInternal(service, user_id, message):
    try:
        message = (service.users().messages().send(userId=user_id, body=message).execute())
        print 'Message Id: %s' % message['id']
        return message
    except errors.HttpError, error:
        print 'An error occurred: %s' % error
        return "error"

def create_message_with_attachment(
    sender, to, subject, msgHtml, msgPlain, attachmentFile):
    """Create a message for an email.

    Args:
      sender: Email address of the sender.
      to: Email address of the receiver.
      subject: The subject of the email message.
      message_text: The text of the email message.
      file: The path to the file to be attached.

    Returns:
      An object containing a base64url encoded email object.
    """
    message = MIMEMultipart('mixed')
    message['to'] = to
    message['from'] = sender
    message['subject'] = subject

    message_alternative = MIMEMultipart('alternative')
    message_related = MIMEMultipart('related')

    message_related.attach(MIMEText(msgHtml, 'html'))
    message_alternative.attach(MIMEText(msgPlain, 'plain'))
    message_alternative.attach(message_related)

    message.attach(message_alternative)

    print "create_message_with_attachment: file:", attachmentFile
    content_type, encoding = mimetypes.guess_type(attachmentFile)

    if content_type is None or encoding is not None:
        content_type = 'application/octet-stream'
    main_type, sub_type = content_type.split('/', 1)
    if main_type == 'text':
        fp = open(attachmentFile, 'rb')
        msg = MIMEText(fp.read(), _subtype=sub_type)
        fp.close()
    elif main_type == 'image':
        fp = open(attachmentFile, 'rb')
        msg = MIMEImage(fp.read(), _subtype=sub_type)
        fp.close()
    elif main_type == 'audio':
        fp = open(attachmentFile, 'rb')
        msg = MIMEAudio(fp.read(), _subtype=sub_type)
        fp.close()
    else:
        fp = open(attachmentFile, 'rb')
        msg = MIMEBase(main_type, sub_type)
        msg.set_payload(fp.read())
        fp.close()
    filename = os.path.basename(attachmentFile)
    msg.add_header('Content-Disposition', 'attachment', filename=filename)
    message.attach(msg)

    return {'raw': base64.urlsafe_b64encode(message.as_string())}


def main():
    to = "to@address.com"
    sender = "from@address.com"
    subject = "subject"
    msgHtml = "Hi<br/>Html Email"
    msgPlain = "Hi\nPlain Email"
    attachment = "/path/to/file.pdf"
    SendMessageWithAttachment(sender, to, subject, msgHtml, msgPlain, attachment)

if __name__ == '__main__':
    main()
apadana
  • 9,926
  • 7
  • 65
  • 88
7

Here is the best: Multipart/mixed mime message with attachments and inline images

And image: https://www.qcode.co.uk/images/mime-nesting-structure.png

From: from@qcode.co.uk
To: to@@qcode.co.uk
Subject: Example Email
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="MixedBoundaryString"

--MixedBoundaryString
Content-Type: multipart/related; boundary="RelatedBoundaryString"

--RelatedBoundaryString
Content-Type: multipart/alternative; boundary="AlternativeBoundaryString"

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

This is the plain text part of the email.

--AlternativeBoundaryString
Content-Type: text/html;charset="utf-8"
Content-Transfer-Encoding: quoted-printable

<html>
  <body>=0D
    <img src=3D=22cid:masthead.png=40qcode.co.uk=22 width 800 height=3D80=
 =5C>=0D
    <p>This is the html part of the email.</p>=0D
    <img src=3D=22cid:logo.png=40qcode.co.uk=22 width 200 height=3D60 =5C=
>=0D
  </body>=0D
</html>=0D

--AlternativeBoundaryString--

--RelatedBoundaryString
Content-Type: image/jpgeg;name="logo.png"
Content-Transfer-Encoding: base64
Content-Disposition: inline;filename="logo.png"
Content-ID: <logo.png@qcode.co.uk>

amtsb2hiaXVvbHJueXZzNXQ2XHVmdGd5d2VoYmFmaGpremxidTh2b2hydHVqd255aHVpbnRyZnhu
dWkgb2l1b3NydGhpdXRvZ2hqdWlyb2h5dWd0aXJlaHN1aWhndXNpaHhidnVqZmtkeG5qaG5iZ3Vy
...
...
a25qbW9nNXRwbF0nemVycHpvemlnc3k5aDZqcm9wdHo7amlodDhpOTA4N3U5Nnkwb2tqMm9sd3An
LGZ2cDBbZWRzcm85eWo1Zmtsc2xrZ3g=

--RelatedBoundaryString
Content-Type: image/jpgeg;name="masthead.png"
Content-Transfer-Encoding: base64
Content-Disposition: inline;filename="masthead.png"
Content-ID: <masthead.png@qcode.co.uk>

aXR4ZGh5Yjd1OHk3MzQ4eXFndzhpYW9wO2tibHB6c2tqOTgwNXE0aW9qYWJ6aXBqOTBpcjl2MC1t
dGlmOTA0cW05dGkwbWk0OXQwYVttaXZvcnBhXGtsbGo7emt2c2pkZnI7Z2lwb2F1amdpNTh1NDlh
...
...
eXN6dWdoeXhiNzhuZzdnaHQ3eW9zemlqb2FqZWt0cmZ1eXZnamhka3JmdDg3aXV2dWd5aGVidXdz
dhyuhehe76YTGSFGA=

--RelatedBoundaryString--

--MixedBoundaryString
Content-Type: application/pdf;name="Invoice_1.pdf"
Content-Transfer-Encoding: base64
Content-Disposition: attachment;filename="Invoice_1.pdf"

aGZqZGtsZ3poZHVpeWZoemd2dXNoamRibngganZodWpyYWRuIHVqO0hmSjtyRVVPIEZSO05SVURF
SEx1aWhudWpoZ3h1XGh1c2loZWRma25kamlsXHpodXZpZmhkcnVsaGpnZmtsaGVqZ2xod2plZmdq
...
...
a2psajY1ZWxqanNveHV5ZXJ3NTQzYXRnZnJhZXdhcmV0eXRia2xhanNueXVpNjRvNWllc3l1c2lw
dWg4NTA0

--MixedBoundaryString
Content-Type: application/pdf;name="SpecialOffer.pdf"
Content-Transfer-Encoding: base64
Content-Disposition: attachment;filename="SpecialOffer.pdf"

aXBvY21odWl0dnI1dWk4OXdzNHU5NTgwcDN3YTt1OTQwc3U4NTk1dTg0dTV5OGlncHE1dW4zOTgw
cS0zNHU4NTk0eWI4OTcwdjg5MHE4cHV0O3BvYTt6dWI7dWlvenZ1em9pdW51dDlvdTg5YnE4N3Z3
...
...
OTViOHk5cDV3dTh5bnB3dWZ2OHQ5dTh2cHVpO2p2Ymd1eTg5MGg3ajY4bjZ2ODl1ZGlvcjQ1amts
dfnhgjdfihn=

--MixedBoundaryString--

.

Schema multipart/related/alternative

Header
|From: email
|To: email
|MIME-Version: 1.0
|Content-Type: multipart/mixed; boundary="boundary1";
Message body
|multipart/mixed --boundary1
|--boundary1
|   multipart/related --boundary2
|   |--boundary2
|   |   multipart/alternative --boundary3
|   |   |--boundary3
|   |   |text/plain
|   |   |--boundary3
|   |   |text/html
|   |   |--boundary3--
|   |--boundary2    
|   |Inline image
|   |--boundary2    
|   |Inline image
|   |--boundary2--
|--boundary1    
|Attachment1
|--boundary1
|Attachment2
|--boundary1
|Attachment3
|--boundary1--
|
.
  • 1
    Links to external resources are encouraged, but please add context around the link so your fellow users will have some idea what it is and why it’s there. Always quote the most relevant part of an important link, in case the target site is unreachable or goes permanently offline. See [how to answer](https://stackoverflow.com/help/how-to-answer) – SilverNak Sep 23 '17 at 10:08
  • This answer doesn't work for me. Only the first attachment is actually sent... – Jim Dec 06 '19 at 21:40
  • The links are dead – hestellezg Sep 02 '20 at 15:31
  • How did you generate the mime mail (the first code snippet)? – a_sid Sep 03 '20 at 23:32
5

Building on Iain's example, I had a similar need to compose these emails with separate plaintext, HTML and multiple attachments, but using PHP. Since we are using Amazon SES to send emails with attachments, the API currently requires you to build the email from scratch using the sendRawEmail(...) function.

After much investigation (and greater than normal frustration), the problem was solved and the PHP source code posted so that it may help others experiencing a similar problem. Hope this help someone out - the troop of monkeys I forced to work on this problem are now exhausted.

PHP Source Code for sending emails with attachments using Amazon SES.

<?php

require_once('AWSSDKforPHP/aws.phar');

use Aws\Ses\SesClient;

/**
 * SESUtils is a tool to make it easier to work with Amazon Simple Email Service
 * Features:
 * A client to prepare emails for use with sending attachments or not
 * 
 * There is no warranty - use this code at your own risk.  
 * @author sbossen with assistance from Michael Deal
 * http://righthandedmonkey.com
 *
 * Update: Error checking and new params input array provided by Michael Deal
 * Update2: Corrected for allowing to send multiple attachments and plain text/html body
 *   Ref: Http://stackoverflow.com/questions/3902455/smtp-multipart-alternative-vs-multipart-mixed/
 */
class SESUtils {

    const version = "1.0";
    const AWS_KEY = "YOUR-KEY";
    const AWS_SEC = "YOUR-SECRET";
    const AWS_REGION = "us-east-1";
    const MAX_ATTACHMENT_NAME_LEN = 60;

    /**
     * Usage:
        $params = array(
          "to" => "email1@gmail.com",
          "subject" => "Some subject",
          "message" => "<strong>Some email body</strong>",
          "from" => "sender@verifiedbyaws",
          //OPTIONAL
          "replyTo" => "reply_to@gmail.com",
          //OPTIONAL
          "files" => array(
            1 => array(
               "name" => "filename1", 
              "filepath" => "/path/to/file1.txt", 
              "mime" => "application/octet-stream"
            ),
            2 => array(
               "name" => "filename2", 
              "filepath" => "/path/to/file2.txt", 
              "mime" => "application/octet-stream"
            ),
          )
        );

      $res = SESUtils::sendMail($params);

     * NOTE: When sending a single file, omit the key (ie. the '1 =>') 
     * or use 0 => array(...) - otherwise the file will come out garbled
     * ie. use:
     *    "files" => array(
     *        0 => array( "name" => "filename", "filepath" => "path/to/file.txt",
     *        "mime" => "application/octet-stream")
     * 
     * For the 'to' parameter, you can send multiple recipiants with an array
     *    "to" => array("email1@gmail.com", "other@msn.com")
     * use $res->success to check if it was successful
     * use $res->message_id to check later with Amazon for further processing
     * use $res->result_text to look for error text if the task was not successful
     * 
     * @param array $params - array of parameters for the email
     * @return \ResultHelper
     */
    public static function sendMail($params) {

        $to = self::getParam($params, 'to', true);
        $subject = self::getParam($params, 'subject', true);
        $body = self::getParam($params, 'message', true);
        $from = self::getParam($params, 'from', true);
        $replyTo = self::getParam($params, 'replyTo');
        $files = self::getParam($params, 'files');

        $res = new ResultHelper();

        // get the client ready
        $client = SesClient::factory(array(
                    'key' => self::AWS_KEY,
                    'secret' => self::AWS_SEC,
                    'region' => self::AWS_REGION
        ));

        // build the message
        if (is_array($to)) {
            $to_str = rtrim(implode(',', $to), ',');
        } else {
            $to_str = $to;
        }

        $msg = "To: $to_str\n";
        $msg .= "From: $from\n";

        if ($replyTo) {
            $msg .= "Reply-To: $replyTo\n";
        }

        // in case you have funny characters in the subject
        $subject = mb_encode_mimeheader($subject, 'UTF-8');
        $msg .= "Subject: $subject\n";
        $msg .= "MIME-Version: 1.0\n";
        $msg .= "Content-Type: multipart/mixed;\n";
        $boundary = uniqid("_Part_".time(), true); //random unique string
        $boundary2 = uniqid("_Part2_".time(), true); //random unique string
        $msg .= " boundary=\"$boundary\"\n";
        $msg .= "\n";

        // now the actual body
        $msg .= "--$boundary\n";

        //since we are sending text and html emails with multiple attachments
        //we must use a combination of mixed and alternative boundaries
        //hence the use of boundary and boundary2
        $msg .= "Content-Type: multipart/alternative;\n";
        $msg .= " boundary=\"$boundary2\"\n";
        $msg .= "\n";
        $msg .= "--$boundary2\n";

        // first, the plain text
        $msg .= "Content-Type: text/plain; charset=utf-8\n";
        $msg .= "Content-Transfer-Encoding: 7bit\n";
        $msg .= "\n";
        $msg .= strip_tags($body); //remove any HTML tags
        $msg .= "\n";

        // now, the html text
        $msg .= "--$boundary2\n";
        $msg .= "Content-Type: text/html; charset=utf-8\n";
        $msg .= "Content-Transfer-Encoding: 7bit\n";
        $msg .= "\n";
        $msg .= $body; 
        $msg .= "\n";
        $msg .= "--$boundary2--\n";

        // add attachments
        if (is_array($files)) {
            $count = count($files);
            foreach ($files as $file) {
                $msg .= "\n";
                $msg .= "--$boundary\n";
                $msg .= "Content-Transfer-Encoding: base64\n";
                $clean_filename = self::clean_filename($file["name"], self::MAX_ATTACHMENT_NAME_LEN);
                $msg .= "Content-Type: {$file['mime']}; name=$clean_filename;\n";
                $msg .= "Content-Disposition: attachment; filename=$clean_filename;\n";
                $msg .= "\n";
                $msg .= base64_encode(file_get_contents($file['filepath']));
                $msg .= "\n--$boundary";
            }
            // close email
            $msg .= "--\n";
        }

        // now send the email out
        try {
            $ses_result = $client->sendRawEmail(
                    array(
                'RawMessage' => array(
                    'Data' => base64_encode($msg)
                )
                    ), array(
                'Source' => $from,
                'Destinations' => $to_str
                    )
            );
            if ($ses_result) {
                $res->message_id = $ses_result->get('MessageId');
            } else {
                $res->success = false;
                $res->result_text = "Amazon SES did not return a MessageId";
            }
        } catch (Exception $e) {
            $res->success = false;
            $res->result_text = $e->getMessage().
                    " - To: $to_str, Sender: $from, Subject: $subject";
        }
        return $res;
    }

    private static function getParam($params, $param, $required = false) {
        $value = isset($params[$param]) ? $params[$param] : null;
        if ($required && empty($value)) {
            throw new Exception('"'.$param.'" parameter is required.');
        } else {
            return $value;
        }
    }

    /**
    Clean filename function - to get a file friendly 
    **/
    public static function clean_filename($str, $limit = 0, $replace=array(), $delimiter='-') {
        if( !empty($replace) ) {
            $str = str_replace((array)$replace, ' ', $str);
        }

        $clean = iconv('UTF-8', 'ASCII//TRANSLIT', $str);
        $clean = preg_replace("/[^a-zA-Z0-9\.\/_| -]/", '', $clean);
        $clean = preg_replace("/[\/| -]+/", '-', $clean);

        if ($limit > 0) {
            //don't truncate file extension
            $arr = explode(".", $clean);
            $size = count($arr);
            $base = "";
            $ext = "";
            if ($size > 0) {
                for ($i = 0; $i < $size; $i++) {
                    if ($i < $size - 1) { //if it's not the last item, add to $bn
                        $base .= $arr[$i];
                        //if next one isn't last, add a dot
                        if ($i < $size - 2)
                            $base .= ".";
                    } else {
                        if ($i > 0)
                            $ext = ".";
                        $ext .= $arr[$i];
                    }
                }
            }
            $bn_size = mb_strlen($base);
            $ex_size = mb_strlen($ext);
            $bn_new = mb_substr($base, 0, $limit - $ex_size);
            // doing again in case extension is long
            $clean = mb_substr($bn_new.$ext, 0, $limit); 
        }
        return $clean;
    }

}

class ResultHelper {

    public $success = true;
    public $result_text = "";
    public $message_id = "";

}

?>
Pang
  • 8,605
  • 144
  • 77
  • 113
RightHandedMonkey
  • 1,578
  • 17
  • 24
  • This is genial solution man. Generaly `$boundary` contain whole body with attachments but only `$boundary2` contain HTML or plain text. Genial solution. Tell me please, this your solution for sending plain text, is this alternative message if mail client not support HTML? Thanks! – Ivijan Stefan Stipić Sep 01 '17 at 08:47
  • Thanks. Yes, I send both plain text and HTML with the above solution. The code simply strips out the HTML using the strip_tags($body) for providing the plain text in cases for browsers that don't want to use the HTML. If desired you could put your own custom string there instead (i.e. $body_plain_text). – RightHandedMonkey Sep 01 '17 at 13:01
5

Great Answer Lain!

There were a couple things I did to make this work in a broader set of devices. At the end I will list the clients I tested on.

  1. I added a new build constructor that did not contain the parameter attachments and did not use MimeMultipart("mixed"). There is no need for mixed if you are sending only inline images.

    public Multipart build(String messageText, String messageHtml, List<URL> messageHtmlInline) throws MessagingException {
    
        final Multipart mpAlternative = new MimeMultipart("alternative");
        {
            //  Note: MUST RENDER HTML LAST otherwise iPad mail client only renders 
            //  the last image and no email
                addTextVersion(mpAlternative,messageText);
                addHtmlVersion(mpAlternative,messageHtml, messageHtmlInline);
        }
    
        return mpAlternative;
    }
    
  2. In addTextVersion method I added charset when adding content this probably could/should be passed in, but I just added it statically.

    textPart.setContent(messageText, "text/plain");
    to
    textPart.setContent(messageText, "text/plain; charset=UTF-8");
    
  3. The last item was adding to the addImagesInline method. I added setting the image filename to the header by the following code. If you don't do this then at least on Android default mail client it will have inline images that have a name of Unknown and will not automatically download them and present in email.

    for (URL img : embeded) {
        final MimeBodyPart htmlPartImg = new MimeBodyPart();
        DataSource htmlPartImgDs = new URLDataSource(img);
        htmlPartImg.setDataHandler(new DataHandler(htmlPartImgDs));
        String fileName = img.getFile();
        fileName = getFileName(fileName);
        String newFileName = cids.get(fileName);
        boolean imageNotReferencedInHtml = newFileName == null;
        if (imageNotReferencedInHtml) continue;
        htmlPartImg.setHeader("Content-ID", "<"+newFileName+">");
        htmlPartImg.setDisposition(BodyPart.INLINE);
        **htmlPartImg.setFileName(newFileName);**
        parent.addBodyPart(htmlPartImg);
    }
    

So finally, this is the list of clients I tested on. Outlook 2010, Outlook Web App, Internet Explorer 11, Firefox, Chrome, Outlook using Apple’s native app, Email going through Gmail - Browser mail client, Internet Explorer 11, Firefox, Chrome, Android default mail client, osx IPhone default mail client, Gmail mail client on Android, Gmail mail client on IPhone, Email going through Yahoo - Browser mail client, Internet Explorer 11, Firefox, Chrome, Android default mail client, osx IPhone default mail client.

Hope that helps anyone else.

XDevOne
  • 53
  • 1
  • 3
  • Thanks for the feedback, all good points. I'll see about including them in my answer. – Iain Apr 29 '16 at 06:32
4

Mixed Subtype

The "mixed" subtype of "multipart" is intended for use when the body parts are independent and need to be bundled in a particular order. Any "multipart" subtypes that an implementation does not recognize must be treated as being of subtype "mixed".

Alternative Subtype

The "multipart/alternative" type is syntactically identical to "multipart/mixed", but the semantics are different. In particular, each of the body parts is an "alternative" version of the same information

Source

AConsumer
  • 1,414
  • 1
  • 10
  • 24
1

I've made a Hierarchy chart to better help visualize the ideal structure. Each message flows separately from Leaf to Root.

Microsoft Reference: MIME Hierarchies of Body Parts

Microsoft Reference: MIME Message Body Parts

img1

suchislife
  • 2,892
  • 8
  • 33
  • 63