HtmlEmail.java

  1. /*
  2.  * Licensed to the Apache Software Foundation (ASF) under one or more
  3.  * contributor license agreements.  See the NOTICE file distributed with
  4.  * this work for additional information regarding copyright ownership.
  5.  * The ASF licenses this file to You under the Apache License, Version 2.0
  6.  * (the "License"); you may not use this file except in compliance with
  7.  * the License.  You may obtain a copy of the License at
  8.  *
  9.  *     http://www.apache.org/licenses/LICENSE-2.0
  10.  *
  11.  * Unless required by applicable law or agreed to in writing, software
  12.  * distributed under the License is distributed on an "AS IS" BASIS,
  13.  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14.  * See the License for the specific language governing permissions and
  15.  * limitations under the License.
  16.  */
  17. package org.apache.commons.mail2.javax;

  18. import java.io.File;
  19. import java.io.IOException;
  20. import java.io.InputStream;
  21. import java.net.MalformedURLException;
  22. import java.net.URL;
  23. import java.util.HashMap;
  24. import java.util.Map;
  25. import java.util.Objects;

  26. import javax.activation.DataHandler;
  27. import javax.activation.DataSource;
  28. import javax.activation.FileDataSource;
  29. import javax.activation.URLDataSource;
  30. import javax.mail.BodyPart;
  31. import javax.mail.MessagingException;
  32. import javax.mail.internet.MimeBodyPart;
  33. import javax.mail.internet.MimeMultipart;

  34. import org.apache.commons.mail2.core.EmailConstants;
  35. import org.apache.commons.mail2.core.EmailException;
  36. import org.apache.commons.mail2.core.EmailUtils;

  37. /**
  38.  * An HTML multipart email.
  39.  * <p>
  40.  * This class is used to send HTML formatted email. A text message can also be set for HTML unaware email clients, such as text-based email clients.
  41.  * </p>
  42.  * <p>
  43.  * This class also inherits from {@link MultiPartEmail}, so it is easy to add attachments to the email.
  44.  * </p>
  45.  * <p>
  46.  * To send an email in HTML, one should create a {@code HtmlEmail}, then use the {@link #setFrom(String)}, {@link #addTo(String)} etc. methods. The HTML content
  47.  * can be set with the {@link #setHtmlMsg(String)} method. The alternative text content can be set with {@link #setTextMsg(String)}.
  48.  * </p>
  49.  * <p>
  50.  * Either the text or HTML can be omitted, in which case the "main" part of the multipart becomes whichever is supplied rather than a
  51.  * {@code multipart/alternative}.
  52.  * </p>
  53.  * <h2>Embedding Images and Media</h2>
  54.  * <p>
  55.  * It is also possible to embed URLs, files, or arbitrary {@code DataSource}s directly into the body of the mail:
  56.  * </p>
  57.  *
  58.  * <pre>
  59.  * HtmlEmail he = new HtmlEmail();
  60.  * File img = new File("my/image.gif");
  61.  * PNGDataSource png = new PNGDataSource(decodedPNGOutputStream); // a custom class
  62.  * StringBuffer msg = new StringBuffer();
  63.  * msg.append("&lt;html&gt;&lt;body&gt;");
  64.  * msg.append("&lt;img src=cid:").append(he.embed(img)).append("&gt;");
  65.  * msg.append("&lt;img src=cid:").append(he.embed(png)).append("&gt;");
  66.  * msg.append("&lt;/body&gt;&lt;/html&gt;");
  67.  * he.setHtmlMsg(msg.toString());
  68.  * // code to set the other email fields (not shown)
  69.  * </pre>
  70.  * <p>
  71.  * Embedded entities are tracked by their name, which for {@code File}s is the file name itself and for {@code URL}s is the canonical path. It is an error to
  72.  * bind the same name to more than one entity, and this class will attempt to validate that for {@code File}s and {@code URL}s. When embedding a
  73.  * {@code DataSource}, the code uses the {@code equals()} method defined on the {@code DataSource}s to make the determination.
  74.  * </p>
  75.  *
  76.  * @since 1.0
  77.  */
  78. public class HtmlEmail extends MultiPartEmail {

  79.     /**
  80.      * Private bean class that encapsulates data about URL contents that are embedded in the final email.
  81.      *
  82.      * @since 1.1
  83.      */
  84.     private static final class InlineImage {

  85.         /** Content id. */
  86.         private final String cid;

  87.         /** {@code DataSource} for the content. */
  88.         private final DataSource dataSource;

  89.         /** The {@code MimeBodyPart} that contains the encoded data. */
  90.         private final MimeBodyPart mimeBodyPart;

  91.         /**
  92.          * Creates an InlineImage object to represent the specified content ID and {@code MimeBodyPart}.
  93.          *
  94.          * @param cid          the generated content ID, not null.
  95.          * @param dataSource   the {@code DataSource} that represents the content, not null.
  96.          * @param mimeBodyPart the {@code MimeBodyPart} that contains the encoded data, not null.
  97.          */
  98.         private InlineImage(final String cid, final DataSource dataSource, final MimeBodyPart mimeBodyPart) {
  99.             this.cid = Objects.requireNonNull(cid, "cid");
  100.             this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
  101.             this.mimeBodyPart = Objects.requireNonNull(mimeBodyPart, "mimeBodyPart");
  102.         }

  103.         @Override
  104.         public boolean equals(final Object obj) {
  105.             if (this == obj) {
  106.                 return true;
  107.             }
  108.             if (!(obj instanceof InlineImage)) {
  109.                 return false;
  110.             }
  111.             final InlineImage other = (InlineImage) obj;
  112.             return Objects.equals(cid, other.cid);
  113.         }

  114.         /**
  115.          * Returns the unique content ID of this InlineImage.
  116.          *
  117.          * @return the unique content ID of this InlineImage
  118.          */
  119.         private String getCid() {
  120.             return cid;
  121.         }

  122.         /**
  123.          * Returns the {@code DataSource} that represents the encoded content.
  124.          *
  125.          * @return the {@code DataSource} representing the encoded content
  126.          */
  127.         private DataSource getDataSource() {
  128.             return dataSource;
  129.         }

  130.         /**
  131.          * Returns the {@code MimeBodyPart} that contains the encoded InlineImage data.
  132.          *
  133.          * @return the {@code MimeBodyPart} containing the encoded InlineImage data
  134.          */
  135.         private MimeBodyPart getMimeBodyPart() {
  136.             return mimeBodyPart;
  137.         }

  138.         @Override
  139.         public int hashCode() {
  140.             return Objects.hash(cid);
  141.         }
  142.     }

  143.     /** Definition of the length of generated CID's. */
  144.     public static final int CID_LENGTH = 10;

  145.     /** Prefix for default HTML mail. */
  146.     private static final String HTML_MESSAGE_START = "<html><body><pre>";

  147.     /** Suffix for default HTML mail. */
  148.     private static final String HTML_MESSAGE_END = "</pre></body></html>";

  149.     /**
  150.      * Text part of the message. This will be used as alternative text if the email client does not support HTML messages.
  151.      */
  152.     private String text;

  153.     /**
  154.      * HTML part of the message.
  155.      */
  156.     private String html;

  157.     /**
  158.      * Embedded images Map&lt;String, InlineImage&gt; where the key is the user-defined image name.
  159.      */
  160.     private final Map<String, InlineImage> inlineEmbeds = new HashMap<>();

  161.     /**
  162.      * Constructs a new instance.
  163.      */
  164.     public HtmlEmail() {
  165.         // empty
  166.     }

  167.     /**
  168.      * @throws EmailException     EmailException
  169.      * @throws MessagingException MessagingException
  170.      */
  171.     private void build() throws MessagingException, EmailException {
  172.         final MimeMultipart rootContainer = getContainer();
  173.         MimeMultipart bodyEmbedsContainer = rootContainer;
  174.         MimeMultipart bodyContainer = rootContainer;
  175.         MimeBodyPart msgHtml = null;
  176.         MimeBodyPart msgText = null;

  177.         rootContainer.setSubType("mixed");

  178.         // determine how to form multiparts of email

  179.         if (EmailUtils.isNotEmpty(html) && !EmailUtils.isEmpty(inlineEmbeds)) {
  180.             // If HTML body and embeds are used, create a related container and add it to the root container
  181.             bodyEmbedsContainer = new MimeMultipart("related");
  182.             bodyContainer = bodyEmbedsContainer;
  183.             addPart(bodyEmbedsContainer, 0);

  184.             // If TEXT body was specified, create a alternative container and add it to the embeds container
  185.             if (EmailUtils.isNotEmpty(text)) {
  186.                 bodyContainer = new MimeMultipart("alternative");
  187.                 final BodyPart bodyPart = createBodyPart();
  188.                 try {
  189.                     bodyPart.setContent(bodyContainer);
  190.                     bodyEmbedsContainer.addBodyPart(bodyPart, 0);
  191.                 } catch (final MessagingException e) {
  192.                     throw new EmailException(e);
  193.                 }
  194.             }
  195.         } else if (EmailUtils.isNotEmpty(text) && EmailUtils.isNotEmpty(html)) {
  196.             // EMAIL-142: if we have both an HTML and TEXT body, but no attachments or
  197.             // inline images, the root container should have mimetype
  198.             // "multipart/alternative".
  199.             // reference: https://tools.ietf.org/html/rfc2046#section-5.1.4
  200.             if (!EmailUtils.isEmpty(inlineEmbeds) || isBoolHasAttachments()) {
  201.                 // If both HTML and TEXT bodies are provided, create an alternative
  202.                 // container and add it to the root container
  203.                 bodyContainer = new MimeMultipart("alternative");
  204.                 this.addPart(bodyContainer, 0);
  205.             } else {
  206.                 // no attachments or embedded images present, change the mimetype
  207.                 // of the root container (= body container)
  208.                 rootContainer.setSubType("alternative");
  209.             }
  210.         }

  211.         if (EmailUtils.isNotEmpty(html)) {
  212.             msgHtml = new MimeBodyPart();
  213.             bodyContainer.addBodyPart(msgHtml, 0);

  214.             // EMAIL-104: call explicitly setText to use default mime charset
  215.             // (property "mail.mime.charset") in case none has been set
  216.             msgHtml.setText(html, getCharsetName(), EmailConstants.TEXT_SUBTYPE_HTML);

  217.             // EMAIL-147: work-around for buggy JavaMail implementations;
  218.             // in case setText(...) does not set the correct content type,
  219.             // use the setContent() method instead.
  220.             final String contentType = msgHtml.getContentType();
  221.             if (contentType == null || !contentType.equals(EmailConstants.TEXT_HTML)) {
  222.                 // apply default charset if one has been set
  223.                 if (EmailUtils.isNotEmpty(getCharsetName())) {
  224.                     msgHtml.setContent(html, EmailConstants.TEXT_HTML + "; charset=" + getCharsetName());
  225.                 } else {
  226.                     // unfortunately, MimeUtility.getDefaultMIMECharset() is package private
  227.                     // and thus can not be used to set the default system charset in case
  228.                     // no charset has been provided by the user
  229.                     msgHtml.setContent(html, EmailConstants.TEXT_HTML);
  230.                 }
  231.             }

  232.             for (final InlineImage image : inlineEmbeds.values()) {
  233.                 bodyEmbedsContainer.addBodyPart(image.getMimeBodyPart());
  234.             }
  235.         }

  236.         if (EmailUtils.isNotEmpty(text)) {
  237.             msgText = new MimeBodyPart();
  238.             bodyContainer.addBodyPart(msgText, 0);

  239.             // EMAIL-104: call explicitly setText to use default mime charset
  240.             // (property "mail.mime.charset") in case none has been set
  241.             msgText.setText(text, getCharsetName());
  242.         }
  243.     }

  244.     /**
  245.      * Builds the MimeMessage. Please note that a user rarely calls this method directly and only if he/she is interested in the sending the underlying
  246.      * MimeMessage without commons-email.
  247.      *
  248.      * @throws EmailException if there was an error.
  249.      * @since 1.0
  250.      */
  251.     @Override
  252.     public void buildMimeMessage() throws EmailException {
  253.         try {
  254.             build();
  255.         } catch (final MessagingException e) {
  256.             throw new EmailException(e);
  257.         }
  258.         super.buildMimeMessage();
  259.     }

  260.     /**
  261.      * Embeds the specified {@code DataSource} in the HTML using a randomly generated Content-ID. Returns the generated Content-ID string.
  262.      *
  263.      * @param dataSource the {@code DataSource} to embed
  264.      * @param name       the name that will be set in the file name header field
  265.      * @return the generated Content-ID for this {@code DataSource}
  266.      * @throws EmailException if the embedding fails or if {@code name} is null or empty
  267.      * @see #embed(DataSource, String, String)
  268.      * @since 1.1
  269.      */
  270.     public String embed(final DataSource dataSource, final String name) throws EmailException {
  271.         // check if the DataSource has already been attached;
  272.         // if so, return the cached CID value.
  273.         final InlineImage inlineImage = inlineEmbeds.get(name);
  274.         if (inlineImage != null) {
  275.             // make sure the supplied URL points to the same thing
  276.             // as the one already associated with this name.
  277.             if (dataSource.equals(inlineImage.getDataSource())) {
  278.                 return inlineImage.getCid();
  279.             }
  280.             throw new EmailException("embedded DataSource '" + name + "' is already bound to name " + inlineImage.getDataSource().toString()
  281.                     + "; existing names cannot be rebound");
  282.         }

  283.         final String cid = EmailUtils.toLower(EmailUtils.randomAlphabetic(CID_LENGTH));
  284.         return embed(dataSource, name, cid);
  285.     }

  286.     /**
  287.      * Embeds the specified {@code DataSource} in the HTML using the specified Content-ID. Returns the specified Content-ID string.
  288.      *
  289.      * @param dataSource the {@code DataSource} to embed
  290.      * @param name       the name that will be set in the file name header field
  291.      * @param cid        the Content-ID to use for this {@code DataSource}
  292.      * @return the URL encoded Content-ID for this {@code DataSource}
  293.      * @throws EmailException if the embedding fails or if {@code name} is null or empty
  294.      * @since 1.1
  295.      */
  296.     public String embed(final DataSource dataSource, final String name, final String cid) throws EmailException {
  297.         EmailException.checkNonEmpty(name, () -> "Name cannot be null or empty");
  298.         final MimeBodyPart mbp = new MimeBodyPart();
  299.         try {
  300.             // URL encode the cid according to RFC 2392
  301.             final String encodedCid = EmailUtils.encodeUrl(cid);
  302.             mbp.setDataHandler(new DataHandler(dataSource));
  303.             mbp.setFileName(name);
  304.             mbp.setDisposition(EmailAttachment.INLINE);
  305.             mbp.setContentID("<" + encodedCid + ">");
  306.             this.inlineEmbeds.put(name, new InlineImage(encodedCid, dataSource, mbp));
  307.             return encodedCid;
  308.         } catch (final MessagingException e) {
  309.             throw new EmailException(e);
  310.         }
  311.     }

  312.     /**
  313.      * Embeds a file in the HTML. This implementation delegates to {@link #embed(File, String)}.
  314.      *
  315.      * @param file The {@code File} object to embed
  316.      * @return A String with the Content-ID of the file.
  317.      * @throws EmailException when the supplied {@code File} cannot be used; also see {@link javax.mail.internet.MimeBodyPart} for definitions
  318.      *
  319.      * @see #embed(File, String)
  320.      * @since 1.1
  321.      */
  322.     public String embed(final File file) throws EmailException {
  323.         return embed(file, EmailUtils.toLower(EmailUtils.randomAlphabetic(CID_LENGTH)));
  324.     }

  325.     /**
  326.      * Embeds a file in the HTML.
  327.      *
  328.      * <p>
  329.      * This method embeds a file located by an URL into the mail body. It allows, for instance, to add inline images to the email. Inline files may be
  330.      * referenced with a {@code cid:xxxxxx} URL, where xxxxxx is the Content-ID returned by the embed function. Files are bound to their names, which is the
  331.      * value returned by {@link java.io.File#getName()}. If the same file is embedded multiple times, the same CID is guaranteed to be returned.
  332.      *
  333.      * <p>
  334.      * While functionally the same as passing {@code FileDataSource} to {@link #embed(DataSource, String, String)}, this method attempts to validate the file
  335.      * before embedding it in the message and will throw {@code EmailException} if the validation fails. In this case, the {@code HtmlEmail} object will not be
  336.      * changed.
  337.      *
  338.      * @param file The {@code File} to embed
  339.      * @param cid  the Content-ID to use for the embedded {@code File}
  340.      * @return A String with the Content-ID of the file.
  341.      * @throws EmailException when the supplied {@code File} cannot be used or if the file has already been embedded; also see
  342.      *                        {@link javax.mail.internet.MimeBodyPart} for definitions
  343.      * @since 1.1
  344.      */
  345.     public String embed(final File file, final String cid) throws EmailException {
  346.         EmailException.checkNonEmpty(file.getName(), () -> "File name cannot be null or empty");

  347.         // verify that the File can provide a canonical path
  348.         String filePath = null;
  349.         try {
  350.             filePath = file.getCanonicalPath();
  351.         } catch (final IOException e) {
  352.             throw new EmailException("couldn't get canonical path for " + file.getName(), e);
  353.         }

  354.         // check if a FileDataSource for this name has already been attached;
  355.         // if so, return the cached CID value.
  356.         final InlineImage inlineImage = inlineEmbeds.get(file.getName());
  357.         if (inlineImage != null) {
  358.             final FileDataSource fileDataSource = (FileDataSource) inlineImage.getDataSource();
  359.             // make sure the supplied file has the same canonical path
  360.             // as the one already associated with this name.
  361.             String existingFilePath = null;
  362.             try {
  363.                 existingFilePath = fileDataSource.getFile().getCanonicalPath();
  364.             } catch (final IOException e) {
  365.                 throw new EmailException("couldn't get canonical path for file " + fileDataSource.getFile().getName() + "which has already been embedded", e);
  366.             }
  367.             if (filePath.equals(existingFilePath)) {
  368.                 return inlineImage.getCid();
  369.             }
  370.             throw new EmailException(
  371.                     "embedded name '" + file.getName() + "' is already bound to file " + existingFilePath + "; existing names cannot be rebound");
  372.         }

  373.         // verify that the file is valid
  374.         if (!file.exists()) {
  375.             throw new EmailException("file " + filePath + " doesn't exist");
  376.         }
  377.         if (!file.isFile()) {
  378.             throw new EmailException("file " + filePath + " isn't a normal file");
  379.         }
  380.         if (!file.canRead()) {
  381.             throw new EmailException("file " + filePath + " isn't readable");
  382.         }

  383.         return embed(new FileDataSource(file), file.getName(), cid);
  384.     }

  385.     /**
  386.      * Parses the specified {@code String} as a URL that will then be embedded in the message.
  387.      *
  388.      * @param urlString String representation of the URL.
  389.      * @param name      The name that will be set in the file name header field.
  390.      * @return A String with the Content-ID of the URL.
  391.      * @throws EmailException when URL supplied is invalid or if {@code name} is null or empty; also see {@link javax.mail.internet.MimeBodyPart} for
  392.      *                        definitions
  393.      *
  394.      * @see #embed(URL, String)
  395.      * @since 1.1
  396.      */
  397.     public String embed(final String urlString, final String name) throws EmailException {
  398.         try {
  399.             return embed(new URL(urlString), name);
  400.         } catch (final MalformedURLException e) {
  401.             throw new EmailException("Invalid URL", e);
  402.         }
  403.     }

  404.     /**
  405.      * Embeds an URL in the HTML.
  406.      *
  407.      * <p>
  408.      * This method embeds a file located by an URL into the mail body. It allows, for instance, to add inline images to the email. Inline files may be
  409.      * referenced with a {@code cid:xxxxxx} URL, where xxxxxx is the Content-ID returned by the embed function. It is an error to bind the same name to more
  410.      * than one URL; if the same URL is embedded multiple times, the same Content-ID is guaranteed to be returned.
  411.      * </p>
  412.      * <p>
  413.      * While functionally the same as passing {@code URLDataSource} to {@link #embed(DataSource, String, String)}, this method attempts to validate the URL
  414.      * before embedding it in the message and will throw {@code EmailException} if the validation fails. In this case, the {@code HtmlEmail} object will not be
  415.      * changed.
  416.      * </p>
  417.      * <p>
  418.      * NOTE: Clients should take care to ensure that different URLs are bound to different names. This implementation tries to detect this and throw
  419.      * {@code EmailException}. However, it is not guaranteed to catch all cases, especially when the URL refers to a remote HTTP host that may be part of a
  420.      * virtual host cluster.
  421.      * </p>
  422.      *
  423.      * @param url  The URL of the file.
  424.      * @param name The name that will be set in the file name header field.
  425.      * @return A String with the Content-ID of the file.
  426.      * @throws EmailException when URL supplied is invalid or if {@code name} is null or empty; also see {@link javax.mail.internet.MimeBodyPart} for
  427.      *                        definitions
  428.      * @since 1.0
  429.      */
  430.     public String embed(final URL url, final String name) throws EmailException {
  431.         EmailException.checkNonEmpty(name, () -> "Name cannot be null or empty");
  432.         // check if a URLDataSource for this name has already been attached;
  433.         // if so, return the cached CID value.
  434.         final InlineImage inlineImage = inlineEmbeds.get(name);
  435.         if (inlineImage != null) {
  436.             final URLDataSource urlDataSource = (URLDataSource) inlineImage.getDataSource();
  437.             // make sure the supplied URL points to the same thing
  438.             // as the one already associated with this name.
  439.             // NOTE: Comparing URLs with URL.equals() is a blocking operation
  440.             // in the case of a network failure therefore we use
  441.             // url.toExternalForm().equals() here.
  442.             if (url.toExternalForm().equals(urlDataSource.getURL().toExternalForm())) {
  443.                 return inlineImage.getCid();
  444.             }
  445.             throw new EmailException("embedded name '" + name + "' is already bound to URL " + urlDataSource.getURL() + "; existing names cannot be rebound");
  446.         }
  447.         // verify that the URL is valid
  448.         try (InputStream inputStream = url.openStream()) {
  449.             // Make sure we can read.
  450.             inputStream.read();
  451.         } catch (final IOException e) {
  452.             throw new EmailException("Invalid URL", e);
  453.         }
  454.         return embed(new URLDataSource(url), name);
  455.     }

  456.     /**
  457.      * Gets the HTML content.
  458.      *
  459.      * @return the HTML content.
  460.      * @since 1.6.0
  461.      */
  462.     public String getHtml() {
  463.         return html;
  464.     }

  465.     /**
  466.      * Gets the message text.
  467.      *
  468.      * @return the message text.
  469.      * @since 1.6.0
  470.      */
  471.     public String getText() {
  472.         return text;
  473.     }

  474.     /**
  475.      * Sets the HTML content.
  476.      *
  477.      * @param html A String.
  478.      * @return An HtmlEmail.
  479.      * @throws EmailException see javax.mail.internet.MimeBodyPart for definitions
  480.      * @since 1.0
  481.      */
  482.     public HtmlEmail setHtmlMsg(final String html) throws EmailException {
  483.         this.html = EmailException.checkNonEmpty(html, () -> "Invalid message.");
  484.         return this;
  485.     }

  486.     /**
  487.      * Sets the message.
  488.      *
  489.      * <p>
  490.      * This method overrides {@link MultiPartEmail#setMsg(String)} in order to send an HTML message instead of a plain text message in the mail body. The
  491.      * message is formatted in HTML for the HTML part of the message; it is left as is in the alternate text part.
  492.      * </p>
  493.      *
  494.      * @param msg the message text to use
  495.      * @return this {@code HtmlEmail}
  496.      * @throws EmailException if msg is null or empty; see javax.mail.internet.MimeBodyPart for definitions
  497.      * @since 1.0
  498.      */
  499.     @Override
  500.     public Email setMsg(final String msg) throws EmailException {
  501.         setTextMsg(msg);
  502.         final StringBuilder htmlMsgBuf = new StringBuilder(msg.length() + HTML_MESSAGE_START.length() + HTML_MESSAGE_END.length());
  503.         htmlMsgBuf.append(HTML_MESSAGE_START).append(msg).append(HTML_MESSAGE_END);
  504.         setHtmlMsg(htmlMsgBuf.toString());
  505.         return this;
  506.     }

  507.     /**
  508.      * Sets the text content.
  509.      *
  510.      * @param text A String.
  511.      * @return An HtmlEmail.
  512.      * @throws EmailException see javax.mail.internet.MimeBodyPart for definitions
  513.      * @since 1.0
  514.      */
  515.     public HtmlEmail setTextMsg(final String text) throws EmailException {
  516.         this.text = EmailException.checkNonEmpty(text, () -> "Invalid message.");
  517.         return this;
  518.     }
  519. }