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
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 javax.activation.DataHandler;
29 import javax.activation.DataSource;
30 import javax.activation.FileDataSource;
31 import javax.activation.URLDataSource;
32 import javax.mail.BodyPart;
33 import javax.mail.MessagingException;
34 import javax.mail.internet.MimeBodyPart;
35 import javax.mail.internet.MimeMultipart;
36
37 import org.apache.commons.mail2.core.EmailConstants;
38 import org.apache.commons.mail2.core.EmailException;
39 import org.apache.commons.mail2.core.EmailUtils;
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("<html><body>");
68 * msg.append("<img src=cid:").append(he.embed(img)).append(">");
69 * msg.append("<img src=cid:").append(he.embed(png)).append(">");
70 * msg.append("</body></html>");
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<String, InlineImage> 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 javax.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 javax.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 javax.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 javax.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 javax.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 javax.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 javax.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 }