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