package eu.faircode.email; /* This file is part of FairEmail. FairEmail is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. NetGuard is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with NetGuard. If not, see <http://www.gnu.org/licenses/>. Copyright 2018 by Marcel Bokhorst (M66B) */ import android.content.Context; import android.text.TextUtils; import android.util.Base64; import android.util.Log; import android.webkit.MimeTypeMap; import org.jsoup.Jsoup; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Properties; import javax.activation.DataHandler; import javax.activation.FileDataSource; import javax.activation.FileTypeMap; import javax.mail.Address; import javax.mail.BodyPart; import javax.mail.Flags; import javax.mail.Message; import javax.mail.MessagingException; import javax.mail.Multipart; import javax.mail.Part; import javax.mail.Session; import javax.mail.internet.ContentType; import javax.mail.internet.InternetAddress; import javax.mail.internet.MimeBodyPart; import javax.mail.internet.MimeMessage; import javax.mail.internet.MimeMultipart; import javax.mail.internet.ParseException; public class MessageHelper { private MimeMessage imessage; private String raw = null; final static int NETWORK_TIMEOUT = 60 * 1000; // milliseconds static Properties getSessionProperties(int auth_type) { Properties props = new Properties(); // https://javaee.github.io/javamail/docs/api/com/sun/mail/imap/package-summary.html#properties props.put("mail.imaps.ssl.checkserveridentity", "true"); props.put("mail.imaps.ssl.trust", "*"); props.put("mail.imaps.starttls.enable", "false"); // TODO: make timeouts configurable? props.put("mail.imaps.connectiontimeout", Integer.toString(NETWORK_TIMEOUT)); props.put("mail.imaps.timeout", Integer.toString(NETWORK_TIMEOUT)); props.put("mail.imaps.writetimeout", Integer.toString(NETWORK_TIMEOUT)); // one thread overhead props.put("mail.imaps.connectionpool.debug", "true"); props.put("mail.imaps.connectionpooltimeout", Integer.toString(3 * 60 * 1000)); // default: 45 sec // "mail.imaps.finalizecleanclose" // https://tools.ietf.org/html/rfc4978 // https://docs.oracle.com/javase/8/docs/api/java/util/zip/Deflater.html if (true) { Log.i(Helper.TAG, "IMAP compress enabled"); props.put("mail.imaps.compress.enable", "true"); //props.put("mail.imaps.compress.level", "-1"); //props.put("mail.imaps.compress.strategy", "0"); } props.put("mail.imaps.fetchsize", Integer.toString(48 * 1024)); // default 16K props.put("mail.imaps.peek", "true"); // https://javaee.github.io/javamail/docs/api/com/sun/mail/smtp/package-summary.html#properties props.put("mail.smtps.ssl.checkserveridentity", "true"); props.put("mail.smtps.ssl.trust", "*"); props.put("mail.smtps.starttls.enable", "false"); props.put("mail.smtps.starttls.required", "false"); props.put("mail.smtps.auth", "true"); props.put("mail.smtps.connectiontimeout", Integer.toString(NETWORK_TIMEOUT)); props.put("mail.smtps.writetimeout", Integer.toString(NETWORK_TIMEOUT)); // one thread overhead props.put("mail.smtps.timeout", Integer.toString(NETWORK_TIMEOUT)); props.put("mail.smtp.ssl.checkserveridentity", "true"); props.put("mail.smtp.ssl.trust", "*"); props.put("mail.smtp.starttls.enable", "true"); props.put("mail.smtp.starttls.required", "true"); props.put("mail.smtp.auth", "true"); props.put("mail.smtp.connectiontimeout", Integer.toString(NETWORK_TIMEOUT)); props.put("mail.smtp.writetimeout", Integer.toString(NETWORK_TIMEOUT)); // one thread overhead props.put("mail.smtp.timeout", Integer.toString(NETWORK_TIMEOUT)); props.put("mail.mime.address.strict", "false"); props.put("mail.mime.decodetext.strict", "false"); props.put("mail.mime.ignoreunknownencoding", "true"); // Content-Transfer-Encoding props.put("mail.mime.decodefilename", "true"); props.put("mail.mime.encodefilename", "true"); // https://docs.oracle.com/javaee/6/api/javax/mail/internet/MimeMultipart.html props.put("mail.mime.multipart.ignoremissingboundaryparameter", "true"); // javax.mail.internet.ParseException: In parameter list props.put("mail.mime.multipart.ignoreexistingboundaryparameter", "true"); // The documentation is unclear/inconsistent whether this are system or session properties: System.setProperty("mail.mime.address.strict", "false"); System.setProperty("mail.mime.decodetext.strict", "false"); System.setProperty("mail.mime.ignoreunknownencoding", "true"); // Content-Transfer-Encoding System.setProperty("mail.mime.decodefilename", "true"); System.setProperty("mail.mime.encodefilename", "true"); System.setProperty("mail.mime.multipart.ignoremissingboundaryparameter", "true"); // javax.mail.internet.ParseException: In parameter list System.setProperty("mail.mime.multipart.ignoreexistingboundaryparameter", "true"); if (false) { Log.i(Helper.TAG, "Prefering IPv4"); System.setProperty("java.net.preferIPv4Stack", "true"); } // https://javaee.github.io/javamail/OAuth2 Log.i(Helper.TAG, "Auth type=" + auth_type); if (auth_type == Helper.AUTH_TYPE_GMAIL) { props.put("mail.imaps.auth.mechanisms", "XOAUTH2"); props.put("mail.smtps.auth.mechanisms", "XOAUTH2"); props.put("mail.smtp.auth.mechanisms", "XOAUTH2"); } return props; } static MimeMessageEx from(Context context, EntityMessage message, EntityMessage reply, List<EntityAttachment> attachments, Session isession) throws MessagingException, IOException { MimeMessageEx imessage = new MimeMessageEx(isession, message.msgid); if (reply == null) imessage.addHeader("References", message.msgid); else { imessage.addHeader("In-Reply-To", reply.msgid); imessage.addHeader("References", (reply.references == null ? "" : reply.references + " ") + reply.msgid); } imessage.setFlag(Flags.Flag.SEEN, message.seen); if (message.from != null && message.from.length > 0) imessage.setFrom(message.from[0]); if (message.to != null && message.to.length > 0) imessage.setRecipients(Message.RecipientType.TO, message.to); if (message.cc != null && message.cc.length > 0) imessage.setRecipients(Message.RecipientType.CC, message.cc); if (message.bcc != null && message.bcc.length > 0) imessage.setRecipients(Message.RecipientType.BCC, message.bcc); if (message.subject != null) imessage.setSubject(message.subject); // TODO: plain message? String body = message.read(context); BodyPart plain = new MimeBodyPart(); plain.setContent(Jsoup.parse(body).text(), "text/plain; charset=" + Charset.defaultCharset().name()); BodyPart html = new MimeBodyPart(); html.setContent(body, "text/html; charset=" + Charset.defaultCharset().name()); Multipart alternative = new MimeMultipart("alternative"); alternative.addBodyPart(plain); alternative.addBodyPart(html); if (attachments.size() == 0) { imessage.setContent(alternative); } else { Multipart multipart = new MimeMultipart("mixed"); BodyPart bp = new MimeBodyPart(); bp.setContent(alternative); multipart.addBodyPart(bp); for (final EntityAttachment attachment : attachments) if (attachment.available) { BodyPart bpAttachment = new MimeBodyPart(); bpAttachment.setFileName(attachment.name); File file = EntityAttachment.getFile(context, attachment.id); FileDataSource dataSource = new FileDataSource(file); dataSource.setFileTypeMap(new FileTypeMap() { @Override public String getContentType(File file) { return attachment.type; } @Override public String getContentType(String filename) { return attachment.type; } }); bpAttachment.setDataHandler(new DataHandler(dataSource)); if (attachment.cid != null) bpAttachment.setHeader("Content-ID", attachment.cid); multipart.addBodyPart(bpAttachment); } imessage.setContent(multipart); } imessage.setSentDate(new Date()); return imessage; } MessageHelper(MimeMessage message) { this.imessage = message; } MessageHelper(String raw, Session isession) throws MessagingException { byte[] bytes = Base64.decode(raw, Base64.URL_SAFE); InputStream is = new ByteArrayInputStream(bytes); this.imessage = new MimeMessage(isession, is); } boolean getSeen() throws MessagingException { return imessage.isSet(Flags.Flag.SEEN); } boolean getFlagged() throws MessagingException { return imessage.isSet(Flags.Flag.FLAGGED); } String getMessageID() throws MessagingException { return imessage.getHeader("Message-ID", null); } String[] getReferences() throws MessagingException { String refs = imessage.getHeader("References", null); return (refs == null ? new String[0] : refs.split("\\s+")); } String getDeliveredTo() throws MessagingException { return imessage.getHeader("Delivered-To", imessage.getHeader("X-Delivered-To", null)); } String getInReplyTo() throws MessagingException { return imessage.getHeader("In-Reply-To", null); } String getThreadId(long uid) throws MessagingException { for (String ref : getReferences()) if (!TextUtils.isEmpty(ref)) return ref; String msgid = getMessageID(); return (TextUtils.isEmpty(msgid) ? Long.toString(uid) : msgid); } Address[] getFrom() throws MessagingException { return imessage.getFrom(); } Address[] getTo() throws MessagingException { return imessage.getRecipients(Message.RecipientType.TO); } Address[] getCc() throws MessagingException { return imessage.getRecipients(Message.RecipientType.CC); } Address[] getBcc() throws MessagingException { return imessage.getRecipients(Message.RecipientType.BCC); } Address[] getReply() throws MessagingException { String[] headers = imessage.getHeader("Reply-To"); if (headers != null && headers.length > 0) return imessage.getReplyTo(); else return null; } Integer getSize() throws MessagingException { int size = imessage.getSize(); return (size < 0 ? null : size); } static String getFormattedAddresses(Address[] addresses, boolean full) { if (addresses == null || addresses.length == 0) return ""; List<String> formatted = new ArrayList<>(); for (Address address : addresses) if (address instanceof InternetAddress) { InternetAddress a = (InternetAddress) address; String personal = a.getPersonal(); if (TextUtils.isEmpty(personal)) formatted.add(address.toString()); else { personal = personal.replaceAll("[\\,\\<\\>]", ""); if (full) formatted.add(personal + " <" + a.getAddress() + ">"); else formatted.add(personal); } } else formatted.add(address.toString()); return TextUtils.join(", ", formatted); } String getHtml() throws MessagingException, IOException { return getHtml(imessage); } private static String getHtml(Part part) throws MessagingException, IOException { if (part.isMimeType("text/*")) { String s; try { s = part.getContent().toString(); } catch (UnsupportedEncodingException ex) { // x-binaryenc Log.w(Helper.TAG, "Unsupported encoding: " + part.getContentType()); // https://javaee.github.io/javamail/FAQ#unsupen InputStream is = part.getInputStream(); ByteArrayOutputStream os = new ByteArrayOutputStream(); byte[] buffer = new byte[4096]; for (int len = is.read(buffer); len != -1; len = is.read(buffer)) os.write(buffer, 0, len); s = new String(os.toByteArray(), "US-ASCII"); } catch (IOException ex) { // IOException; Unknown encoding: none Log.w(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); s = ex.toString(); } if (part.isMimeType("text/plain")) s = "<pre>" + s.replaceAll("\\r?\\n", "<br />") + "</pre>"; return s; } if (part.isMimeType("multipart/alternative")) { String text = null; try { Multipart mp = (Multipart) part.getContent(); for (int i = 0; i < mp.getCount(); i++) { Part bp = mp.getBodyPart(i); if (bp.isMimeType("text/plain")) { if (text == null) text = getHtml(bp); } else if (bp.isMimeType("text/html")) { String s = getHtml(bp); if (s != null) return s; } else return getHtml(bp); } } catch (ParseException ex) { // ParseException: In parameter list boundary="...">, expected parameter name, got ";" Log.w(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); text = ex.toString(); } return text; } if (part.isMimeType("multipart/*")) try { Multipart mp = (Multipart) part.getContent(); for (int i = 0; i < mp.getCount(); i++) { String s = getHtml(mp.getBodyPart(i)); if (s != null) return s; } } catch (ParseException ex) { Log.w(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); return ex.toString(); } return null; } public List<EntityAttachment> getAttachments() throws IOException, MessagingException { List<EntityAttachment> result = new ArrayList<>(); try { Object content = imessage.getContent(); if (content instanceof String) return result; if (content instanceof Multipart) { Multipart multipart = (Multipart) content; for (int i = 0; i < multipart.getCount(); i++) result.addAll(getAttachments(multipart.getBodyPart(i))); } } catch (ParseException ex) { Log.w(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); } return result; } private static List<EntityAttachment> getAttachments(BodyPart part) throws IOException, MessagingException { List<EntityAttachment> result = new ArrayList<>(); Object content; try { content = part.getContent(); } catch (UnsupportedEncodingException ex) { Log.w(Helper.TAG, "attachment content type=" + part.getContentType()); content = part.getInputStream(); } catch (ParseException ex) { Log.w(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); content = null; } if (content instanceof InputStream || content instanceof String) { String disposition; try { disposition = part.getDisposition(); } catch (MessagingException ex) { Log.w(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); disposition = null; } String filename; try { filename = part.getFileName(); } catch (MessagingException ex) { Log.w(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); filename = null; } if (Part.ATTACHMENT.equalsIgnoreCase(disposition) || part.isMimeType("image/*") || !TextUtils.isEmpty(filename)) { ContentType ct = new ContentType(part.getContentType()); String[] cid = part.getHeader("Content-ID"); EntityAttachment attachment = new EntityAttachment(); attachment.name = filename; attachment.type = ct.getBaseType().toLowerCase(); attachment.size = part.getSize(); attachment.cid = (cid == null || cid.length == 0 ? null : cid[0]); attachment.part = part; // Try to guess a better content type // Sometimes PDF files are sent using the wrong type if ("application/octet-stream".equals(attachment.type)) { String extension = Helper.getExtension(attachment.name); if (extension != null) { String type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase()); if (type != null) { Log.w(Helper.TAG, "Guessing file=" + attachment.name + " type=" + type); attachment.type = type; } } } if (attachment.size < 0) attachment.size = null; result.add(attachment); } } else if (content instanceof Multipart) { Multipart multipart = (Multipart) content; for (int i = 0; i < multipart.getCount(); i++) result.addAll(getAttachments(multipart.getBodyPart(i))); } return result; } String getRaw() throws IOException, MessagingException { if (raw == null) { ByteArrayOutputStream os = new ByteArrayOutputStream(); imessage.writeTo(os); raw = Base64.encodeToString(os.toByteArray(), Base64.URL_SAFE); } return raw; } }