View Javadoc
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.jakarta;
18  
19  import java.io.File;
20  import java.io.IOException;
21  import java.io.InputStream;
22  import java.net.MalformedURLException;
23  import java.net.URL;
24  import java.util.HashMap;
25  import java.util.Map;
26  import java.util.Objects;
27  
28  import org.apache.commons.mail2.core.EmailConstants;
29  import org.apache.commons.mail2.core.EmailException;
30  import org.apache.commons.mail2.core.EmailUtils;
31  
32  import jakarta.activation.DataHandler;
33  import jakarta.activation.DataSource;
34  import jakarta.activation.FileDataSource;
35  import jakarta.activation.URLDataSource;
36  import jakarta.mail.BodyPart;
37  import jakarta.mail.MessagingException;
38  import jakarta.mail.internet.MimeBodyPart;
39  import jakarta.mail.internet.MimeMultipart;
40  
41  /**
42   * An HTML multipart email.
43   * <p>
44   * 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.
45   * </p>
46   * <p>
47   * This class also inherits from {@link MultiPartEmail}, so it is easy to add attachments to the email.
48   * </p>
49   * <p>
50   * 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
51   * can be set with the {@link #setHtmlMsg(String)} method. The alternative text content can be set with {@link #setTextMsg(String)}.
52   * </p>
53   * <p>
54   * Either the text or HTML can be omitted, in which case the "main" part of the multipart becomes whichever is supplied rather than a
55   * {@code multipart/alternative}.
56   * </p>
57   * <h2>Embedding Images and Media</h2>
58   * <p>
59   * It is also possible to embed URLs, files, or arbitrary {@code DataSource}s directly into the body of the mail:
60   * </p>
61   *
62   * <pre>
63   * HtmlEmail he = new HtmlEmail();
64   * File img = new File("my/image.gif");
65   * PNGDataSource png = new PNGDataSource(decodedPNGOutputStream); // a custom class
66   * StringBuffer msg = new StringBuffer();
67   * msg.append("&lt;html&gt;&lt;body&gt;");
68   * msg.append("&lt;img src=cid:").append(he.embed(img)).append("&gt;");
69   * msg.append("&lt;img src=cid:").append(he.embed(png)).append("&gt;");
70   * msg.append("&lt;/body&gt;&lt;/html&gt;");
71   * he.setHtmlMsg(msg.toString());
72   * // code to set the other email fields (not shown)
73   * </pre>
74   * <p>
75   * 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
76   * 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
77   * {@code DataSource}, the code uses the {@code equals()} method defined on the {@code DataSource}s to make the determination.
78   * </p>
79   *
80   * @since 1.0
81   */
82  public class HtmlEmail extends MultiPartEmail {
83  
84      /**
85       * Private bean class that encapsulates data about URL contents that are embedded in the final email.
86       *
87       * @since 1.1
88       */
89      private static final class InlineImage {
90  
91          /** Content id. */
92          private final String cid;
93  
94          /** {@code DataSource} for the content. */
95          private final DataSource dataSource;
96  
97          /** The {@code MimeBodyPart} that contains the encoded data. */
98          private final MimeBodyPart mimeBodyPart;
99  
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 }