    001/*
    002 * Licensed to the Apache Software Foundation (ASF) under one or more
    003 * contributor license agreements.  See the NOTICE file distributed with
    004 * this work for additional information regarding copyright ownership.
    005 * The ASF licenses this file to You under the Apache License, Version 2.0
    006 * (the "License"); you may not use this file except in compliance with
    007 * the License.  You may obtain a copy of the License at
    008 *
    009 *     http://www.apache.org/licenses/LICENSE-2.0
    010 *
    011 * Unless required by applicable law or agreed to in writing, software
    012 * distributed under the License is distributed on an "AS IS" BASIS,
    013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    014 * See the License for the specific language governing permissions and
    015 * limitations under the License.
    016 */
    017package org.apache.commons.mail;
    018
    019import java.io.File;
    020import java.io.IOException;
    021import java.io.InputStream;
    022import java.net.MalformedURLException;
    023import java.net.URL;
    024import java.util.HashMap;
    025import java.util.List;
    026import java.util.Map;
    027import java.util.Objects;
    028
    029import javax.activation.DataHandler;
    030import javax.activation.DataSource;
    031import javax.activation.FileDataSource;
    032import javax.activation.URLDataSource;
    033import javax.mail.BodyPart;
    034import javax.mail.MessagingException;
    035import javax.mail.internet.MimeBodyPart;
    036import javax.mail.internet.MimeMultipart;
    037
    038/**
    039 * An HTML multipart email.
    040 * <p>
    041 * 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.
    042 * </p>
    043 * <p>
    044 * This class also inherits from {@link MultiPartEmail}, so it is easy to add attachments to the email.
    045 * </p>
    046 * <p>
    047 * 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
    048 * can be set with the {@link #setHtmlMsg(String)} method. The alternative text content can be set with {@link #setTextMsg(String)}.
    049 * </p>
    050 * <p>
    051 * Either the text or HTML can be omitted, in which case the "main" part of the multipart becomes whichever is supplied rather than a
    052 * {@code multipart/alternative}.
    053 * </p>
    054 * <h2>Embedding Images and Media</h2>
    055 * <p>
    056 * It is also possible to embed URLs, files, or arbitrary {@code DataSource}s directly into the body of the mail:
    057 * </p>
    058 *
    059 * <pre>
    060 * HtmlEmail he = new HtmlEmail();
    061 * File img = new File("my/image.gif");
    062 * PNGDataSource png = new PNGDataSource(decodedPNGOutputStream); // a custom class
    063 * StringBuffer msg = new StringBuffer();
    064 * msg.append("&lt;html&gt;&lt;body&gt;");
    065 * msg.append("&lt;img src=cid:").append(he.embed(img)).append("&gt;");
    066 * msg.append("&lt;img src=cid:").append(he.embed(png)).append("&gt;");
    067 * msg.append("&lt;/body&gt;&lt;/html&gt;");
    068 * he.setHtmlMsg(msg.toString());
    069 * // code to set the other email fields (not shown)
    070 * </pre>
    071 * <p>
    072 * 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
    073 * 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
    074 * {@code DataSource}, the code uses the {@code equals()} method defined on the {@code DataSource}s to make the determination.
    075 * </p>
    076 *
    077 * @since 1.0
    078 */
    079public class HtmlEmail extends MultiPartEmail {
    080
    081    /**
    082     * Private bean class that encapsulates data about URL contents that are embedded in the final email.
    083     *
    084     * @since 1.1
    085     */
    086    private static final class InlineImage {
    087
    088        /** Content id. */
    089        private final String cid;
    090
    091        /** {@code DataSource} for the content. */
    092        private final DataSource dataSource;
    093
    094        /** The {@code MimeBodyPart} that contains the encoded data. */
    095        private final MimeBodyPart mimeBodyPart;
    096
    097        /**
    098         * Creates an InlineImage object to represent the specified content ID and {@code MimeBodyPart}.
    099         *
    100         * @param cid          the generated content ID, not null.
    101         * @param dataSource   the {@code DataSource} that represents the content, not null.
    102         * @param mimeBodyPart the {@code MimeBodyPart} that contains the encoded data, not null.
    103         */
    104        private InlineImage(final String cid, final DataSource dataSource, final MimeBodyPart mimeBodyPart) {
    105            this.cid = Objects.requireNonNull(cid, "cid");
    106            this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
    107            this.mimeBodyPart = Objects.requireNonNull(mimeBodyPart, "mimeBodyPart");
    108        }
    109
    110        @Override
    111        public boolean equals(final Object obj) {
    112            if (this == obj) {
    113                return true;
    114            }
    115            if (!(obj instanceof InlineImage)) {
    116                return false;
    117            }
    118            final InlineImage other = (InlineImage) obj;
    119            return Objects.equals(cid, other.cid);
    120        }
    121
    122        /**
    123         * Returns the unique content ID of this InlineImage.
    124         *
    125         * @return the unique content ID of this InlineImage
    126         */
    127        private String getCid() {
    128            return cid;
    129        }
    130
    131        /**
    132         * Returns the {@code DataSource} that represents the encoded content.
    133         *
    134         * @return the {@code DataSource} representing the encoded content
    135         */
    136        private DataSource getDataSource() {
    137            return dataSource;
    138        }
    139
    140        /**
    141         * Returns the {@code MimeBodyPart} that contains the encoded InlineImage data.
    142         *
    143         * @return the {@code MimeBodyPart} containing the encoded InlineImage data
    144         */
    145        private MimeBodyPart getMimeBodyPart() {
    146            return mimeBodyPart;
    147        }
    148
    149        @Override
    150        public int hashCode() {
    151            return Objects.hash(cid);
    152        }
    153    }
    154
    155    /** Definition of the length of generated CID's. */
    156    public static final int CID_LENGTH = 10;
    157
    158    /** Prefix for default HTML mail. */
    159    private static final String HTML_MESSAGE_START = "<html><body><pre>";
    160
    161    /** Suffix for default HTML mail. */
    162    private static final String HTML_MESSAGE_END = "</pre></body></html>";
    163
    164    /**
    165     * Text part of the message. This will be used as alternative text if the email client does not support HTML messages.
    166     *
    167     * @deprecated Use getters and getters.
    168     */
    169    @Deprecated
    170    protected String text;
    171
    172    /**
    173     * HTML part of the message.
    174     *
    175     * @deprecated Use getters and getters.
    176     */
    177    @Deprecated
    178    protected String html;
    179
    180    /**
    181     * @deprecated As of commons-email 1.1, no longer used. Inline embedded objects are now stored in {@link #inlineEmbeds}.
    182     */
    183    @Deprecated
    184    protected List<InlineImage> inlineImages;
    185
    186    /**
    187     * Embedded images Map&lt;String, InlineImage&gt; where the key is the user-defined image name.
    188     *
    189     * @deprecated Use getters and getters.
    190     */
    191    @Deprecated
    192    protected Map<String, InlineImage> inlineEmbeds = new HashMap<>();
    193
    194    /**
    195     * Constructs a new instance.
    196     */
    197    public HtmlEmail() {
    198        // empty
    199    }
    200
    201    /**
    202     * @throws EmailException     EmailException
    203     * @throws MessagingException MessagingException
    204     */
    205    private void build() throws MessagingException, EmailException {
    206        final MimeMultipart rootContainer = getContainer();
    207        MimeMultipart bodyEmbedsContainer = rootContainer;
    208        MimeMultipart bodyContainer = rootContainer;
    209        MimeBodyPart msgHtml = null;
    210        MimeBodyPart msgText = null;
    211
    212        rootContainer.setSubType("mixed");
    213
    214        // determine how to form multiparts of email
    215
    216        if (EmailUtils.isNotEmpty(html) && !EmailUtils.isEmpty(inlineEmbeds)) {
    217            // If HTML body and embeds are used, create a related container and add it to the root container
    218            bodyEmbedsContainer = new MimeMultipart("related");
    219            bodyContainer = bodyEmbedsContainer;
    220            addPart(bodyEmbedsContainer, 0);
    221
    222            // If TEXT body was specified, create a alternative container and add it to the embeds container
    223            if (EmailUtils.isNotEmpty(text)) {
    224                bodyContainer = new MimeMultipart("alternative");
    225                final BodyPart bodyPart = createBodyPart();
    226                try {
    227                    bodyPart.setContent(bodyContainer);
    228                    bodyEmbedsContainer.addBodyPart(bodyPart, 0);
    229                } catch (final MessagingException e) {
    230                    throw new EmailException(e);
    231                }
    232            }
    233        } else if (EmailUtils.isNotEmpty(text) && EmailUtils.isNotEmpty(html)) {
    234            // EMAIL-142: if we have both an HTML and TEXT body, but no attachments or
    235            // inline images, the root container should have mimetype
    236            // "multipart/alternative".
    237            // reference: http://tools.ietf.org/html/rfc2046#section-5.1.4
    238            if (!EmailUtils.isEmpty(inlineEmbeds) || isBoolHasAttachments()) {
    239                // If both HTML and TEXT bodies are provided, create an alternative
    240                // container and add it to the root container
    241                bodyContainer = new MimeMultipart("alternative");
    242                this.addPart(bodyContainer, 0);
    243            } else {
    244                // no attachments or embedded images present, change the mimetype
    245                // of the root container (= body container)
    246                rootContainer.setSubType("alternative");
    247            }
    248        }
    249
    250        if (EmailUtils.isNotEmpty(html)) {
    251            msgHtml = new MimeBodyPart();
    252            bodyContainer.addBodyPart(msgHtml, 0);
    253
    254            // EMAIL-104: call explicitly setText to use default mime charset
    255            // (property "mail.mime.charset") in case none has been set
    256            msgHtml.setText(html, getCharsetName(), EmailConstants.TEXT_SUBTYPE_HTML);
    257
    258            // EMAIL-147: work-around for buggy JavaMail implementations;
    259            // in case setText(...) does not set the correct content type,
    260            // use the setContent() method instead.
    261            final String contentType = msgHtml.getContentType();
    262            if (contentType == null || !contentType.equals(EmailConstants.TEXT_HTML)) {
    263                // apply default charset if one has been set
    264                if (EmailUtils.isNotEmpty(getCharsetName())) {
    265                    msgHtml.setContent(html, EmailConstants.TEXT_HTML + "; charset=" + getCharsetName());
    266                } else {
    267                    // unfortunately, MimeUtility.getDefaultMIMECharset() is package private
    268                    // and thus can not be used to set the default system charset in case
    269                    // no charset has been provided by the user
    270                    msgHtml.setContent(html, EmailConstants.TEXT_HTML);
    271                }
    272            }
    273
    274            for (final InlineImage image : inlineEmbeds.values()) {
    275                bodyEmbedsContainer.addBodyPart(image.getMimeBodyPart());
    276            }
    277        }
    278
    279        if (EmailUtils.isNotEmpty(text)) {
    280            msgText = new MimeBodyPart();
    281            bodyContainer.addBodyPart(msgText, 0);
    282
    283            // EMAIL-104: call explicitly setText to use default mime charset
    284            // (property "mail.mime.charset") in case none has been set
    285            msgText.setText(text, getCharsetName());
    286        }
    287    }
    288
    289    /**
    290     * 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
    291     * MimeMessage without commons-email.
    292     *
    293     * @throws EmailException if there was an error.
    294     * @since 1.0
    295     */
    296    @Override
    297    public void buildMimeMessage() throws EmailException {
    298        try {
    299            build();
    300        } catch (final MessagingException e) {
    301            throw new EmailException(e);
    302        }
    303        super.buildMimeMessage();
    304    }
    305
    306    /**
    307     * Embeds the specified {@code DataSource} in the HTML using a randomly generated Content-ID. Returns the generated Content-ID string.
    308     *
    309     * @param dataSource the {@code DataSource} to embed
    310     * @param name       the name that will be set in the file name header field
    311     * @return the generated Content-ID for this {@code DataSource}
    312     * @throws EmailException if the embedding fails or if {@code name} is null or empty
    313     * @see #embed(DataSource, String, String)
    314     * @since 1.1
    315     */
    316    public String embed(final DataSource dataSource, final String name) throws EmailException {
    317        // check if the DataSource has already been attached;
    318        // if so, return the cached CID value.
    319        final InlineImage inlineImage = inlineEmbeds.get(name);
    320        if (inlineImage != null) {
    321            // make sure the supplied URL points to the same thing
    322            // as the one already associated with this name.
    323            if (dataSource.equals(inlineImage.getDataSource())) {
    324                return inlineImage.getCid();
    325            }
    326            throw new EmailException("embedded DataSource '" + name + "' is already bound to name " + inlineImage.getDataSource().toString()
    327                    + "; existing names cannot be rebound");
    328        }
    329
    330        final String cid = EmailUtils.toLower(EmailUtils.randomAlphabetic(HtmlEmail.CID_LENGTH));
    331        return embed(dataSource, name, cid);
    332    }
    333
    334    /**
    335     * Embeds the specified {@code DataSource} in the HTML using the specified Content-ID. Returns the specified Content-ID string.
    336     *
    337     * @param dataSource the {@code DataSource} to embed
    338     * @param name       the name that will be set in the file name header field
    339     * @param cid        the Content-ID to use for this {@code DataSource}
    340     * @return the URL encoded Content-ID for this {@code DataSource}
    341     * @throws EmailException if the embedding fails or if {@code name} is null or empty
    342     * @since 1.1
    343     */
    344    public String embed(final DataSource dataSource, final String name, final String cid) throws EmailException {
    345        EmailException.checkNonEmpty(name, () -> "Name cannot be null or empty");
    346        final MimeBodyPart mbp = new MimeBodyPart();
    347        try {
    348            // URL encode the cid according to RFC 2392
    349            final String encodedCid = EmailUtils.encodeUrl(cid);
    350            mbp.setDataHandler(new DataHandler(dataSource));
    351            mbp.setFileName(name);
    352            mbp.setDisposition(EmailAttachment.INLINE);
    353            mbp.setContentID("<" + encodedCid + ">");
    354            this.inlineEmbeds.put(name, new InlineImage(encodedCid, dataSource, mbp));
    355            return encodedCid;
    356        } catch (final MessagingException e) {
    357            throw new EmailException(e);
    358        }
    359    }
    360
    361    /**
    362     * Embeds a file in the HTML. This implementation delegates to {@link #embed(File, String)}.
    363     *
    364     * @param file The {@code File} object to embed
    365     * @return A String with the Content-ID of the file.
    366     * @throws EmailException when the supplied {@code File} cannot be used; also see {@link javax.mail.internet.MimeBodyPart} for definitions
    367     *
    368     * @see #embed(File, String)
    369     * @since 1.1
    370     */
    371    public String embed(final File file) throws EmailException {
    372        return embed(file, EmailUtils.toLower(EmailUtils.randomAlphabetic(HtmlEmail.CID_LENGTH)));
    373    }
    374
    375    /**
    376     * Embeds a file in the HTML.
    377     *
    378     * <p>
    379     * 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
    380     * 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
    381     * value returned by {@link java.io.File#getName()}. If the same file is embedded multiple times, the same CID is guaranteed to be returned.
    382     *
    383     * <p>
    384     * While functionally the same as passing {@code FileDataSource} to {@link #embed(DataSource, String, String)}, this method attempts to validate the file
    385     * 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
    386     * changed.
    387     *
    388     * @param file The {@code File} to embed
    389     * @param cid  the Content-ID to use for the embedded {@code File}
    390     * @return A String with the Content-ID of the file.
    391     * @throws EmailException when the supplied {@code File} cannot be used or if the file has already been embedded; also see
    392     *                        {@link javax.mail.internet.MimeBodyPart} for definitions
    393     * @since 1.1
    394     */
    395    public String embed(final File file, final String cid) throws EmailException {
    396        EmailException.checkNonEmpty(file.getName(), () -> "File name cannot be null or empty");
    397
    398        // verify that the File can provide a canonical path
    399        String filePath = null;
    400        try {
    401            filePath = file.getCanonicalPath();
    402        } catch (final IOException e) {
    403            throw new EmailException("couldn't get canonical path for " + file.getName(), e);
    404        }
    405
    406        // check if a FileDataSource for this name has already been attached;
    407        // if so, return the cached CID value.
    408        final InlineImage inlineImage = inlineEmbeds.get(file.getName());
    409        if (inlineImage != null) {
    410            final FileDataSource fileDataSource = (FileDataSource) inlineImage.getDataSource();
    411            // make sure the supplied file has the same canonical path
    412            // as the one already associated with this name.
    413            String existingFilePath = null;
    414            try {
    415                existingFilePath = fileDataSource.getFile().getCanonicalPath();
    416            } catch (final IOException e) {
    417                throw new EmailException("couldn't get canonical path for file " + fileDataSource.getFile().getName() + "which has already been embedded", e);
    418            }
    419            if (filePath.equals(existingFilePath)) {
    420                return inlineImage.getCid();
    421            }
    422            throw new EmailException(
    423                    "embedded name '" + file.getName() + "' is already bound to file " + existingFilePath + "; existing names cannot be rebound");
    424        }
    425
    426        // verify that the file is valid
    427        if (!file.exists()) {
    428            throw new EmailException("file " + filePath + " doesn't exist");
    429        }
    430        if (!file.isFile()) {
    431            throw new EmailException("file " + filePath + " isn't a normal file");
    432        }
    433        if (!file.canRead()) {
    434            throw new EmailException("file " + filePath + " isn't readable");
    435        }
    436
    437        return embed(new FileDataSource(file), file.getName(), cid);
    438    }
    439
    440    /**
    441     * Parses the specified {@code String} as a URL that will then be embedded in the message.
    442     *
    443     * @param urlString String representation of the URL.
    444     * @param name      The name that will be set in the file name header field.
    445     * @return A String with the Content-ID of the URL.
    446     * @throws EmailException when URL supplied is invalid or if {@code name} is null or empty; also see {@link javax.mail.internet.MimeBodyPart} for
    447     *                        definitions
    448     *
    449     * @see #embed(URL, String)
    450     * @since 1.1
    451     */
    452    public String embed(final String urlString, final String name) throws EmailException {
    453        try {
    454            return embed(new URL(urlString), name);
    455        } catch (final MalformedURLException e) {
    456            throw new EmailException("Invalid URL", e);
    457        }
    458    }
    459
    460    /**
    461     * Embeds an URL in the HTML.
    462     *
    463     * <p>
    464     * 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
    465     * 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
    466     * than one URL; if the same URL is embedded multiple times, the same Content-ID is guaranteed to be returned.
    467     * </p>
    468     * <p>
    469     * While functionally the same as passing {@code URLDataSource} to {@link #embed(DataSource, String, String)}, this method attempts to validate the URL
    470     * 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
    471     * changed.
    472     * </p>
    473     * <p>
    474     * NOTE: Clients should take care to ensure that different URLs are bound to different names. This implementation tries to detect this and throw
    475     * {@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
    476     * virtual host cluster.
    477     * </p>
    478     *
    479     * @param url  The URL of the file.
    480     * @param name The name that will be set in the file name header field.
    481     * @return A String with the Content-ID of the file.
    482     * @throws EmailException when URL supplied is invalid or if {@code name} is null or empty; also see {@link javax.mail.internet.MimeBodyPart} for
    483     *                        definitions
    484     * @since 1.0
    485     */
    486    public String embed(final URL url, final String name) throws EmailException {
    487        EmailException.checkNonEmpty(name, () -> "Name cannot be null or empty");
    488        // check if a URLDataSource for this name has already been attached;
    489        // if so, return the cached CID value.
    490        final InlineImage inlineImage = inlineEmbeds.get(name);
    491        if (inlineImage != null) {
    492            final URLDataSource urlDataSource = (URLDataSource) inlineImage.getDataSource();
    493            // make sure the supplied URL points to the same thing
    494            // as the one already associated with this name.
    495            // NOTE: Comparing URLs with URL.equals() is a blocking operation
    496            // in the case of a network failure therefore we use
    497            // url.toExternalForm().equals() here.
    498            if (url.toExternalForm().equals(urlDataSource.getURL().toExternalForm())) {
    499                return inlineImage.getCid();
    500            }
    501            throw new EmailException("embedded name '" + name + "' is already bound to URL " + urlDataSource.getURL() + "; existing names cannot be rebound");
    502        }
    503        // verify that the URL is valid
    504        try (InputStream inputStream = url.openStream()) {
    505            // Make sure we can read.
    506            inputStream.read();
    507        } catch (final IOException e) {
    508            throw new EmailException("Invalid URL", e);
    509        }
    510        return embed(new URLDataSource(url), name);
    511    }
    512
    513    /**
    514     * Gets the HTML content.
    515     *
    516     * @return the HTML content.
    517     * @since 1.6.0
    518     */
    519    public String getHtml() {
    520        return html;
    521    }
    522
    523    /**
    524     * Gets the message text.
    525     *
    526     * @return the message text.
    527     * @since 1.6.0
    528     */
    529    public String getText() {
    530        return text;
    531    }
    532
    533    /**
    534     * Sets the HTML content.
    535     *
    536     * @param html A String.
    537     * @return An HtmlEmail.
    538     * @throws EmailException see javax.mail.internet.MimeBodyPart for definitions
    539     * @since 1.0
    540     */
    541    public HtmlEmail setHtmlMsg(final String html) throws EmailException {
    542        this.html = EmailException.checkNonEmpty(html, () -> "Invalid message.");
    543        return this;
    544    }
    545
    546    /**
    547     * Sets the message.
    548     *
    549     * <p>
    550     * 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
    551     * message is formatted in HTML for the HTML part of the message; it is left as is in the alternate text part.
    552     * </p>
    553     *
    554     * @param msg the message text to use
    555     * @return this {@code HtmlEmail}
    556     * @throws EmailException if msg is null or empty; see javax.mail.internet.MimeBodyPart for definitions
    557     * @since 1.0
    558     */
    559    @Override
    560    public Email setMsg(final String msg) throws EmailException {
    561        setTextMsg(msg);
    562        final StringBuilder htmlMsgBuf = new StringBuilder(msg.length() + HTML_MESSAGE_START.length() + HTML_MESSAGE_END.length());
    563        htmlMsgBuf.append(HTML_MESSAGE_START).append(msg).append(HTML_MESSAGE_END);
    564        setHtmlMsg(htmlMsgBuf.toString());
    565        return this;
    566    }
    567
    568    /**
    569     * Sets the text content.
    570     *
    571     * @param text A String.
    572     * @return An HtmlEmail.
    573     * @throws EmailException see javax.mail.internet.MimeBodyPart for definitions
    574     * @since 1.0
    575     */
    576    public HtmlEmail setTextMsg(final String text) throws EmailException {
    577        this.text = EmailException.checkNonEmpty(text, () -> "Invalid message.");
    578        return this;
    579    }
    580}