001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.commons.mail;
018
019import java.io.File;
020import java.io.IOException;
021import java.io.InputStream;
022import java.io.UnsupportedEncodingException;
023import java.net.MalformedURLException;
024import java.net.URL;
025import java.util.HashMap;
026import java.util.List;
027import java.util.Locale;
028import java.util.Map;
029
030import javax.activation.DataHandler;
031import javax.activation.DataSource;
032import javax.activation.FileDataSource;
033import javax.activation.URLDataSource;
034import javax.mail.BodyPart;
035import javax.mail.MessagingException;
036import javax.mail.internet.MimeBodyPart;
037import javax.mail.internet.MimeMultipart;
038
039/**
040 * An HTML multipart email.
041 *
042 * <p>This class is used to send HTML formatted email.  A text message
043 * can also be set for HTML unaware email clients, such as text-based
044 * email clients.
045 *
046 * <p>This class also inherits from {@link MultiPartEmail}, so it is easy to
047 * add attachments to the email.
048 *
049 * <p>To send an email in HTML, one should create a <code>HtmlEmail</code>, then
050 * use the {@link #setFrom(String)}, {@link #addTo(String)} etc. methods.
051 * The HTML content can be set with the {@link #setHtmlMsg(String)} method. The
052 * alternative text content can be set with {@link #setTextMsg(String)}.
053 *
054 * <p>Either the text or HTML can be omitted, in which case the "main"
055 * part of the multipart becomes whichever is supplied rather than a
056 * <code>multipart/alternative</code>.
057 *
058 * <h3>Embedding Images and Media</h3>
059 *
060 * <p>It is also possible to embed URLs, files, or arbitrary
061 * <code>DataSource</code>s directly into the body of the mail:
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 *
075 * <p>Embedded entities are tracked by their name, which for <code>File</code>s is
076 * the filename itself and for <code>URL</code>s is the canonical path. It is
077 * an error to bind the same name to more than one entity, and this class will
078 * attempt to validate that for <code>File</code>s and <code>URL</code>s. When
079 * embedding a <code>DataSource</code>, the code uses the <code>equals()</code>
080 * method defined on the <code>DataSource</code>s to make the determination.
081 *
082 * @since 1.0
083 * @version $Id: HtmlEmail.html 952467 2015-05-23 18:45:36Z tn $
084 */
085public class HtmlEmail extends MultiPartEmail
086{
087    /** Definition of the length of generated CID's. */
088    public static final int CID_LENGTH = 10;
089
090    /** prefix for default HTML mail. */
091    private static final String HTML_MESSAGE_START = "<html><body><pre>";
092    /** suffix for default HTML mail. */
093    private static final String HTML_MESSAGE_END = "</pre></body></html>";
094
095
096    /**
097     * Text part of the message. This will be used as alternative text if
098     * the email client does not support HTML messages.
099     */
100    protected String text;
101
102    /** Html part of the message. */
103    protected String html;
104
105    /**
106     * @deprecated As of commons-email 1.1, no longer used. Inline embedded
107     * objects are now stored in {@link #inlineEmbeds}.
108     */
109    @Deprecated
110    protected List<InlineImage> inlineImages;
111
112    /**
113     * Embedded images Map&lt;String, InlineImage&gt; where the key is the
114     * user-defined image name.
115     */
116    protected Map<String, InlineImage> inlineEmbeds = new HashMap<String, InlineImage>();
117
118    /**
119     * Set the text content.
120     *
121     * @param aText A String.
122     * @return An HtmlEmail.
123     * @throws EmailException see javax.mail.internet.MimeBodyPart
124     *  for definitions
125     * @since 1.0
126     */
127    public HtmlEmail setTextMsg(final String aText) throws EmailException
128    {
129        if (EmailUtils.isEmpty(aText))
130        {
131            throw new EmailException("Invalid message supplied");
132        }
133
134        this.text = aText;
135        return this;
136    }
137
138    /**
139     * Set the HTML content.
140     *
141     * @param aHtml A String.
142     * @return An HtmlEmail.
143     * @throws EmailException see javax.mail.internet.MimeBodyPart
144     *  for definitions
145     * @since 1.0
146     */
147    public HtmlEmail setHtmlMsg(final String aHtml) throws EmailException
148    {
149        if (EmailUtils.isEmpty(aHtml))
150        {
151            throw new EmailException("Invalid message supplied");
152        }
153
154        this.html = aHtml;
155        return this;
156    }
157
158    /**
159     * Set the message.
160     *
161     * <p>This method overrides {@link MultiPartEmail#setMsg(String)} in
162     * order to send an HTML message instead of a plain text message in
163     * the mail body. The message is formatted in HTML for the HTML
164     * part of the message; it is left as is in the alternate text
165     * part.
166     *
167     * @param msg the message text to use
168     * @return this <code>HtmlEmail</code>
169     * @throws EmailException if msg is null or empty;
170     * see javax.mail.internet.MimeBodyPart for definitions
171     * @since 1.0
172     */
173    @Override
174    public Email setMsg(final String msg) throws EmailException
175    {
176        if (EmailUtils.isEmpty(msg))
177        {
178            throw new EmailException("Invalid message supplied");
179        }
180
181        setTextMsg(msg);
182
183        final StringBuffer htmlMsgBuf = new StringBuffer(
184            msg.length()
185            + HTML_MESSAGE_START.length()
186            + HTML_MESSAGE_END.length()
187        );
188
189        htmlMsgBuf.append(HTML_MESSAGE_START)
190            .append(msg)
191            .append(HTML_MESSAGE_END);
192
193        setHtmlMsg(htmlMsgBuf.toString());
194
195        return this;
196    }
197
198    /**
199     * Attempts to parse the specified <code>String</code> as a URL that will
200     * then be embedded in the message.
201     *
202     * @param urlString String representation of the URL.
203     * @param name The name that will be set in the filename header field.
204     * @return A String with the Content-ID of the URL.
205     * @throws EmailException when URL supplied is invalid or if {@code name} is null
206     * or empty; also see {@link javax.mail.internet.MimeBodyPart} for definitions
207     *
208     * @see #embed(URL, String)
209     * @since 1.1
210     */
211    public String embed(final String urlString, final String name) throws EmailException
212    {
213        try
214        {
215            return embed(new URL(urlString), name);
216        }
217        catch (final MalformedURLException e)
218        {
219            throw new EmailException("Invalid URL", e);
220        }
221    }
222
223    /**
224     * Embeds an URL in the HTML.
225     *
226     * <p>This method embeds a file located by an URL into
227     * the mail body. It allows, for instance, to add inline images
228     * to the email.  Inline files may be referenced with a
229     * <code>cid:xxxxxx</code> URL, where xxxxxx is the Content-ID
230     * returned by the embed function. It is an error to bind the same name
231     * to more than one URL; if the same URL is embedded multiple times, the
232     * same Content-ID is guaranteed to be returned.
233     *
234     * <p>While functionally the same as passing <code>URLDataSource</code> to
235     * {@link #embed(DataSource, String, String)}, this method attempts
236     * to validate the URL before embedding it in the message and will throw
237     * <code>EmailException</code> if the validation fails. In this case, the
238     * <code>HtmlEmail</code> object will not be changed.
239     *
240     * <p>
241     * NOTE: Clients should take care to ensure that different URLs are bound to
242     * different names. This implementation tries to detect this and throw
243     * <code>EmailException</code>. However, it is not guaranteed to catch
244     * all cases, especially when the URL refers to a remote HTTP host that
245     * may be part of a virtual host cluster.
246     *
247     * @param url The URL of the file.
248     * @param name The name that will be set in the filename header
249     * field.
250     * @return A String with the Content-ID of the file.
251     * @throws EmailException when URL supplied is invalid or if {@code name} is null
252     * or empty; also see {@link javax.mail.internet.MimeBodyPart} for definitions
253     * @since 1.0
254     */
255    public String embed(final URL url, final String name) throws EmailException
256    {
257        if (EmailUtils.isEmpty(name))
258        {
259            throw new EmailException("name cannot be null or empty");
260        }
261
262        // check if a URLDataSource for this name has already been attached;
263        // if so, return the cached CID value.
264        if (inlineEmbeds.containsKey(name))
265        {
266            final InlineImage ii = inlineEmbeds.get(name);
267            final URLDataSource urlDataSource = (URLDataSource) ii.getDataSource();
268            // make sure the supplied URL points to the same thing
269            // as the one already associated with this name.
270            // NOTE: Comparing URLs with URL.equals() is a blocking operation
271            // in the case of a network failure therefore we use
272            // url.toExternalForm().equals() here.
273            if (url.toExternalForm().equals(urlDataSource.getURL().toExternalForm()))
274            {
275                return ii.getCid();
276            }
277            throw new EmailException("embedded name '" + name
278                + "' is already bound to URL " + urlDataSource.getURL()
279                + "; existing names cannot be rebound");
280        }
281
282        // verify that the URL is valid
283        InputStream is = null;
284        try
285        {
286            is = url.openStream();
287        }
288        catch (final IOException e)
289        {
290            throw new EmailException("Invalid URL", e);
291        }
292        finally
293        {
294            try
295            {
296                if (is != null)
297                {
298                    is.close();
299                }
300            }
301            catch (final IOException ioe) // NOPMD
302            { /* sigh */ }
303        }
304
305        return embed(new URLDataSource(url), name);
306    }
307
308    /**
309     * Embeds a file in the HTML. This implementation delegates to
310     * {@link #embed(File, String)}.
311     *
312     * @param file The <code>File</code> object to embed
313     * @return A String with the Content-ID of the file.
314     * @throws EmailException when the supplied <code>File</code> cannot be
315     * used; also see {@link javax.mail.internet.MimeBodyPart} for definitions
316     *
317     * @see #embed(File, String)
318     * @since 1.1
319     */
320    public String embed(final File file) throws EmailException
321    {
322        final String cid = EmailUtils.randomAlphabetic(HtmlEmail.CID_LENGTH).toLowerCase(Locale.ENGLISH);
323        return embed(file, cid);
324    }
325
326    /**
327     * Embeds a file in the HTML.
328     *
329     * <p>This method embeds a file located by an URL into
330     * the mail body. It allows, for instance, to add inline images
331     * to the email.  Inline files may be referenced with a
332     * <code>cid:xxxxxx</code> URL, where xxxxxx is the Content-ID
333     * returned by the embed function. Files are bound to their names, which is
334     * the value returned by {@link java.io.File#getName()}. If the same file
335     * is embedded multiple times, the same CID is guaranteed to be returned.
336     *
337     * <p>While functionally the same as passing <code>FileDataSource</code> to
338     * {@link #embed(DataSource, String, String)}, this method attempts
339     * to validate the file before embedding it in the message and will throw
340     * <code>EmailException</code> if the validation fails. In this case, the
341     * <code>HtmlEmail</code> object will not be changed.
342     *
343     * @param file The <code>File</code> to embed
344     * @param cid the Content-ID to use for the embedded <code>File</code>
345     * @return A String with the Content-ID of the file.
346     * @throws EmailException when the supplied <code>File</code> cannot be used
347     *  or if the file has already been embedded;
348     *  also see {@link javax.mail.internet.MimeBodyPart} for definitions
349     * @since 1.1
350     */
351    public String embed(final File file, final String cid) throws EmailException
352    {
353        if (EmailUtils.isEmpty(file.getName()))
354        {
355            throw new EmailException("file name cannot be null or empty");
356        }
357
358        // verify that the File can provide a canonical path
359        String filePath = null;
360        try
361        {
362            filePath = file.getCanonicalPath();
363        }
364        catch (final IOException ioe)
365        {
366            throw new EmailException("couldn't get canonical path for "
367                    + file.getName(), ioe);
368        }
369
370        // check if a FileDataSource for this name has already been attached;
371        // if so, return the cached CID value.
372        if (inlineEmbeds.containsKey(file.getName()))
373        {
374            final InlineImage ii = inlineEmbeds.get(file.getName());
375            final FileDataSource fileDataSource = (FileDataSource) ii.getDataSource();
376            // make sure the supplied file has the same canonical path
377            // as the one already associated with this name.
378            String existingFilePath = null;
379            try
380            {
381                existingFilePath = fileDataSource.getFile().getCanonicalPath();
382            }
383            catch (final IOException ioe)
384            {
385                throw new EmailException("couldn't get canonical path for file "
386                        + fileDataSource.getFile().getName()
387                        + "which has already been embedded", ioe);
388            }
389            if (filePath.equals(existingFilePath))
390            {
391                return ii.getCid();
392            }
393            throw new EmailException("embedded name '" + file.getName()
394                + "' is already bound to file " + existingFilePath
395                + "; existing names cannot be rebound");
396        }
397
398        // verify that the file is valid
399        if (!file.exists())
400        {
401            throw new EmailException("file " + filePath + " doesn't exist");
402        }
403        if (!file.isFile())
404        {
405            throw new EmailException("file " + filePath + " isn't a normal file");
406        }
407        if (!file.canRead())
408        {
409            throw new EmailException("file " + filePath + " isn't readable");
410        }
411
412        return embed(new FileDataSource(file), file.getName(), cid);
413    }
414
415    /**
416     * Embeds the specified <code>DataSource</code> in the HTML using a
417     * randomly generated Content-ID. Returns the generated Content-ID string.
418     *
419     * @param dataSource the <code>DataSource</code> to embed
420     * @param name the name that will be set in the filename header field
421     * @return the generated Content-ID for this <code>DataSource</code>
422     * @throws EmailException if the embedding fails or if <code>name</code> is
423     * null or empty
424     * @see #embed(DataSource, String, String)
425     * @since 1.1
426     */
427    public String embed(final DataSource dataSource, final String name) throws EmailException
428    {
429        // check if the DataSource has already been attached;
430        // if so, return the cached CID value.
431        if (inlineEmbeds.containsKey(name))
432        {
433            final InlineImage ii = inlineEmbeds.get(name);
434            // make sure the supplied URL points to the same thing
435            // as the one already associated with this name.
436            if (dataSource.equals(ii.getDataSource()))
437            {
438                return ii.getCid();
439            }
440            throw new EmailException("embedded DataSource '" + name
441                + "' is already bound to name " + ii.getDataSource().toString()
442                + "; existing names cannot be rebound");
443        }
444
445        final String cid = EmailUtils.randomAlphabetic(HtmlEmail.CID_LENGTH).toLowerCase();
446        return embed(dataSource, name, cid);
447    }
448
449    /**
450     * Embeds the specified <code>DataSource</code> in the HTML using the
451     * specified Content-ID. Returns the specified Content-ID string.
452     *
453     * @param dataSource the <code>DataSource</code> to embed
454     * @param name the name that will be set in the filename header field
455     * @param cid the Content-ID to use for this <code>DataSource</code>
456     * @return the URL encoded Content-ID for this <code>DataSource</code>
457     * @throws EmailException if the embedding fails or if <code>name</code> is
458     * null or empty
459     * @since 1.1
460     */
461    public String embed(final DataSource dataSource, final String name, final String cid)
462        throws EmailException
463    {
464        if (EmailUtils.isEmpty(name))
465        {
466            throw new EmailException("name cannot be null or empty");
467        }
468
469        final MimeBodyPart mbp = new MimeBodyPart();
470
471        try
472        {
473            // URL encode the cid according to RFC 2392
474            String encodedCid = EmailUtils.encodeUrl(cid);
475
476            mbp.setDataHandler(new DataHandler(dataSource));
477            mbp.setFileName(name);
478            mbp.setDisposition(EmailAttachment.INLINE);
479            mbp.setContentID("<" + encodedCid + ">");
480
481            final InlineImage ii = new InlineImage(encodedCid, dataSource, mbp);
482            this.inlineEmbeds.put(name, ii);
483
484            return encodedCid;
485        }
486        catch (final MessagingException me)
487        {
488            throw new EmailException(me);
489        }
490        catch (final UnsupportedEncodingException uee)
491        {
492            throw new EmailException(uee);
493        }
494    }
495
496    /**
497     * Does the work of actually building the MimeMessage. Please note that
498     * a user rarely calls this method directly and only if he/she is
499     * interested in the sending the underlying MimeMessage without
500     * commons-email.
501     *
502     * @exception EmailException if there was an error.
503     * @since 1.0
504     */
505    @Override
506    public void buildMimeMessage() throws EmailException
507    {
508        try
509        {
510            build();
511        }
512        catch (final MessagingException me)
513        {
514            throw new EmailException(me);
515        }
516        super.buildMimeMessage();
517    }
518
519    /**
520     * @throws EmailException EmailException
521     * @throws MessagingException MessagingException
522     */
523    private void build() throws MessagingException, EmailException
524    {
525        final MimeMultipart rootContainer = this.getContainer();
526        MimeMultipart bodyEmbedsContainer = rootContainer;
527        MimeMultipart bodyContainer = rootContainer;
528        MimeBodyPart msgHtml = null;
529        MimeBodyPart msgText = null;
530
531        rootContainer.setSubType("mixed");
532
533        // determine how to form multiparts of email
534
535        if (EmailUtils.isNotEmpty(this.html) && this.inlineEmbeds.size() > 0)
536        {
537            //If HTML body and embeds are used, create a related container and add it to the root container
538            bodyEmbedsContainer = new MimeMultipart("related");
539            bodyContainer = bodyEmbedsContainer;
540            this.addPart(bodyEmbedsContainer, 0);
541
542            // If TEXT body was specified, create a alternative container and add it to the embeds container
543            if (EmailUtils.isNotEmpty(this.text))
544            {
545                bodyContainer = new MimeMultipart("alternative");
546                final BodyPart bodyPart = createBodyPart();
547                try
548                {
549                    bodyPart.setContent(bodyContainer);
550                    bodyEmbedsContainer.addBodyPart(bodyPart, 0);
551                }
552                catch (final MessagingException me)
553                {
554                    throw new EmailException(me);
555                }
556            }
557        }
558        else if (EmailUtils.isNotEmpty(this.text) && EmailUtils.isNotEmpty(this.html))
559        {
560            // EMAIL-142: if we have both an HTML and TEXT body, but no attachments or
561            //            inline images, the root container should have mimetype
562            //            "multipart/alternative".
563            // reference: http://tools.ietf.org/html/rfc2046#section-5.1.4
564            if (this.inlineEmbeds.size() > 0 || isBoolHasAttachments())
565            {
566                // If both HTML and TEXT bodies are provided, create an alternative
567                // container and add it to the root container
568                bodyContainer = new MimeMultipart("alternative");
569                this.addPart(bodyContainer, 0);
570            }
571            else
572            {
573                // no attachments or embedded images present, change the mimetype
574                // of the root container (= body container)
575                rootContainer.setSubType("alternative");
576            }
577        }
578
579        if (EmailUtils.isNotEmpty(this.html))
580        {
581            msgHtml = new MimeBodyPart();
582            bodyContainer.addBodyPart(msgHtml, 0);
583
584            // EMAIL-104: call explicitly setText to use default mime charset
585            //            (property "mail.mime.charset") in case none has been set
586            msgHtml.setText(this.html, this.charset, EmailConstants.TEXT_SUBTYPE_HTML);
587
588            // EMAIL-147: work-around for buggy JavaMail implementations;
589            //            in case setText(...) does not set the correct content type,
590            //            use the setContent() method instead.
591            final String contentType = msgHtml.getContentType();
592            if (contentType == null || !contentType.equals(EmailConstants.TEXT_HTML))
593            {
594                // apply default charset if one has been set
595                if (EmailUtils.isNotEmpty(this.charset))
596                {
597                    msgHtml.setContent(this.html, EmailConstants.TEXT_HTML + "; charset=" + this.charset);
598                }
599                else
600                {
601                    // unfortunately, MimeUtility.getDefaultMIMECharset() is package private
602                    // and thus can not be used to set the default system charset in case
603                    // no charset has been provided by the user
604                    msgHtml.setContent(this.html, EmailConstants.TEXT_HTML);
605                }
606            }
607
608            for (final InlineImage image : this.inlineEmbeds.values())
609            {
610                bodyEmbedsContainer.addBodyPart(image.getMbp());
611            }
612        }
613
614        if (EmailUtils.isNotEmpty(this.text))
615        {
616            msgText = new MimeBodyPart();
617            bodyContainer.addBodyPart(msgText, 0);
618
619            // EMAIL-104: call explicitly setText to use default mime charset
620            //            (property "mail.mime.charset") in case none has been set
621            msgText.setText(this.text, this.charset);
622        }
623    }
624
625    /**
626     * Private bean class that encapsulates data about URL contents
627     * that are embedded in the final email.
628     * @since 1.1
629     */
630    private static class InlineImage
631    {
632        /** content id. */
633        private final String cid;
634        /** <code>DataSource</code> for the content. */
635        private final DataSource dataSource;
636        /** the <code>MimeBodyPart</code> that contains the encoded data. */
637        private final MimeBodyPart mbp;
638
639        /**
640         * Creates an InlineImage object to represent the
641         * specified content ID and <code>MimeBodyPart</code>.
642         * @param cid the generated content ID
643         * @param dataSource the <code>DataSource</code> that represents the content
644         * @param mbp the <code>MimeBodyPart</code> that contains the encoded
645         * data
646         */
647        public InlineImage(final String cid, final DataSource dataSource, final MimeBodyPart mbp)
648        {
649            this.cid = cid;
650            this.dataSource = dataSource;
651            this.mbp = mbp;
652        }
653
654        /**
655         * Returns the unique content ID of this InlineImage.
656         * @return the unique content ID of this InlineImage
657         */
658        public String getCid()
659        {
660            return cid;
661        }
662
663        /**
664         * Returns the <code>DataSource</code> that represents the encoded content.
665         * @return the <code>DataSource</code> representing the encoded content
666         */
667        public DataSource getDataSource()
668        {
669            return dataSource;
670        }
671
672        /**
673         * Returns the <code>MimeBodyPart</code> that contains the
674         * encoded InlineImage data.
675         * @return the <code>MimeBodyPart</code> containing the encoded
676         * InlineImage data
677         */
678        public MimeBodyPart getMbp()
679        {
680            return mbp;
681        }
682
683        // equals()/hashCode() implementations, since this class
684        // is stored as a entry in a Map.
685        /**
686         * {@inheritDoc}
687         * @return true if the other object is also an InlineImage with the same cid.
688         */
689        @Override
690        public boolean equals(final Object obj)
691        {
692            if (this == obj)
693            {
694                return true;
695            }
696            if (!(obj instanceof InlineImage))
697            {
698                return false;
699            }
700
701            final InlineImage that = (InlineImage) obj;
702
703            return this.cid.equals(that.cid);
704        }
705
706        /**
707         * {@inheritDoc}
708         * @return the cid hashCode.
709         */
710        @Override
711        public int hashCode()
712        {
713            return cid.hashCode();
714        }
715    }
716}