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("<html><body>"); 068 * msg.append("<img src=cid:").append(he.embed(img)).append(">"); 069 * msg.append("<img src=cid:").append(he.embed(png)).append(">"); 070 * msg.append("</body></html>"); 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<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 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}