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