diff options
| author | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
|---|---|---|
| committer | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
| commit | 50ce5d5e6cd0d720ff437d35eace545816848428 (patch) | |
| tree | b59af206a36265cf081945e434900b32b0c43edd /mail/common/src/main/java | |
| download | monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2 monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip | |
Inital
Diffstat (limited to 'mail/common/src/main/java')
93 files changed, 9703 insertions, 0 deletions
diff --git a/mail/common/src/main/java/com/fsck/k9/helper/EmailHelper.kt b/mail/common/src/main/java/com/fsck/k9/helper/EmailHelper.kt new file mode 100644 index 0000000..2da8c16 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/helper/EmailHelper.kt @@ -0,0 +1,15 @@ +package com.fsck.k9.helper + +object EmailHelper { + @JvmStatic + fun getLocalPartFromEmailAddress(email: String): String? { + val index = email.lastIndexOf('@') + return if (index == -1 || index == email.lastIndex) null else email.substring(0, index) + } + + @JvmStatic + fun getDomainFromEmailAddress(email: String): String? { + val index = email.lastIndexOf('@') + return if (index == -1 || index == email.lastIndex) null else email.substring(index + 1) + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/helper/ExceptionHelper.java b/mail/common/src/main/java/com/fsck/k9/helper/ExceptionHelper.java new file mode 100644 index 0000000..146e92a --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/helper/ExceptionHelper.java @@ -0,0 +1,28 @@ +package com.fsck.k9.helper; + + +import com.fsck.k9.mail.MessagingException; + + +public class ExceptionHelper { + public static String getRootCauseMessage(Throwable t) { + Throwable rootCause = t; + Throwable nextCause; + do { + nextCause = rootCause.getCause(); + if (nextCause != null) { + rootCause = nextCause; + } + } while (nextCause != null); + + if (rootCause instanceof MessagingException) { + return rootCause.getMessage(); + } + + // Remove the namespace on the exception so we have a fighting chance of seeing more of the error in the + // notification. + String simpleName = rootCause.getClass().getSimpleName(); + return (rootCause.getLocalizedMessage() != null) ? + simpleName + ": " + rootCause.getLocalizedMessage() : simpleName; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/logging/Logger.kt b/mail/common/src/main/java/com/fsck/k9/logging/Logger.kt new file mode 100644 index 0000000..854e935 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/logging/Logger.kt @@ -0,0 +1,26 @@ +package com.fsck.k9.logging + +/** + * Logging abstraction based on Timber. + */ +interface Logger { + fun v(message: String?, vararg args: Any?) + fun v(t: Throwable?, message: String?, vararg args: Any?) + fun v(t: Throwable?) + + fun d(message: String?, vararg args: Any?) + fun d(t: Throwable?, message: String?, vararg args: Any?) + fun d(t: Throwable?) + + fun i(message: String?, vararg args: Any?) + fun i(t: Throwable?, message: String?, vararg args: Any?) + fun i(t: Throwable?) + + fun w(message: String?, vararg args: Any?) + fun w(t: Throwable?, message: String?, vararg args: Any?) + fun w(t: Throwable?) + + fun e(message: String?, vararg args: Any?) + fun e(t: Throwable?, message: String?, vararg args: Any?) + fun e(t: Throwable?) +} diff --git a/mail/common/src/main/java/com/fsck/k9/logging/NoOpLogger.kt b/mail/common/src/main/java/com/fsck/k9/logging/NoOpLogger.kt new file mode 100644 index 0000000..bc0a10c --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/logging/NoOpLogger.kt @@ -0,0 +1,36 @@ +package com.fsck.k9.logging + +/** + * A [Logger] implementation that does nothing. + */ +class NoOpLogger : Logger { + override fun v(message: String?, vararg args: Any?) = Unit + + override fun v(t: Throwable?, message: String?, vararg args: Any?) = Unit + + override fun v(t: Throwable?) = Unit + + override fun d(message: String?, vararg args: Any?) = Unit + + override fun d(t: Throwable?, message: String?, vararg args: Any?) = Unit + + override fun d(t: Throwable?) = Unit + + override fun i(message: String?, vararg args: Any?) = Unit + + override fun i(t: Throwable?, message: String?, vararg args: Any?) = Unit + + override fun i(t: Throwable?) = Unit + + override fun w(message: String?, vararg args: Any?) = Unit + + override fun w(t: Throwable?, message: String?, vararg args: Any?) = Unit + + override fun w(t: Throwable?) = Unit + + override fun e(message: String?, vararg args: Any?) = Unit + + override fun e(t: Throwable?, message: String?, vararg args: Any?) = Unit + + override fun e(t: Throwable?) = Unit +} diff --git a/mail/common/src/main/java/com/fsck/k9/logging/Timber.kt b/mail/common/src/main/java/com/fsck/k9/logging/Timber.kt new file mode 100644 index 0000000..4e77f39 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/logging/Timber.kt @@ -0,0 +1,83 @@ +package com.fsck.k9.logging + +/** + * Our fake `Timber` object. + */ +object Timber { + var logger: Logger = NoOpLogger() + + @JvmStatic + fun v(message: String?, vararg args: Any?) { + logger.v(message, *args) + } + + @JvmStatic + fun v(t: Throwable?, message: String?, vararg args: Any?) { + logger.v(t, message, *args) + } + + @JvmStatic + fun v(t: Throwable?) { + logger.v(t) + } + + @JvmStatic + fun d(message: String?, vararg args: Any?) { + logger.d(message, *args) + } + + @JvmStatic + fun d(t: Throwable?, message: String?, vararg args: Any?) { + logger.d(t, message, *args) + } + + @JvmStatic + fun d(t: Throwable?) { + logger.d(t) + } + + @JvmStatic + fun i(message: String?, vararg args: Any?) { + logger.i(message, *args) + } + + @JvmStatic + fun i(t: Throwable?, message: String?, vararg args: Any?) { + logger.i(t, message, *args) + } + + @JvmStatic + fun i(t: Throwable?) { + logger.i(t) + } + + @JvmStatic + fun w(message: String?, vararg args: Any?) { + logger.w(message, *args) + } + + @JvmStatic + fun w(t: Throwable?, message: String?, vararg args: Any?) { + logger.w(t, message, *args) + } + + @JvmStatic + fun w(t: Throwable?) { + logger.w(t) + } + + @JvmStatic + fun e(message: String?, vararg args: Any?) { + logger.e(message, *args) + } + + @JvmStatic + fun e(t: Throwable?, message: String?, vararg args: Any?) { + logger.e(t, message, *args) + } + + @JvmStatic + fun e(t: Throwable?) { + logger.e(t) + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/Address.java b/mail/common/src/main/java/com/fsck/k9/mail/Address.java new file mode 100644 index 0000000..fd27263 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/Address.java @@ -0,0 +1,311 @@ + +package com.fsck.k9.mail; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import com.fsck.k9.logging.Timber; +import com.fsck.k9.mail.helper.Rfc822Token; +import com.fsck.k9.mail.helper.Rfc822Tokenizer; +import com.fsck.k9.mail.helper.TextUtils; +import org.apache.james.mime4j.MimeException; +import org.apache.james.mime4j.codec.DecodeMonitor; +import org.apache.james.mime4j.codec.EncoderUtil; +import org.apache.james.mime4j.dom.address.Mailbox; +import org.apache.james.mime4j.dom.address.MailboxList; +import org.apache.james.mime4j.field.address.DefaultAddressParser; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.VisibleForTesting; + +public class Address implements Serializable { + private static final Pattern ATOM = Pattern.compile("^(?:[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]|\\s)+$"); + + /** + * Immutable empty {@link Address} array + */ + private static final Address[] EMPTY_ADDRESS_ARRAY = new Address[0]; + + @NotNull + private String mAddress; + + private String mPersonal; + + public Address(Address address) { + mAddress = address.mAddress; + mPersonal = address.mPersonal; + } + + public Address(String address, String personal) { + this(address, personal, true); + } + + public Address(String address) { + this(address, null, true); + } + + private Address(String address, String personal, boolean parse) { + if (address == null) { + throw new IllegalArgumentException("address"); + } + if (parse) { + Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address); + if (tokens.length > 0) { + Rfc822Token token = tokens[0]; + if (token.getAddress() == null) { + throw new IllegalArgumentException("token.getAddress()"); + } + mAddress = token.getAddress(); + String name = token.getName(); + if (!TextUtils.isEmpty(name)) { + /* + * Don't use the "personal" argument if "address" is of the form: + * James Bond <james.bond@mi6.uk> + * + * See issue 2920 + */ + mPersonal = name; + } else { + mPersonal = (personal == null) ? null : personal.trim(); + } + } else { + Timber.e("Invalid address: %s", address); + } + } else { + mAddress = address; + mPersonal = personal; + } + } + + public String getAddress() { + return mAddress; + } + + public String getHostname() { + if (mAddress == null) { + return null; + } + + int hostIdx = mAddress.lastIndexOf("@"); + + if (hostIdx == -1) { + return null; + } + + return mAddress.substring(hostIdx + 1); + } + + public String getPersonal() { + return mPersonal; + } + + /** + * Parse a comma separated list of email addresses in human readable format and return an + * array of Address objects, RFC-822 encoded. + * + * @param addressList + * @return An array of 0 or more Addresses. + */ + public static Address[] parseUnencoded(String addressList) { + List<Address> addresses = new ArrayList<>(); + if (!TextUtils.isEmpty(addressList)) { + Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressList); + for (Rfc822Token token : tokens) { + String address = token.getAddress(); + if (!TextUtils.isEmpty(address)) { + addresses.add(new Address(token.getAddress(), token.getName(), false)); + } + } + } + return addresses.toArray(EMPTY_ADDRESS_ARRAY); + } + + /** + * Parse a comma separated list of addresses in RFC-822 format and return an + * array of Address objects. + * + * @param addressList + * @return An array of 0 or more Addresses. + */ + public static Address[] parse(String addressList) { + if (TextUtils.isEmpty(addressList)) { + return EMPTY_ADDRESS_ARRAY; + } + List<Address> addresses = new ArrayList<>(); + try { + MailboxList parsedList = DefaultAddressParser.DEFAULT.parseAddressList(addressList, DecodeMonitor.SILENT).flatten(); + + for (int i = 0, count = parsedList.size(); i < count; i++) { + Mailbox mailbox = parsedList.get(i); + addresses.add(new Address(mailbox.getLocalPart() + "@" + mailbox.getDomain(), mailbox.getName(), false)); + } + } catch (MimeException pe) { + Timber.e(pe, "MimeException in Address.parse()"); + // broken addresses are never added to the resulting array + } + return addresses.toArray(EMPTY_ADDRESS_ARRAY); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + Address address = (Address) o; + + if (mAddress != null ? !mAddress.equals(address.mAddress) : address.mAddress != null) { + return false; + } + + return mPersonal != null ? mPersonal.equals(address.mPersonal) : address.mPersonal == null; + } + + @Override + public int hashCode() { + int hash = 0; + if (mAddress != null) { + hash += mAddress.hashCode(); + } + if (mPersonal != null) { + hash += 3 * mPersonal.hashCode(); + } + return hash; + } + + @Override + public String toString() { + if (!TextUtils.isEmpty(mPersonal)) { + return quoteAtoms(mPersonal) + " <" + mAddress + ">"; + } else { + return mAddress; + } + } + + public static String toString(Address[] addresses) { + if (addresses == null) { + return null; + } + return TextUtils.join(", ", addresses); + } + + public String toEncodedString() { + if (!TextUtils.isEmpty(mPersonal)) { + return EncoderUtil.encodeAddressDisplayName(mPersonal) + " <" + mAddress + ">"; + } else { + return mAddress; + } + } + + /** + * Unpacks an address list previously packed with packAddressList() + * @param addressList Packed address list. + * @return Unpacked list. + */ + public static Address[] unpack(String addressList) { + if (addressList == null) { + return new Address[] { }; + } + List<Address> addresses = new ArrayList<>(); + int length = addressList.length(); + int pairStartIndex = 0; + int pairEndIndex = 0; + int addressEndIndex = 0; + while (pairStartIndex < length) { + pairEndIndex = addressList.indexOf(",\u0001", pairStartIndex); + if (pairEndIndex == -1) { + pairEndIndex = length; + } + addressEndIndex = addressList.indexOf(";\u0001", pairStartIndex); + String address = null; + String personal = null; + if (addressEndIndex == -1 || addressEndIndex > pairEndIndex) { + address = addressList.substring(pairStartIndex, pairEndIndex); + } else { + address = addressList.substring(pairStartIndex, addressEndIndex); + personal = addressList.substring(addressEndIndex + 2, pairEndIndex); + } + addresses.add(new Address(address, personal, false)); + pairStartIndex = pairEndIndex + 2; + } + return addresses.toArray(new Address[addresses.size()]); + } + + /** + * Packs an address list into a String that is very quick to read + * and parse. Packed lists can be unpacked with unpackAddressList() + * The packed list is a ",\u0001" separated list of: + * address;\u0001personal + * @param addresses Array of addresses to pack. + * @return Packed addresses. + */ + public static String pack(Address[] addresses) { + if (addresses == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0, count = addresses.length; i < count; i++) { + Address address = addresses[i]; + sb.append(address.getAddress()); + String personal = address.getPersonal(); + if (personal != null) { + sb.append(";\u0001"); + // Escape quotes in the address part on the way in + personal = personal.replaceAll("\"", "\\\""); + sb.append(personal); + } + if (i < count - 1) { + sb.append(",\u0001"); + } + } + return sb.toString(); + } + + /** + * Quote a string, if necessary, based upon the definition of an "atom," as defined by RFC2822 + * (http://tools.ietf.org/html/rfc2822#section-3.2.4). Strings that consist purely of atoms are + * left unquoted; anything else is returned as a quoted string. + * @param text String to quote. + * @return Possibly quoted string. + */ + public static String quoteAtoms(final String text) { + if (ATOM.matcher(text).matches()) { + return text; + } else { + return quoteString(text); + } + } + + /** + * Ensures that the given string starts and ends with the double quote character. + * The string is not modified in any way except to add the double quote character to start + * and end if it's not already there. + * sample -> "sample" + * "sample" -> "sample" + * ""sample"" -> ""sample"" + * "sample"" -> "sample" + * sa"mp"le -> "sa"mp"le" + * "sa"mp"le" -> "sa"mp"le" + * (empty string) -> "" + * " -> """ + * @param s + * @return + */ + @VisibleForTesting + static String quoteString(String s) { + if (s == null) { + return null; + } + if (!s.matches("^\".*\"$")) { + return "\"" + s + "\""; + } else { + return s; + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/AuthType.java b/mail/common/src/main/java/com/fsck/k9/mail/AuthType.java new file mode 100644 index 0000000..70c1967 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/AuthType.java @@ -0,0 +1,34 @@ +package com.fsck.k9.mail; + +public enum AuthType { + /* + * The names of these authentication types are saved as strings when + * settings are exported and are also saved as part of the Server URI stored + * in the account settings. + * + * PLAIN and CRAM_MD5 originally referred to specific SASL authentication + * mechanisms. Their meaning has since been broadened to mean authentication + * with unencrypted and encrypted passwords, respectively. Nonetheless, + * their original names have been retained for backward compatibility with + * user settings. + */ + PLAIN, + CRAM_MD5, + EXTERNAL, + + /** + * XOAUTH2 is an OAuth2.0 protocol designed/used by GMail. + * + * https://developers.google.com/gmail/xoauth2_protocol#the_sasl_xoauth2_mechanism + */ + XOAUTH2, + + /* + * The following are obsolete authentication settings that were used with + * SMTP. They are no longer presented to the user as options, but they may + * still exist in a user's settings from a previous version or may be found + * when importing settings. + */ + AUTOMATIC, + LOGIN +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/Authentication.java b/mail/common/src/main/java/com/fsck/k9/mail/Authentication.java new file mode 100644 index 0000000..bcb96f4 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/Authentication.java @@ -0,0 +1,93 @@ +package com.fsck.k9.mail; + +import java.io.UnsupportedEncodingException; +import java.security.MessageDigest; + +import com.fsck.k9.mail.filter.Base64; +import com.fsck.k9.mail.filter.Hex; +import okio.ByteString; + + +public class Authentication { + private static final String US_ASCII = "US-ASCII"; + private static final String XOAUTH_FORMAT = "user=%1s\001auth=Bearer %2s\001\001"; + + /** + * Computes the response for CRAM-MD5 authentication mechanism given the user credentials and + * the server-provided nonce. + * + * @param username The username. + * @param password The password. + * @param b64Nonce The nonce as base64-encoded string. + * @return The CRAM-MD5 response. + * + * @throws MessagingException If something went wrong. + * + * @see Authentication#computeCramMd5Bytes(String, String, byte[]) + */ + public static String computeCramMd5(String username, String password, String b64Nonce) + throws MessagingException { + + try { + byte[] b64NonceBytes = b64Nonce.getBytes(US_ASCII); + byte[] b64CRAM = computeCramMd5Bytes(username, password, b64NonceBytes); + return new String(b64CRAM, US_ASCII); + } catch (MessagingException e) { + throw e; + } catch (Exception e) { + throw new MessagingException("This shouldn't happen", e); + } + } + + /** + * Computes the response for CRAM-MD5 authentication mechanism given the user credentials and + * the server-provided nonce. + * + * @param username The username. + * @param password The password. + * @param b64Nonce The nonce as base64-encoded byte array. + * @return The CRAM-MD5 response as byte array. + * + * @throws MessagingException If something went wrong. + * + * @see <a href="https://tools.ietf.org/html/rfc2195">RFC 2195</a> + */ + public static byte[] computeCramMd5Bytes(String username, String password, byte[] b64Nonce) + throws MessagingException { + + try { + byte[] nonce = Base64.decodeBase64(b64Nonce); + + byte[] secretBytes = password.getBytes(); + MessageDigest md = MessageDigest.getInstance("MD5"); + if (secretBytes.length > 64) { + secretBytes = md.digest(secretBytes); + } + + byte[] ipad = new byte[64]; + byte[] opad = new byte[64]; + System.arraycopy(secretBytes, 0, ipad, 0, secretBytes.length); + System.arraycopy(secretBytes, 0, opad, 0, secretBytes.length); + for (int i = 0; i < ipad.length; i++) ipad[i] ^= 0x36; + for (int i = 0; i < opad.length; i++) opad[i] ^= 0x5c; + + md.update(ipad); + byte[] firstPass = md.digest(nonce); + + md.update(opad); + byte[] result = md.digest(firstPass); + + String plainCRAM = username + " " + Hex.encodeHex(result); + + return Base64.encodeBase64(plainCRAM.getBytes()); + + } catch (Exception e) { + throw new MessagingException("Something went wrong during CRAM-MD5 computation", e); + } + } + + public static String computeXoauth(String username, String authToken) { + String formattedAuthenticationString = String.format(XOAUTH_FORMAT, username, authToken); + return ByteString.encodeUtf8(formattedAuthenticationString).base64(); + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/AuthenticationFailedException.kt b/mail/common/src/main/java/com/fsck/k9/mail/AuthenticationFailedException.kt new file mode 100644 index 0000000..aeab484 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/AuthenticationFailedException.kt @@ -0,0 +1,9 @@ +package com.fsck.k9.mail + +class AuthenticationFailedException @JvmOverloads constructor( + message: String, + throwable: Throwable? = null, + val messageFromServer: String? = null +) : MessagingException(message, throwable) { + val isMessageFromServerAvailable = messageFromServer != null +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/Body.java b/mail/common/src/main/java/com/fsck/k9/mail/Body.java new file mode 100644 index 0000000..97613f6 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/Body.java @@ -0,0 +1,26 @@ + +package com.fsck.k9.mail; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public interface Body { + /** + * Returns the raw data of the body, without transfer encoding etc applied. + * TODO perhaps it would be better to have an intermediate "simple part" class where this method could reside + * because it makes no sense for multiparts + */ + InputStream getInputStream() throws MessagingException; + + /** + * Sets the content transfer encoding (7bit, 8bit, quoted-printable or base64). + */ + void setEncoding(String encoding) throws MessagingException; + + /** + * Writes the body's data to the given {@link OutputStream}. + * The written data is transfer encoded (e.g. transformed to Base64 when needed). + */ + void writeTo(OutputStream out) throws IOException, MessagingException; +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/BodyFactory.java b/mail/common/src/main/java/com/fsck/k9/mail/BodyFactory.java new file mode 100644 index 0000000..c11a74e --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/BodyFactory.java @@ -0,0 +1,10 @@ +package com.fsck.k9.mail; + + +import java.io.IOException; +import java.io.InputStream; + + +public interface BodyFactory { + Body createBody(String contentTransferEncoding, String contentType, InputStream inputStream) throws IOException; +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/BodyPart.java b/mail/common/src/main/java/com/fsck/k9/mail/BodyPart.java new file mode 100644 index 0000000..f9f1dc2 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/BodyPart.java @@ -0,0 +1,25 @@ +package com.fsck.k9.mail; + + +public abstract class BodyPart implements Part { + private String serverExtra; + private Multipart parent; + + @Override + public String getServerExtra() { + return serverExtra; + } + + @Override + public void setServerExtra(String serverExtra) { + this.serverExtra = serverExtra; + } + + public Multipart getParent() { + return parent; + } + + public void setParent(Multipart parent) { + this.parent = parent; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/BoundaryGenerator.kt b/mail/common/src/main/java/com/fsck/k9/mail/BoundaryGenerator.kt new file mode 100644 index 0000000..a8ce9fe --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/BoundaryGenerator.kt @@ -0,0 +1,33 @@ +package com.fsck.k9.mail + +import java.util.Random +import org.jetbrains.annotations.VisibleForTesting + +class BoundaryGenerator @VisibleForTesting internal constructor(private val random: Random) { + + fun generateBoundary(): String { + return buildString(4 + BOUNDARY_CHARACTER_COUNT) { + append("----") + + repeat(BOUNDARY_CHARACTER_COUNT) { + append(BASE36_MAP[random.nextInt(36)]) + } + } + } + + companion object { + private const val BOUNDARY_CHARACTER_COUNT = 30 + + private val BASE36_MAP = charArrayOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', + 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', + 'U', 'V', 'W', 'X', 'Y', 'Z' + ) + + private val INSTANCE = BoundaryGenerator(Random()) + + @JvmStatic + fun getInstance() = INSTANCE + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/CertificateChainException.java b/mail/common/src/main/java/com/fsck/k9/mail/CertificateChainException.java new file mode 100644 index 0000000..2dc1af2 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/CertificateChainException.java @@ -0,0 +1,27 @@ +package com.fsck.k9.mail; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +/** + * A {@link CertificateException} extension that provides access to + * the pertinent certificate chain. + * + */ +public class CertificateChainException extends CertificateException { + + private static final long serialVersionUID = 1103894512106650107L; + private X509Certificate[] mCertChain; + + public CertificateChainException(String msg, X509Certificate[] chain, Throwable cause) { + super(msg, cause); + setCertChain(chain); + } + + public void setCertChain(X509Certificate[] chain) { + mCertChain = chain; + } + public X509Certificate[] getCertChain() { + return mCertChain; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/CertificateValidationException.java b/mail/common/src/main/java/com/fsck/k9/mail/CertificateValidationException.java new file mode 100644 index 0000000..4362842 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/CertificateValidationException.java @@ -0,0 +1,127 @@ + +package com.fsck.k9.mail; + +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +import javax.net.ssl.SSLHandshakeException; + +public class CertificateValidationException extends MessagingException { + public static final long serialVersionUID = -1; + private final Reason mReason; + private X509Certificate[] mCertChain; + private boolean mNeedsUserAttention = false; + private String mAlias; + + public enum Reason { + Unknown, UseMessage, Expired, MissingCapability, RetrievalFailure + } + + public CertificateValidationException(String message) { + this(message, Reason.UseMessage, null); + } + + public CertificateValidationException(Reason reason) { + this(null, reason, null); + } + + public CertificateValidationException(String message, Reason reason, String alias) { + super(message); + /* + * Instances created without a Throwable parameter as a cause are + * presumed to need user attention. + */ + mNeedsUserAttention = true; + mReason = reason; + mAlias = alias; + } + + public CertificateValidationException(final String message, Throwable throwable) { + super(message, throwable); + mReason = Reason.Unknown; + scanForCause(); + } + + public String getAlias() { + return mAlias; + } + + public Reason getReason() { + return mReason; + } + + private void scanForCause() { + Throwable throwable = getCause(); + + /* + * User attention is required if the server certificate was deemed + * invalid or if there was a problem with a client certificate. + * + * A CertificateException is known to be thrown by the default + * X509TrustManager.checkServerTrusted() if the server certificate + * doesn't validate. The cause of the CertificateException will be a + * CertPathValidatorException. However, it's unlikely those exceptions + * will be encountered here, because they are caught in + * SecureX509TrustManager.checkServerTrusted(), which throws a + * CertificateChainException instead (an extension of + * CertificateException). + * + * A CertificateChainException will likely result in (or, be the cause + * of) an SSLHandshakeException (an extension of SSLException). + * + * The various mail protocol handlers (IMAP, POP3, ...) will catch an + * SSLException and throw a CertificateValidationException (this class) + * with the SSLException as the cause. (They may also throw a + * CertificateValidationException when STARTTLS is not available, just + * for the purpose of triggering a user notification.) + * + * SSLHandshakeException is also known to occur if the *client* + * certificate was not accepted by the server (unknown CA, certificate + * expired, etc.). In this case, the SSLHandshakeException will not have + * a CertificateChainException as a cause. + * + * KeyChainException is known to occur if the device has no client + * certificate that's associated with the alias stored in the server + * settings. + */ + while (throwable != null + && !(throwable instanceof CertPathValidatorException) + && !(throwable instanceof CertificateException) + && !("android.security.KeyChainException".equals(throwable.getClass().getCanonicalName())) + && !(throwable instanceof SSLHandshakeException)) { + throwable = throwable.getCause(); + } + + if (throwable != null) { + mNeedsUserAttention = true; + + // See if there is a server certificate chain attached to the SSLHandshakeException + if (throwable instanceof SSLHandshakeException) { + while (throwable != null && !(throwable instanceof CertificateChainException)) { + throwable = throwable.getCause(); + } + } + + if (throwable != null && throwable instanceof CertificateChainException) { + mCertChain = ((CertificateChainException) throwable).getCertChain(); + } + } + } + + public boolean needsUserAttention() { + return mNeedsUserAttention; + } + + /** + * If the cause of this {@link CertificateValidationException} was a + * {@link CertificateChainException}, then the offending chain is available + * for return. + * + * @return An {@link X509Certificate X509Certificate[]} containing the Cert. + * chain, or else null. + */ + public X509Certificate[] getCertChain() { + return mCertChain; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/ConnectionSecurity.java b/mail/common/src/main/java/com/fsck/k9/mail/ConnectionSecurity.java new file mode 100644 index 0000000..d4a4926 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/ConnectionSecurity.java @@ -0,0 +1,7 @@ +package com.fsck.k9.mail; + +public enum ConnectionSecurity { + NONE, + STARTTLS_REQUIRED, + SSL_TLS_REQUIRED +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/DefaultBodyFactory.java b/mail/common/src/main/java/com/fsck/k9/mail/DefaultBodyFactory.java new file mode 100644 index 0000000..1cbffbe --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/DefaultBodyFactory.java @@ -0,0 +1,43 @@ +package com.fsck.k9.mail; + + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import com.fsck.k9.mail.internet.BinaryTempFileBody; +import com.fsck.k9.mail.internet.BinaryTempFileMessageBody; +import com.fsck.k9.mail.internet.MimeUtility; +import org.apache.commons.io.IOUtils; +import org.apache.james.mime4j.util.MimeUtil; + + +public class DefaultBodyFactory implements BodyFactory { + public Body createBody(String contentTransferEncoding, String contentType, InputStream inputStream) + throws IOException { + + if (contentTransferEncoding != null) { + contentTransferEncoding = MimeUtility.getHeaderParameter(contentTransferEncoding, null); + } + + final BinaryTempFileBody tempBody; + if (MimeUtil.isMessage(contentType)) { + tempBody = new BinaryTempFileMessageBody(contentTransferEncoding); + } else { + tempBody = new BinaryTempFileBody(contentTransferEncoding); + } + + OutputStream outputStream = tempBody.getOutputStream(); + try { + copyData(inputStream, outputStream); + } finally { + outputStream.close(); + } + + return tempBody; + } + + protected void copyData(InputStream inputStream, OutputStream outputStream) throws IOException { + IOUtils.copy(inputStream, outputStream); + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/FetchProfile.java b/mail/common/src/main/java/com/fsck/k9/mail/FetchProfile.java new file mode 100644 index 0000000..e1cfba8 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/FetchProfile.java @@ -0,0 +1,59 @@ + +package com.fsck.k9.mail; + +import java.util.ArrayList; + +/** + * <pre> + * A FetchProfile is a list of items that should be downloaded in bulk for a set of messages. + * FetchProfile can contain the following objects: + * FetchProfile.Item: Described below. + * Message: Indicates that the body of the entire message should be fetched. + * Synonymous with FetchProfile.Item.BODY. + * Part: Indicates that the given Part should be fetched. The provider + * is expected have previously created the given BodyPart and stored + * any information it needs to download the content. + * </pre> + */ +public class FetchProfile extends ArrayList<FetchProfile.Item> { + private static final long serialVersionUID = -5520076119120964166L; + + /** + * Default items available for pre-fetching. It should be expected that any + * item fetched by using these items could potentially include all of the + * previous items. + */ + public enum Item { + /** + * Download the flags of the message. + */ + FLAGS, + + /** + * Download the envelope of the message. This should include at minimum + * the size and the following headers: date, subject, from, content-type, to, cc + */ + ENVELOPE, + + /** + * Download the structure of the message. This maps directly to IMAP's BODYSTRUCTURE + * and may map to other providers. + * The provider should, if possible, fill in a properly formatted MIME structure in + * the message without actually downloading any message data. If the provider is not + * capable of this operation it should specifically set the body of the message to null + * so that upper levels can detect that a full body download is needed. + */ + STRUCTURE, + + /** + * A sane portion of the entire message, cut off at a provider determined limit. + * This should generally be around 50kB. + */ + BODY_SANE, + + /** + * The entire message. + */ + BODY, + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/Flag.java b/mail/common/src/main/java/com/fsck/k9/mail/Flag.java new file mode 100644 index 0000000..2f55b8e --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/Flag.java @@ -0,0 +1,70 @@ + +package com.fsck.k9.mail; + +/** + * Flags that can be applied to Messages. + */ +public enum Flag { + DELETED, + SEEN, + ANSWERED, + FLAGGED, + DRAFT, + RECENT, + FORWARDED, + + /* + * The following flags are for internal library use only. + */ + /** + * Delete and remove from the LocalStore immediately. + */ + X_DESTROYED, + + /** + * Sending of an unsent message failed. It will be retried. Used to show status. + */ + X_SEND_FAILED, + + /** + * Sending of an unsent message is in progress. + */ + X_SEND_IN_PROGRESS, + + /** + * Indicates that a message is fully downloaded from the server and can be viewed normally. + * This does not include attachments, which are never downloaded fully. + */ + X_DOWNLOADED_FULL, + + /** + * Indicates that a message is partially downloaded from the server and can be viewed but + * more content is available on the server. + * This does not include attachments, which are never downloaded fully. + */ + X_DOWNLOADED_PARTIAL, + + /** + * Indicates that the copy of a message to the Sent folder has started. + */ + X_REMOTE_COPY_STARTED, + + /** + * Messages with this flag have been migrated from database version 50 or earlier. + * This earlier database format did not preserve the original mime structure of a + * mail, which means messages migrated to the newer database structure may be + * incomplete or broken. + * TODO Messages with this flag should be redownloaded, if possible. + */ + X_MIGRATED_FROM_V50, + + /** + * This flag is used for drafts where the message should be sent as PGP/INLINE. + */ + X_DRAFT_OPENPGP_INLINE, + + /** + * This flag is added to messages when their subject is overridden with a decrypted one in the database. + */ + X_SUBJECT_DECRYPTED, +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/FolderClass.java b/mail/common/src/main/java/com/fsck/k9/mail/FolderClass.java new file mode 100644 index 0000000..940dac2 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/FolderClass.java @@ -0,0 +1,5 @@ +package com.fsck.k9.mail; + +public enum FolderClass { + NO_CLASS, INHERITED, FIRST_CLASS, SECOND_CLASS +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/FolderType.java b/mail/common/src/main/java/com/fsck/k9/mail/FolderType.java new file mode 100644 index 0000000..fedfda4 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/FolderType.java @@ -0,0 +1,12 @@ +package com.fsck.k9.mail; + +public enum FolderType { + REGULAR, + INBOX, + OUTBOX, + DRAFTS, + SENT, + TRASH, + SPAM, + ARCHIVE +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/Header.kt b/mail/common/src/main/java/com/fsck/k9/mail/Header.kt new file mode 100644 index 0000000..09e8457 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/Header.kt @@ -0,0 +1,3 @@ +package com.fsck.k9.mail + +data class Header(val name: String, val value: String) diff --git a/mail/common/src/main/java/com/fsck/k9/mail/K9MailLib.java b/mail/common/src/main/java/com/fsck/k9/mail/K9MailLib.java new file mode 100644 index 0000000..7649944 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/K9MailLib.java @@ -0,0 +1,101 @@ +package com.fsck.k9.mail; + + +public class K9MailLib { + private static DebugStatus debugStatus = new DefaultDebugStatus(); + + private K9MailLib() { + } + + public static final int PUSH_WAKE_LOCK_TIMEOUT = 60000; + public static final String IDENTITY_HEADER = "X-K9mail-Identity"; + public static final String CHAT_HEADER = "Chat-Version"; + + /** + * Should K-9 log the conversation it has over the wire with + * SMTP servers? + */ + public static boolean DEBUG_PROTOCOL_SMTP = true; + + /** + * Should K-9 log the conversation it has over the wire with + * IMAP servers? + */ + public static boolean DEBUG_PROTOCOL_IMAP = true; + + /** + * Should K-9 log the conversation it has over the wire with + * POP3 servers? + */ + public static boolean DEBUG_PROTOCOL_POP3 = true; + + /** + * Should K-9 log the conversation it has over the wire with + * WebDAV servers? + */ + public static boolean DEBUG_PROTOCOL_WEBDAV = true; + + public static boolean isDebug() { + return debugStatus.enabled(); + } + + public static boolean isDebugSensitive() { + return debugStatus.debugSensitive(); + } + + public static void setDebugSensitive(boolean b) { + if (debugStatus instanceof WritableDebugStatus) { + ((WritableDebugStatus) debugStatus).setSensitive(b); + } + } + + public static void setDebug(boolean b) { + if (debugStatus instanceof WritableDebugStatus) { + ((WritableDebugStatus) debugStatus).setEnabled(b); + } + } + + public interface DebugStatus { + boolean enabled(); + + boolean debugSensitive(); + } + + public static void setDebugStatus(DebugStatus status) { + if (status == null) { + throw new IllegalArgumentException("status cannot be null"); + } + debugStatus = status; + } + + private interface WritableDebugStatus extends DebugStatus { + void setEnabled(boolean enabled); + + void setSensitive(boolean sensitive); + } + + private static class DefaultDebugStatus implements WritableDebugStatus { + private boolean enabled; + private boolean sensitive; + + @Override + public boolean enabled() { + return enabled; + } + + @Override + public boolean debugSensitive() { + return sensitive; + } + + @Override + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + @Override + public void setSensitive(boolean sensitive) { + this.sensitive = sensitive; + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/Message.java b/mail/common/src/main/java/com/fsck/k9/mail/Message.java new file mode 100644 index 0000000..5ccc21e --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/Message.java @@ -0,0 +1,171 @@ + +package com.fsck.k9.mail; + +import java.io.IOException; +import java.util.Collections; +import java.util.Date; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +import com.fsck.k9.logging.Timber; +import com.fsck.k9.mail.filter.CountingOutputStream; +import com.fsck.k9.mail.filter.EOLConvertingOutputStream; +import org.jetbrains.annotations.NotNull; + + +public abstract class Message implements Part, Body { + protected static final String DEFAULT_MIME_TYPE = "text/plain"; + + + public enum RecipientType { + TO, CC, BCC, X_ORIGINAL_TO, DELIVERED_TO, X_ENVELOPE_TO + } + + protected String mUid; + + private Set<Flag> mFlags = EnumSet.noneOf(Flag.class); + + private Date mInternalDate; + + public boolean olderThan(Date earliestDate) { + if (earliestDate == null) { + return false; + } + Date myDate = getSentDate(); + if (myDate == null) { + myDate = getInternalDate(); + } + return myDate != null && myDate.before(earliestDate); + } + + public String getUid() { + return mUid; + } + + public void setUid(String uid) { + this.mUid = uid; + } + + public abstract String getSubject(); + + public abstract void setSubject(String subject); + + public Date getInternalDate() { + return mInternalDate; + } + + public void setInternalDate(Date internalDate) { + this.mInternalDate = internalDate; + } + + public abstract Date getSentDate(); + + public abstract void setSentDate(Date sentDate, boolean hideTimeZone); + + public abstract Address[] getRecipients(RecipientType type); + + public abstract Address[] getFrom(); + + public abstract void setFrom(Address from); + + public abstract Address[] getSender(); + + public abstract void setSender(Address sender); + + public abstract Address[] getReplyTo(); + + public abstract void setReplyTo(Address[] from); + + public abstract String getMessageId(); + + public abstract void setInReplyTo(String inReplyTo); + + public abstract String[] getReferences(); + + public abstract void setReferences(String references); + + @Override + public abstract Body getBody(); + + @Override + public abstract void addHeader(String name, String value); + + @Override + public abstract void addRawHeader(String name, String raw); + + @Override + public abstract void setHeader(String name, String value); + + @NotNull + @Override + public abstract String[] getHeader(String name); + + public abstract List<Header> getHeaders(); + + @Override + public abstract void removeHeader(String name); + + @Override + public abstract void setBody(Body body); + + public abstract boolean hasAttachments(); + + public abstract long getSize(); + + /* + * TODO Refactor Flags at some point to be able to store user defined flags. + */ + public Set<Flag> getFlags() { + return Collections.unmodifiableSet(mFlags); + } + + /** + * @param flag + * Flag to set. Never <code>null</code>. + * @param set + * If <code>true</code>, the flag is added. If <code>false</code> + * , the flag is removed. + * @throws MessagingException + */ + public void setFlag(Flag flag, boolean set) throws MessagingException { + if (set) { + mFlags.add(flag); + } else { + mFlags.remove(flag); + } + } + + /** + * This method calls setFlag(Flag, boolean) + * @param flags + * @param set + */ + public void setFlags(final Set<Flag> flags, boolean set) throws MessagingException { + for (Flag flag : flags) { + setFlag(flag, set); + } + } + + public boolean isSet(Flag flag) { + return mFlags.contains(flag); + } + + + public void destroy() throws MessagingException {} + + @Override + public abstract void setEncoding(String encoding) throws MessagingException; + + public long calculateSize() { + try (CountingOutputStream out = new CountingOutputStream()) { + EOLConvertingOutputStream eolOut = new EOLConvertingOutputStream(out); + writeTo(eolOut); + eolOut.flush(); + return out.getCount(); + } catch (IOException | MessagingException e) { + Timber.e(e, "Failed to calculate a message size"); + } + return 0; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/MessageDownloadState.kt b/mail/common/src/main/java/com/fsck/k9/mail/MessageDownloadState.kt new file mode 100644 index 0000000..b4a3304 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/MessageDownloadState.kt @@ -0,0 +1,7 @@ +package com.fsck.k9.mail + +enum class MessageDownloadState { + ENVELOPE, + PARTIAL, + FULL +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/MessageRetrievalListener.java b/mail/common/src/main/java/com/fsck/k9/mail/MessageRetrievalListener.java new file mode 100644 index 0000000..0503aa4 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/MessageRetrievalListener.java @@ -0,0 +1,7 @@ + +package com.fsck.k9.mail; + + +public interface MessageRetrievalListener<T extends Message> { + void messageFinished(T message); +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/MessagingException.java b/mail/common/src/main/java/com/fsck/k9/mail/MessagingException.java new file mode 100644 index 0000000..97c1fe2 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/MessagingException.java @@ -0,0 +1,35 @@ + +package com.fsck.k9.mail; + +public class MessagingException extends Exception { + public static final long serialVersionUID = -1; + + private boolean permanentFailure = false; + + public MessagingException(Throwable cause) { + super(cause); + } + + public MessagingException(String message) { + super(message); + } + + public MessagingException(String message, boolean perm) { + super(message); + permanentFailure = perm; + } + + public MessagingException(String message, Throwable throwable) { + super(message, throwable); + } + + public MessagingException(String message, boolean perm, Throwable throwable) { + super(message, throwable); + permanentFailure = perm; + } + + public boolean isPermanentFailure() { + return permanentFailure; + } + +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/MimeType.kt b/mail/common/src/main/java/com/fsck/k9/mail/MimeType.kt new file mode 100644 index 0000000..33122ad --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/MimeType.kt @@ -0,0 +1,44 @@ +package com.fsck.k9.mail + +class MimeType private constructor( + val type: String, + val subtype: String +) { + override fun toString(): String { + return "$type/$subtype" + } + + override fun equals(other: Any?): Boolean { + return other is MimeType && type == other.type && subtype == other.subtype + } + + override fun hashCode(): Int { + return toString().hashCode() + } + + companion object { + private const val TOKEN = "([a-zA-Z0-9-!#$%&'*+.^_`{|}~]+)" + private val MIME_TYPE = Regex("$TOKEN/$TOKEN") + + @JvmStatic + @JvmName("parse") + fun String.toMimeType(): MimeType { + val matchResult = requireNotNull(MIME_TYPE.matchEntire(this)) { "Invalid MIME type: $this" } + + val type = matchResult.groupValues[1].lowercase() + val subtype = matchResult.groupValues[2].lowercase() + + return MimeType(type, subtype) + } + + @JvmStatic + @JvmName("parseOrNull") + fun String?.toMimeTypeOrNull(): MimeType? { + return try { + this?.toMimeType() + } catch (e: IllegalArgumentException) { + null + } + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/Multipart.java b/mail/common/src/main/java/com/fsck/k9/mail/Multipart.java new file mode 100644 index 0000000..2e41b8d --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/Multipart.java @@ -0,0 +1,56 @@ + +package com.fsck.k9.mail; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.james.mime4j.util.MimeUtil; + +public abstract class Multipart implements Body { + private Part mParent; + + private final List<BodyPart> mParts = new ArrayList<>(); + + public void addBodyPart(BodyPart part) { + mParts.add(part); + part.setParent(this); + } + + public BodyPart getBodyPart(int index) { + return mParts.get(index); + } + + public List<BodyPart> getBodyParts() { + return Collections.unmodifiableList(mParts); + } + + public abstract String getMimeType(); + + public abstract String getBoundary(); + + public int getCount() { + return mParts.size(); + } + + public Part getParent() { + return mParent; + } + + public void setParent(Part parent) { + this.mParent = parent; + } + + @Override + public void setEncoding(String encoding) throws MessagingException { + if (!MimeUtil.ENC_7BIT.equalsIgnoreCase(encoding) + && !MimeUtil.ENC_8BIT.equalsIgnoreCase(encoding)) { + throw new MessagingException("Incompatible content-transfer-encoding for a multipart/* body"); + } + + /* Nothing else to do. Each subpart has its own separate encoding */ + } + + public abstract byte[] getPreamble(); + public abstract byte[] getEpilogue(); +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/NetworkTimeouts.kt b/mail/common/src/main/java/com/fsck/k9/mail/NetworkTimeouts.kt new file mode 100644 index 0000000..4133748 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/NetworkTimeouts.kt @@ -0,0 +1,6 @@ +package com.fsck.k9.mail + +object NetworkTimeouts { + const val SOCKET_CONNECT_TIMEOUT = 30000 + const val SOCKET_READ_TIMEOUT = 60000 +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/Part.java b/mail/common/src/main/java/com/fsck/k9/mail/Part.java new file mode 100644 index 0000000..655c3bb --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/Part.java @@ -0,0 +1,46 @@ + +package com.fsck.k9.mail; + +import java.io.IOException; +import java.io.OutputStream; + +import org.jetbrains.annotations.NotNull; + + +public interface Part { + void addHeader(String name, String value); + + void addRawHeader(String name, String raw); + + void removeHeader(String name); + + void setHeader(String name, String value); + + Body getBody(); + + String getContentType(); + + String getDisposition(); + + String getContentId(); + + /** + * Returns an array of headers of the given name. The array may be empty. + */ + @NotNull + String[] getHeader(String name); + + boolean isMimeType(String mimeType); + + String getMimeType(); + + void setBody(Body body); + + void writeTo(OutputStream out) throws IOException, MessagingException; + + void writeHeaderTo(OutputStream out) throws IOException, MessagingException; + + String getServerExtra(); + + void setServerExtra(String serverExtra); +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/ServerSettings.kt b/mail/common/src/main/java/com/fsck/k9/mail/ServerSettings.kt new file mode 100644 index 0000000..ca365fb --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/ServerSettings.kt @@ -0,0 +1,33 @@ +package com.fsck.k9.mail + +/** + * Container for incoming or outgoing server settings + */ +data class ServerSettings @JvmOverloads constructor( + @JvmField val type: String, + @JvmField val host: String?, + @JvmField val port: Int, + @JvmField val connectionSecurity: ConnectionSecurity, + @JvmField val authenticationType: AuthType, + @JvmField val username: String, + @JvmField val password: String?, + @JvmField val clientCertificateAlias: String?, + val extra: Map<String, String?> = emptyMap() +) { + val isMissingCredentials: Boolean = when (authenticationType) { + AuthType.EXTERNAL -> clientCertificateAlias == null + else -> username.isNotBlank() && password == null + } + + init { + require(type == type.lowercase()) { "type must be all lower case" } + } + + fun newPassword(newPassword: String?): ServerSettings { + return this.copy(password = newPassword) + } + + fun newAuthenticationType(authType: AuthType): ServerSettings { + return this.copy(authenticationType = authType) + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/filter/Base64.java b/mail/common/src/main/java/com/fsck/k9/mail/filter/Base64.java new file mode 100644 index 0000000..9f7cadf --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/filter/Base64.java @@ -0,0 +1,785 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.fsck.k9.mail.filter; + +import java.math.BigInteger; +import java.nio.charset.Charset; + +/** + * Provides Base64 encoding and decoding as defined by RFC 2045. + * + * <p> + * This class implements section <cite>6.8. Base64 Content-Transfer-Encoding</cite> from RFC 2045 <cite>Multipurpose + * Internet Mail Extensions (MIME) Part One: Format of Internet Message Bodies</cite> by Freed and Borenstein. + * </p> + * + * @see <a href="http://www.ietf.org/rfc/rfc2045.txt">RFC 2045</a> + * @author Apache Software Foundation + * @since 1.0-dev + * @version $Id$ + */ +public class Base64 { + public static String decode(String encoded) { + if (encoded == null) { + return null; + } + byte[] decoded = new Base64().decode(encoded.getBytes()); + return new String(decoded); + } + + public static String encode(String s) { + if (s == null) { + return null; + } + byte[] encoded = new Base64().encode(s.getBytes()); + return new String(encoded); + } + + /** + * Chunk size per RFC 2045 section 6.8. + * + * <p> + * The {@value} character limit does not count the trailing CRLF, but counts all other characters, including any + * equal signs. + * </p> + * + * @see <a href="http://www.ietf.org/rfc/rfc2045.txt">RFC 2045 section 6.8</a> + */ + static final int CHUNK_SIZE = 76; + + /** + * Chunk separator per RFC 2045 section 2.1. + * + * @see <a href="http://www.ietf.org/rfc/rfc2045.txt">RFC 2045 section 2.1</a> + */ + static final byte[] CHUNK_SEPARATOR = {'\r', '\n'}; + + /** + * This array is a lookup table that translates 6-bit positive integer + * index values into their "Base64 Alphabet" equivalents as specified + * in Table 1 of RFC 2045. + * + * Thanks to "commons" project in ws.apache.org for this code. + * http://svn.apache.org/repos/asf/webservices/commons/trunk/modules/util/ + */ + private static final byte[] intToBase64 = { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', + 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', + 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/' + }; + + /** + * Byte used to pad output. + */ + private static final byte PAD = '='; + + /** + * This array is a lookup table that translates unicode characters + * drawn from the "Base64 Alphabet" (as specified in Table 1 of RFC 2045) + * into their 6-bit positive integer equivalents. Characters that + * are not in the Base64 alphabet but fall within the bounds of the + * array are translated to -1. + * + * Thanks to "commons" project in ws.apache.org for this code. + * http://svn.apache.org/repos/asf/webservices/commons/trunk/modules/util/ + */ + private static final byte[] base64ToInt = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, + 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, + 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, + 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51 + }; + + /** Mask used to extract 6 bits, used when encoding */ + private static final int MASK_6BITS = 0x3f; + + /** Mask used to extract 8 bits, used in decoding base64 bytes */ + private static final int MASK_8BITS = 0xff; + + // The static final fields above are used for the original static byte[] methods on Base64. + // The private member fields below are used with the new streaming approach, which requires + // some state be preserved between calls of encode() and decode(). + + + /** + * Line length for encoding. Not used when decoding. A value of zero or less implies + * no chunking of the base64 encoded data. + */ + private final int lineLength; + + /** + * Line separator for encoding. Not used when decoding. Only used if lineLength > 0. + */ + private final byte[] lineSeparator; + + /** + * Convenience variable to help us determine when our buffer is going to run out of + * room and needs resizing. <code>decodeSize = 3 + lineSeparator.length;</code> + */ + private final int decodeSize; + + /** + * Convenience variable to help us determine when our buffer is going to run out of + * room and needs resizing. <code>encodeSize = 4 + lineSeparator.length;</code> + */ + private final int encodeSize; + + /** + * Buffer for streaming. + */ + private byte[] buf; + + /** + * Position where next character should be written in the buffer. + */ + private int pos; + + /** + * Position where next character should be read from the buffer. + */ + private int readPos; + + /** + * Variable tracks how many characters have been written to the current line. + * Only used when encoding. We use it to make sure each encoded line never + * goes beyond lineLength (if lineLength > 0). + */ + private int currentLinePos; + + /** + * Writes to the buffer only occur after every 3 reads when encoding, an + * every 4 reads when decoding. This variable helps track that. + */ + private int modulus; + + /** + * Boolean flag to indicate the EOF has been reached. Once EOF has been + * reached, this Base64 object becomes useless, and must be thrown away. + */ + private boolean eof; + + /** + * Place holder for the 3 bytes we're dealing with for our base64 logic. + * Bitwise operations store and extract the base64 encoding or decoding from + * this variable. + */ + private int x; + + /** + * Default constructor: lineLength is 76, and the lineSeparator is CRLF + * when encoding, and all forms can be decoded. + */ + public Base64() { + this(CHUNK_SIZE, CHUNK_SEPARATOR); + } + + /** + * <p> + * Consumer can use this constructor to choose a different lineLength + * when encoding (lineSeparator is still CRLF). All forms of data can + * be decoded. + * </p><p> + * Note: lineLengths that aren't multiples of 4 will still essentially + * end up being multiples of 4 in the encoded data. + * </p> + * + * @param lineLength each line of encoded data will be at most this long + * (rounded up to nearest multiple of 4). + * If lineLength <= 0, then the output will not be divided into lines (chunks). + * Ignored when decoding. + */ + public Base64(int lineLength) { + this(lineLength, CHUNK_SEPARATOR); + } + + /** + * <p> + * Consumer can use this constructor to choose a different lineLength + * and lineSeparator when encoding. All forms of data can + * be decoded. + * </p><p> + * Note: lineLengths that aren't multiples of 4 will still essentially + * end up being multiples of 4 in the encoded data. + * </p> + * @param lineLength Each line of encoded data will be at most this long + * (rounded up to nearest multiple of 4). Ignored when decoding. + * If <= 0, then output will not be divided into lines (chunks). + * @param lineSeparator Each line of encoded data will end with this + * sequence of bytes. + * If lineLength <= 0, then the lineSeparator is not used. + * @throws IllegalArgumentException The provided lineSeparator included + * some base64 characters. That's not going to work! + */ + public Base64(int lineLength, byte[] lineSeparator) { + this.lineLength = lineLength; + this.lineSeparator = new byte[lineSeparator.length]; + System.arraycopy(lineSeparator, 0, this.lineSeparator, 0, lineSeparator.length); + if (lineLength > 0) { + this.encodeSize = 4 + lineSeparator.length; + } else { + this.encodeSize = 4; + } + this.decodeSize = encodeSize - 1; + if (containsBase64Byte(lineSeparator)) { + String sep = new String(lineSeparator, Charset.forName("UTF-8")); + + throw new IllegalArgumentException("lineSeperator must not contain base64 characters: [" + sep + "]"); + } + } + + /** + * Returns true if this Base64 object has buffered data for reading. + * + * @return true if there is Base64 object still available for reading. + */ + boolean hasData() { + return buf != null; + } + + /** + * Returns the amount of buffered data available for reading. + * + * @return The amount of buffered data available for reading. + */ + int avail() { + return buf != null ? pos - readPos : 0; + } + + /** Doubles our buffer. */ + private void resizeBuf() { + if (buf == null) { + buf = new byte[8192]; + pos = 0; + readPos = 0; + } else { + byte[] b = new byte[buf.length * 2]; + System.arraycopy(buf, 0, b, 0, buf.length); + buf = b; + } + } + + /** + * Extracts buffered data into the provided byte[] array, starting + * at position bPos, up to a maximum of bAvail bytes. Returns how + * many bytes were actually extracted. + * + * @param b byte[] array to extract the buffered data into. + * @param bPos position in byte[] array to start extraction at. + * @param bAvail amount of bytes we're allowed to extract. We may extract + * fewer (if fewer are available). + * @return The number of bytes successfully extracted into the provided + * byte[] array. + */ + int readResults(byte[] b, int bPos, int bAvail) { + if (buf != null) { + int len = Math.min(avail(), bAvail); + if (buf != b) { + System.arraycopy(buf, readPos, b, bPos, len); + readPos += len; + if (readPos >= pos) { + buf = null; + } + } else { + // Re-using the original consumer's output array is only + // allowed for one round. + buf = null; + } + return len; + } else { + return eof ? -1 : 0; + } + } + + /** + * Small optimization where we try to buffer directly to the consumer's + * output array for one round (if consumer calls this method first!) instead + * of starting our own buffer. + * + * @param out byte[] array to buffer directly to. + * @param outPos Position to start buffering into. + * @param outAvail Amount of bytes available for direct buffering. + */ + void setInitialBuffer(byte[] out, int outPos, int outAvail) { + // We can re-use consumer's original output array under + // special circumstances, saving on some System.arraycopy(). + if (out != null && out.length == outAvail) { + buf = out; + pos = outPos; + readPos = outPos; + } + } + + /** + * <p> + * Encodes all of the provided data, starting at inPos, for inAvail bytes. + * Must be called at least twice: once with the data to encode, and once + * with inAvail set to "-1" to alert encoder that EOF has been reached, + * so flush last remaining bytes (if not multiple of 3). + * </p><p> + * Thanks to "commons" project in ws.apache.org for the bitwise operations, + * and general approach. + * http://svn.apache.org/repos/asf/webservices/commons/trunk/modules/util/ + * </p> + * + * @param in byte[] array of binary data to base64 encode. + * @param inPos Position to start reading data from. + * @param inAvail Amount of bytes available from input for encoding. + */ + void encode(byte[] in, int inPos, int inAvail) { + if (eof) { + return; + } + + // inAvail < 0 is how we're informed of EOF in the underlying data we're + // encoding. + if (inAvail < 0) { + eof = true; + if (buf == null || buf.length - pos < encodeSize) { + resizeBuf(); + } + switch (modulus) { + case 1: + buf[pos++] = intToBase64[(x >> 2) & MASK_6BITS]; + buf[pos++] = intToBase64[(x << 4) & MASK_6BITS]; + buf[pos++] = PAD; + buf[pos++] = PAD; + break; + + case 2: + buf[pos++] = intToBase64[(x >> 10) & MASK_6BITS]; + buf[pos++] = intToBase64[(x >> 4) & MASK_6BITS]; + buf[pos++] = intToBase64[(x << 2) & MASK_6BITS]; + buf[pos++] = PAD; + break; + } + if (lineLength > 0) { + System.arraycopy(lineSeparator, 0, buf, pos, lineSeparator.length); + pos += lineSeparator.length; + } + } else { + for (int i = 0; i < inAvail; i++) { + if (buf == null || buf.length - pos < encodeSize) { + resizeBuf(); + } + modulus = (++modulus) % 3; + int b = in[inPos++]; + if (b < 0) { + b += 256; + } + x = (x << 8) + b; + if (0 == modulus) { + buf[pos++] = intToBase64[(x >> 18) & MASK_6BITS]; + buf[pos++] = intToBase64[(x >> 12) & MASK_6BITS]; + buf[pos++] = intToBase64[(x >> 6) & MASK_6BITS]; + buf[pos++] = intToBase64[x & MASK_6BITS]; + currentLinePos += 4; + if (lineLength > 0 && lineLength <= currentLinePos) { + System.arraycopy(lineSeparator, 0, buf, pos, lineSeparator.length); + pos += lineSeparator.length; + currentLinePos = 0; + } + } + } + } + } + + /** + * <p> + * Decodes all of the provided data, starting at inPos, for inAvail bytes. + * Should be called at least twice: once with the data to decode, and once + * with inAvail set to "-1" to alert decoder that EOF has been reached. + * The "-1" call is not necessary when decoding, but it doesn't hurt, either. + * </p><p> + * Ignores all non-base64 characters. This is how chunked (e.g. 76 character) + * data is handled, since CR and LF are silently ignored, but has implications + * for other bytes, too. This method subscribes to the garbage-in, garbage-out + * philosophy: it will not check the provided data for validity. + * </p><p> + * Thanks to "commons" project in ws.apache.org for the bitwise operations, + * and general approach. + * http://svn.apache.org/repos/asf/webservices/commons/trunk/modules/util/ + * </p> + + * @param in byte[] array of ascii data to base64 decode. + * @param inPos Position to start reading data from. + * @param inAvail Amount of bytes available from input for encoding. + */ + void decode(byte[] in, int inPos, int inAvail) { + if (eof) { + return; + } + if (inAvail < 0) { + eof = true; + } + for (int i = 0; i < inAvail; i++) { + if (buf == null || buf.length - pos < decodeSize) { + resizeBuf(); + } + byte b = in[inPos++]; + if (b == PAD) { + x = x << 6; + switch (modulus) { + case 2: + x = x << 6; + buf[pos++] = (byte)((x >> 16) & MASK_8BITS); + break; + case 3: + buf[pos++] = (byte)((x >> 16) & MASK_8BITS); + buf[pos++] = (byte)((x >> 8) & MASK_8BITS); + break; + } + // WE'RE DONE!!!! + eof = true; + return; + } else { + if (b >= 0 && b < base64ToInt.length) { + int result = base64ToInt[b]; + if (result >= 0) { + modulus = (++modulus) % 4; + x = (x << 6) + result; + if (modulus == 0) { + buf[pos++] = (byte)((x >> 16) & MASK_8BITS); + buf[pos++] = (byte)((x >> 8) & MASK_8BITS); + buf[pos++] = (byte)(x & MASK_8BITS); + } + } + } + } + } + } + + /** + * Returns whether or not the <code>octet</code> is in the base 64 alphabet. + * + * @param octet + * The value to test + * @return <code>true</code> if the value is defined in the the base 64 alphabet, <code>false</code> otherwise. + */ + public static boolean isBase64(byte octet) { + return octet == PAD || (octet >= 0 && octet < base64ToInt.length && base64ToInt[octet] != -1); + } + + /** + * Tests a given byte array to see if it contains only valid characters within the Base64 alphabet. + * Currently the method treats whitespace as valid. + * + * @param arrayOctet + * byte array to test + * @return <code>true</code> if all bytes are valid characters in the Base64 alphabet or if the byte array is + * empty; false, otherwise + */ + public static boolean isArrayByteBase64(byte[] arrayOctet) { + for (byte anArrayOctet : arrayOctet) { + if (!isBase64(anArrayOctet) && !isWhiteSpace(anArrayOctet)) { + return false; + } + } + return true; + } + + /* + * Tests a given byte array to see if it contains only valid characters within the Base64 alphabet. + * + * @param arrayOctet + * byte array to test + * @return <code>true</code> if any byte is a valid character in the Base64 alphabet; false herwise + */ + private static boolean containsBase64Byte(byte[] arrayOctet) { + for (byte element : arrayOctet) { + if (isBase64(element)) { + return true; + } + } + return false; + } + + /** + * Encodes binary data using the base64 algorithm but does not chunk the output. + * + * @param binaryData + * binary data to encode + * @return Base64 characters + */ + public static byte[] encodeBase64(byte[] binaryData) { + return encodeBase64(binaryData, false); + } + + /** + * Encodes binary data using the base64 algorithm and chunks the encoded output into 76 character blocks + * + * @param binaryData + * binary data to encode + * @return Base64 characters chunked in 76 character blocks + */ + public static byte[] encodeBase64Chunked(byte[] binaryData) { + return encodeBase64(binaryData, true); + } + + /** + * Decodes an Object using the base64 algorithm. This method is provided in order to satisfy the requirements of the + * Decoder interface, and will throw a DecoderException if the supplied object is not of type byte[]. + * + * @param pObject + * Object to decode + * @return An object (of type byte[]) containing the binary data which corresponds to the byte[] supplied. + * @throws DecoderException + * if the parameter supplied is not of type byte[] + */ + public Object decode(Object pObject) throws DecoderException { + if (!(pObject instanceof byte[])) { + throw new DecoderException("Parameter supplied to Base64 decode is not a byte[]"); + } + return decode((byte[]) pObject); + } + + /** + * Decodes a byte[] containing containing characters in the Base64 alphabet. + * + * @param pArray + * A byte array containing Base64 character data + * @return a byte array containing binary data + */ + public byte[] decode(byte[] pArray) { + return decodeBase64(pArray); + } + + /** + * Encodes binary data using the base64 algorithm, optionally chunking the output into 76 character blocks. + * + * @param binaryData + * Array containing binary data to encode. + * @param isChunked + * if <code>true</code> this encoder will chunk the base64 output into 76 character blocks + * @return Base64-encoded data. + * @throws IllegalArgumentException + * Thrown when the input array needs an output array bigger than {@link Integer#MAX_VALUE} + */ + public static byte[] encodeBase64(byte[] binaryData, boolean isChunked) { + if (binaryData == null || binaryData.length == 0) { + return binaryData; + } + Base64 b64 = isChunked ? new Base64() : new Base64(0); + + long len = (binaryData.length * 4) / 3; + long mod = len % 4; + if (mod != 0) { + len += 4 - mod; + } + if (isChunked) { + len += (1 + (len / CHUNK_SIZE)) * CHUNK_SEPARATOR.length; + } + + if (len > Integer.MAX_VALUE) { + throw new IllegalArgumentException( + "Input array too big, output array would be bigger than Integer.MAX_VALUE=" + Integer.MAX_VALUE); + } + byte[] buf = new byte[(int) len]; + b64.setInitialBuffer(buf, 0, buf.length); + b64.encode(binaryData, 0, binaryData.length); + b64.encode(binaryData, 0, -1); // Notify encoder of EOF. + + // Encoder might have resized, even though it was unnecessary. + if (b64.buf != buf) { + b64.readResults(buf, 0, buf.length); + } + return buf; + } + + /** + * Decodes Base64 data into octets + * + * @param base64Data Byte array containing Base64 data + * @return Array containing decoded data. + */ + public static byte[] decodeBase64(byte[] base64Data) { + if (base64Data == null || base64Data.length == 0) { + return base64Data; + } + Base64 b64 = new Base64(); + + long len = (base64Data.length * 3) / 4; + byte[] buf = new byte[(int) len]; + b64.setInitialBuffer(buf, 0, buf.length); + b64.decode(base64Data, 0, base64Data.length); + b64.decode(base64Data, 0, -1); // Notify decoder of EOF. + + // We have no idea what the line-length was, so we + // cannot know how much of our array wasn't used. + byte[] result = new byte[b64.pos]; + b64.readResults(result, 0, result.length); + return result; + } + + /** + * Check if a byte value is whitespace or not. + * + * @param byteToCheck the byte to check + * @return true if byte is whitespace, false otherwise + */ + private static boolean isWhiteSpace(byte byteToCheck) { + switch (byteToCheck) { + case ' ' : + case '\n' : + case '\r' : + case '\t' : + return true; + default : + return false; + } + } + + /** + * Discards any characters outside of the base64 alphabet, per the requirements on page 25 of RFC 2045 - "Any + * characters outside of the base64 alphabet are to be ignored in base64 encoded data." + * + * @param data + * The base-64 encoded data to groom + * @return The data, less non-base64 characters (see RFC 2045). + */ + static byte[] discardNonBase64(byte[] data) { + byte groomedData[] = new byte[data.length]; + int bytesCopied = 0; + + for (byte element : data) { + if (isBase64(element)) { + groomedData[bytesCopied++] = element; + } + } + + byte packedData[] = new byte[bytesCopied]; + + System.arraycopy(groomedData, 0, packedData, 0, bytesCopied); + + return packedData; + } + + // Implementation of the Encoder Interface + + /** + * Encodes an Object using the base64 algorithm. This method is provided in order to satisfy the requirements of the + * Encoder interface, and will throw an EncoderException if the supplied object is not of type byte[]. + * + * @param pObject + * Object to encode + * @return An object (of type byte[]) containing the base64 encoded data which corresponds to the byte[] supplied. + * @throws EncoderException + * if the parameter supplied is not of type byte[] + */ + public Object encode(Object pObject) throws EncoderException { + if (!(pObject instanceof byte[])) { + throw new EncoderException("Parameter supplied to Base64 encode is not a byte[]"); + } + return encode((byte[]) pObject); + } + + /** + * Encodes a byte[] containing binary data, into a byte[] containing characters in the Base64 alphabet. + * + * @param pArray + * a byte array containing binary data + * @return A byte array containing only Base64 character data + */ + public byte[] encode(byte[] pArray) { + return encodeBase64(pArray, false); + } + + // Implementation of integer encoding used for crypto + /** + * Decode a byte64-encoded integer according to crypto + * standards such as W3C's XML-Signature + * + * @param pArray a byte array containing base64 character data + * @return A BigInteger + */ + public static BigInteger decodeInteger(byte[] pArray) { + return new BigInteger(1, decodeBase64(pArray)); + } + + /** + * Encode to a byte64-encoded integer according to crypto + * standards such as W3C's XML-Signature + * + * @param bigInt a BigInteger + * @return A byte array containing base64 character data + * @throws NullPointerException if null is passed in + */ + public static byte[] encodeInteger(BigInteger bigInt) { + if (bigInt == null) { + throw new NullPointerException("encodeInteger called with null parameter"); + } + + return encodeBase64(toIntegerBytes(bigInt), false); + } + + /** + * Returns a byte-array representation of a <code>BigInteger</code> + * without sign bit. + * + * @param bigInt <code>BigInteger</code> to be converted + * @return a byte array representation of the BigInteger parameter + */ + static byte[] toIntegerBytes(BigInteger bigInt) { + int bitlen = bigInt.bitLength(); + // round bitlen + bitlen = ((bitlen + 7) >> 3) << 3; + byte[] bigBytes = bigInt.toByteArray(); + + if (((bigInt.bitLength() % 8) != 0) && + (((bigInt.bitLength() / 8) + 1) == (bitlen / 8))) { + return bigBytes; + } + + // set up params for copying everything but sign bit + int startSrc = 0; + int len = bigBytes.length; + + // if bigInt is exactly byte-aligned, just skip signbit in copy + if ((bigInt.bitLength() % 8) == 0) { + startSrc = 1; + len--; + } + + int startDst = bitlen / 8 - len; // to pad w/ nulls as per spec + byte[] resizedBytes = new byte[bitlen / 8]; + + System.arraycopy(bigBytes, startSrc, resizedBytes, startDst, len); + + return resizedBytes; + } + + static class DecoderException extends Exception { + private static final long serialVersionUID = -3786485780312120437L; + + DecoderException(String error) { + super(error); + } + } + + static class EncoderException extends Exception { + private static final long serialVersionUID = -5204809025392124652L; + + EncoderException(String error) { + super(error); + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/filter/Base64OutputStream.java b/mail/common/src/main/java/com/fsck/k9/mail/filter/Base64OutputStream.java new file mode 100644 index 0000000..f37551f --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/filter/Base64OutputStream.java @@ -0,0 +1,183 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.fsck.k9.mail.filter; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * Provides Base64 encoding and decoding in a streaming fashion (unlimited size). + * When encoding the default lineLength is 76 characters and the default + * lineEnding is CRLF, but these can be overridden by using the appropriate + * constructor. + * <p> + * The default behaviour of the Base64OutputStream is to ENCODE, whereas the + * default behaviour of the Base64InputStream is to DECODE. But this behaviour + * can be overridden by using a different constructor. + * </p><p> + * This class implements section <cite>6.8. Base64 Content-Transfer-Encoding</cite> from RFC 2045 <cite>Multipurpose + * Internet Mail Extensions (MIME) Part One: Format of Internet Message Bodies</cite> by Freed and Borenstein. + * </p> + * + * @author Apache Software Foundation + * @version $Id $ + * @see <a href="http://www.ietf.org/rfc/rfc2045.txt">RFC 2045</a> + * @since 1.0-dev + */ +public class Base64OutputStream extends FilterOutputStream { + private final boolean doEncode; + private final Base64 base64; + private final byte[] singleByte = new byte[1]; + + /** + * Creates a Base64OutputStream such that all data written is Base64-encoded + * to the original provided OutputStream. + * + * @param out OutputStream to wrap. + */ + public Base64OutputStream(OutputStream out) { + this(out, true); + } + + /** + * Creates a Base64OutputStream such that all data written is either + * Base64-encoded or Base64-decoded to the original provided OutputStream. + * + * @param out OutputStream to wrap. + * @param doEncode true if we should encode all data written to us, + * false if we should decode. + */ + public Base64OutputStream(OutputStream out, boolean doEncode) { + super(out); + this.doEncode = doEncode; + this.base64 = new Base64(); + } + + /** + * Creates a Base64OutputStream such that all data written is either + * Base64-encoded or Base64-decoded to the original provided OutputStream. + * + * @param out OutputStream to wrap. + * @param doEncode true if we should encode all data written to us, + * false if we should decode. + * @param lineLength If doEncode is true, each line of encoded + * data will contain lineLength characters. + * If lineLength <=0, the encoded data is not divided into lines. + * If doEncode is false, lineLength is ignored. + * @param lineSeparator If doEncode is true, each line of encoded + * data will be terminated with this byte sequence (e.g. \r\n). + * If lineLength <= 0, the lineSeparator is not used. + * If doEncode is false lineSeparator is ignored. + */ + public Base64OutputStream(OutputStream out, boolean doEncode, int lineLength, byte[] lineSeparator) { + super(out); + this.doEncode = doEncode; + this.base64 = new Base64(lineLength, lineSeparator); + } + + /** + * Writes the specified <code>byte</code> to this output stream. + */ + @Override + public void write(int i) throws IOException { + singleByte[0] = (byte) i; + write(singleByte, 0, 1); + } + + /** + * Writes <code>len</code> bytes from the specified + * <code>b</code> array starting at <code>offset</code> to + * this output stream. + * + * @param b source byte array + * @param offset where to start reading the bytes + * @param len maximum number of bytes to write + * + * @throws IOException if an I/O error occurs. + * @throws NullPointerException if the byte array parameter is null + * @throws IndexOutOfBoundsException if offset, len or buffer size are invalid + */ + @Override + public void write(byte b[], int offset, int len) throws IOException { + if (b == null) { + throw new NullPointerException(); + } else if (offset < 0 || len < 0 || offset + len < 0) { + throw new IndexOutOfBoundsException(); + } else if (offset > b.length || offset + len > b.length) { + throw new IndexOutOfBoundsException(); + } else if (len > 0) { + if (doEncode) { + base64.encode(b, offset, len); + } else { + base64.decode(b, offset, len); + } + flush(false); + } + } + + /** + * Flushes this output stream and forces any buffered output bytes + * to be written out to the stream. If propagate is true, the wrapped + * stream will also be flushed. + * + * @param propagate boolean flag to indicate whether the wrapped + * OutputStream should also be flushed. + * @throws IOException if an I/O error occurs. + */ + private void flush(boolean propagate) throws IOException { + int avail = base64.avail(); + if (avail > 0) { + byte[] buf = new byte[avail]; + int c = base64.readResults(buf, 0, avail); + if (c > 0) { + out.write(buf, 0, c); + } + } + if (propagate) { + out.flush(); + } + } + + /** + * Flushes this output stream and forces any buffered output bytes + * to be written out to the stream. + * + * @throws IOException if an I/O error occurs. + */ + @Override + public void flush() throws IOException { + flush(true); + } + + /** + * Closes this output stream, flushing any remaining bytes that must be encoded. The + * underlying stream is flushed but not closed. + */ + @Override + public void close() throws IOException { + // Notify encoder of EOF (-1). + if (doEncode) { + base64.encode(singleByte, 0, -1); + } else { + base64.decode(singleByte, 0, -1); + } + flush(); + } + +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/filter/CountingOutputStream.java b/mail/common/src/main/java/com/fsck/k9/mail/filter/CountingOutputStream.java new file mode 100644 index 0000000..06b335b --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/filter/CountingOutputStream.java @@ -0,0 +1,34 @@ +package com.fsck.k9.mail.filter; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * A simple OutputStream that does nothing but count how many bytes are written to it and + * makes that count available to callers. + */ +public class CountingOutputStream extends OutputStream { + private long mCount; + + public CountingOutputStream() { + } + + public long getCount() { + return mCount; + } + + @Override + public void write(int oneByte) throws IOException { + mCount++; + } + + @Override + public void write(byte b[], int offset, int len) throws IOException { + mCount += len; + } + + @Override + public void write(byte[] b) throws IOException { + mCount += b.length; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/filter/EOLConvertingOutputStream.java b/mail/common/src/main/java/com/fsck/k9/mail/filter/EOLConvertingOutputStream.java new file mode 100644 index 0000000..66ed5ef --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/filter/EOLConvertingOutputStream.java @@ -0,0 +1,64 @@ +package com.fsck.k9.mail.filter; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +public class EOLConvertingOutputStream extends FilterOutputStream { + private static final int CR = '\r'; + private static final int LF = '\n'; + + + private int lastByte; + private boolean ignoreLf = false; + + + public EOLConvertingOutputStream(OutputStream out) { + super(out); + } + + @Override + public void write(int oneByte) throws IOException { + if (oneByte == LF && ignoreLf) { + ignoreLf = false; + return; + } + if (oneByte == LF && lastByte != CR) { + writeByte(CR); + } else if (oneByte != LF && lastByte == CR) { + writeByte(LF); + } + writeByte(oneByte); + ignoreLf = false; + } + + @Override + public void flush() throws IOException { + completeCrLf(); + super.flush(); + } + + public void endWithCrLfAndFlush() throws IOException { + completeCrLf(); + if (lastByte != LF) { + writeByte(CR); + writeByte(LF); + } + super.flush(); + } + + private void completeCrLf() throws IOException { + if (lastByte == CR) { + writeByte(LF); + // We have to ignore the next character if it is <LF>. Otherwise it + // will be expanded to an additional <CR><LF> sequence although it + // belongs to the one just completed. + ignoreLf = true; + } + } + + private void writeByte(int oneByte) throws IOException { + super.write(oneByte); + lastByte = oneByte; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/filter/FixedLengthInputStream.java b/mail/common/src/main/java/com/fsck/k9/mail/filter/FixedLengthInputStream.java new file mode 100644 index 0000000..9535822 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/filter/FixedLengthInputStream.java @@ -0,0 +1,78 @@ + +package com.fsck.k9.mail.filter; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; + +/** + * A filtering InputStream that stops allowing reads after the given length has been read. This + * is used to allow a client to read directly from an underlying protocol stream without reading + * past where the protocol handler intended the client to read. + */ +public class FixedLengthInputStream extends InputStream { + private final InputStream mIn; + private final int mLength; + private int mCount = 0; + + public FixedLengthInputStream(InputStream in, int length) { + this.mIn = in; + this.mLength = length; + } + + @Override + public int available() throws IOException { + return mLength - mCount; + } + + @Override + public int read() throws IOException { + if (mCount >= mLength) { + return -1; + } + + int d = mIn.read(); + if (d != -1) { + mCount++; + } + return d; + } + + @Override + public int read(byte[] b, int offset, int length) throws IOException { + if (mCount >= mLength) { + return -1; + } + + int d = mIn.read(b, offset, Math.min(mLength - mCount, length)); + if (d != -1) { + mCount += d; + } + return d; + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + @Override + public long skip(long n) throws IOException { + long d = mIn.skip(Math.min(n, available())); + if (d > 0) { + mCount += d; + } + return d; + } + + @Override + public String toString() { + return String.format(Locale.US, "FixedLengthInputStream(in=%s, length=%d)", mIn.toString(), mLength); + } + + public void skipRemaining() throws IOException { + while (available() > 0) { + skip(available()); + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/filter/Hex.kt b/mail/common/src/main/java/com/fsck/k9/mail/filter/Hex.kt new file mode 100644 index 0000000..fb973a5 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/filter/Hex.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2018 The K-9 Dog Walkers + * Copyright 2001-2004 The Apache Software Foundation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.fsck.k9.mail.filter + +/** + * This code was copied from the Apache Commons project. + * The unnecessary parts have been left out. + */ +object Hex { + private val LOWER_CASE = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') + private val UPPER_CASE = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F') + + /** + * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. + * The returned array will be double the length of the passed array, as it takes two characters to represent any + * given byte. + * + * @param data a byte[] to convert to Hex characters + * @return A String containing lower-case hexadecimal characters + */ + @JvmStatic + fun encodeHex(data: ByteArray): String { + val l = data.size + val out = CharArray(l shl 1) + + // two characters form the hex value. + var i = 0 + var j = 0 + while (i < l) { + out[j++] = LOWER_CASE[data[i].toInt() shr 4 and 0x0F] + out[j++] = LOWER_CASE[data[i].toInt() and 0x0F] + i++ + } + + return String(out) + } + + fun StringBuilder.appendHex(value: Byte, lowerCase: Boolean = true) { + val digits = if (lowerCase) LOWER_CASE else UPPER_CASE + append(digits[value.toInt() shr 4 and 0x0F]) + append(digits[value.toInt() and 0x0F]) + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/filter/LineWrapOutputStream.java b/mail/common/src/main/java/com/fsck/k9/mail/filter/LineWrapOutputStream.java new file mode 100644 index 0000000..1656103 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/filter/LineWrapOutputStream.java @@ -0,0 +1,85 @@ +package com.fsck.k9.mail.filter; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +public class LineWrapOutputStream extends FilterOutputStream { + private static final byte[] CRLF = new byte[] {'\r', '\n'}; + + private byte[] buffer; + private int bufferStart = 0; + private int lineLength = 0; + private int endOfLastWord = 0; + + + public LineWrapOutputStream(OutputStream out, int maxLineLength) { + super(out); + buffer = new byte[maxLineLength - 2]; + } + + @Override + public void write(int oneByte) throws IOException { + // Buffer full? + if (lineLength == buffer.length) { + // Usable word-boundary found earlier? + if (endOfLastWord > 0) { + // Yes, so output everything up to that word-boundary + out.write(buffer, bufferStart, endOfLastWord - bufferStart); + out.write(CRLF); + + bufferStart = 0; + + // Skip the <SPACE> in the buffer + endOfLastWord++; + lineLength = buffer.length - endOfLastWord; + if (lineLength > 0) { + // Copy rest of the buffer to the front + System.arraycopy(buffer, endOfLastWord, buffer, 0, lineLength); + } + endOfLastWord = 0; + } else { + // No word-boundary found, so output whole buffer + out.write(buffer, bufferStart, buffer.length - bufferStart); + out.write(CRLF); + lineLength = 0; + bufferStart = 0; + } + } + + if ((oneByte == '\n') || (oneByte == '\r')) { + // <CR> or <LF> character found, so output buffer ... + if (lineLength - bufferStart > 0) { + out.write(buffer, bufferStart, lineLength - bufferStart); + } + // ... and that character + out.write(oneByte); + lineLength = 0; + bufferStart = 0; + endOfLastWord = 0; + } else { + // Remember this position as last word-boundary if <SPACE> found + if (oneByte == ' ') { + endOfLastWord = lineLength; + } + + // Write character to the buffer + buffer[lineLength] = (byte)oneByte; + lineLength++; + } + } + + @Override + public void flush() throws IOException { + // Buffer empty? + if (lineLength > bufferStart) { + // Output everything we have up till now + out.write(buffer, bufferStart, lineLength - bufferStart); + + // Mark current position as new start of the buffer + bufferStart = (lineLength == buffer.length) ? 0 : lineLength; + endOfLastWord = 0; + } + out.flush(); + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/filter/PeekableInputStream.java b/mail/common/src/main/java/com/fsck/k9/mail/filter/PeekableInputStream.java new file mode 100644 index 0000000..4d8836f --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/filter/PeekableInputStream.java @@ -0,0 +1,68 @@ +package com.fsck.k9.mail.filter; + + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; + + +/** + * A filtering InputStream that allows single byte "peeks" without consuming the byte. The + * client of this stream can call peek() to see the next available byte in the stream + * and a subsequent read will still return the peeked byte. + */ +public class PeekableInputStream extends FilterInputStream { + private boolean peeked; + private int peekedByte; + + + public PeekableInputStream(InputStream in) { + super(in); + } + + @Override + public int read() throws IOException { + if (!peeked) { + return in.read(); + } else { + peeked = false; + return peekedByte; + } + } + + public int peek() throws IOException { + if (!peeked) { + peekedByte = in.read(); + peeked = true; + } + return peekedByte; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + if (!peeked) { + return in.read(buffer, offset, length); + } else { + buffer[offset] = (byte) peekedByte; + peeked = false; + int r = in.read(buffer, offset + 1, length - 1); + if (r == -1) { + return 1; + } else { + return r + 1; + } + } + } + + @Override + public int read(byte[] buffer) throws IOException { + return read(buffer, 0, buffer.length); + } + + @Override + public String toString() { + return String.format(Locale.US, "PeekableInputStream(in=%s, peeked=%b, peekedByte=%d)", + in.toString(), peeked, peekedByte); + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/filter/SignSafeOutputStream.java b/mail/common/src/main/java/com/fsck/k9/mail/filter/SignSafeOutputStream.java new file mode 100644 index 0000000..4b29f42 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/filter/SignSafeOutputStream.java @@ -0,0 +1,213 @@ +package com.fsck.k9.mail.filter; + + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + + +/** + * Further encode a quoted-printable stream into a safer format for signed email. + * + * @see <a href="http://tools.ietf.org/html/rfc2015">RFC-2015</a> + */ +public class SignSafeOutputStream extends FilterOutputStream { + private static final byte[] ESCAPED_SPACE = new byte[] { '=', '2', '0' }; + private static final int DEFAULT_BUFFER_SIZE = 1024; + + + private State state = State.cr_FROM; + private final byte[] outBuffer; + private int outputIndex; + + private boolean closed = false; + + + public SignSafeOutputStream(OutputStream out) { + super(out); + outBuffer = new byte[DEFAULT_BUFFER_SIZE]; + } + + public void encode(byte next) throws IOException { + State nextState = state.nextState(next); + if (nextState == State.SPACE_FROM) { + state = State.INIT; + writeToBuffer(ESCAPED_SPACE[0]); + writeToBuffer(ESCAPED_SPACE[1]); + writeToBuffer(ESCAPED_SPACE[2]); + } else { + state = nextState; + writeToBuffer(next); + } + } + + private void writeToBuffer(byte next) throws IOException { + outBuffer[outputIndex++] = next; + if (outputIndex >= outBuffer.length) { + flushOutput(); + } + } + + void flushOutput() throws IOException { + if (outputIndex < outBuffer.length) { + out.write(outBuffer, 0, outputIndex); + } else { + out.write(outBuffer); + } + outputIndex = 0; + } + + @Override + public void write(int b) throws IOException { + if (closed) { + throw new IOException("Stream has been closed"); + } + encode((byte) b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (closed) { + throw new IOException("Stream has been closed"); + } + for (int inputIndex = off; inputIndex < len + off; inputIndex++) { + encode(b[inputIndex]); + } + } + + @Override + public void flush() throws IOException { + flushOutput(); + out.flush(); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + + try { + flush(); + } finally { + closed = true; + } + } + + enum State { + INIT { + @Override + public State nextState(int b) { + switch (b) { + case '\r': + return lf_FROM; + default: + return INIT; + + } + } + }, + lf_FROM { + @Override + public State nextState(int b) { + switch (b) { + case '\n': + return cr_FROM; + case '\r': + return lf_FROM; + default: + return INIT; + + } + } + }, + cr_FROM { + @Override + public State nextState(int b) { + switch (b) { + case 'F': + return F_FROM; + case '\r': + return lf_FROM; + default: + return INIT; + + } + } + }, + F_FROM { + @Override + public State nextState(int b) { + switch (b) { + case 'r': + return R_FROM; + case '\r': + return lf_FROM; + default: + return INIT; + + } + } + }, + R_FROM { + @Override + public State nextState(int b) { + switch (b) { + case 'o': + return O_FROM; + case '\r': + return lf_FROM; + default: + return INIT; + + } + } + }, + O_FROM { + @Override + public State nextState(int b) { + switch (b) { + case 'm': + return M_FROM; + case '\r': + return lf_FROM; + default: + return INIT; + + } + } + }, + M_FROM { + @Override + public State nextState(int b) { + switch (b) { + case ' ': + return SPACE_FROM; + case '\r': + return lf_FROM; + default: + return INIT; + + } + } + + }, + SPACE_FROM { + @Override + public State nextState(int b) { + switch (b) { + case '\r': + return lf_FROM; + default: + return INIT; + + } + } + + }; + + public abstract State nextState(int b); + } +} + + diff --git a/mail/common/src/main/java/com/fsck/k9/mail/filter/SmtpDataStuffing.java b/mail/common/src/main/java/com/fsck/k9/mail/filter/SmtpDataStuffing.java new file mode 100644 index 0000000..dfd7afd --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/filter/SmtpDataStuffing.java @@ -0,0 +1,33 @@ +package com.fsck.k9.mail.filter; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +public class SmtpDataStuffing extends FilterOutputStream { + private static final int STATE_NORMAL = 0; + private static final int STATE_CR = 1; + private static final int STATE_CRLF = 2; + + private int state = STATE_CRLF; + + public SmtpDataStuffing(OutputStream out) { + super(out); + } + + @Override + public void write(int oneByte) throws IOException { + if (oneByte == '\r') { + state = STATE_CR; + } else if ((state == STATE_CR) && (oneByte == '\n')) { + state = STATE_CRLF; + } else if ((state == STATE_CRLF) && (oneByte == '.')) { + // Read <CR><LF><DOT> so this line needs an additional period. + super.write('.'); + state = STATE_NORMAL; + } else { + state = STATE_NORMAL; + } + super.write(oneByte); + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/helper/FetchProfileHelper.kt b/mail/common/src/main/java/com/fsck/k9/mail/helper/FetchProfileHelper.kt new file mode 100644 index 0000000..3ff78e4 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/helper/FetchProfileHelper.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.mail.helper + +import com.fsck.k9.mail.FetchProfile + +fun fetchProfileOf(vararg items: FetchProfile.Item): FetchProfile { + return FetchProfile().apply { + for (item in items) { + add(item) + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/helper/Rfc822Token.java b/mail/common/src/main/java/com/fsck/k9/mail/helper/Rfc822Token.java new file mode 100644 index 0000000..bf0721f --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/helper/Rfc822Token.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.fsck.k9.mail.helper; + + +import org.jetbrains.annotations.Nullable; + + +/** + * This class stores an RFC 822-like name, address, and comment, + * and provides methods to convert them to quoted strings. + */ +public class Rfc822Token { + @Nullable + private String mName, mAddress, mComment; + + /** + * Creates a new Rfc822Token with the specified name, address, + * and comment. + */ + public Rfc822Token(@Nullable String name, @Nullable String address, @Nullable String comment) { + mName = name; + mAddress = address; + mComment = comment; + } + + /** + * Returns the name part. + */ + @Nullable + public String getName() { + return mName; + } + + /** + * Returns the address part. + */ + @Nullable + public String getAddress() { + return mAddress; + } + + /** + * Returns the comment part. + */ + @Nullable + public String getComment() { + return mComment; + } + + /** + * Changes the name to the specified name. + */ + public void setName(@Nullable String name) { + mName = name; + } + + /** + * Changes the address to the specified address. + */ + public void setAddress(@Nullable String address) { + mAddress = address; + } + + /** + * Changes the comment to the specified comment. + */ + public void setComment(@Nullable String comment) { + mComment = comment; + } + + /** + * Returns the name (with quoting added if necessary), + * the comment (in parentheses), and the address (in angle brackets). + * This should be suitable for inclusion in an RFC 822 address list. + */ + public String toString() { + StringBuilder sb = new StringBuilder(); + + if (mName != null && mName.length() != 0) { + sb.append(quoteNameIfNecessary(mName)); + sb.append(' '); + } + + if (mComment != null && mComment.length() != 0) { + sb.append('('); + sb.append(quoteComment(mComment)); + sb.append(") "); + } + + if (mAddress != null && mAddress.length() != 0) { + sb.append('<'); + sb.append(mAddress); + sb.append('>'); + } + + return sb.toString(); + } + + /** + * Returns the name, conservatively quoting it if there are any + * characters that are likely to cause trouble outside of a + * quoted string, or returning it literally if it seems safe. + */ + public static String quoteNameIfNecessary(String name) { + int len = name.length(); + + for (int i = 0; i < len; i++) { + char c = name.charAt(i); + + if (! ((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c == ' ') || + (c >= '0' && c <= '9'))) { + return '"' + quoteName(name) + '"'; + } + } + + return name; + } + + /** + * Returns the name, with internal backslashes and quotation marks + * preceded by backslashes. The outer quote marks themselves are not + * added by this method. + */ + public static String quoteName(String name) { + StringBuilder sb = new StringBuilder(); + + int len = name.length(); + for (int i = 0; i < len; i++) { + char c = name.charAt(i); + + if (c == '\\' || c == '"') { + sb.append('\\'); + } + + sb.append(c); + } + + return sb.toString(); + } + + /** + * Returns the comment, with internal backslashes and parentheses + * preceded by backslashes. The outer parentheses themselves are + * not added by this method. + */ + public static String quoteComment(String comment) { + int len = comment.length(); + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < len; i++) { + char c = comment.charAt(i); + + if (c == '(' || c == ')' || c == '\\') { + sb.append('\\'); + } + + sb.append(c); + } + + return sb.toString(); + } + + public int hashCode() { + int result = 17; + if (mName != null) result = 31 * result + mName.hashCode(); + if (mAddress != null) result = 31 * result + mAddress.hashCode(); + if (mComment != null) result = 31 * result + mComment.hashCode(); + return result; + } + + private static boolean stringEquals(String a, String b) { + if (a == null) { + return (b == null); + } else { + return (a.equals(b)); + } + } + + public boolean equals(@Nullable Object o) { + if (!(o instanceof Rfc822Token)) { + return false; + } + Rfc822Token other = (Rfc822Token) o; + return (stringEquals(mName, other.mName) && + stringEquals(mAddress, other.mAddress) && + stringEquals(mComment, other.mComment)); + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/helper/Rfc822Tokenizer.java b/mail/common/src/main/java/com/fsck/k9/mail/helper/Rfc822Tokenizer.java new file mode 100644 index 0000000..faf640a --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/helper/Rfc822Tokenizer.java @@ -0,0 +1,313 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.fsck.k9.mail.helper; + + +import java.util.ArrayList; +import java.util.Collection; + +/** + * This class works as a Tokenizer for MultiAutoCompleteTextView for + * address list fields, and also provides a method for converting + * a string of addresses (such as might be typed into such a field) + * into a series of Rfc822Tokens. + */ +public class Rfc822Tokenizer { + + /** + * This constructor will try to take a string like + * "Foo Bar (something) <foo\@google.com>, + * blah\@google.com (something)" + * and convert it into one or more Rfc822Tokens, output into the supplied + * collection. + * + * It does *not* decode MIME encoded-words; charset conversion + * must already have taken place if necessary. + * It will try to be tolerant of broken syntax instead of + * returning an error. + * + */ + public static void tokenize(CharSequence text, Collection<Rfc822Token> out) { + StringBuilder name = new StringBuilder(); + StringBuilder address = new StringBuilder(); + StringBuilder comment = new StringBuilder(); + + int i = 0; + int cursor = text.length(); + + while (i < cursor) { + char c = text.charAt(i); + + if (c == ',' || c == ';') { + i++; + + while (i < cursor && text.charAt(i) == ' ') { + i++; + } + + crunch(name); + + if (address.length() > 0) { + out.add(new Rfc822Token(name.toString(), + address.toString(), + comment.toString())); + } else if (name.length() > 0) { + out.add(new Rfc822Token(null, + name.toString(), + comment.toString())); + } + + name.setLength(0); + address.setLength(0); + comment.setLength(0); + } else if (c == '"') { + i++; + + while (i < cursor) { + c = text.charAt(i); + + if (c == '"') { + i++; + break; + } else if (c == '\\') { + if (i + 1 < cursor) { + name.append(text.charAt(i + 1)); + } + i += 2; + } else { + name.append(c); + i++; + } + } + } else if (c == '(') { + int level = 1; + i++; + + while (i < cursor && level > 0) { + c = text.charAt(i); + + if (c == ')') { + if (level > 1) { + comment.append(c); + } + + level--; + i++; + } else if (c == '(') { + comment.append(c); + level++; + i++; + } else if (c == '\\') { + if (i + 1 < cursor) { + comment.append(text.charAt(i + 1)); + } + i += 2; + } else { + comment.append(c); + i++; + } + } + } else if (c == '<') { + i++; + + while (i < cursor) { + c = text.charAt(i); + + if (c == '>') { + i++; + break; + } else { + address.append(c); + i++; + } + } + } else if (c == ' ') { + name.append('\0'); + i++; + } else { + name.append(c); + i++; + } + } + + crunch(name); + + if (address.length() > 0) { + out.add(new Rfc822Token(name.toString(), + address.toString(), + comment.toString())); + } else if (name.length() > 0) { + out.add(new Rfc822Token(null, + name.toString(), + comment.toString())); + } + } + + /** + * This method will try to take a string like + * "Foo Bar (something) <foo\@google.com>, + * blah\@google.com (something)" + * and convert it into one or more Rfc822Tokens. + * It does *not* decode MIME encoded-words; charset conversion + * must already have taken place if necessary. + * It will try to be tolerant of broken syntax instead of + * returning an error. + */ + public static Rfc822Token[] tokenize(CharSequence text) { + ArrayList<Rfc822Token> out = new ArrayList<Rfc822Token>(); + tokenize(text, out); + return out.toArray(new Rfc822Token[out.size()]); + } + + private static void crunch(StringBuilder sb) { + int i = 0; + int len = sb.length(); + + while (i < len) { + char c = sb.charAt(i); + + if (c == '\0') { + if (i == 0 || i == len - 1 || + sb.charAt(i - 1) == ' ' || + sb.charAt(i - 1) == '\0' || + sb.charAt(i + 1) == ' ' || + sb.charAt(i + 1) == '\0') { + sb.deleteCharAt(i); + len--; + } else { + i++; + } + } else { + i++; + } + } + + for (i = 0; i < len; i++) { + if (sb.charAt(i) == '\0') { + sb.setCharAt(i, ' '); + } + } + } + + /** + * {@inheritDoc} + */ + public int findTokenStart(CharSequence text, int cursor) { + /* + * It's hard to search backward, so search forward until + * we reach the cursor. + */ + + int best = 0; + int i = 0; + + while (i < cursor) { + i = findTokenEnd(text, i); + + if (i < cursor) { + i++; // Skip terminating punctuation + + while (i < cursor && text.charAt(i) == ' ') { + i++; + } + + if (i < cursor) { + best = i; + } + } + } + + return best; + } + + /** + * {@inheritDoc} + */ + public int findTokenEnd(CharSequence text, int cursor) { + int len = text.length(); + int i = cursor; + + while (i < len) { + char c = text.charAt(i); + + if (c == ',' || c == ';') { + return i; + } else if (c == '"') { + i++; + + while (i < len) { + c = text.charAt(i); + + if (c == '"') { + i++; + break; + } else if (c == '\\' && i + 1 < len) { + i += 2; + } else { + i++; + } + } + } else if (c == '(') { + int level = 1; + i++; + + while (i < len && level > 0) { + c = text.charAt(i); + + if (c == ')') { + level--; + i++; + } else if (c == '(') { + level++; + i++; + } else if (c == '\\' && i + 1 < len) { + i += 2; + } else { + i++; + } + } + } else if (c == '<') { + i++; + + while (i < len) { + c = text.charAt(i); + + if (c == '>') { + i++; + break; + } else { + i++; + } + } + } else { + i++; + } + } + + return i; + } + + /** + * Terminates the specified address with a comma and space. + * This assumes that the specified text already has valid syntax. + * The Adapter subclass's convertToString() method must make that + * guarantee. + */ + public CharSequence terminateToken(CharSequence text) { + return text + ", "; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/helper/TextUtils.kt b/mail/common/src/main/java/com/fsck/k9/mail/helper/TextUtils.kt new file mode 100644 index 0000000..44fdf30 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/helper/TextUtils.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.mail.helper + +object TextUtils { + @JvmStatic + fun isEmpty(text: String?) = text.isNullOrEmpty() + + @JvmStatic + fun join(separator: String, items: Array<Any>): String { + return items.joinToString(separator) + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/helper/UrlEncodingHelper.java b/mail/common/src/main/java/com/fsck/k9/mail/helper/UrlEncodingHelper.java new file mode 100644 index 0000000..7523077 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/helper/UrlEncodingHelper.java @@ -0,0 +1,28 @@ +package com.fsck.k9.mail.helper; + + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.net.URLEncoder; + + +public final class UrlEncodingHelper { + private UrlEncodingHelper() { + } + + public static String decodeUtf8(String s) { + try { + return URLDecoder.decode(s, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("UTF-8 not found"); + } + } + + public static String encodeUtf8(String s) { + try { + return URLEncoder.encode(s, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("UTF-8 not found"); + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/helper/Utf8.kt b/mail/common/src/main/java/com/fsck/k9/mail/helper/Utf8.kt new file mode 100644 index 0000000..a8c20dc --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/helper/Utf8.kt @@ -0,0 +1,115 @@ +/* + * These functions are based on Okio's UTF-8 code. + * + * Copyright (C) 2018 The K-9 Dog Walkers + * Copyright (C) 2017 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.fsck.k9.mail.helper + +/** + * Encodes this string using UTF-8. + */ +inline fun String.encodeUtf8(beginIndex: Int = 0, endIndex: Int = length, crossinline writeByte: (Byte) -> Unit) { + require(beginIndex >= 0) { "beginIndex < 0: $beginIndex" } + require(endIndex >= beginIndex) { "endIndex < beginIndex: $endIndex < $beginIndex" } + require(endIndex <= length) { "endIndex > length: $endIndex > $length" } + + // Transcode a UTF-16 Java String to UTF-8 bytes. + var i = beginIndex + while (i < endIndex) { + val c = this[i].code + + if (c < 0x80) { + // Emit a 7-bit character with 1 byte. + writeByte(c.toByte()) // 0xxxxxxx + i++ + } else if (c < 0x800) { + // Emit a 11-bit character with 2 bytes. + writeByte((c shr 6 or 0xc0).toByte()) // 110xxxxx + writeByte((c and 0x3f or 0x80).toByte()) // 10xxxxxx + i++ + } else if (c < 0xd800 || c > 0xdfff) { + // Emit a 16-bit character with 3 bytes. + writeByte((c shr 12 or 0xe0).toByte()) // 1110xxxx + writeByte((c shr 6 and 0x3f or 0x80).toByte()) // 10xxxxxx + writeByte((c and 0x3f or 0x80).toByte()) // 10xxxxxx + i++ + } else { + // c is a surrogate. Make sure it is a high surrogate and that its successor is a low surrogate. + // If not, the UTF-16 is invalid, in which case we emit a replacement character. + val low = if (i + 1 < endIndex) this[i + 1].code else 0 + if (c > 0xdbff || low < 0xdc00 || low > 0xdfff) { + writeByte('?'.code.toByte()) + i++ + continue + } + + // UTF-16 high surrogate: 110110xxxxxxxxxx (10 bits) + // UTF-16 low surrogate: 110111yyyyyyyyyy (10 bits) + // Unicode code point: 00010000000000000000 + xxxxxxxxxxyyyyyyyyyy (21 bits) + val codePoint = 0x010000 + (c and 0xd800.inv() shl 10 or (low and 0xdc00.inv())) + + // Emit a 21-bit character with 4 bytes. + writeByte((codePoint shr 18 or 0xf0).toByte()) // 11110xxx + writeByte((codePoint shr 12 and 0x3f or 0x80).toByte()) // 10xxxxxx + writeByte((codePoint shr 6 and 0x3f or 0x80).toByte()) // 10xxyyyy + writeByte((codePoint and 0x3f or 0x80).toByte()) // 10yyyyyy + i += 2 + } + } +} + +/** + * Returns the number of bytes used to encode `string` as UTF-8 when using [Int.encodeUtf8]. + */ +fun Int.utf8Size(): Int { + return when { + this < 0x80 -> 1 + this < 0x800 -> 2 + this < 0xd800 -> 3 + this < 0xe000 -> 1 + this < 0x10000 -> 3 + else -> 4 + } +} + +/** + * Encodes this code point using UTF-8. + */ +inline fun Int.encodeUtf8(crossinline writeByte: (Byte) -> Unit) { + val codePoint = this + if (codePoint < 0x80) { + // Emit a 7-bit character with 1 byte. + writeByte(codePoint.toByte()) // 0xxxxxxx + } else if (codePoint < 0x800) { + // Emit a 11-bit character with 2 bytes. + writeByte((codePoint shr 6 or 0xc0).toByte()) // 110xxxxx + writeByte((codePoint and 0x3f or 0x80).toByte()) // 10xxxxxx + } else if (codePoint < 0xd800 || codePoint in 0xe000..0x10000) { + // Emit a 16-bit character with 3 bytes. + writeByte((codePoint shr 12 or 0xe0).toByte()) // 1110xxxx + writeByte((codePoint shr 6 and 0x3f or 0x80).toByte()) // 10xxxxxx + writeByte((codePoint and 0x3f or 0x80).toByte()) // 10xxxxxx + } else if (codePoint in 0xd800..0xdfff) { + // codePoint is a surrogate. Emit a replacement character + writeByte('?'.code.toByte()) + } else { + // Emit a 21-bit character with 4 bytes. + writeByte((codePoint shr 18 or 0xf0).toByte()) // 11110xxx + writeByte((codePoint shr 12 and 0x3f or 0x80).toByte()) // 10xxxxxx + writeByte((codePoint shr 6 and 0x3f or 0x80).toByte()) // 10xxyyyy + writeByte((codePoint and 0x3f or 0x80).toByte()) // 10yyyyyy + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/AddressHeaderBuilder.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/AddressHeaderBuilder.kt new file mode 100644 index 0000000..cba718b --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/AddressHeaderBuilder.kt @@ -0,0 +1,35 @@ +package com.fsck.k9.mail.internet + +import com.fsck.k9.mail.Address + +/** + * Encode and fold email addresses for use in header values. + */ +object AddressHeaderBuilder { + @JvmStatic + fun createHeaderValue(addresses: Array<Address>): String { + require(addresses.isNotEmpty()) { "addresses must not be empty" } + + return buildString { + var lineLength = 0 + for ((index, address) in addresses.withIndex()) { + val encodedAddress = address.toEncodedString() + val encodedAddressLength = encodedAddress.length + + if (index > 0 && lineLength + 2 + encodedAddressLength + 1 > RECOMMENDED_MAX_LINE_LENGTH) { + append(",$CRLF ") + append(encodedAddress) + lineLength = encodedAddressLength + 1 + } else { + if (index > 0) { + append(", ") + lineLength += 2 + } + + append(encodedAddress) + lineLength += encodedAddressLength + } + } + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileBody.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileBody.java new file mode 100644 index 0000000..1422b3e --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileBody.java @@ -0,0 +1,149 @@ +package com.fsck.k9.mail.internet; + + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import com.fsck.k9.logging.Timber; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.filter.Base64OutputStream; +import org.apache.commons.io.IOUtils; +import org.apache.james.mime4j.codec.QuotedPrintableOutputStream; +import org.apache.james.mime4j.util.MimeUtil; + + +/** + * A Body that is backed by a temp file. The Body exposes a getOutputStream method that allows + * the user to write to the temp file. After the write the body is available via getInputStream + * and writeTo one time. After writeTo is called, or the InputStream returned from + * getInputStream is closed the file is deleted and the Body should be considered disposed of. + */ +public class BinaryTempFileBody implements RawDataBody, SizeAware { + private static File mTempDirectory; + + private File mFile; + + String mEncoding = null; + + public static void setTempDirectory(File tempDirectory) { + mTempDirectory = tempDirectory; + } + + public static File getTempDirectory() { + return mTempDirectory; + } + + @Override + public String getEncoding() { + return mEncoding; + } + + public void setEncoding(String encoding) throws MessagingException { + if (mEncoding != null && mEncoding.equalsIgnoreCase(encoding)) { + return; + } + + // The encoding changed, so we need to convert the message + if (!MimeUtil.ENC_8BIT.equalsIgnoreCase(mEncoding)) { + throw new RuntimeException("Can't convert from encoding: " + mEncoding); + } + + try { + File newFile = File.createTempFile("body", null, mTempDirectory); + final OutputStream out = new FileOutputStream(newFile); + try { + OutputStream wrappedOut; + if (MimeUtil.ENC_QUOTED_PRINTABLE.equals(encoding)) { + wrappedOut = new QuotedPrintableOutputStream(out, false); + } else if (MimeUtil.ENC_BASE64.equals(encoding)) { + wrappedOut = new Base64OutputStream(out); + } else { + throw new RuntimeException("Target encoding not supported: " + encoding); + } + + InputStream in = getInputStream(); + try { + IOUtils.copy(in, wrappedOut); + } finally { + IOUtils.closeQuietly(in); + IOUtils.closeQuietly(wrappedOut); + } + } finally { + IOUtils.closeQuietly(out); + } + + mFile = newFile; + mEncoding = encoding; + } catch (IOException e) { + throw new MessagingException("Unable to convert body", e); + } + } + + public BinaryTempFileBody(String encoding) { + if (mTempDirectory == null) { + throw new RuntimeException("setTempDirectory has not been called on BinaryTempFileBody!"); + } + + mEncoding = encoding; + } + + public OutputStream getOutputStream() throws IOException { + mFile = File.createTempFile("body", null, mTempDirectory); + mFile.deleteOnExit(); + return new FileOutputStream(mFile); + } + + public InputStream getInputStream() throws MessagingException { + try { + return new BinaryTempFileBodyInputStream(new FileInputStream(mFile)); + } catch (IOException ioe) { + throw new MessagingException("Unable to open body", ioe); + } + } + + public void writeTo(OutputStream out) throws IOException, MessagingException { + InputStream in = getInputStream(); + try { + IOUtils.copy(in, out); + } finally { + IOUtils.closeQuietly(in); + } + } + + @Override + public long getSize() { + return mFile.length(); + } + + public File getFile() { + return mFile; + } + + class BinaryTempFileBodyInputStream extends FilterInputStream { + public BinaryTempFileBodyInputStream(InputStream in) { + super(in); + } + + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + Timber.d("Deleting temporary binary file: %s", mFile.getName()); + boolean fileSuccessfullyDeleted = mFile.delete(); + if (!fileSuccessfullyDeleted) { + Timber.i("Failed to delete temporary binary file: %s", mFile.getName()); + } + } + } + + public void closeWithoutDeleting() throws IOException { + super.close(); + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileMessageBody.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileMessageBody.java new file mode 100644 index 0000000..e283fc5 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileMessageBody.java @@ -0,0 +1,25 @@ +package com.fsck.k9.mail.internet; + + +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.MessagingException; +import org.apache.james.mime4j.util.MimeUtil; + +/** + * A {@link BinaryTempFileBody} extension containing a body of type message/rfc822. + */ +public class BinaryTempFileMessageBody extends BinaryTempFileBody implements Body { + + public BinaryTempFileMessageBody(String encoding) { + super(encoding); + } + + @Override + public void setEncoding(String encoding) throws MessagingException { + if (!MimeUtil.ENC_7BIT.equalsIgnoreCase(encoding) + && !MimeUtil.ENC_8BIT.equalsIgnoreCase(encoding)) { + throw new MessagingException("Incompatible content-transfer-encoding for a message/rfc822 body"); + } + mEncoding = encoding; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/CharsetSupport.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/CharsetSupport.java new file mode 100644 index 0000000..c0f7603 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/CharsetSupport.java @@ -0,0 +1,1096 @@ +package com.fsck.k9.mail.internet; + +import com.fsck.k9.logging.Timber; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.MessagingException; + +import org.apache.commons.io.IOUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.util.Locale; + +import static com.fsck.k9.mail.internet.JisSupport.SHIFT_JIS; + +public class CharsetSupport { + private static final String DEFAULT_CHARSET = "US-ASCII"; + + /** + * Table for character set fall-back. + * + * Table format: unsupported charset (regular expression), fall-back charset + */ + private static final String[][] CHARSET_FALLBACK_MAP = new String[][] { + // Some Android versions don't support KOI8-U + {"koi8-u", "koi8-r"}, + {"iso-2022-jp-[\\d]+", "iso-2022-jp"} + }; + + + static String fixupCharset(String charset, Message message) throws MessagingException { + if (charset == null || "0".equals(charset)) + charset = DEFAULT_CHARSET; + + charset = charset.toLowerCase(Locale.US); + if (charset.equals("cp932")) + charset = SHIFT_JIS; + + if (charset.equals(SHIFT_JIS) || charset.equals("iso-2022-jp")) { + String variant = JisSupport.getJisVariantFromMessage(message); + if (variant != null) + charset = "x-" + variant + "-" + charset + "-2007"; + } + return charset; + } + + + static String readToString(InputStream in, String charset) throws IOException { + boolean isIphoneString = false; + + // iso-2022-jp variants are supported by no versions as of Dec 2010. + if (charset.length() > 19 && charset.startsWith("x-") && + charset.endsWith("-iso-2022-jp-2007") && !Charset.isSupported(charset)) { + in = new Iso2022JpToShiftJisInputStream(in); + charset = "x-" + charset.substring(2, charset.length() - 17) + "-shift_jis-2007"; + } + + // shift_jis variants are supported by Eclair and later. + if (JisSupport.isShiftJis(charset) && !Charset.isSupported(charset)) { + // If the JIS variant is iPhone, map the Unicode private use area in iPhone to the one in Android after + // converting the character set from the standard Shift JIS to Unicode. + if (charset.substring(2, charset.length() - 15).equals("iphone")) + isIphoneString = true; + + charset = SHIFT_JIS; + } + + /* + * See if there is conversion from the MIME charset to the Java one. + * this function may also throw an exception if the charset name is not known + */ + boolean supported; + try { + supported = Charset.isSupported(charset); + } catch (IllegalCharsetNameException e) { + supported = false; + } + + for (String[] rule: CHARSET_FALLBACK_MAP) { + if (supported) { + break; + } + + if (charset.matches(rule[0])) { + Timber.e("I don't know how to deal with the charset %s. Falling back to %s", charset, rule[1]); + charset = rule[1]; + try { + supported = Charset.isSupported(charset); + } catch (IllegalCharsetNameException e) { + supported = false; + } + } + } + + if (!supported) { + charset = DEFAULT_CHARSET; + } + + /* + * Convert and return as new String + */ + String str = IOUtils.toString(in, charset); + + if (isIphoneString) + str = importStringFromIphone(str); + return str; + } + + private static String importStringFromIphone(String str) { + StringBuilder buff = new StringBuilder(str.length()); + for (int i = 0; i < str.length(); i = str.offsetByCodePoints(i, 1)) { + int codePoint = str.codePointAt(i); + buff.appendCodePoint(importCodePointFromIphone(codePoint)); + } + return buff.toString(); + } + + private static int importCodePointFromIphone(int codePoint) { + switch (codePoint) { + case 0xE001: + return 0xFE19B; + case 0xE002: + return 0xFE19C; + case 0xE003: + return 0xFE823; + case 0xE004: + return 0xFE19D; + case 0xE005: + return 0xFE19E; + case 0xE006: + return 0xFE4CF; + case 0xE007: + return 0xFE4CD; + case 0xE008: + return 0xFE4EF; + case 0xE009: + return 0xFE523; + case 0xE00A: + return 0xFE525; + case 0xE00B: + return 0xFE528; + case 0xE00C: + return 0xFE538; + case 0xE00D: + return 0xFEB96; + case 0xE00E: + return 0xFEB97; + case 0xE00F: + return 0xFEB98; + case 0xE010: + return 0xFEB93; + case 0xE011: + return 0xFEB94; + case 0xE012: + return 0xFEB95; + case 0xE013: + return 0xFE7D5; + case 0xE014: + return 0xFE7D2; + case 0xE015: + return 0xFE7D3; + case 0xE016: + return 0xFE7D1; + case 0xE017: + return 0xFE7DA; + case 0xE018: + return 0xFE7D4; + case 0xE019: + return 0xFE1BD; + case 0xE01A: + return 0xFE1BE; + case 0xE01B: + return 0xFE7E4; + case 0xE01C: + return 0xFE7EA; + case 0xE01D: + return 0xFE7E9; + case 0xE01E: + return 0xFE7DF; + case 0xE01F: + return 0xFE7E3; + case 0xE020: + return 0xFEB09; + case 0xE021: + return 0xFEB04; + case 0xE022: + return 0xFEB0C; + case 0xE023: + return 0xFEB0E; + case 0xE024: + return 0xFE01E; + case 0xE025: + return 0xFE01F; + case 0xE026: + return 0xFE020; + case 0xE027: + return 0xFE021; + case 0xE028: + return 0xFE022; + case 0xE029: + return 0xFE023; + case 0xE02A: + return 0xFE024; + case 0xE02B: + return 0xFE025; + case 0xE02C: + return 0xFE026; + case 0xE02D: + return 0xFE027; + case 0xE02E: + return 0xFE028; + case 0xE02F: + return 0xFE029; + case 0xE030: + return 0xFE040; + case 0xE031: + return 0xFE4D2; + case 0xE032: + return 0xFE041; + case 0xE033: + return 0xFE512; + case 0xE034: + return 0xFE825; + case 0xE035: + return 0xFE826; + case 0xE036: + return 0xFE4B0; + case 0xE037: + return 0xFE4BB; + case 0xE038: + return 0xFE4B2; + case 0xE039: + return 0xFE7EC; + case 0xE03A: + return 0xFE7F5; + case 0xE03B: + return 0xFE4C3; + case 0xE03C: + return 0xFE800; + case 0xE03D: + return 0xFE801; + case 0xE03E: + return 0xFE813; + case 0xE03F: + return 0xFEB82; + case 0xE040: + return 0xFE815; + case 0xE041: + return 0xFE816; + case 0xE042: + return 0xFE818; + case 0xE043: + return 0xFE980; + case 0xE044: + return 0xFE982; + case 0xE045: + return 0xFE981; + case 0xE046: + return 0xFE962; + case 0xE047: + return 0xFE983; + case 0xE048: + return 0xFE003; + case 0xE049: + return 0xFE001; + case 0xE04A: + return 0xFE000; + case 0xE04B: + return 0xFE002; + case 0xE04C: + return 0xFE014; + case 0xE04D: + return 0xFE009; + case 0xE04E: + return 0xFE1AF; + case 0xE04F: + return 0xFE1B8; + case 0xE050: + return 0xFE1C0; + case 0xE051: + return 0xFE1C1; + case 0xE052: + return 0xFE1B7; + case 0xE053: + return 0xFE1C2; + case 0xE054: + return 0xFE1C3; + case 0xE055: + return 0xFE1BC; + case 0xE056: + return 0xFE335; + case 0xE057: + return 0xFE330; + case 0xE058: + return 0xFE323; + case 0xE059: + return 0xFE320; + case 0xE05A: + return 0xFE4F4; + case 0xE101: + return 0xFE52D; + case 0xE102: + return 0xFE52E; + case 0xE103: + return 0xFE52B; + case 0xE104: + return 0xFE526; + case 0xE105: + return 0xFE329; + case 0xE106: + return 0xFE327; + case 0xE107: + return 0xFE341; + case 0xE108: + return 0xFE344; + case 0xE109: + return 0xFE1C4; + case 0xE10A: + return 0xFE1C5; + case 0xE10B: + return 0xFE1BF; + case 0xE10C: + return 0xFE1B0; + case 0xE10D: + return 0xFE7ED; + case 0xE10E: + return 0xFE4D1; + case 0xE10F: + return 0xFEB56; + case 0xE110: + return 0xFE03C; + case 0xE111: + return 0xFE827; + case 0xE112: + return 0xFE510; + case 0xE113: + return 0xFE4F5; + case 0xE114: + return 0xFEB85; + case 0xE115: + return 0xFE7D9; + case 0xE116: + return 0xFE4CA; + case 0xE117: + return 0xFE515; + case 0xE118: + return 0xFE03F; + case 0xE119: + return 0xFE042; + case 0xE11A: + return 0xFE1B2; + case 0xE11B: + return 0xFE1AE; + case 0xE11C: + return 0xFE1B3; + case 0xE11D: + return 0xFE4F6; + case 0xE11E: + return 0xFE53B; + case 0xE11F: + return 0xFE537; + case 0xE120: + return 0xFE960; + case 0xE121: + return 0xFE4BC; + case 0xE122: + return 0xFE7FB; + case 0xE123: + return 0xFE7FA; + case 0xE124: + return 0xFE7FD; + case 0xE125: + return 0xFE807; + case 0xE126: + return 0xFE81D; + case 0xE127: + return 0xFE81E; + case 0xE128: + return 0xFE81F; + case 0xE129: + return 0xFE820; + case 0xE12A: + return 0xFE81C; + case 0xE12B: + return 0xFE1B1; + case 0xE12C: + return 0xFE81B; + case 0xE12D: + return 0xFE80B; + case 0xE12E: + return 0xFEB32; + case 0xE12F: + return 0xFE4DD; + case 0xE130: + return 0xFE80C; + case 0xE131: + return 0xFE7DB; + case 0xE132: + return 0xFE7D7; + case 0xE133: + return 0xFE80D; + case 0xE134: + return 0xFE7DC; + case 0xE135: + return 0xFE7EE; + case 0xE136: + return 0xFE7EB; + case 0xE137: + return 0xFE7F8; + case 0xE138: + return 0xFEB33; + case 0xE139: + return 0xFEB34; + case 0xE13A: + return 0xFEB35; + case 0xE13B: + return 0xFE509; + case 0xE13C: + return 0xFEB59; + case 0xE13D: + return 0xFE004; + case 0xE13E: + return 0xFE4D6; + case 0xE13F: + return 0xFE505; + case 0xE140: + return 0xFE507; + case 0xE141: + return 0xFE821; + case 0xE142: + return 0xFE52F; + case 0xE143: + return 0xFE514; + case 0xE144: + return 0xFEB86; + case 0xE145: + return 0xFEB87; + case 0xE146: + return 0xFE00B; + case 0xE147: + return 0xFE965; + case 0xE148: + return 0xFE546; + case 0xE149: + return 0xFE4DE; + case 0xE14A: + return 0xFE4DF; + case 0xE14B: + return 0xFE531; + case 0xE14C: + return 0xFEB5E; + case 0xE14D: + return 0xFE4B5; + case 0xE14E: + return 0xFE7F7; + case 0xE14F: + return 0xFE7F6; + case 0xE150: + return 0xFE7E7; + case 0xE151: + return 0xFE506; + case 0xE152: + return 0xFE1A1; + case 0xE153: + return 0xFE4B3; + case 0xE154: + return 0xFE4B6; + case 0xE155: + return 0xFE4B4; + case 0xE156: + return 0xFE4B9; + case 0xE157: + return 0xFE4BA; + case 0xE158: + return 0xFE4B7; + case 0xE159: + return 0xFE7E6; + case 0xE15A: + return 0xFE7EF; + case 0xE201: + return 0xFE7F0; + case 0xE202: + return 0xFE7E8; + case 0xE203: + return 0xFEB24; + case 0xE204: + return 0xFEB19; + case 0xE205: + return 0xFEB61; + case 0xE206: + return 0xFEB62; + case 0xE207: + return 0xFEB25; + case 0xE208: + return 0xFEB1F; + case 0xE209: + return 0xFE044; + case 0xE20A: + return 0xFEB20; + case 0xE20B: + return 0xFE838; + case 0xE20C: + return 0xFEB1A; + case 0xE20D: + return 0xFEB1C; + case 0xE20E: + return 0xFEB1B; + case 0xE20F: + return 0xFEB1D; + case 0xE210: + return 0xFE82C; + case 0xE211: + return 0xFE82B; + case 0xE212: + return 0xFEB36; + case 0xE213: + return 0xFEB37; + case 0xE214: + return 0xFEB38; + case 0xE215: + return 0xFEB39; + case 0xE216: + return 0xFEB3A; + case 0xE217: + return 0xFEB3B; + case 0xE218: + return 0xFEB3C; + case 0xE219: + return 0xFEB63; + case 0xE21A: + return 0xFEB64; + case 0xE21B: + return 0xFEB67; + case 0xE21C: + return 0xFE82E; + case 0xE21D: + return 0xFE82F; + case 0xE21E: + return 0xFE830; + case 0xE21F: + return 0xFE831; + case 0xE220: + return 0xFE832; + case 0xE221: + return 0xFE833; + case 0xE222: + return 0xFE834; + case 0xE223: + return 0xFE835; + case 0xE224: + return 0xFE836; + case 0xE225: + return 0xFE837; + case 0xE226: + return 0xFEB3D; + case 0xE227: + return 0xFEB3E; + case 0xE228: + return 0xFEB3F; + case 0xE229: + return 0xFEB81; + case 0xE22A: + return 0xFEB31; + case 0xE22B: + return 0xFEB2F; + case 0xE22C: + return 0xFEB40; + case 0xE22D: + return 0xFEB41; + case 0xE22E: + return 0xFEB99; + case 0xE22F: + return 0xFEB9A; + case 0xE230: + return 0xFEB9B; + case 0xE231: + return 0xFEB9C; + case 0xE232: + return 0xFEAF8; + case 0xE233: + return 0xFEAF9; + case 0xE234: + return 0xFEAFA; + case 0xE235: + return 0xFEAFB; + case 0xE236: + return 0xFEAF0; + case 0xE237: + return 0xFEAF2; + case 0xE238: + return 0xFEAF1; + case 0xE239: + return 0xFEAF3; + case 0xE23A: + return 0xFEAFC; + case 0xE23B: + return 0xFEAFD; + case 0xE23C: + return 0xFEAFE; + case 0xE23D: + return 0xFEAFF; + case 0xE23E: + return 0xFE4F8; + case 0xE23F: + return 0xFE02B; + case 0xE240: + return 0xFE02C; + case 0xE241: + return 0xFE02D; + case 0xE242: + return 0xFE02E; + case 0xE243: + return 0xFE02F; + case 0xE244: + return 0xFE030; + case 0xE245: + return 0xFE031; + case 0xE246: + return 0xFE032; + case 0xE247: + return 0xFE033; + case 0xE248: + return 0xFE034; + case 0xE249: + return 0xFE035; + case 0xE24A: + return 0xFE036; + case 0xE24B: + return 0xFE037; + case 0xE24C: + return 0xFEB42; + case 0xE24D: + return 0xFEB27; + case 0xE24E: + return 0xFEB29; + case 0xE24F: + return 0xFEB2D; + case 0xE250: + return 0xFE839; + case 0xE251: + return 0xFE83A; + case 0xE252: + return 0xFEB23; + case 0xE253: + return 0xFE1B4; + case 0xE254: + return 0xFEE77; + case 0xE255: + return 0xFEE78; + case 0xE256: + return 0xFEE79; + case 0xE257: + return 0xFEE7A; + case 0xE258: + return 0xFEE7B; + case 0xE259: + return 0xFEE7C; + case 0xE25A: + return 0xFEE7D; + case 0xE301: + return 0xFE527; + case 0xE302: + return 0xFE4D3; + case 0xE303: + return 0xFE045; + case 0xE304: + return 0xFE03D; + case 0xE305: + return 0xFE046; + case 0xE306: + return 0xFE828; + case 0xE307: + return 0xFE047; + case 0xE308: + return 0xFE048; + case 0xE309: + return 0xFE508; + case 0xE30A: + return 0xFE803; + case 0xE30B: + return 0xFE985; + case 0xE30C: + return 0xFE987; + case 0xE30D: + return 0xFEB43; + case 0xE30E: + return 0xFEB1E; + case 0xE30F: + return 0xFE50A; + case 0xE310: + return 0xFE516; + case 0xE311: + return 0xFEB58; + case 0xE312: + return 0xFE517; + case 0xE313: + return 0xFE53E; + case 0xE314: + return 0xFE50F; + case 0xE315: + return 0xFEB2B; + case 0xE316: + return 0xFE53C; + case 0xE317: + return 0xFE530; + case 0xE318: + return 0xFE4D4; + case 0xE319: + return 0xFE4D5; + case 0xE31A: + return 0xFE4D7; + case 0xE31B: + return 0xFE4D8; + case 0xE31C: + return 0xFE195; + case 0xE31D: + return 0xFE196; + case 0xE31E: + return 0xFE197; + case 0xE31F: + return 0xFE198; + case 0xE320: + return 0xFE199; + case 0xE321: + return 0xFE4D9; + case 0xE322: + return 0xFE4DA; + case 0xE323: + return 0xFE4F0; + case 0xE324: + return 0xFE808; + case 0xE325: + return 0xFE4F2; + case 0xE326: + return 0xFE814; + case 0xE327: + return 0xFEB0D; + case 0xE328: + return 0xFEB11; + case 0xE329: + return 0xFEB12; + case 0xE32A: + return 0xFEB13; + case 0xE32B: + return 0xFEB14; + case 0xE32C: + return 0xFEB15; + case 0xE32D: + return 0xFEB16; + case 0xE32E: + return 0xFEB60; + case 0xE32F: + return 0xFEB68; + case 0xE330: + return 0xFEB5D; + case 0xE331: + return 0xFEB5B; + case 0xE332: + return 0xFEB44; + case 0xE333: + return 0xFEB45; + case 0xE334: + return 0xFEB57; + case 0xE335: + return 0xFEB69; + case 0xE336: + return 0xFEB0A; + case 0xE337: + return 0xFEB0B; + case 0xE338: + return 0xFE984; + case 0xE339: + return 0xFE964; + case 0xE33A: + return 0xFE966; + case 0xE33B: + return 0xFE967; + case 0xE33C: + return 0xFE968; + case 0xE33D: + return 0xFE969; + case 0xE33E: + return 0xFE96A; + case 0xE33F: + return 0xFE96B; + case 0xE340: + return 0xFE963; + case 0xE341: + return 0xFE96C; + case 0xE342: + return 0xFE961; + case 0xE343: + return 0xFE96D; + case 0xE344: + return 0xFE96E; + case 0xE345: + return 0xFE051; + case 0xE346: + return 0xFE052; + case 0xE347: + return 0xFE053; + case 0xE348: + return 0xFE054; + case 0xE349: + return 0xFE055; + case 0xE34A: + return 0xFE056; + case 0xE34B: + return 0xFE511; + case 0xE34C: + return 0xFE96F; + case 0xE34D: + return 0xFE970; + case 0xE401: + return 0xFE345; + case 0xE402: + return 0xFE343; + case 0xE403: + return 0xFE340; + case 0xE404: + return 0xFE333; + case 0xE405: + return 0xFE347; + case 0xE406: + return 0xFE33C; + case 0xE407: + return 0xFE33F; + case 0xE408: + return 0xFE342; + case 0xE409: + return 0xFE32A; + case 0xE40A: + return 0xFE33E; + case 0xE40B: + return 0xFE33B; + case 0xE40C: + return 0xFE32E; + case 0xE40D: + return 0xFE32F; + case 0xE40E: + return 0xFE326; + case 0xE40F: + return 0xFE325; + case 0xE410: + return 0xFE322; + case 0xE411: + return 0xFE33A; + case 0xE412: + return 0xFE334; + case 0xE413: + return 0xFE339; + case 0xE414: + return 0xFE336; + case 0xE415: + return 0xFE338; + case 0xE416: + return 0xFE33D; + case 0xE417: + return 0xFE32D; + case 0xE418: + return 0xFE32C; + case 0xE419: + return 0xFE190; + case 0xE41A: + return 0xFE192; + case 0xE41B: + return 0xFE191; + case 0xE41C: + return 0xFE193; + case 0xE41D: + return 0xFE35B; + case 0xE41E: + return 0xFEB9D; + case 0xE41F: + return 0xFEB9E; + case 0xE420: + return 0xFEB9F; + case 0xE421: + return 0xFEBA0; + case 0xE422: + return 0xFEBA1; + case 0xE423: + return 0xFE351; + case 0xE424: + return 0xFE352; + case 0xE425: + return 0xFE829; + case 0xE426: + return 0xFE353; + case 0xE427: + return 0xFE358; + case 0xE428: + return 0xFE1A0; + case 0xE429: + return 0xFE1A2; + case 0xE42A: + return 0xFE7D6; + case 0xE42B: + return 0xFE7DD; + case 0xE42C: + return 0xFE80E; + case 0xE42D: + return 0xFE7DE; + case 0xE42E: + return 0xFE7E5; + case 0xE42F: + return 0xFE7F1; + case 0xE430: + return 0xFE7F2; + case 0xE431: + return 0xFE7F3; + case 0xE432: + return 0xFE7F4; + case 0xE433: + return 0xFE7FE; + case 0xE434: + return 0xFE7E0; + case 0xE435: + return 0xFE7E2; + case 0xE436: + return 0xFE518; + case 0xE437: + return 0xFEB17; + case 0xE438: + return 0xFE519; + case 0xE439: + return 0xFE51A; + case 0xE43A: + return 0xFE51B; + case 0xE43B: + return 0xFE51C; + case 0xE43C: + return 0xFE007; + case 0xE43D: + return 0xFE82A; + case 0xE43E: + return 0xFE038; + case 0xE43F: + return 0xFE971; + case 0xE440: + return 0xFE51D; + case 0xE441: + return 0xFE1C6; + case 0xE442: + return 0xFE51E; + case 0xE443: + return 0xFE005; + case 0xE444: + return 0xFE049; + case 0xE445: + return 0xFE51F; + case 0xE446: + return 0xFE017; + case 0xE447: + return 0xFE043; + case 0xE448: + return 0xFE513; + case 0xE449: + return 0xFE00A; + case 0xE44A: + return 0xFE00C; + case 0xE44B: + return 0xFE008; + case 0xE44C: + return 0xFE00D; + case 0xE501: + return 0xFE4B8; + case 0xE502: + return 0xFE804; + case 0xE503: + return 0xFE805; + case 0xE504: + return 0xFE4BD; + case 0xE505: + return 0xFE4BE; + case 0xE506: + return 0xFE4BF; + case 0xE507: + return 0xFE802; + case 0xE508: + return 0xFE4C0; + case 0xE509: + return 0xFE4C4; + case 0xE50A: + return 0xFE4C5; + case 0xE50B: + return 0xFE4E5; + case 0xE50C: + return 0xFE4E6; + case 0xE50D: + return 0xFE4E7; + case 0xE50E: + return 0xFE4E8; + case 0xE50F: + return 0xFE4E9; + case 0xE510: + return 0xFE4EA; + case 0xE511: + return 0xFE4EB; + case 0xE512: + return 0xFE4EC; + case 0xE513: + return 0xFE4ED; + case 0xE514: + return 0xFE4EE; + case 0xE515: + return 0xFE1A4; + case 0xE516: + return 0xFE1A5; + case 0xE517: + return 0xFE1A6; + case 0xE518: + return 0xFE1A7; + case 0xE519: + return 0xFE1A8; + case 0xE51A: + return 0xFE1A9; + case 0xE51B: + return 0xFE1AA; + case 0xE51C: + return 0xFE1AB; + case 0xE51D: + return 0xFE4C6; + case 0xE51E: + return 0xFE1B5; + case 0xE51F: + return 0xFE1B6; + case 0xE520: + return 0xFE1C7; + case 0xE521: + return 0xFE1C8; + case 0xE522: + return 0xFE1C9; + case 0xE523: + return 0xFE1BA; + case 0xE524: + return 0xFE1CA; + case 0xE525: + return 0xFE1CB; + case 0xE526: + return 0xFE1CC; + case 0xE527: + return 0xFE1CD; + case 0xE528: + return 0xFE1CE; + case 0xE529: + return 0xFE1CF; + case 0xE52A: + return 0xFE1D0; + case 0xE52B: + return 0xFE1D1; + case 0xE52C: + return 0xFE1D2; + case 0xE52D: + return 0xFE1D3; + case 0xE52E: + return 0xFE1D4; + case 0xE52F: + return 0xFE1D5; + case 0xE530: + return 0xFE1D6; + case 0xE531: + return 0xFE1D7; + case 0xE532: + return 0xFE50B; + case 0xE533: + return 0xFE50C; + case 0xE534: + return 0xFE50D; + case 0xE535: + return 0xFE50E; + case 0xE536: + return 0xFE553; + case 0xE537: + return 0xFEB2A; + case 0xE538: + return 0xFEE70; + case 0xE539: + return 0xFEE71; + case 0xE53A: + return 0xFEE72; + case 0xE53B: + return 0xFEE73; + case 0xE53C: + return 0xFEE74; + case 0xE53D: + return 0xFEE75; + case 0xE53E: + return 0xFEE76; + default: + return codePoint; + } + } + +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/DecoderUtil.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/DecoderUtil.kt new file mode 100644 index 0000000..eb5cec8 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/DecoderUtil.kt @@ -0,0 +1,201 @@ +package com.fsck.k9.mail.internet + +import com.fsck.k9.logging.Timber +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.MessagingException +import java.io.ByteArrayInputStream +import java.io.IOException +import okio.Buffer +import okio.ByteString +import okio.ByteString.Companion.decodeBase64 +import okio.buffer +import okio.source +import org.apache.james.mime4j.codec.QuotedPrintableInputStream +import org.apache.james.mime4j.util.CharsetUtil + +/** + * Decoder for encoded words (RFC 2047). + * + * This class is based on `org.apache.james.mime4j.decoder.DecoderUtil`. It was modified in order to support early + * non-Unicode emoji variants. + */ +internal object DecoderUtil { + /** + * Decodes a string containing encoded words as defined by RFC 2047. + * + * Encoded words have the form `=?charset?enc?Encoded word?=` where `enc` is either 'Q' or 'q' for + * quoted-printable and 'B' or 'b' for Base64. + * + * @param body The string to decode. + * @param message The message containing the string. It will be used to figure out which JIS variant to use for + * charset decoding. May be `null`. + * @return The decoded string. + */ + @JvmStatic + fun decodeEncodedWords(body: String, message: Message?): String { + // Most strings will not include "=?". So a quick test can prevent unneeded work. + if (!body.contains("=?")) return body + + var previousWord: EncodedWord? = null + var previousEnd = 0 + val output = StringBuilder() + + while (true) { + val begin = body.indexOf("=?", previousEnd) + if (begin == -1) { + decodePreviousAndAppendSuffix(output, previousWord, body, previousEnd) + return output.toString() + } + + val qm1 = body.indexOf('?', begin + 2) + if (qm1 == -1) { + decodePreviousAndAppendSuffix(output, previousWord, body, previousEnd) + return output.toString() + } + + val qm2 = body.indexOf('?', qm1 + 1) + if (qm2 == -1) { + decodePreviousAndAppendSuffix(output, previousWord, body, previousEnd) + return output.toString() + } + + var end = body.indexOf("?=", qm2 + 1) + if (end == -1) { + decodePreviousAndAppendSuffix(output, previousWord, body, previousEnd) + return output.toString() + } + end += 2 + + val sep = body.substring(previousEnd, begin) + val word = extractEncodedWord(body, begin, end, message) + + if (previousWord == null) { + output.append(sep) + if (word == null) { + output.append(body, begin, end) + } + } else if (word == null) { + output.append(charsetDecode(previousWord)) + output.append(sep) + output.append(body, begin, end) + } else if (!CharsetUtil.isWhitespace(sep)) { + output.append(charsetDecode(previousWord)) + output.append(sep) + } else if (previousWord.canBeCombinedWith(word)) { + word.data = previousWord.data + word.data + } else { + output.append(charsetDecode(previousWord)) + } + + previousWord = word + previousEnd = end + } + } + + private fun decodePreviousAndAppendSuffix( + output: StringBuilder, + previousWord: EncodedWord?, + body: String, + previousEnd: Int + ) { + if (previousWord != null) { + output.append(charsetDecode(previousWord)) + } + output.append(body, previousEnd, body.length) + } + + private fun charsetDecode(word: EncodedWord): String? { + return try { + val inputStream = Buffer().write(word.data).inputStream() + CharsetSupport.readToString(inputStream, word.charset) + } catch (e: IOException) { + null + } + } + + private fun extractEncodedWord(body: String, begin: Int, end: Int, message: Message?): EncodedWord? { + val qm1 = body.indexOf('?', begin + 2) + if (qm1 == end - 2) return null + + val qm2 = body.indexOf('?', qm1 + 1) + if (qm2 == end - 2) return null + + // Extract charset, skipping language information if present (example: =?utf-8*en?Q?Text?=) + val charsetPart = body.substring(begin + 2, qm1) + val languageSuffixStart = charsetPart.indexOf('*') + val languageSuffixFound = languageSuffixStart != -1 + val mimeCharset = if (languageSuffixFound) charsetPart.substring(0, languageSuffixStart) else charsetPart + + val encoding = body.substring(qm1 + 1, qm2) + val encodedText = body.substring(qm2 + 1, end - 2) + + val charset = try { + CharsetSupport.fixupCharset(mimeCharset, message) + } catch (e: MessagingException) { + return null + } + + if (encodedText.isEmpty()) { + Timber.w("Missing encoded text in encoded word: '%s'", body.substring(begin, end)) + return null + } + + return if (encoding.equals("Q", ignoreCase = true)) { + EncodedWord(charset, Encoding.Q, decodeQ(encodedText)) + } else if (encoding.equals("B", ignoreCase = true)) { + EncodedWord(charset, Encoding.B, decodeB(encodedText)) + } else { + Timber.w("Warning: Unknown encoding in encoded word '%s'", body.substring(begin, end)) + null + } + } + + private fun decodeQ(encodedWord: String): ByteString { + // Replace _ with =20 + val bytes = buildString { + for (character in encodedWord) { + if (character == '_') { + append("=20") + } else { + append(character) + } + } + }.toByteArray(Charsets.US_ASCII) + + return QuotedPrintableInputStream(ByteArrayInputStream(bytes)).use { inputStream -> + try { + inputStream.source().buffer().readByteString() + } catch (e: IOException) { + ByteString.EMPTY + } + } + } + + private fun decodeB(encodedText: String): ByteString { + return encodedText.decodeBase64() ?: ByteString.EMPTY + } + + private operator fun ByteString.plus(second: ByteString): ByteString { + return Buffer().write(this).write(second).readByteString() + } + + private val ASCII_ESCAPE_SEQUENCE = byteArrayOf(0x1B, 0x28, 0x42) + + private class EncodedWord( + val charset: String, + val encoding: Encoding, + var data: ByteString + ) { + fun canBeCombinedWith(other: EncodedWord): Boolean { + return encoding == other.encoding && charset == other.charset && !isAsciiEscapeSequence() + } + + private fun isAsciiEscapeSequence(): Boolean { + return charset.startsWith("ISO-2022-JP", ignoreCase = true) && data.endsWith(ASCII_ESCAPE_SEQUENCE) + } + } + + private enum class Encoding { + Q, B + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/EncoderUtil.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/EncoderUtil.java new file mode 100644 index 0000000..281604a --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/EncoderUtil.java @@ -0,0 +1,184 @@ + +package com.fsck.k9.mail.internet; + +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.BitSet; + +import org.apache.james.mime4j.Charsets; + +/** + * Static methods for encoding header field values. This includes encoded-words + * as defined in <a href='http://www.faqs.org/rfcs/rfc2047.html'>RFC 2047</a> + * or display-names of an e-mail address, for example. + * + * This class is copied from the org.apache.james.mime4j.decoder.EncoderUtil class. + */ +class EncoderUtil { + private static final BitSet Q_RESTRICTED_CHARS = initChars("=_?\"#$%&'(),.:;<>@[\\]^`{|}~"); + + private static final String ENC_WORD_PREFIX = "=?"; + private static final String ENC_WORD_SUFFIX = "?="; + + private static final int ENCODED_WORD_MAX_LENGTH = 75; // RFC 2047 + + private static BitSet initChars(String specials) { + BitSet bs = new BitSet(128); + for (char ch = 33; ch < 127; ch++) { + if (specials.indexOf(ch) == -1) { + bs.set(ch); + } + } + return bs; + } + + /** + * Selects one of the two encodings specified in RFC 2047. + */ + public enum Encoding { + /** The B encoding (identical to base64 defined in RFC 2045). */ + B, + /** The Q encoding (similar to quoted-printable defined in RFC 2045). */ + Q + } + + private EncoderUtil() { + } + + /** + * Encodes the specified text into an encoded word or a sequence of encoded + * words separated by space. The text is separated into a sequence of + * encoded words if it does not fit in a single one. + * + * @param text + * text to encode. + * @return the encoded word (or sequence of encoded words if the given text + * does not fit in a single encoded word). + */ + public static String encodeEncodedWord(String text) { + if (text == null) + throw new IllegalArgumentException(); + + Charset charset = determineCharset(text); + String mimeCharset = charset.name(); + + byte[] bytes = encode(text, charset); + + Encoding encoding = determineEncoding(bytes); + + if (encoding == Encoding.B) { + String prefix = ENC_WORD_PREFIX + mimeCharset + "?B?"; + return encodeB(prefix, text, charset, bytes); + } else { + String prefix = ENC_WORD_PREFIX + mimeCharset + "?Q?"; + return encodeQ(prefix, text, charset, bytes); + } + } + + private static String encodeB(String prefix, String text, Charset charset, byte[] bytes) { + int encodedLength = bEncodedLength(bytes); + + int totalLength = prefix.length() + encodedLength + + ENC_WORD_SUFFIX.length(); + if (totalLength <= ENCODED_WORD_MAX_LENGTH) { + return prefix + org.apache.james.mime4j.codec.EncoderUtil.encodeB(bytes) + ENC_WORD_SUFFIX; + } else { + int splitAt = text.length() / 2; + + if (Character.isHighSurrogate(text.charAt(splitAt - 1))) { + splitAt--; + } + + String part1 = text.substring(0, splitAt); + byte[] bytes1 = encode(part1, charset); + String word1 = encodeB(prefix, part1, charset, bytes1); + + String part2 = text.substring(splitAt); + byte[] bytes2 = encode(part2, charset); + String word2 = encodeB(prefix, part2, charset, bytes2); + + return word1 + " " + word2; + } + } + + private static int bEncodedLength(byte[] bytes) { + return (bytes.length + 2) / 3 * 4; + } + + private static String encodeQ(String prefix, String text, Charset charset, byte[] bytes) { + int encodedLength = qEncodedLength(bytes); + + int totalLength = prefix.length() + encodedLength + + ENC_WORD_SUFFIX.length(); + if (totalLength <= ENCODED_WORD_MAX_LENGTH) { + return prefix + org.apache.james.mime4j.codec.EncoderUtil.encodeQ(bytes, org.apache.james.mime4j.codec.EncoderUtil.Usage.WORD_ENTITY) + ENC_WORD_SUFFIX; + } else { + int splitAt = text.length() / 2; + + if (Character.isHighSurrogate(text.charAt(splitAt - 1))) { + splitAt--; + } + + String part1 = text.substring(0, splitAt); + byte[] bytes1 = encode(part1, charset); + String word1 = encodeQ(prefix, part1, charset, bytes1); + + String part2 = text.substring(splitAt); + byte[] bytes2 = encode(part2, charset); + String word2 = encodeQ(prefix, part2, charset, bytes2); + + return word1 + " " + word2; + } + } + + private static int qEncodedLength(byte[] bytes) { + int count = 0; + + for (byte b : bytes) { + int v = b & 0xff; + if (v == 32) { + count++; + } else if (!Q_RESTRICTED_CHARS.get(v)) { + count += 3; + } else { + count++; + } + } + + return count; + } + + private static byte[] encode(String text, Charset charset) { + ByteBuffer buffer = charset.encode(text); + byte[] bytes = new byte[buffer.limit()]; + buffer.get(bytes); + return bytes; + } + + private static Charset determineCharset(String text) { + final int len = text.length(); + for (int index = 0; index < len; index++) { + char ch = text.charAt(index); + if (ch > 0x7f) { + return Charsets.UTF_8; + } + } + return Charsets.US_ASCII; + } + + private static Encoding determineEncoding(byte[] bytes) { + if (bytes.length == 0) + return Encoding.Q; + + int qEncoded = 0; + for (int i = 0; i < bytes.length; i++) { + int v = bytes[i] & 0xff; + if (v != 32 && !Q_RESTRICTED_CHARS.get(v)) { + qEncoded++; + } + } + + int percentage = qEncoded * 100 / bytes.length; + return percentage > 30 ? Encoding.B : Encoding.Q; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/FlowedMessageUtils.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/FlowedMessageUtils.kt new file mode 100644 index 0000000..600fe23 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/FlowedMessageUtils.kt @@ -0,0 +1,83 @@ +package com.fsck.k9.mail.internet + +/** + * Decodes text encoded as `text/plain; format=flowed` (RFC 3676). + */ +object FlowedMessageUtils { + private const val QUOTE = '>' + private const val SPACE = ' ' + private const val CR = '\r' + private const val LF = '\n' + private const val SIGNATURE = "-- " + private const val CRLF = "\r\n" + + @JvmStatic + fun deflow(text: String, delSp: Boolean): String { + var lineStartIndex = 0 + var lastLineQuoteDepth = 0 + var lastLineFlowed = false + + return buildString { + while (lineStartIndex <= text.lastIndex) { + var quoteDepth = 0 + while (lineStartIndex <= text.lastIndex && text[lineStartIndex] == QUOTE) { + quoteDepth++ + lineStartIndex++ + } + + // Remove space stuffing + if (lineStartIndex <= text.lastIndex && text[lineStartIndex] == SPACE) { + lineStartIndex++ + } + + // We support both LF and CRLF line endings. To cover both cases we search for LF. + val lineFeedIndex = text.indexOf(LF, lineStartIndex) + val lineBreakFound = lineFeedIndex != -1 + + var lineEndIndex = if (lineBreakFound) lineFeedIndex else text.length + if (lineEndIndex > 0 && text[lineEndIndex - 1] == CR) { + lineEndIndex-- + } + + if (lastLineFlowed && quoteDepth != lastLineQuoteDepth) { + append(CRLF) + lastLineFlowed = false + } + + val lineIsSignatureMarker = lineEndIndex - lineStartIndex == SIGNATURE.length && + text.regionMatches(lineStartIndex, SIGNATURE, 0, SIGNATURE.length) + + var lineFlowed = false + if (lineIsSignatureMarker) { + if (lastLineFlowed) { + append(CRLF) + lastLineFlowed = false + } + } else if (lineEndIndex > lineStartIndex && text[lineEndIndex - 1] == SPACE) { + lineFlowed = true + if (delSp) { + lineEndIndex-- + } + } + + if (!lastLineFlowed && quoteDepth > 0) { + // This is not a continuation line, so prefix the text with quote characters. + repeat(quoteDepth) { + append(QUOTE) + } + append(SPACE) + } + + append(text, lineStartIndex, lineEndIndex) + + if (!lineFlowed && lineBreakFound) { + append(CRLF) + } + + lineStartIndex = if (lineBreakFound) lineFeedIndex + 1 else text.length + lastLineQuoteDepth = quoteDepth + lastLineFlowed = lineFlowed + } + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/FormatFlowedHelper.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/FormatFlowedHelper.kt new file mode 100644 index 0000000..65efd9c --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/FormatFlowedHelper.kt @@ -0,0 +1,28 @@ +package com.fsck.k9.mail.internet + +internal object FormatFlowedHelper { + private const val TEXT_PLAIN = "text/plain" + private const val HEADER_PARAM_FORMAT = "format" + private const val HEADER_FORMAT_FLOWED = "flowed" + private const val HEADER_PARAM_DELSP = "delsp" + private const val HEADER_DELSP_YES = "yes" + + @JvmStatic + fun checkFormatFlowed(contentTypeHeaderValue: String?): FormatFlowedResult { + if (contentTypeHeaderValue == null) return negativeResult() + + val mimeValue = MimeParameterDecoder.decode(contentTypeHeaderValue) + if (!MimeUtility.isSameMimeType(TEXT_PLAIN, mimeValue.value)) return negativeResult() + + val formatParameter = mimeValue.parameters[HEADER_PARAM_FORMAT]?.lowercase() + if (formatParameter != HEADER_FORMAT_FLOWED) return negativeResult() + + val delSpParameter = mimeValue.parameters[HEADER_PARAM_DELSP]?.lowercase() + + return FormatFlowedResult(isFormatFlowed = true, isDelSp = delSpParameter == HEADER_DELSP_YES) + } + + private fun negativeResult() = FormatFlowedResult(isFormatFlowed = false, isDelSp = false) +} + +internal data class FormatFlowedResult(val isFormatFlowed: Boolean, val isDelSp: Boolean) diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/Headers.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/Headers.kt new file mode 100644 index 0000000..3120abd --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/Headers.kt @@ -0,0 +1,36 @@ +package com.fsck.k9.mail.internet + +object Headers { + @JvmStatic + fun contentType(mimeType: String, name: String): String { + return MimeParameterEncoder.encode(mimeType, mapOf("name" to name)) + } + + @JvmStatic + fun contentType(mimeType: String, charset: String, name: String?): String { + val parameters = if (name == null) { + mapOf("charset" to charset) + } else { + mapOf("charset" to charset, "name" to name) + } + + return MimeParameterEncoder.encode(mimeType, parameters) + } + + @JvmStatic + fun contentTypeForMultipart(mimeType: String, boundary: String): String { + return MimeParameterEncoder.encode(mimeType, mapOf("boundary" to boundary)) + } + + @JvmStatic + @JvmOverloads + fun contentDisposition(disposition: String, fileName: String, size: Long? = null): String { + val parameters = if (size == null) { + mapOf("filename" to fileName) + } else { + mapOf("filename" to fileName, "size" to size.toString()) + } + + return MimeParameterEncoder.encode(disposition, parameters) + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/Iso2022JpToShiftJisInputStream.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/Iso2022JpToShiftJisInputStream.java new file mode 100644 index 0000000..1078074 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/Iso2022JpToShiftJisInputStream.java @@ -0,0 +1,76 @@ +package com.fsck.k9.mail.internet; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.MalformedInputException; + +class Iso2022JpToShiftJisInputStream extends InputStream { + private enum Charset { + ASCII, JISX0201, JISX0208, + } + + private InputStream mIn; + private Charset charset = Charset.ASCII; + private int out; + private boolean hasOut = false; + + public Iso2022JpToShiftJisInputStream(InputStream in) { + mIn = in; + } + + @Override + public int read() throws IOException { + if (hasOut) { + hasOut = false; + return out; + } + + int in1 = mIn.read(); + while (in1 == 0x1b) { + in1 = mIn.read(); + if (in1 == '(') { + in1 = mIn.read(); + if (in1 == 'B' || in1 == 'J') + charset = Charset.ASCII; + else if (in1 == 'I') // Not defined in RFC 1468 but in CP50221. + charset = Charset.JISX0201; + else + throw new MalformedInputException(0); + } else if (in1 == '$') { + in1 = mIn.read(); + if (in1 == '@' || in1 == 'B') + charset = Charset.JISX0208; + else + throw new MalformedInputException(0); + } else + throw new MalformedInputException(0); + in1 = mIn.read(); + } + + if (in1 == '\n' || in1 == '\r') + charset = Charset.ASCII; + + if (in1 < 0x21 || in1 >= 0x7f) + return in1; + + switch (charset) { + case ASCII: + return in1; + case JISX0201: + return in1 + 0x80; + case JISX0208: + int in2 = mIn.read(); + if (in2 < 0x21 || in2 >= 0x7f) + throw new MalformedInputException(0); + + int out1 = (in1 + 1) / 2 + (in1 < 0x5f ? 0x70 : 0xb0); + + out = in2 + (in1 % 2 == 0 ? 0x7e : in2 < 0x60 ? 0x1f : 0x20); + hasOut = true; + + return out1; + default: + throw new RuntimeException(); + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/JisSupport.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/JisSupport.java new file mode 100644 index 0000000..bd60776 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/JisSupport.java @@ -0,0 +1,112 @@ +package com.fsck.k9.mail.internet; + +import com.fsck.k9.mail.Address; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Part; + +class JisSupport { + public static final String SHIFT_JIS = "shift_jis"; + + public static String getJisVariantFromMessage(Message message) throws MessagingException { + if (message == null) { + return null; + } + + // If a receiver is known to use a JIS variant, the sender transfers the message after converting the + // charset as a convention. + String variant = getJisVariantFromReceivedHeaders(message); + if (variant != null) { + return variant; + } + + // If a receiver is not known to use any JIS variants, the sender transfers the message without converting + // the charset. + variant = getJisVariantFromFromHeaders(message); + if (variant != null) { + return variant; + } + + return getJisVariantFromMailerHeaders(message); + } + + public static boolean isShiftJis(String charset) { + return charset.length() > 17 && charset.startsWith("x-") + && charset.endsWith("-shift_jis-2007"); + } + + public static String getJisVariantFromAddress(String address) { + if (address == null) { + return null; + } + if (isInDomain(address, "docomo.ne.jp") || isInDomain(address, "dwmail.jp") || + isInDomain(address, "pdx.ne.jp") || isInDomain(address, "willcom.com") || + isInDomain(address, "emnet.ne.jp") || isInDomain(address, "emobile.ne.jp")) { + return "docomo"; + } else if (isInDomain(address, "softbank.ne.jp") || isInDomain(address, "vodafone.ne.jp") || + isInDomain(address, "disney.ne.jp") || isInDomain(address, "vertuclub.ne.jp")) { + return "softbank"; + } else if (isInDomain(address, "ezweb.ne.jp") || isInDomain(address, "ido.ne.jp")) { + return "kddi"; + } + return null; + } + + + private static String getJisVariantFromMailerHeaders(Message message) { + String[] mailerHeaders = message.getHeader("X-Mailer"); + if (mailerHeaders.length == 0) { + return null; + } + + if (mailerHeaders[0].startsWith("iPhone Mail ") || mailerHeaders[0].startsWith("iPad Mail ")) { + return "iphone"; + } + + return null; + } + + + private static String getJisVariantFromReceivedHeaders(Part message) { + String[] receivedHeaders = message.getHeader("Received"); + if (receivedHeaders.length == 0) { + return null; + } + + for (String receivedHeader : receivedHeaders) { + String address = getAddressFromReceivedHeader(receivedHeader); + if (address == null) { + continue; + } + String variant = getJisVariantFromAddress(address); + if (variant != null) { + return variant; + } + } + return null; + } + + private static String getAddressFromReceivedHeader(String receivedHeader) { + // Not implemented yet! Extract an address from the FOR clause of the given Received header. + return null; + } + + private static String getJisVariantFromFromHeaders(Message message) { + Address addresses[] = message.getFrom(); + if (addresses == null || addresses.length == 0) { + return null; + } + + return getJisVariantFromAddress(addresses[0].getAddress()); + } + + private static boolean isInDomain(String address, String domain) { + int index = address.length() - domain.length() - 1; + if (index < 0) { + return false; + } + + char c = address.charAt(index); + return (c == '@' || c == '.') && address.endsWith(domain); + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MessageExtractor.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/MessageExtractor.java new file mode 100644 index 0000000..60b4bd5 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MessageExtractor.java @@ -0,0 +1,456 @@ +package com.fsck.k9.mail.internet; + + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.fsck.k9.logging.Timber; +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Multipart; +import com.fsck.k9.mail.Part; +import org.apache.commons.io.input.BoundedInputStream; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static com.fsck.k9.mail.internet.CharsetSupport.fixupCharset; +import static com.fsck.k9.mail.internet.MimeUtility.isSameMimeType; +import static com.fsck.k9.mail.internet.Viewable.Alternative; +import static com.fsck.k9.mail.internet.Viewable.Html; +import static com.fsck.k9.mail.internet.Viewable.MessageHeader; +import static com.fsck.k9.mail.internet.Viewable.Text; +import static com.fsck.k9.mail.internet.Viewable.Textual; + +public class MessageExtractor { + public static final long NO_TEXT_SIZE_LIMIT = -1L; + + + private MessageExtractor() {} + + public static String getTextFromPart(Part part) { + return getTextFromPart(part, NO_TEXT_SIZE_LIMIT); + } + + public static String getTextFromPart(Part part, long textSizeLimit) { + if (part == null) { + throw new IllegalArgumentException("Argument 'part' must not be null"); + } + + try { + Body body = part.getBody(); + if (body == null) { + Timber.v("No body present for this message part"); + return null; + } + + if (body instanceof TextBody) { + TextBody textBody = (TextBody) body; + return textBody.getRawText(); + } + + String mimeType = part.getMimeType(); + if (mimeType != null && MimeUtility.mimeTypeMatches(mimeType, "text/*") || + part.isMimeType("application/pgp")) { + return getTextFromTextPart(part, body, mimeType, textSizeLimit); + } + + Timber.w("Provided non-text part: %s", mimeType); + } catch (IOException | MessagingException e) { + Timber.e(e, "Unable to getTextFromPart"); + } + + return null; + } + + private static String getTextFromTextPart(Part part, Body body, String mimeType, long textSizeLimit) + throws IOException, MessagingException { + /* + * We've got a text part, so let's see if it needs to be processed further. + */ + String charset = PartExtensions.getCharset(part); + /* + * determine the charset from HTML message. + */ + if (isSameMimeType(mimeType, "text/html") && charset == null) { + InputStream in = MimeUtility.decodeBody(body); + try { + byte[] buf = new byte[256]; + in.read(buf, 0, buf.length); + String str = new String(buf, "US-ASCII"); + + if (str.isEmpty()) { + return ""; + } + Pattern p = Pattern.compile("<meta http-equiv=\"?Content-Type\"? content=\"text/html; charset=(.+?)\">", Pattern.CASE_INSENSITIVE); + Matcher m = p.matcher(str); + if (m.find()) { + charset = m.group(1); + } + } finally { + try { + MimeUtility.closeInputStreamWithoutDeletingTemporaryFiles(in); + } catch (IOException e) { /* ignore */ } + } + } + charset = fixupCharset(charset, getMessageFromPart(part)); + /* + * Now we read the part into a buffer for further processing. Because + * the stream is now wrapped we'll remove any transfer encoding at this point. + */ + InputStream in = MimeUtility.decodeBody(body); + InputStream possiblyLimitedIn = + textSizeLimit != NO_TEXT_SIZE_LIMIT ? new BoundedInputStream(in, textSizeLimit) : in; + + String text; + try { + text = CharsetSupport.readToString(possiblyLimitedIn, charset); + } finally { + try { + MimeUtility.closeInputStreamWithoutDeletingTemporaryFiles(in); + } catch (IOException e) { /* Ignore */ } + } + + FormatFlowedResult result = FormatFlowedHelper.checkFormatFlowed(part.getContentType()); + if (result.isFormatFlowed()) { + return FlowedMessageUtils.deflow(text, result.isDelSp()); + } else { + return text; + } + } + + public static boolean hasMissingParts(Part part) { + Body body = part.getBody(); + if (body == null) { + return true; + } + if (body instanceof Multipart) { + Multipart multipart = (Multipart) body; + for (Part subPart : multipart.getBodyParts()) { + if (hasMissingParts(subPart)) { + return true; + } + } + } + return false; + } + + + /** Traverse the MIME tree of a message and extract viewable parts. */ + public static void findViewablesAndAttachments(Part part, + @Nullable List<Viewable> outputViewableParts, @Nullable List<Part> outputNonViewableParts) + throws MessagingException { + boolean skipSavingNonViewableParts = outputNonViewableParts == null; + boolean skipSavingViewableParts = outputViewableParts == null; + if (skipSavingNonViewableParts && skipSavingViewableParts) { + throw new IllegalArgumentException("method was called but no output is to be collected - this a bug!"); + } + + Body body = part.getBody(); + if (body instanceof Multipart) { + Multipart multipart = (Multipart) body; + if (isSameMimeType(part.getMimeType(), "multipart/alternative")) { + /* + * For multipart/alternative parts we try to find a text/plain and a text/html + * child. Everything else we find is put into 'attachments'. + */ + List<Viewable> text = findTextPart(multipart, true); + + Set<Part> knownTextParts = getParts(text); + List<Viewable> html = findHtmlPart(multipart, knownTextParts, outputNonViewableParts, true); + + if (skipSavingViewableParts) { + return; + } + if (!text.isEmpty() || !html.isEmpty()) { + Alternative alternative = new Alternative(text, html); + outputViewableParts.add(alternative); + } + } else if (isSameMimeType(part.getMimeType(), "multipart/signed")) { + if (multipart.getCount() > 0) { + BodyPart bodyPart = multipart.getBodyPart(0); + findViewablesAndAttachments(bodyPart, outputViewableParts, outputNonViewableParts); + } + } else { + // For all other multipart parts we recurse to grab all viewable children. + for (Part bodyPart : multipart.getBodyParts()) { + findViewablesAndAttachments(bodyPart, outputViewableParts, outputNonViewableParts); + } + } + } else if (body instanceof Message && + !("attachment".equalsIgnoreCase(getContentDisposition(part)))) { + if (skipSavingViewableParts) { + return; + } + /* + * We only care about message/rfc822 parts whose Content-Disposition header has a value + * other than "attachment". + */ + Message message = (Message) body; + + // We add the Message object so we can extract the filename later. + outputViewableParts.add(new MessageHeader(part, message)); + + // Recurse to grab all viewable parts and attachments from that message. + findViewablesAndAttachments(message, outputViewableParts, outputNonViewableParts); + } else if (isPartTextualBody(part)) { + if (skipSavingViewableParts) { + return; + } + String mimeType = part.getMimeType(); + Viewable viewable; + if (isSameMimeType(mimeType, "text/plain")) { + viewable = new Text(part); + } else { + viewable = new Html(part); + } + outputViewableParts.add(viewable); + } else if (isSameMimeType(part.getMimeType(), "application/pgp-signature")) { + // ignore this type explicitly + } else if (isSameMimeType(part.getMimeType(), "text/rfc822-headers")) { + // ignore this type explicitly + } else { + if (skipSavingNonViewableParts) { + return; + } + // Everything else is treated as attachment. + outputNonViewableParts.add(part); + } + } + + public static Set<Part> getTextParts(Part part) throws MessagingException { + List<Viewable> viewableParts = new ArrayList<>(); + List<Part> nonViewableParts = new ArrayList<>(); + findViewablesAndAttachments(part, viewableParts, nonViewableParts); + return getParts(viewableParts); + } + + /** + * Collect the viewable textual parts of a message. + * @return A set of viewable parts of the message. + * @throws MessagingException In case of an error. + */ + public static Set<Part> collectTextParts(Message message) throws MessagingException { + try { + return getTextParts(message); + } catch (Exception e) { + throw new MessagingException("Couldn't extract viewable parts", e); + } + } + + private static Message getMessageFromPart(Part part) { + while (part != null) { + if (part instanceof Message) + return (Message)part; + + if (!(part instanceof BodyPart)) + return null; + + Multipart multipart = ((BodyPart)part).getParent(); + if (multipart == null) + return null; + + part = multipart.getParent(); + } + return null; + } + + /** + * Search the children of a {@link Multipart} for {@code text/plain} parts. + * + * @param multipart The {@code Multipart} to search through. + * @param directChild If {@code true}, this method will return after the first {@code text/plain} was + * found. + * + * @return A list of {@link Text} viewables. + * + * @throws MessagingException + * In case of an error. + */ + private static List<Viewable> findTextPart(Multipart multipart, boolean directChild) + throws MessagingException { + List<Viewable> viewables = new ArrayList<>(); + + for (Part part : multipart.getBodyParts()) { + Body body = part.getBody(); + if (body instanceof Multipart) { + Multipart innerMultipart = (Multipart) body; + + /* + * Recurse to find text parts. Since this is a multipart that is a child of a + * multipart/alternative we don't want to stop after the first text/plain part + * we find. This will allow to get all text parts for constructions like this: + * + * 1. multipart/alternative + * 1.1. multipart/mixed + * 1.1.1. text/plain + * 1.1.2. text/plain + * 1.2. text/html + */ + List<Viewable> textViewables = findTextPart(innerMultipart, false); + + if (!textViewables.isEmpty()) { + viewables.addAll(textViewables); + if (directChild) { + break; + } + } + } else if (isPartTextualBody(part) && isSameMimeType(part.getMimeType(), "text/plain")) { + Text text = new Text(part); + viewables.add(text); + if (directChild) { + break; + } + } + } + return viewables; + } + + /** + * Search the children of a {@link Multipart} for {@code text/html} parts. + * Every part that is not a {@code text/html} we want to display, we add to 'attachments'. + * + * @param multipart The {@code Multipart} to search through. + * @param knownTextParts A set of {@code text/plain} parts that shouldn't be added to 'attachments'. + * @param outputNonViewableParts A list that will receive the parts that are considered attachments. + * @param directChild If {@code true}, this method will add all {@code text/html} parts except the first + * found to 'attachments'. + * + * @return A list of {@link Text} viewables. + * + * @throws MessagingException In case of an error. + */ + private static List<Viewable> findHtmlPart(Multipart multipart, Set<Part> knownTextParts, + @Nullable List<Part> outputNonViewableParts, boolean directChild) throws MessagingException { + boolean saveNonViewableParts = outputNonViewableParts != null; + List<Viewable> viewables = new ArrayList<>(); + + boolean partFound = false; + for (Part part : multipart.getBodyParts()) { + Body body = part.getBody(); + if (body instanceof Multipart) { + Multipart innerMultipart = (Multipart) body; + + if (directChild && partFound) { + if (saveNonViewableParts) { + // We already found our text/html part. Now we're only looking for attachments. + findAttachments(innerMultipart, knownTextParts, outputNonViewableParts); + } + } else { + /* + * Recurse to find HTML parts. Since this is a multipart that is a child of a + * multipart/alternative we don't want to stop after the first text/html part + * we find. This will allow to get all text parts for constructions like this: + * + * 1. multipart/alternative + * 1.1. text/plain + * 1.2. multipart/mixed + * 1.2.1. text/html + * 1.2.2. text/html + * 1.3. image/jpeg + */ + List<Viewable> htmlViewables = findHtmlPart(innerMultipart, knownTextParts, + outputNonViewableParts, false); + + if (!htmlViewables.isEmpty()) { + partFound = true; + viewables.addAll(htmlViewables); + } + } + } else if (!(directChild && partFound) && isPartTextualBody(part) && + isSameMimeType(part.getMimeType(), "text/html")) { + Html html = new Html(part); + viewables.add(html); + partFound = true; + } else if (!knownTextParts.contains(part)) { + if (saveNonViewableParts) { + // Only add this part as attachment if it's not a viewable text/plain part found earlier + outputNonViewableParts.add(part); + } + } + } + + return viewables; + } + + /** + * Traverse the MIME tree and add everything that's not a known text part to 'attachments'. + * + * @param multipart + * The {@link Multipart} to start from. + * @param knownTextParts + * A set of known text parts we don't want to end up in 'attachments'. + * @param attachments + * A list that will receive the parts that are considered attachments. + */ + private static void findAttachments(Multipart multipart, Set<Part> knownTextParts, + @NotNull List<Part> attachments) { + for (Part part : multipart.getBodyParts()) { + Body body = part.getBody(); + if (body instanceof Multipart) { + Multipart innerMultipart = (Multipart) body; + findAttachments(innerMultipart, knownTextParts, attachments); + } else if (!knownTextParts.contains(part)) { + attachments.add(part); + } + } + } + + /** + * Build a set of message parts for fast lookups. + * + * @param viewables + * A list of {@link Viewable}s containing references to the message parts to include in + * the set. + * + * @return The set of viewable {@code Part}s. + * + * @see MessageExtractor#findHtmlPart(Multipart, Set, List, boolean) + * @see MessageExtractor#findAttachments(Multipart, Set, List) + */ + private static Set<Part> getParts(List<Viewable> viewables) { + Set<Part> parts = new HashSet<>(); + + for (Viewable viewable : viewables) { + if (viewable instanceof Textual) { + parts.add(((Textual) viewable).getPart()); + } else if (viewable instanceof Alternative) { + Alternative alternative = (Alternative) viewable; + parts.addAll(getParts(alternative.getText())); + parts.addAll(getParts(alternative.getHtml())); + } + } + + return parts; + } + + private static Boolean isPartTextualBody(Part part) throws MessagingException { + String disposition = part.getDisposition(); + String dispositionType = null; + String dispositionFilename = null; + if (disposition != null) { + dispositionType = MimeUtility.getHeaderParameter(disposition, null); + dispositionFilename = MimeUtility.getHeaderParameter(disposition, "filename"); + } + + boolean isAttachmentDisposition = "attachment".equalsIgnoreCase(dispositionType) || dispositionFilename != null; + return !isAttachmentDisposition && + (part.isMimeType("text/html") || part.isMimeType("text/plain") || part.isMimeType("application/pgp")); + + } + + private static String getContentDisposition(Part part) { + String disposition = part.getDisposition(); + if (disposition != null) { + return MimeUtility.getHeaderParameter(disposition, null); + } + return null; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MessageIdGenerator.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/MessageIdGenerator.kt new file mode 100644 index 0000000..286a643 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MessageIdGenerator.kt @@ -0,0 +1,33 @@ +package com.fsck.k9.mail.internet + +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.Message +import java.util.UUID + +class MessageIdGenerator(private val uuidGenerator: UuidGenerator) { + fun generateMessageId(message: Message): String { + val uuid = uuidGenerator.randomUuid() + val hostname = message.from.firstHostname ?: message.replyTo.firstHostname ?: "fallback.k9mail.app" + + return "<$uuid@$hostname>" + } + + private val Array<Address>?.firstHostname: String? + get() = this?.firstOrNull()?.hostname + + companion object { + @JvmStatic + fun getInstance(): MessageIdGenerator = MessageIdGenerator(K9UuidGenerator()) + } +} + +interface UuidGenerator { + fun randomUuid(): String +} + +class K9UuidGenerator : UuidGenerator { + override fun randomUuid(): String { + // We use upper case here to match Apple Mail Message-ID format (for privacy) + return UUID.randomUUID().toString().uppercase() + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MessageIdParser.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/MessageIdParser.kt new file mode 100644 index 0000000..cefdb81 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MessageIdParser.kt @@ -0,0 +1,205 @@ +package com.fsck.k9.mail.internet + +/** + * Read Message identifier(s). + * + * Used in the `Message-ID`, `In-Reply-To`, and `References` header fields. + * This does not support the obsolete syntax. + * + * See RFC 5322 + * ``` + * msg-id = [CFWS] "<" id-left "@" id-right ">" [CFWS] + * id-left = dot-atom-text / obs-id-left + * id-right = dot-atom-text / no-fold-literal / obs-id-right + * + * dot-atom-text = 1*atext *("." 1*atext) + * no-fold-literal = "[" *dtext "]" + * CFWS = (1*([FWS] comment) [FWS]) / FWS + * FWS = ([*WSP CRLF] 1*WSP) / obs-FWS ; Folding white space + * comment = "(" *([FWS] ccontent) [FWS] ")" + * ccontent = ctext / quoted-pair / comment + * quoted-pair = ("\" (VCHAR / WSP)) / obs-qp + * ``` + */ +class MessageIdParser private constructor(private val input: String) { + private val endIndex = input.length + private var currentIndex = 0 + + fun parse(): String { + val messageId = readMessageId() + + if (!endReached()) { + throw MimeHeaderParserException("Expected end of input", currentIndex) + } + + return messageId + } + + fun parseList(): List<String> { + if (input.isEmpty()) { + throw MimeHeaderParserException("Expected message identifier", errorIndex = 0) + } + + val messageIds = mutableListOf<String>() + while (!endReached()) { + messageIds.add(readMessageId()) + } + + return messageIds + } + + private fun readMessageId(): String { + skipCfws() + expect('<') + val idLeft = readIdLeft() + expect('@') + val idRight = readIdRight() + expect('>') + skipCfws() + + return "<$idLeft@$idRight>" + } + + private fun readIdLeft(): String { + return readDotAtom() + } + + private fun readIdRight(): String { + return if (peek() == '[') { + readDText() + } else { + readDotAtom() + } + } + + private fun readDotAtom(): String { + val startIndex = currentIndex + + do { + expect("atext") { it.isAText() } + if (peek() == '.') { + expect('.') + expect("atext") { it.isAText() } + } + } while (peek().isAText()) + + return input.substring(startIndex, currentIndex) + } + + private fun readDText(): String { + val startIndex = currentIndex + + expect('[') + + while (peek().isDText()) { + skip() + } + + expect(']') + + return input.substring(startIndex, currentIndex) + } + + private fun skipCfws() { + do { + val lastIndex = currentIndex + + skipFws() + + if (!endReached() && peek() == '(') { + expectComment() + } + } while (currentIndex != lastIndex && !endReached()) + } + + private fun skipFws() { + skipWsp() + if (!endReached() && peek() == CR) { + expectCr() + expectLf() + expectWsp() + skipWsp() + } + } + + private fun expectComment() { + expect('(') + var level = 1 + + do { + skipFws() + + val char = peek() + when { + char == '(' -> { + expect('(') + level++ + } + char == '\\' -> { + expectQuotedPair() + } + char.isCText() -> { + skip() + } + else -> { + expect(')') + level-- + } + } + } while (level > 0) + } + + private fun expectQuotedPair() { + expect('\\') + expect("VCHAR or WSP") { it.isVChar() || it.isWsp() } + } + + private fun expectCr() = expect("CR", CR) + + private fun expectLf() = expect("LF", LF) + + private fun expectWsp() = expect("WSP") { it.isWsp() } + + private fun skipWsp() { + while (!endReached() && peek().isWsp()) { + skip() + } + } + + private fun endReached() = currentIndex >= endIndex + + private fun peek(): Char { + if (currentIndex >= input.length) { + throw MimeHeaderParserException("End of input reached unexpectedly", currentIndex) + } + + return input[currentIndex] + } + + private fun skip() { + currentIndex++ + } + + private fun expect(character: Char) { + expect("'$character'") { it == character } + } + + private fun expect(displayInError: String, character: Char) { + expect(displayInError) { it == character } + } + + private inline fun expect(displayInError: String, predicate: (Char) -> Boolean) { + if (!endReached() && predicate(peek())) { + skip() + } else { + throw MimeHeaderParserException("Expected $displayInError", currentIndex) + } + } + + companion object { + fun parse(input: String): String = MessageIdParser(input).parse() + + @JvmStatic + fun parseList(input: String): List<String> = MessageIdParser(input).parseList() + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeBodyPart.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeBodyPart.java new file mode 100644 index 0000000..591f823 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeBodyPart.java @@ -0,0 +1,186 @@ + +package com.fsck.k9.mail.internet; + +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.MimeType; +import com.fsck.k9.mail.Multipart; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; + +import org.jetbrains.annotations.NotNull; + +import static com.fsck.k9.mail.internet.MimeUtility.isSameMimeType; + + +/** + * TODO this is a close approximation of Message, need to update along with + * Message. + */ +public class MimeBodyPart extends BodyPart { + private final MimeHeader mHeader; + private Body mBody; + + /** + * Creates an instance that will check the header field syntax when adding headers. + */ + public static MimeBodyPart create(Body body) throws MessagingException { + return new MimeBodyPart(body, null, true); + } + + /** + * Creates an instance that will check the header field syntax when adding headers. + */ + public static MimeBodyPart create(Body body, String contentType) throws MessagingException { + return new MimeBodyPart(body, contentType, true); + } + + public MimeBodyPart() throws MessagingException { + this(null); + } + + public MimeBodyPart(Body body) throws MessagingException { + this(body, null); + } + + public MimeBodyPart(Body body, String contentType) throws MessagingException { + this(body, contentType, false); + } + + private MimeBodyPart(Body body, String contentType, boolean checkHeaders) throws MessagingException { + mHeader = new MimeHeader(); + mHeader.setCheckHeaders(checkHeaders); + if (contentType != null) { + addHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType); + } + MimeMessageHelper.setBody(this, body); + } + + MimeBodyPart(MimeHeader header, Body body) throws MessagingException { + mHeader = header; + MimeMessageHelper.setBody(this, body); + } + + private String getFirstHeader(String name) { + return mHeader.getFirstHeader(name); + } + + @Override + public void addHeader(String name, String value) { + mHeader.addHeader(name, value); + } + + @Override + public void addRawHeader(String name, String raw) { + mHeader.addRawHeader(name, raw); + } + + @Override + public void setHeader(String name, String value) { + mHeader.setHeader(name, value); + } + + @NotNull + @Override + public String[] getHeader(String name) { + return mHeader.getHeader(name); + } + + @Override + public void removeHeader(String name) { + mHeader.removeHeader(name); + } + + @Override + public Body getBody() { + return mBody; + } + + @Override + public void setBody(Body body) { + this.mBody = body; + } + + public void setEncoding(String encoding) throws MessagingException { + if (mBody != null) { + mBody.setEncoding(encoding); + } + setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, encoding); + } + + @Override + public String getContentType() { + String contentType = getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE); + if (contentType != null) { + return contentType; + } + + return getDefaultMimeType(); + } + + @NotNull + private String getDefaultMimeType() { + Multipart parent = getParent(); + if (parent != null && isSameMimeType(parent.getMimeType(), "multipart/digest")) { + return "message/rfc822"; + } + + return "text/plain"; + } + + @Override + public String getDisposition() { + return getFirstHeader(MimeHeader.HEADER_CONTENT_DISPOSITION); + } + + @Override + public String getContentId() { + String contentId = getFirstHeader(MimeHeader.HEADER_CONTENT_ID); + if (contentId == null) { + return null; + } + + int first = contentId.indexOf('<'); + int last = contentId.lastIndexOf('>'); + + return (first != -1 && last != -1) ? + contentId.substring(first + 1, last) : + contentId; + } + + @Override + public String getMimeType() { + String mimeTypeFromHeader = MimeUtility.getHeaderParameter(getContentType(), null); + MimeType mimeType = MimeType.parseOrNull(mimeTypeFromHeader); + return mimeType != null ? mimeType.toString() : getDefaultMimeType(); + } + + @Override + public boolean isMimeType(String mimeType) { + return getMimeType().equalsIgnoreCase(mimeType); + } + + /** + * Write the MimeMessage out in MIME format. + */ + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); + mHeader.writeTo(out); + writer.write("\r\n"); + writer.flush(); + if (mBody != null) { + mBody.writeTo(out); + } + } + + @Override + public void writeHeaderTo(OutputStream out) throws IOException, MessagingException { + mHeader.writeTo(out); + } + +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeExtensions.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeExtensions.kt new file mode 100644 index 0000000..779518b --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeExtensions.kt @@ -0,0 +1,63 @@ +package com.fsck.k9.mail.internet + +// RFC 5322, section 2.1.1 +internal const val RECOMMENDED_MAX_LINE_LENGTH = 78 + +// RFC 2045: tspecials := "(" / ")" / "<" / ">" / "@" / "," / ";" / ":" / "\" / <"> / "/" / "[" / "]" / "?" / "=" +private val TSPECIALS = charArrayOf('(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=') + +private val ATEXT_SPECIAL = charArrayOf( + '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~' +) + +// RFC 5234: HTAB = %x09 +internal const val HTAB = '\t' + +// RFC 5234: SP = %x20 +internal const val SPACE = ' ' + +// RFC 5234: CRLF = %d13.10 +internal const val CRLF = "\r\n" + +internal const val CR = '\r' +internal const val LF = '\n' +internal const val DQUOTE = '"' +internal const val SEMICOLON = ';' +internal const val EQUALS_SIGN = '=' +internal const val ASTERISK = '*' +internal const val SINGLE_QUOTE = '\'' +internal const val BACKSLASH = '\\' + +internal fun Char.isTSpecial() = this in TSPECIALS + +// RFC 2045: token := 1*<any (US-ASCII) CHAR except SPACE, CTLs, or tspecials> +// RFC 5234: CTL = %x00-1F / %x7F +internal fun Char.isTokenChar() = isVChar() && !isTSpecial() + +// RFC 5234: VCHAR = %x21-7E +internal fun Char.isVChar() = code in 33..126 + +// RFC 5234: WSP = SP / HTAB +internal fun Char.isWsp() = this == SPACE || this == HTAB + +internal fun Char.isWspOrCrlf() = this == SPACE || this == HTAB || this == CR || this == LF + +// RFC 2231: attribute-char := <any (US-ASCII) CHAR except SPACE, CTLs, "*", "'", "%", or tspecials> +internal fun Char.isAttributeChar() = isVChar() && this != '*' && this != '\'' && this != '%' && !isTSpecial() + +// RFC 5322: ctext = %d33-39 / %d42-91 / %d93-126 +internal fun Char.isCText() = code.let { it in 33..39 || it in 42..91 || it in 93..126 } + +// RFC 5234: DIGIT = %x30-39 ; 0-9 +internal fun Char.isDIGIT() = this in '0'..'9' + +// RFC 5234: ALPHA = %x41-5A / %x61-7A ; A-Z / a-z +internal fun Char.isALPHA() = this in 'A'..'Z' || this in 'a'..'z' + +// RFC 5322: atext = ALPHA / DIGIT / "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "/" / "=" / "?" / "^" / +// "_" / "`" / "{" / "|" / "}" / "~" +internal fun Char.isAText() = isALPHA() || isDIGIT() || this in ATEXT_SPECIAL + +// RFC 5322: Printable US-ASCII characters not including "[", "]", or "\" +// dtext = %d33-90 / %d94-126 / obs-dtext +internal fun Char.isDText() = code.let { it in 33..90 || it in 94..126 } diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeader.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeader.kt new file mode 100644 index 0000000..7ecf0bc --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeader.kt @@ -0,0 +1,146 @@ +package com.fsck.k9.mail.internet + +import com.fsck.k9.mail.Header +import com.fsck.k9.mail.internet.MimeHeader.Field.NameValueField +import com.fsck.k9.mail.internet.MimeHeader.Field.RawField +import java.io.IOException +import java.io.OutputStream +import java.util.ArrayList +import java.util.LinkedHashSet + +class MimeHeader { + private val fields: MutableList<Field> = ArrayList() + + val headerNames: Set<String> + get() = fields.mapTo(LinkedHashSet()) { it.name } + + val headers: List<Header> + get() = fields.map { Header(it.name, it.value) } + + var checkHeaders = false + + fun clear() { + fields.clear() + } + + fun getFirstHeader(name: String): String? { + return getHeader(name).firstOrNull() + } + + fun addHeader(name: String, value: String) { + requireValidHeader(name, value) + val field = NameValueField(name, value) + fields.add(field) + } + + fun addRawHeader(name: String, raw: String) { + requireValidRawHeader(name, raw) + val field = RawField(name, raw) + fields.add(field) + } + + fun setHeader(name: String, value: String) { + removeHeader(name) + addHeader(name, value) + } + + fun getHeader(name: String): Array<String> { + return fields.asSequence() + .filter { field -> field.name.equals(name, ignoreCase = true) } + .map { field -> field.value } + .toList() + .toTypedArray() + } + + fun removeHeader(name: String) { + fields.removeAll { field -> field.name.equals(name, ignoreCase = true) } + } + + override fun toString(): String { + return buildString { + appendFields() + } + } + + @Throws(IOException::class) + fun writeTo(out: OutputStream) { + val writer = out.writer().buffered(1024) + writer.appendFields() + writer.flush() + } + + private fun Appendable.appendFields() { + for (field in fields) { + when (field) { + is RawField -> append(field.raw) + is NameValueField -> appendNameValueField(field) + } + append(CRLF) + } + } + + private fun Appendable.appendNameValueField(field: Field) { + append(field.name) + append(": ") + append(field.value) + } + + private fun requireValidHeader(name: String, value: String) { + if (checkHeaders) { + checkHeader(name, value) + } + } + + private fun requireValidRawHeader(name: String, raw: String) { + if (checkHeaders) { + if (!raw.startsWith(name)) throw AssertionError("Raw header value needs to start with header name") + val delimiterIndex = raw.indexOf(':') + val value = if (delimiterIndex == raw.lastIndex) "" else raw.substring(delimiterIndex + 1).trimStart() + + checkHeader(name, value) + } + } + + private fun checkHeader(name: String, value: String) { + try { + MimeHeaderChecker.checkHeader(name, value) + } catch (e: MimeHeaderParserException) { + // Use AssertionError so we crash the app + throw AssertionError("Invalid header", e) + } + } + + companion object { + const val SUBJECT = "Subject" + const val HEADER_CONTENT_TYPE = "Content-Type" + const val HEADER_CONTENT_TRANSFER_ENCODING = "Content-Transfer-Encoding" + const val HEADER_CONTENT_DISPOSITION = "Content-Disposition" + const val HEADER_CONTENT_ID = "Content-ID" + } + + private sealed class Field(val name: String) { + abstract val value: String + + class NameValueField(name: String, override val value: String) : Field(name) { + override fun toString(): String { + return "$name: $value" + } + } + + class RawField(name: String, val raw: String) : Field(name) { + override val value: String + get() { + val delimiterIndex = raw.indexOf(':') + return if (delimiterIndex == raw.lastIndex) { + "" + } else { + raw.substring(delimiterIndex + 1).trim() + } + } + + override fun toString(): String { + return raw + } + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderChecker.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderChecker.kt new file mode 100644 index 0000000..09fdeef --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderChecker.kt @@ -0,0 +1,115 @@ +package com.fsck.k9.mail.internet + +/** + * Check unstructured header field syntax. + * + * This does not allow the obsolete syntax. Only use this for messages constructed by K-9 Mail, not incoming messages. + * + * See RFC 5322 + * ``` + * optional-field = field-name ":" unstructured CRLF + * field-name = 1*ftext + * ftext = %d33-57 / %d59-126 ; Printable US-ASCII characters not including ":". + * + * unstructured = (*([FWS] VCHAR) *WSP) / obs-unstruct + * FWS = ([*WSP CRLF] 1*WSP) / obs-FWS ; Folding white space + * ``` + */ +object MimeHeaderChecker { + fun checkHeader(name: String, value: String) { + if (!name.isValidFieldName()) { + throw MimeHeaderParserException("Header name contains characters not allowed: $name") + } + + val initialLineLength = name.length + 2 // name + colon + space + UnstructuredHeaderChecker(value, initialLineLength).checkHeaderValue() + } + + private fun String.isValidFieldName() = all { it.isFieldText() } + + private fun Char.isFieldText() = isVChar() && this != ':' +} + +private class UnstructuredHeaderChecker(val input: String, initialLineLength: Int) { + private val endIndex = input.length + private var currentIndex = 0 + private var lineLength = initialLineLength + + fun checkHeaderValue() { + while (!endReached()) { + val char = peek() + when { + char == CR -> { + expectCr() + expectLf() + + if (lineLength > 1000) { + throw MimeHeaderParserException("Line exceeds 998 characters", currentIndex - 1) + } + lineLength = 0 + + expectWsp() + skipWsp() + expectVChar() + } + char.isVChar() || char.isWsp() -> { + skipVCharAndWsp() + } + else -> { + throw MimeHeaderParserException("Unexpected character (${char.code})", currentIndex) + } + } + } + + if (lineLength > 998) { + throw MimeHeaderParserException("Line exceeds 998 characters", currentIndex) + } + } + + private fun expectCr() = expect("CR", CR) + + private fun expectLf() = expect("LF", LF) + + private fun expectVChar() = expect("VCHAR") { it.isVChar() } + + private fun expectWsp() = expect("WSP") { it.isWsp() } + + private fun skipWsp() { + while (!endReached() && peek().isWsp()) { + skip() + } + } + + private fun skipVCharAndWsp() { + while (!endReached() && peek().let { it.isVChar() || it.isWsp() }) { + skip() + } + } + + private fun endReached() = currentIndex >= endIndex + + private fun peek(): Char { + if (currentIndex >= input.length) { + throw MimeHeaderParserException("End of input reached unexpectedly", currentIndex) + } + + return input[currentIndex] + } + + private fun skip() { + currentIndex++ + lineLength++ + } + + private fun expect(displayInError: String, character: Char) { + expect(displayInError) { it == character } + } + + private inline fun expect(displayInError: String, predicate: (Char) -> Boolean) { + if (!endReached() && predicate(peek())) { + skip() + } else { + throw MimeHeaderParserException("Expected $displayInError", currentIndex) + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderEncoder.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderEncoder.kt new file mode 100644 index 0000000..6080afb --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderEncoder.kt @@ -0,0 +1,26 @@ +package com.fsck.k9.mail.internet + +object MimeHeaderEncoder { + @JvmStatic + fun encode(name: String, value: String): String { + // TODO: Fold long text that provides enough opportunities for folding and doesn't contain any characters that + // need to be encoded. + return if (hasToBeEncoded(name, value)) { + EncoderUtil.encodeEncodedWord(value) + } else { + value + } + } + + private fun hasToBeEncoded(name: String, value: String): Boolean { + return exceedsRecommendedLineLength(name, value) || charactersNeedEncoding(value) + } + + private fun exceedsRecommendedLineLength(name: String, value: String): Boolean { + return name.length + 2 /* colon + space */ + value.length > RECOMMENDED_MAX_LINE_LENGTH + } + + private fun charactersNeedEncoding(text: String): Boolean { + return text.any { !it.isVChar() && !it.isWspOrCrlf() } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderParser.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderParser.kt new file mode 100644 index 0000000..f013b87 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderParser.kt @@ -0,0 +1,187 @@ +package com.fsck.k9.mail.internet + +import okio.Buffer + +class MimeHeaderParser(private val input: String) { + private val endIndex = input.length + private var currentIndex = 0 + + fun readHeaderValue(): String { + return buildString { + var whitespace = false + loop@ while (!endReached()) { + val character = peek() + when { + character == ';' -> break@loop + character.isWsp() || character == CR || character == LF -> { + skipWhitespace() + whitespace = true + } + character == '(' -> skipComment() + else -> { + if (isNotEmpty() && whitespace) { + append(' ') + } + append(character) + currentIndex++ + whitespace = false + } + } + } + } + } + + fun readUntil(character: Char) = readWhile { peek() != character } + + fun readExtendedParameterValueInto(output: Buffer) { + while (!endReached() && peek() != ';') { + val c = read() + when { + c == '%' -> output.writeByte(readPercentEncoded()) + c.isAttributeChar() -> output.writeByte(c.code) + else -> return + } + } + } + + private fun readPercentEncoded(): Int { + val value1 = readHexDigit() + val value2 = readHexDigit() + + return (value1 shl 4) + value2 + } + + private fun readHexDigit(): Int { + return when (val character = read()) { + in '0'..'9' -> character - '0' + in 'a'..'f' -> character - 'a' + 10 + in 'A'..'F' -> character - 'A' + 10 + else -> throw MimeHeaderParserException("Expected hex character", currentIndex - 1) + } + } + + fun readQuotedString(): String { + expect('"') + + val text = buildString { + while (!endReached() && peek() != '\"') { + val c = read() + when (c) { + CR -> Unit + LF -> Unit + '\\' -> append(read()) + else -> append(c) + } + } + } + + expect('"') + + return text + } + + fun readToken(): String { + skipCFWS() + val startIndex = currentIndex + while (!endReached() && peek().isTokenChar()) { + currentIndex++ + } + + if (startIndex == currentIndex) { + throw MimeHeaderParserException("At least one character expected in token", currentIndex) + } + + return input.substring(startIndex, currentIndex) + } + + fun optional(character: Char): Boolean { + if (peek() == character) { + currentIndex++ + return true + } + + return false + } + + fun endReached() = currentIndex >= endIndex + + fun position() = currentIndex + + fun expect(character: Char) { + if (!endReached() && peek() == character) { + currentIndex++ + } else { + throw MimeHeaderParserException("Expected '$character' (${character.code})", currentIndex) + } + } + + private fun skipWhitespace() { + while (!endReached() && peek().let { it.isWsp() || it == CR || it == LF }) { + currentIndex++ + } + } + + fun skipCFWS() { + while (!endReached()) { + val character = peek() + when { + character.isWsp() || character == CR || character == LF -> currentIndex++ + character == '(' -> skipComment() + else -> return + } + } + } + + private fun skipComment() { + expect('(') + var depth = 1 + while (!endReached() && depth > 0) { + val character = read() + when { + character == '(' -> depth++ + character == ')' -> depth-- + character == '\\' -> currentIndex++ + character == CR -> Unit + character == LF -> Unit + character.isWsp() -> Unit + character.isVChar() -> Unit + else -> { + currentIndex-- + throw MimeHeaderParserException( + "Unexpected '$character' (${character.code}) in comment", + errorIndex = currentIndex + ) + } + } + } + } + + fun peek(): Char { + if (currentIndex >= input.length) { + throw MimeHeaderParserException("End of input reached unexpectedly", currentIndex) + } + + return input[currentIndex] + } + + fun read(): Char { + if (currentIndex >= input.length) { + throw MimeHeaderParserException("End of input reached unexpectedly", currentIndex) + } + + val value = input[currentIndex] + currentIndex++ + return value + } + + private inline fun readWhile(crossinline predicate: () -> Boolean): String { + val startIndex = currentIndex + while (!endReached() && predicate()) { + currentIndex++ + } + + return input.substring(startIndex, currentIndex) + } +} + +class MimeHeaderParserException(message: String, val errorIndex: Int = -1) : RuntimeException(message) diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMessage.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMessage.java new file mode 100644 index 0000000..bc633ce --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMessage.java @@ -0,0 +1,648 @@ + +package com.fsck.k9.mail.internet; + + +import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; + +import com.fsck.k9.logging.Timber; +import com.fsck.k9.mail.Address; +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.BodyFactory; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.DefaultBodyFactory; +import com.fsck.k9.mail.Header; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.MimeType; +import com.fsck.k9.mail.Multipart; +import com.fsck.k9.mail.Part; +import org.apache.commons.io.IOUtils; +import org.apache.james.mime4j.MimeException; +import org.apache.james.mime4j.dom.field.DateTimeField; +import org.apache.james.mime4j.field.DefaultFieldParser; +import org.apache.james.mime4j.io.EOLConvertingInputStream; +import org.apache.james.mime4j.parser.ContentHandler; +import org.apache.james.mime4j.parser.MimeStreamParser; +import org.apache.james.mime4j.stream.BodyDescriptor; +import org.apache.james.mime4j.stream.Field; +import org.apache.james.mime4j.stream.MimeConfig; +import org.jetbrains.annotations.NotNull; + + +/** + * An implementation of Message that stores all of it's metadata in RFC 822 and + * RFC 2045 style headers. + */ +public class MimeMessage extends Message { + private MimeHeader mHeader = new MimeHeader(); + protected Address[] mFrom; + protected Address[] mSender; + protected Address[] mTo; + protected Address[] mCc; + protected Address[] mBcc; + protected Address[] mReplyTo; + protected Address[] xOriginalTo; + protected Address[] deliveredTo; + protected Address[] xEnvelopeTo; + + protected String mMessageId; + private String[] mReferences; + private String[] mInReplyTo; + + private Date mSentDate; + private SimpleDateFormat mDateFormat; + + private Body mBody; + protected int mSize; + private String serverExtra; + + + public static MimeMessage parseMimeMessage(InputStream in, boolean recurse) throws IOException, MessagingException { + MimeMessage mimeMessage = new MimeMessage(); + mimeMessage.parse(in, recurse); + return mimeMessage; + } + + /** + * Creates an instance that will check the header field syntax when adding headers. + */ + public static MimeMessage create() { + return new MimeMessage(true); + } + + public MimeMessage() { + this(false); + } + + private MimeMessage(boolean checkHeaders) { + mHeader.setCheckHeaders(checkHeaders); + } + + /** + * Parse the given InputStream using Apache Mime4J to build a MimeMessage. + * Does not recurse through nested bodyparts. + */ + public final void parse(InputStream in) throws IOException, MessagingException { + parse(in, false); + } + + private void parse(InputStream in, boolean recurse) throws IOException, MessagingException { + mHeader.clear(); + mFrom = null; + mTo = null; + mCc = null; + mBcc = null; + mReplyTo = null; + xOriginalTo = null; + deliveredTo = null; + xEnvelopeTo = null; + + mMessageId = null; + mReferences = null; + mInReplyTo = null; + + mSentDate = null; + + mBody = null; + + MimeConfig parserConfig = new MimeConfig.Builder() + // The default is a mere 10k + .setMaxHeaderLen(-1) + // The default is 1000 characters. Some MUAs generate REALLY long References: headers + .setMaxLineLen(-1) + // Disable the check for header count. + .setMaxHeaderCount(-1) + .build(); + + MimeStreamParser parser = new MimeStreamParser(parserConfig); + parser.setContentHandler(new MimeMessageBuilder(new DefaultBodyFactory())); + if (recurse) { + parser.setRecurse(); + } + try { + parser.parse(new EOLConvertingInputStream(in)); + } catch (MimeException me) { + throw new MessagingException(me.getMessage(), me); + } + } + + @Override + public Date getSentDate() { + if (mSentDate == null) { + String dateHeaderBody = getFirstHeader("Date"); + if (dateHeaderBody == null) { + return null; + } + + try { + DateTimeField field = (DateTimeField) DefaultFieldParser.parse("Date: " + dateHeaderBody); + mSentDate = field.getDate(); + } catch (Exception e) { + Timber.d(e, "Couldn't parse Date header field"); + } + } + return mSentDate; + } + + /** + * Sets the sent date object member as well as *adds* the 'Date' header + * instead of setting it (for performance reasons). + * + * @see #mSentDate + * @param sentDate + * @throws com.fsck.k9.mail.MessagingException + */ + public void addSentDate(Date sentDate, boolean hideTimeZone) { + if (mDateFormat == null) { + mDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US); + } + + if (hideTimeZone) { + mDateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + addHeader("Date", mDateFormat.format(sentDate)); + setInternalSentDate(sentDate); + } + + @Override + public void setSentDate(Date sentDate, boolean hideTimeZone) { + removeHeader("Date"); + addSentDate(sentDate, hideTimeZone); + } + + public void setInternalSentDate(Date sentDate) { + this.mSentDate = sentDate; + } + + @Override + public String getContentType() { + String contentType = getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE); + return (contentType == null) ? DEFAULT_MIME_TYPE : contentType; + } + + @Override + public String getDisposition() { + return getFirstHeader(MimeHeader.HEADER_CONTENT_DISPOSITION); + } + + @Override + public String getContentId() { + return null; + } + + @Override + public String getMimeType() { + String mimeTypeFromHeader = MimeUtility.getHeaderParameter(getContentType(), null); + MimeType mimeType = MimeType.parseOrNull(mimeTypeFromHeader); + return mimeType != null ? mimeType.toString() : DEFAULT_MIME_TYPE; + } + + @Override + public boolean isMimeType(String mimeType) { + return getMimeType().equalsIgnoreCase(mimeType); + } + + @Override + public long getSize() { + return mSize; + } + + /** + * Returns a list of the given recipient type from this message. If no addresses are + * found the method returns an empty array. + */ + @Override + public Address[] getRecipients(RecipientType type) { + switch (type) { + case TO: { + if (mTo == null) { + mTo = Address.parse(MimeUtility.unfold(getFirstHeader("To"))); + } + return mTo; + } + case CC: { + if (mCc == null) { + mCc = Address.parse(MimeUtility.unfold(getFirstHeader("CC"))); + } + return mCc; + } + case BCC: { + if (mBcc == null) { + mBcc = Address.parse(MimeUtility.unfold(getFirstHeader("BCC"))); + } + return mBcc; + } + case X_ORIGINAL_TO: { + if (xOriginalTo == null) { + xOriginalTo = Address.parse(MimeUtility.unfold(getFirstHeader("X-Original-To"))); + } + return xOriginalTo; + } + case DELIVERED_TO: { + if (deliveredTo == null) { + deliveredTo = Address.parse(MimeUtility.unfold(getFirstHeader("Delivered-To"))); + } + return deliveredTo; + } + case X_ENVELOPE_TO: { + if (xEnvelopeTo == null) { + xEnvelopeTo = Address.parse(MimeUtility.unfold(getFirstHeader("X-Envelope-To"))); + } + return xEnvelopeTo; + } + } + + throw new IllegalArgumentException("Unrecognized recipient type."); + } + + /** + * Returns the unfolded, decoded value of the Subject header. + */ + @Override + public String getSubject() { + return MimeUtility.unfoldAndDecode(getFirstHeader(MimeHeader.SUBJECT), this); + } + + @Override + public void setSubject(String subject) { + String encodedSubject = MimeHeaderEncoder.encode(MimeHeader.SUBJECT, subject); + setHeader(MimeHeader.SUBJECT, encodedSubject); + } + + @Override + public Address[] getFrom() { + if (mFrom == null) { + String list = MimeUtility.unfold(getFirstHeader("From")); + if (list == null || list.length() == 0) { + list = MimeUtility.unfold(getFirstHeader("Sender")); + } + mFrom = Address.parse(list); + } + return mFrom; + } + + @Override + public void setFrom(Address from) { + if (from != null) { + setHeader("From", from.toEncodedString()); + this.mFrom = new Address[] { + from + }; + } else { + this.mFrom = null; + } + } + + @Override + public Address[] getSender() { + return Address.parse(MimeUtility.unfold(getFirstHeader("Sender"))); + } + + @Override + public void setSender(Address sender) { + if (sender != null) { + setHeader("Sender", sender.toEncodedString()); + this.mSender = new Address[] { + sender + }; + } else { + this.mSender = null; + } + } + + @Override + public Address[] getReplyTo() { + if (mReplyTo == null) { + mReplyTo = Address.parse(MimeUtility.unfold(getFirstHeader("Reply-to"))); + } + return mReplyTo; + } + + @Override + public void setReplyTo(Address[] replyTo) { + if (replyTo == null || replyTo.length == 0) { + removeHeader("Reply-to"); + mReplyTo = null; + } else { + setHeader("Reply-to", AddressHeaderBuilder.createHeaderValue(replyTo)); + mReplyTo = replyTo; + } + } + + @Override + public String getMessageId() { + if (mMessageId == null) { + mMessageId = getFirstHeader("Message-ID"); + } + return mMessageId; + } + + public void setMessageId(String messageId) { + setHeader("Message-ID", messageId); + mMessageId = messageId; + } + + @Override + public void setInReplyTo(String inReplyTo) { + setHeader("In-Reply-To", inReplyTo); + } + + @Override + public String[] getReferences() { + if (mReferences == null) { + mReferences = getHeader("References"); + } + return mReferences; + } + + @Override + public void setReferences(String references) { + /* + * Make sure the References header doesn't exceed the maximum header + * line length and won't get (Q-)encoded later. Otherwise some clients + * will break threads apart. + * + * For more information see issue 1559. + */ + + // Make sure separator is SPACE to prevent Q-encoding when TAB is encountered + references = references.replaceAll("\\s+", " "); + + /* + * NOTE: Usually the maximum header line is 998 + CRLF = 1000 characters. + * But at least one implementations seems to have problems with 998 + * characters, so we adjust for that fact. + */ + final int limit = 1000 - 2 /* CRLF */ - 12 /* "References: " */ - 1 /* Off-by-one bugs */; + final int originalLength = references.length(); + if (originalLength >= limit) { + // Find start of first reference + final int start = references.indexOf('<'); + + // First reference + SPACE + final String firstReference = references.substring(start, + references.indexOf('<', start + 1)); + + // Find longest tail + final String tail = references.substring(references.indexOf('<', + firstReference.length() + originalLength - limit)); + + references = firstReference + tail; + } + setHeader("References", references); + } + + @Override + public Body getBody() { + return mBody; + } + + @Override + public void setBody(Body body) { + this.mBody = body; + } + + private String getFirstHeader(String name) { + return mHeader.getFirstHeader(name); + } + + @Override + public void addHeader(String name, String value) { + mHeader.addHeader(name, value); + } + + @Override + public void addRawHeader(String name, String raw) { + mHeader.addRawHeader(name, raw); + } + + @Override + public void setHeader(String name, String value) { + mHeader.setHeader(name, value); + } + + @NotNull + @Override + public String[] getHeader(String name) { + return mHeader.getHeader(name); + } + + @Override + public void removeHeader(String name) { + mHeader.removeHeader(name); + } + + @Override + public List<Header> getHeaders() { + return mHeader.getHeaders(); + } + + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); + mHeader.writeTo(out); + writer.write("\r\n"); + writer.flush(); + if (mBody != null) { + mBody.writeTo(out); + } + } + + @Override + public void writeHeaderTo(OutputStream out) throws IOException, MessagingException { + mHeader.writeTo(out); + } + + @Override + public InputStream getInputStream() throws MessagingException { + throw new UnsupportedOperationException(); + } + + @Override + public void setEncoding(String encoding) throws MessagingException { + if (mBody != null) { + mBody.setEncoding(encoding); + } + setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, encoding); + } + + private class MimeMessageBuilder implements ContentHandler { + private final LinkedList<Object> stack = new LinkedList<>(); + private final BodyFactory bodyFactory; + + public MimeMessageBuilder(BodyFactory bodyFactory) { + this.bodyFactory = bodyFactory; + } + + private void expect(Class<?> c) { + if (!c.isInstance(stack.peek())) { + throw new IllegalStateException("Internal stack error: " + "Expected '" + + c.getName() + "' found '" + stack.peek().getClass().getName() + "'"); + } + } + + @Override + public void startMessage() { + if (stack.isEmpty()) { + stack.addFirst(MimeMessage.this); + } else { + expect(Part.class); + Part part = (Part) stack.peek(); + + MimeMessage m = new MimeMessage(); + part.setBody(m); + stack.addFirst(m); + } + } + + @Override + public void endMessage() { + expect(MimeMessage.class); + stack.removeFirst(); + } + + @Override + public void startHeader() { + expect(Part.class); + } + + @Override + public void endHeader() { + expect(Part.class); + } + + @Override + public void startMultipart(BodyDescriptor bd) throws MimeException { + expect(Part.class); + + Part e = (Part)stack.peek(); + String mimeType = bd.getMimeType(); + String boundary = bd.getBoundary(); + MimeMultipart multiPart = new MimeMultipart(mimeType, boundary); + e.setBody(multiPart); + stack.addFirst(multiPart); + } + + @Override + public void body(BodyDescriptor bd, InputStream in) throws IOException, MimeException { + expect(Part.class); + Body body = bodyFactory.createBody(bd.getTransferEncoding(), bd.getMimeType(), in); + ((Part)stack.peek()).setBody(body); + } + + @Override + public void endMultipart() { + expect(Multipart.class); + Multipart multipart = (Multipart) stack.removeFirst(); + + boolean hasNoBodyParts = multipart.getCount() == 0; + boolean hasNoEpilogue = multipart.getEpilogue() == null; + if (hasNoBodyParts && hasNoEpilogue) { + /* + * The parser is calling startMultipart(), preamble(), and endMultipart() when all we have is + * headers of a "multipart/*" part. But there's really no point in keeping a Multipart body if all + * of the content is missing. + */ + expect(Part.class); + Part part = (Part) stack.peek(); + part.setBody(null); + } + } + + @Override + public void startBodyPart() throws MimeException { + expect(MimeMultipart.class); + + try { + MimeBodyPart bodyPart = new MimeBodyPart(); + ((MimeMultipart)stack.peek()).addBodyPart(bodyPart); + stack.addFirst(bodyPart); + } catch (MessagingException me) { + throw new MimeException(me); + } + } + + @Override + public void endBodyPart() { + expect(BodyPart.class); + stack.removeFirst(); + } + + @Override + public void preamble(InputStream is) throws IOException { + expect(MimeMultipart.class); + ByteArrayOutputStream preamble = new ByteArrayOutputStream(); + IOUtils.copy(is, preamble); + ((MimeMultipart)stack.peek()).setPreamble(preamble.toByteArray()); + } + + @Override + public void epilogue(InputStream is) throws IOException { + expect(MimeMultipart.class); + ByteArrayOutputStream epilogue = new ByteArrayOutputStream(); + IOUtils.copy(is, epilogue); + ((MimeMultipart) stack.peek()).setEpilogue(epilogue.toByteArray()); + } + + @Override + public void raw(InputStream is) throws IOException { + throw new UnsupportedOperationException("Not supported"); + } + + @Override + public void field(Field parsedField) throws MimeException { + expect(Part.class); + String name = parsedField.getName(); + String raw = parsedField.getRaw().toString(); + ((Part) stack.peek()).addRawHeader(name, raw); + } + } + + @Override + public boolean hasAttachments() { + return false; + } + + @Override + public String getServerExtra() { + return serverExtra; + } + + @Override + public void setServerExtra(String serverExtra) { + this.serverExtra = serverExtra; + } + + /** + * Convert a top level message into a bodypart. + * Returned body part shouldn't contain inappropriate headers such as smtp + * headers or MIME-VERSION. + * Both Message and MimeBodyPart might share structures. + * @return the body part + * @throws MessagingException + */ + public MimeBodyPart toBodyPart() throws MessagingException { + MimeHeader contentHeaders = new MimeHeader(); + for (String header : mHeader.getHeaderNames()) { + if (header.toLowerCase(Locale.ROOT).startsWith("content-")) { + for (String value : mHeader.getHeader(header)) { + contentHeaders.addHeader(header, value); + } + } + } + return new MimeBodyPart(contentHeaders, getBody()); + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMessageHelper.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMessageHelper.java new file mode 100644 index 0000000..80cb895 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMessageHelper.java @@ -0,0 +1,56 @@ +package com.fsck.k9.mail.internet; + + +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Multipart; +import com.fsck.k9.mail.Part; +import org.apache.james.mime4j.util.MimeUtil; + + +public class MimeMessageHelper { + private MimeMessageHelper() { + } + + public static void setBody(Part part, Body body) throws MessagingException { + part.setBody(body); + + if (part instanceof Message) { + part.setHeader("MIME-Version", "1.0"); + } + + if (body instanceof Multipart) { + Multipart multipart = ((Multipart) body); + multipart.setParent(part); + String contentType = Headers.contentTypeForMultipart(multipart.getMimeType(), multipart.getBoundary()); + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType); + // note: if this is ever changed to 8bit, multipart/signed parts must always be 7bit! + setEncoding(part, MimeUtil.ENC_7BIT); + } else if (body instanceof TextBody) { + MimeValue contentTypeHeader = MimeParameterDecoder.decode(part.getContentType()); + String mimeType = contentTypeHeader.getValue(); + if (MimeUtility.mimeTypeMatches(mimeType, "text/*")) { + String name = contentTypeHeader.getParameters().get("name"); + + String contentType = Headers.contentType(mimeType, "utf-8", name); + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType); + } else { + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, mimeType); + } + + setEncoding(part, MimeUtil.ENC_QUOTED_PRINTABLE); + } else if (body instanceof RawDataBody) { + String encoding = ((RawDataBody) body).getEncoding(); + part.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, encoding); + } + } + + public static void setEncoding(Part part, String encoding) throws MessagingException { + Body body = part.getBody(); + if (body != null) { + body.setEncoding(encoding); + } + part.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, encoding); + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMultipart.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMultipart.java new file mode 100644 index 0000000..22a7ff9 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMultipart.java @@ -0,0 +1,111 @@ + +package com.fsck.k9.mail.internet; + + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; + +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.BoundaryGenerator; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Multipart; + +public class MimeMultipart extends Multipart { + private String mimeType; + private byte[] preamble; + private byte[] epilogue; + private final String boundary; + + + public static MimeMultipart newInstance() { + String boundary = BoundaryGenerator.getInstance().generateBoundary(); + return new MimeMultipart(boundary); + } + + public MimeMultipart(String boundary) { + this("multipart/mixed", boundary); + } + + public MimeMultipart(String mimeType, String boundary) { + if (mimeType == null) { + throw new IllegalArgumentException("mimeType can't be null"); + } + if (boundary == null) { + throw new IllegalArgumentException("boundary can't be null"); + } + + this.mimeType = mimeType; + this.boundary = boundary; + } + + @Override + public String getBoundary() { + return boundary; + } + + public byte[] getPreamble() { + return preamble; + } + + public void setPreamble(byte[] preamble) { + this.preamble = preamble; + } + + public byte[] getEpilogue() { + return epilogue; + } + + public void setEpilogue(byte[] epilogue) { + this.epilogue = epilogue; + } + + @Override + public String getMimeType() { + return mimeType; + } + + public void setSubType(String subType) { + mimeType = "multipart/" + subType; + } + + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); + + if (preamble != null) { + out.write(preamble); + writer.write("\r\n"); + } + + if (getBodyParts().isEmpty()) { + writer.write("--"); + writer.write(boundary); + writer.write("\r\n"); + } else { + for (BodyPart bodyPart : getBodyParts()) { + writer.write("--"); + writer.write(boundary); + writer.write("\r\n"); + writer.flush(); + bodyPart.writeTo(out); + writer.write("\r\n"); + } + } + + writer.write("--"); + writer.write(boundary); + writer.write("--\r\n"); + writer.flush(); + if (epilogue != null) { + out.write(epilogue); + } + } + + @Override + public InputStream getInputStream() throws MessagingException { + throw new UnsupportedOperationException(); + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterDecoder.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterDecoder.kt new file mode 100644 index 0000000..ebe89d3 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterDecoder.kt @@ -0,0 +1,310 @@ +package com.fsck.k9.mail.internet + +import java.nio.charset.Charset +import java.nio.charset.IllegalCharsetNameException +import okio.Buffer + +private typealias Parameters = Map<String, String> +private typealias BasicParameters = Map<String, ParameterValue> +private typealias SimpleParameter = Pair<String, String> +private typealias IgnoredParameters = List<SimpleParameter> + +/** + * Decode MIME parameter values as specified in RFC 2045 and RFC 2231. + * + * Parsing MIME header parameters is quite challenging because a lot of things have to be considered: + * - RFC 822 allows comments and (folding) whitespace between all tokens in structured header fields + * - parameter names are case insensitive + * - the ordering of parameters is not significant + * - parameters could be present in RFC 2045 style and RFC 2231 style + * - it's not specified what happens when an extended parameter value (RFC 2231) doesn't specify a charset value + * - some clients don't use the method described in RFC 2231 to encode parameter values with non-ASCII characters; but + * encoded words as described in RFC 2047 + * + * This class takes a very lenient approach in order to be able to decode as many real world messages as possible. + * First it does a pass to extract RFC 2045 style parameter names and values while remembering how the values were + * encoded (token vs. quoted string). When a parsing error is encountered parsing is stopped, but anything successfully + * parsed before that is kept. A second pass then checks if successfully parsed parameters are RFC 2231 encoded and + * combines/decodes them. + * If a parameter is present encoded according to RFC 2045 and also also as described in RFC 2231 the latter version + * is preferred. In case only a RFC 2045 style parameter is present this class attempts to RFC 2047 (encoded word) + * decode it. This is not a valid encoding for the structured header fields `Content-Type` and `Content-Disposition`, + * but it is seen in the wild. + */ +object MimeParameterDecoder { + + @JvmStatic + fun decode(headerBody: String): MimeValue { + val parser = MimeHeaderParser(headerBody) + + val value = parser.readHeaderValue() + parser.skipCFWS() + if (parser.endReached()) { + return MimeValue(value) + } + + val (basicParameters, duplicateParameters, parserErrorIndex) = readBasicParameters(parser) + val (parameters, ignoredParameters) = reconstructParameters(basicParameters) + + return MimeValue( + value = value, + parameters = parameters, + ignoredParameters = duplicateParameters + ignoredParameters, + parserErrorIndex = parserErrorIndex + ) + } + + fun decodeBasic(headerBody: String): MimeValue { + val parser = MimeHeaderParser(headerBody) + + val value = parser.readHeaderValue() + parser.skipCFWS() + if (parser.endReached()) { + return MimeValue(value) + } + + val (basicParameters, duplicateParameters, parserErrorIndex) = readBasicParameters(parser) + val parameters = basicParameters.mapValues { (_, parameterValue) -> parameterValue.value } + + return MimeValue( + value = value, + parameters = parameters, + ignoredParameters = duplicateParameters, + parserErrorIndex = parserErrorIndex + ) + } + + @JvmStatic + fun extractHeaderValue(headerBody: String): String { + val parser = MimeHeaderParser(headerBody) + return parser.readHeaderValue() + } + + private fun readBasicParameters(parser: MimeHeaderParser): BasicParameterResults { + val parameters = mutableMapOf<String, ParameterValue>() + val duplicateParameterNames = mutableSetOf<String>() + val ignoredParameters = mutableListOf<SimpleParameter>() + val parserErrorIndex = try { + do { + parser.expect(SEMICOLON) + + val parameterName = parser.readToken().lowercase() + + parser.skipCFWS() + parser.expect(EQUALS_SIGN) + parser.skipCFWS() + + val parameterValue = if (parser.peek() == DQUOTE) { + ParameterValue(parser.readQuotedString(), wasToken = false) + } else { + ParameterValue(parser.readToken(), wasToken = true) + } + + val existingParameterValue = parameters.remove(parameterName) + when { + existingParameterValue != null -> { + duplicateParameterNames.add(parameterName) + ignoredParameters.add(parameterName to existingParameterValue.value) + ignoredParameters.add(parameterName to parameterValue.value) + } + parameterName !in duplicateParameterNames -> parameters[parameterName] = parameterValue + else -> ignoredParameters.add(parameterName to parameterValue.value) + } + + parser.skipCFWS() + } while (!parser.endReached()) + + null + } catch (e: MimeHeaderParserException) { + e.errorIndex + } + + return BasicParameterResults(parameters, ignoredParameters, parserErrorIndex) + } + + private fun reconstructParameters(basicParameters: BasicParameters): Pair<Parameters, IgnoredParameters> { + val parameterSectionMap = mutableMapOf<String, MutableList<ParameterSection>>() + val singleParameters = mutableMapOf<String, String>() + + for ((parameterName, parameterValue) in basicParameters) { + val parameterSection = convertToParameterSection(parameterName, parameterValue) + + if (parameterSection == null) { + singleParameters[parameterName] = parameterValue.value + } else { + parameterSectionMap.getOrPut(parameterSection.name) { mutableListOf() } + .add(parameterSection) + } + } + + val parameters = mutableMapOf<String, String>() + for ((parameterName, parameterSections) in parameterSectionMap) { + parameterSections.sortBy { it.section } + + if (areParameterSectionsValid(parameterSections)) { + parameters[parameterName] = combineParameterSections(parameterSections) + } else { + for (parameterSection in parameterSections) { + val originalParameterName = parameterSection.originalName + parameters[originalParameterName] = basicParameters[originalParameterName]!!.value + } + } + } + + val ignoredParameters = mutableListOf<Pair<String, String>>() + for ((parameterName, parameterValue) in singleParameters) { + if (parameterName !in parameters) { + parameters[parameterName] = DecoderUtil.decodeEncodedWords(parameterValue, null) + } else { + ignoredParameters.add(parameterName to parameterValue) + } + } + + return Pair(parameters, ignoredParameters) + } + + private fun convertToParameterSection(parameterName: String, parameterValue: ParameterValue): ParameterSection? { + val extendedValue = parameterName.endsWith(ASTERISK) + if (extendedValue && !parameterValue.wasToken) { + return null + } + + val parts = parameterName.split(ASTERISK) + if (parts.size !in 2..3 || parts.size == 3 && parts[2].isNotEmpty()) { + return null + } + + val newParameterName = parts[0] + val sectionText = parts[1] + val section = when { + parts.size == 2 && extendedValue -> null + sectionText == "0" -> 0 + sectionText.startsWith('0') -> return null + sectionText.isNotAsciiNumber() -> return null + else -> parts[1].toIntOrNull() ?: return null + } + + val parameterText = parameterValue.value + return if (extendedValue) { + val parser = MimeHeaderParser(parameterText) + if (section == null || section == 0) { + readExtendedParameterValue(parser, parameterName, newParameterName, section, parameterText) + } else { + val data = Buffer() + parser.readExtendedParameterValueInto(data) + + ExtendedValueParameterSection(newParameterName, parameterName, section, data) + } + } else { + RegularValueParameterSection(newParameterName, parameterName, section, parameterText) + } + } + + private fun readExtendedParameterValue( + parser: MimeHeaderParser, + parameterName: String, + newParameterName: String, + section: Int?, + parameterText: String + ): ParameterSection? { + return try { + val charsetName = parser.readUntil(SINGLE_QUOTE) + parser.expect(SINGLE_QUOTE) + val language = parser.readUntil(SINGLE_QUOTE) + parser.expect(SINGLE_QUOTE) + + if (charsetName.isSupportedCharset()) { + val data = Buffer() + parser.readExtendedParameterValueInto(data) + + InitialExtendedValueParameterSection( + newParameterName, + parameterName, + section, + charsetName, + language, + data + ) + } else { + val encodedParameterText = parameterText.substring(parser.position()) + RegularValueParameterSection(newParameterName, parameterName, section, encodedParameterText) + } + } catch (e: MimeHeaderParserException) { + null + } + } + + private fun areParameterSectionsValid(parameterSections: MutableList<ParameterSection>): Boolean { + if (parameterSections.size == 1) { + val section = parameterSections.first().section + return section == null || section == 0 + } + + val isExtendedValue = parameterSections.first() is InitialExtendedValueParameterSection + + parameterSections.forEachIndexed { index, parameterSection -> + if (parameterSection.section != index || + !isExtendedValue && parameterSection is ExtendedValueParameterSection + ) { + return false + } + } + + return true + } + + private fun combineParameterSections(parameterSections: MutableList<ParameterSection>): String { + val initialParameterSection = parameterSections.first() + return if (initialParameterSection is InitialExtendedValueParameterSection) { + val charset = Charset.forName(initialParameterSection.charsetName) + combineExtendedParameterSections(parameterSections, charset) + } else { + combineRegularParameterSections(parameterSections) + } + } + + private fun combineExtendedParameterSections(parameterSections: List<ParameterSection>, charset: Charset): String { + val buffer = Buffer() + return buildString { + for (parameterSection in parameterSections) { + when (parameterSection) { + is ExtendedValueParameterSection -> buffer.writeAll(parameterSection.data) + is RegularValueParameterSection -> { + append(buffer.readString(charset)) + append(parameterSection.text) + } + } + } + append(buffer.readString(charset)) + } + } + + private fun combineRegularParameterSections(parameterSections: MutableList<ParameterSection>): String { + return buildString { + for (parameterSection in parameterSections) { + if (parameterSection !is RegularValueParameterSection) throw AssertionError() + append(parameterSection.text) + } + } + } + + private fun String.isSupportedCharset(): Boolean { + if (isEmpty()) return false + + return try { + Charset.isSupported(this) + } catch (e: IllegalCharsetNameException) { + false + } + } + + private fun String.isNotAsciiNumber(): Boolean = any { character -> character !in '0'..'9' } +} + +private data class ParameterValue(val value: String, val wasToken: Boolean) + +private data class BasicParameterResults( + val parameters: BasicParameters, + val ignoredParameters: IgnoredParameters, + val parserErrorIndex: Int? +) diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterEncoder.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterEncoder.kt new file mode 100644 index 0000000..fe6b861 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterEncoder.kt @@ -0,0 +1,211 @@ +package com.fsck.k9.mail.internet + +import com.fsck.k9.mail.filter.Hex.appendHex +import com.fsck.k9.mail.helper.encodeUtf8 +import com.fsck.k9.mail.helper.utf8Size + +/** + * Encode MIME parameter values as specified in RFC 2045 and RFC 2231. + */ +object MimeParameterEncoder { + // RFC 5322, section 2.1.1 + private const val MAX_LINE_LENGTH = 78 + + private const val ENCODED_VALUE_PREFIX = "UTF-8''" + + /** + * Create header field value with parameters encoded if necessary. + */ + @JvmStatic + fun encode(value: String, parameters: Map<String, String>): String { + return if (parameters.isEmpty()) { + value + } else { + buildString { + append(value) + encodeAndAppendParameters(parameters) + } + } + } + + private fun StringBuilder.encodeAndAppendParameters(parameters: Map<String, String>) { + for ((name, value) in parameters) { + encodeAndAppendParameter(name, value) + } + } + + private fun StringBuilder.encodeAndAppendParameter(name: String, value: String) { + val fixedCostLength = 1 /* folding space */ + name.length + 1 /* equals sign */ + 1 /* semicolon */ + val unencodedValueFitsOnSingleLine = fixedCostLength + value.length <= MAX_LINE_LENGTH + val quotedValueMightFitOnSingleLine = fixedCostLength + value.length + 2 /* quotes */ <= MAX_LINE_LENGTH + + if (unencodedValueFitsOnSingleLine && value.isToken()) { + appendParameter(name, value) + } else if (quotedValueMightFitOnSingleLine && value.isQuotable() && + fixedCostLength + value.quotedLength() <= MAX_LINE_LENGTH + ) { + appendParameter(name, value.quoted()) + } else { + rfc2231EncodeAndAppendParameter(name, value) + } + } + + private fun StringBuilder.appendParameter(name: String, value: String) { + append(";$CRLF ") + append(name).append('=').append(value) + } + + private fun StringBuilder.rfc2231EncodeAndAppendParameter(name: String, value: String) { + val encodedValueLength = 1 /* folding space */ + name.length + 1 /* asterisk */ + 1 /* equal sign */ + + ENCODED_VALUE_PREFIX.length + value.rfc2231EncodedLength() + 1 /* semicolon */ + + if (encodedValueLength <= MAX_LINE_LENGTH) { + appendRfc2231SingleLineParameter(name, value.rfc2231Encoded()) + } else { + encodeAndAppendRfc2231MultiLineParameter(name, value) + } + } + + private fun StringBuilder.appendRfc2231SingleLineParameter(name: String, encodedValue: String) { + append(";$CRLF ") + append(name) + append("*=$ENCODED_VALUE_PREFIX") + append(encodedValue) + } + + private fun StringBuilder.encodeAndAppendRfc2231MultiLineParameter(name: String, value: String) { + var index = 0 + var line = 0 + var startOfLine = true + var remainingSpaceInLine = 0 + val endIndex = value.length + while (index < endIndex) { + if (startOfLine) { + append(";$CRLF ") + val lineStartIndex = length - 1 + append(name).append('*').append(line).append("*=") + if (line == 0) { + append(ENCODED_VALUE_PREFIX) + } + + remainingSpaceInLine = MAX_LINE_LENGTH - (length - lineStartIndex) - 1 /* semicolon */ + if (remainingSpaceInLine < 3) { + throw UnsupportedOperationException("Parameter name too long") + } + + startOfLine = false + line++ + } + + val codePoint = value.codePointAt(index) + + // Keep all characters encoding a single code point on the same line + val utf8Size = codePoint.utf8Size() + if (utf8Size == 1 && codePoint.toChar().isAttributeChar() && remainingSpaceInLine >= 1) { + append(codePoint.toChar()) + index++ + remainingSpaceInLine-- + } else if (remainingSpaceInLine >= utf8Size * 3) { + codePoint.encodeUtf8 { + append('%') + appendHex(it, lowerCase = false) + remainingSpaceInLine -= 3 + } + index += Character.charCount(codePoint) + } else { + startOfLine = true + } + } + } + + private fun String.rfc2231Encoded() = buildString { + this@rfc2231Encoded.encodeUtf8 { byte -> + val c = byte.toInt().toChar() + if (c.isAttributeChar()) { + append(c) + } else { + append('%') + appendHex(byte, lowerCase = false) + } + } + } + + private fun String.rfc2231EncodedLength(): Int { + var length = 0 + encodeUtf8 { byte -> + length += if (byte.toInt().toChar().isAttributeChar()) 1 else 3 + } + return length + } + + fun String.isToken() = when { + isEmpty() -> false + else -> all { it.isTokenChar() } + } + + private fun String.isQuotable() = all { it.isQuotable() } + + private fun String.quoted(): String { + // quoted-string = [CFWS] DQUOTE *([FWS] qcontent) [FWS] DQUOTE [CFWS] + // qcontent = qtext / quoted-pair + // quoted-pair = ("\" (VCHAR / WSP)) + + return buildString(capacity = length + 16) { + append(DQUOTE) + for (c in this@quoted) { + if (c.isQText() || c.isWsp()) { + append(c) + } else if (c.isVChar()) { + append('\\').append(c) + } else { + throw IllegalArgumentException("Unsupported character: $c") + } + } + append(DQUOTE) + } + } + + // RFC 6532-style header values + // Right now we only create such values for internal use (see IMAP BODYSTRUCTURE response parsing code) + fun String.quotedUtf8(): String { + return buildString(capacity = length + 16) { + append(DQUOTE) + for (c in this@quotedUtf8) { + if (c == DQUOTE || c == BACKSLASH) { + append('\\').append(c) + } else { + append(c) + } + } + append(DQUOTE) + } + } + + private fun String.quotedLength(): Int { + var length = 2 /* start and end quote */ + for (c in this) { + if (c.isQText() || c.isWsp()) { + length++ + } else if (c.isVChar()) { + length += 2 + } else { + throw IllegalArgumentException("Unsupported character: $c") + } + } + return length + } + + private fun Char.isQuotable() = when { + isWsp() -> true + isVChar() -> true + else -> false + } + + // RFC 5322: qtext = %d33 / %d35-91 / %d93-126 / obs-qtext + private fun Char.isQText() = when (code) { + 33 -> true + in 35..91 -> true + in 93..126 -> true + else -> false + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeUtility.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeUtility.java new file mode 100644 index 0000000..da697b1 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeUtility.java @@ -0,0 +1,226 @@ + +package com.fsck.k9.mail.internet; + + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +import com.fsck.k9.logging.Timber; +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Multipart; +import com.fsck.k9.mail.Part; +import org.apache.james.mime4j.codec.Base64InputStream; +import org.apache.james.mime4j.codec.QuotedPrintableInputStream; +import org.apache.james.mime4j.util.MimeUtil; + + +public class MimeUtility { + public static String unfold(String s) { + if (s == null) { + return null; + } + return s.replaceAll("\r|\n", ""); + } + + private static String decode(String s, Message message) { + if (s == null) { + return null; + } else { + return DecoderUtil.decodeEncodedWords(s, message); + } + } + + public static String unfoldAndDecode(String s) { + return unfoldAndDecode(s, null); + } + + public static String unfoldAndDecode(String s, Message message) { + return decode(unfold(s), message); + } + + /** + * Returns the named parameter of a header field. + * + * <p> + * If name is {@code null} the "value" of the header is returned, i.e. "text/html" in the following example: + * <br> + *{@code Content-Type: text/html; charset="utf-8"} + * </p> + * <p> + * Note: Parsing header parameters is not a very cheap operation. Prefer using {@code MimeParameterDecoder} + * directly over calling this method multiple times for extracting different parameters from the the same header. + * </p> + * + * @param headerBody The header body. + * @param parameterName The parameter name. Might be {@code null}. + * @return the (parameter) value. if the parameter cannot be found the method returns null. + */ + public static String getHeaderParameter(String headerBody, String parameterName) { + if (headerBody == null) { + return null; + } + + if (parameterName == null) { + return MimeParameterDecoder.extractHeaderValue(headerBody); + } else { + MimeValue mimeValue = MimeParameterDecoder.decode(headerBody); + return mimeValue.getParameters().get(parameterName.toLowerCase(Locale.ROOT)); + } + } + + public static Map<String,String> getAllHeaderParameters(String headerValue) { + Map<String,String> result = new HashMap<>(); + + headerValue = headerValue.replaceAll("\r|\n", ""); + String[] parts = headerValue.split(";"); + for (String part : parts) { + String[] partParts = part.split("=", 2); + if (partParts.length == 2) { + String parameterName = partParts[0].trim().toLowerCase(Locale.US); + String parameterValue = partParts[1].trim(); + result.put(parameterName, parameterValue); + } + } + return result; + } + + + public static Part findFirstPartByMimeType(Part part, String mimeType) { + if (part.getBody() instanceof Multipart) { + Multipart multipart = (Multipart)part.getBody(); + for (BodyPart bodyPart : multipart.getBodyParts()) { + Part ret = MimeUtility.findFirstPartByMimeType(bodyPart, mimeType); + if (ret != null) { + return ret; + } + } + } else if (isSameMimeType(part.getMimeType(), mimeType)) { + return part; + } + return null; + } + + /** + * Returns true if the given mimeType matches the matchAgainst specification. + * @param mimeType A MIME type to check. + * @param matchAgainst A MIME type to check against. May include wildcards such as image/* or + * * /*. + * @return + */ + public static boolean mimeTypeMatches(String mimeType, String matchAgainst) { + Pattern p = Pattern.compile(matchAgainst.replaceAll("\\*", "\\.\\*"), Pattern.CASE_INSENSITIVE); + return p.matcher(mimeType).matches(); + } + + /** + * Get decoded contents of a body. + * <p/> + * Right now only some classes retain the original encoding of the body contents. Those classes have to implement + * the {@link RawDataBody} interface in order for this method to decode the data delivered by + * {@link Body#getInputStream()}. + * <p/> + * The ultimate goal is to get to a point where all classes retain the original data and {@code RawDataBody} can be + * merged into {@link Body}. + */ + public static InputStream decodeBody(Body body) throws MessagingException { + InputStream inputStream; + if (body instanceof RawDataBody) { + RawDataBody rawDataBody = (RawDataBody) body; + String encoding = rawDataBody.getEncoding(); + final InputStream rawInputStream = rawDataBody.getInputStream(); + if (MimeUtil.ENC_7BIT.equalsIgnoreCase(encoding) || MimeUtil.ENC_8BIT.equalsIgnoreCase(encoding) + || MimeUtil.ENC_BINARY.equalsIgnoreCase(encoding)) { + inputStream = rawInputStream; + } else if (MimeUtil.ENC_BASE64.equalsIgnoreCase(encoding)) { + inputStream = new Base64InputStream(rawInputStream, false) { + @Override + public void close() throws IOException { + super.close(); + closeInputStreamWithoutDeletingTemporaryFiles(rawInputStream); + } + }; + } else if (MimeUtil.ENC_QUOTED_PRINTABLE.equalsIgnoreCase(encoding)) { + inputStream = new QuotedPrintableInputStream(rawInputStream) { + @Override + public void close() { + super.close(); + try { + closeInputStreamWithoutDeletingTemporaryFiles(rawInputStream); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }; + } else { + Timber.w("Unsupported encoding: %s", encoding); + inputStream = rawInputStream; + } + } else { + inputStream = body.getInputStream(); + } + + return inputStream; + } + + public static void closeInputStreamWithoutDeletingTemporaryFiles(InputStream rawInputStream) throws IOException { + if (rawInputStream instanceof BinaryTempFileBody.BinaryTempFileBodyInputStream) { + ((BinaryTempFileBody.BinaryTempFileBodyInputStream) rawInputStream).closeWithoutDeleting(); + } else { + rawInputStream.close(); + } + } + + /** + * Get a default content-transfer-encoding for use with a given content-type + * when adding an unencoded attachment. It's possible that 8bit encodings + * may later be converted to 7bit for 7bit transport. + * <ul> + * <li>null: base64 + * <li>message/rfc822: 8bit + * <li>message/*: 7bit + * <li>multipart/signed: 7bit + * <li>multipart/*: 8bit + * <li>*/*: base64 + * </ul> + * + * @param type + * A String representing a MIME content-type + * @return A String representing a MIME content-transfer-encoding + */ + public static String getEncodingforType(String type) { + if (type == null) { + return (MimeUtil.ENC_BASE64); + } else if (MimeUtil.isMessage(type)) { + return (MimeUtil.ENC_8BIT); + } else if (isSameMimeType(type, "multipart/signed") || isMessage(type)) { + return (MimeUtil.ENC_7BIT); + } else if (isMultipart(type)) { + return (MimeUtil.ENC_8BIT); + } else { + return (MimeUtil.ENC_BASE64); + } + } + + public static boolean isMultipart(String mimeType) { + return mimeType != null && mimeType.toLowerCase(Locale.US).startsWith("multipart/"); + } + + public static boolean isMessage(String mimeType) { + return isSameMimeType(mimeType, "message/rfc822"); + } + + public static boolean isMessageType(String mimeType) { + return mimeType != null && mimeType.toLowerCase(Locale.ROOT).startsWith("message/"); + } + + public static boolean isSameMimeType(String mimeType, String otherMimeType) { + return mimeType != null && mimeType.equalsIgnoreCase(otherMimeType); + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeValue.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeValue.kt new file mode 100644 index 0000000..df8c2ce --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeValue.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.mail.internet + +data class MimeValue( + val value: String, + val parameters: Map<String, String> = emptyMap(), + val ignoredParameters: List<Pair<String, String>> = emptyList(), + val parserErrorIndex: Int? = null +) diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/ParameterSection.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/ParameterSection.kt new file mode 100644 index 0000000..c7b9b12 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/ParameterSection.kt @@ -0,0 +1,32 @@ +package com.fsck.k9.mail.internet + +import okio.Buffer + +internal sealed class ParameterSection( + val name: String, + val originalName: String, + val section: Int? +) + +internal open class ExtendedValueParameterSection( + name: String, + originalName: String, + section: Int?, + val data: Buffer +) : ParameterSection(name, originalName, section) + +internal class InitialExtendedValueParameterSection( + name: String, + originalName: String, + section: Int?, + val charsetName: String, + val language: String?, + data: Buffer +) : ExtendedValueParameterSection(name, originalName, section, data) + +internal class RegularValueParameterSection( + name: String, + originalName: String = name, + section: Int? = null, + val text: String +) : ParameterSection(name, originalName, section) diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/PartExtensions.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/PartExtensions.kt new file mode 100644 index 0000000..dbae2e2 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/PartExtensions.kt @@ -0,0 +1,25 @@ +@file:JvmName("PartExtensions") + +package com.fsck.k9.mail.internet + +import com.fsck.k9.mail.Part + +/** + * Return the `charset` parameter value of this [Part]'s `Content-Type` header. + */ +val Part.charset: String? + get() { + val contentTypeHeader = this.contentType ?: return null + val (_, parameters, duplicateParameters) = MimeParameterDecoder.decodeBasic(contentTypeHeader) + return parameters["charset"] ?: extractNonConflictingCharsetValue(duplicateParameters) + } + +// If there are multiple "charset" parameters, but they all agree on the value, we use that value. +private fun extractNonConflictingCharsetValue(duplicateParameters: List<Pair<String, String>>): String? { + val charsets = duplicateParameters.asSequence() + .filter { (parameterName, _) -> parameterName == "charset" } + .map { (_, charset) -> charset.lowercase() } + .toSet() + + return if (charsets.size == 1) charsets.first() else null +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/RawDataBody.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/RawDataBody.java new file mode 100644 index 0000000..e3dee61 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/RawDataBody.java @@ -0,0 +1,12 @@ +package com.fsck.k9.mail.internet; + + +import com.fsck.k9.mail.Body; + + +/** + * See {@link MimeUtility#decodeBody(Body)} + */ +public interface RawDataBody extends Body { + String getEncoding(); +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/SizeAware.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/SizeAware.java new file mode 100644 index 0000000..07d5fde --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/SizeAware.java @@ -0,0 +1,6 @@ +package com.fsck.k9.mail.internet; + + +public interface SizeAware { + long getSize(); +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/TextBody.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/TextBody.java new file mode 100644 index 0000000..6a5902a --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/TextBody.java @@ -0,0 +1,133 @@ + +package com.fsck.k9.mail.internet; + + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.filter.CountingOutputStream; +import com.fsck.k9.mail.filter.SignSafeOutputStream; + +import org.apache.james.mime4j.Charsets; +import org.apache.james.mime4j.codec.QuotedPrintableOutputStream; +import org.apache.james.mime4j.util.MimeUtil; +import org.jetbrains.annotations.Nullable; + + +public class TextBody implements Body, SizeAware { + private static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + + + private final String text; + private String encoding; + // Length of the message composed (as opposed to quoted). I don't like the name of this variable and am open to + // suggestions as to what it should otherwise be. -achen 20101207 + @Nullable + private Integer composedMessageLength; + // Offset from position 0 where the composed message begins. + @Nullable + private Integer composedMessageOffset; + + public TextBody(String body) { + this.text = body; + } + + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + if (text != null) { + byte[] bytes = text.getBytes(Charsets.UTF_8); + if (MimeUtil.ENC_QUOTED_PRINTABLE.equalsIgnoreCase(encoding)) { + writeSignSafeQuotedPrintable(out, bytes); + } else if (MimeUtil.ENC_8BIT.equalsIgnoreCase(encoding)) { + out.write(bytes); + } else { + throw new IllegalStateException("Cannot get size for encoding!"); + } + } + } + + public String getRawText() { + return text; + } + + @Override + public InputStream getInputStream() throws MessagingException { + byte[] b; + if (text != null) { + b = text.getBytes(Charsets.UTF_8); + } else { + b = EMPTY_BYTE_ARRAY; + } + return new ByteArrayInputStream(b); + } + + @Override + public void setEncoding(String encoding) { + boolean isSupportedEncoding = MimeUtil.ENC_QUOTED_PRINTABLE.equalsIgnoreCase(encoding) || + MimeUtil.ENC_8BIT.equalsIgnoreCase(encoding); + if (!isSupportedEncoding) { + throw new IllegalArgumentException("Cannot encode to " + encoding); + } + + this.encoding = encoding; + } + + @Nullable + public Integer getComposedMessageLength() { + return composedMessageLength; + } + + public void setComposedMessageLength(@Nullable Integer composedMessageLength) { + this.composedMessageLength = composedMessageLength; + } + + @Nullable + public Integer getComposedMessageOffset() { + return composedMessageOffset; + } + + public void setComposedMessageOffset(@Nullable Integer composedMessageOffset) { + this.composedMessageOffset = composedMessageOffset; + } + + @Override + public long getSize() { + try { + byte[] bytes = text.getBytes(Charsets.UTF_8); + + if (MimeUtil.ENC_QUOTED_PRINTABLE.equalsIgnoreCase(encoding)) { + return getLengthWhenQuotedPrintableEncoded(bytes); + } else if (MimeUtil.ENC_8BIT.equalsIgnoreCase(encoding)) { + return bytes.length; + } else { + throw new IllegalStateException("Cannot get size for encoding!"); + } + } catch (IOException e) { + throw new RuntimeException("Couldn't get body size", e); + } + } + + private long getLengthWhenQuotedPrintableEncoded(byte[] bytes) throws IOException { + try (CountingOutputStream countingOutputStream = new CountingOutputStream()) { + writeSignSafeQuotedPrintable(countingOutputStream, bytes); + return countingOutputStream.getCount(); + } + } + + private void writeSignSafeQuotedPrintable(OutputStream out, byte[] bytes) throws IOException { + try (SignSafeOutputStream signSafeOutputStream = new SignSafeOutputStream(out)) { + try (QuotedPrintableOutputStream signSafeQuotedPrintableOutputStream = new QuotedPrintableOutputStream( + signSafeOutputStream, false)) { + signSafeQuotedPrintableOutputStream.write(bytes); + } + } + } + + public String getEncoding() { + return encoding; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/Viewable.java b/mail/common/src/main/java/com/fsck/k9/mail/internet/Viewable.java new file mode 100644 index 0000000..f8e53d1 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/Viewable.java @@ -0,0 +1,104 @@ +package com.fsck.k9.mail.internet; + +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.Part; + +import java.util.List; + +/** + * Empty marker class interface the class hierarchy used by + * {@link MessageExtractor#findViewablesAndAttachments(Part, List, List)} + * + * @see Viewable.Text + * @see Viewable.Html + * @see Viewable.MessageHeader + * @see Viewable.Alternative + */ +public interface Viewable { + /** + * Class representing textual parts of a message that aren't marked as attachments. + * + * @see com.fsck.k9.mail.internet.MessageExtractor#isPartTextualBody(com.fsck.k9.mail.Part) + */ + abstract class Textual implements Viewable { + private Part mPart; + + public Textual(Part part) { + mPart = part; + } + + public Part getPart() { + return mPart; + } + } + + /** + * Class representing a {@code text/plain} part of a message. + */ + class Text extends Textual { + public Text(Part part) { + super(part); + } + } + + /** + * Class representing a {@code text/html} part of a message. + */ + class Html extends Textual { + public Html(Part part) { + super(part); + } + } + + /** + * Class representing a {@code message/rfc822} part of a message. + * + * <p> + * This is used to extract basic header information when the message contents are displayed + * inline. + * </p> + */ + class MessageHeader implements Viewable { + private Part mContainerPart; + private Message mMessage; + + public MessageHeader(Part containerPart, Message message) { + mContainerPart = containerPart; + mMessage = message; + } + + public Part getContainerPart() { + return mContainerPart; + } + + public Message getMessage() { + return mMessage; + } + } + + /** + * Class representing a {@code multipart/alternative} part of a message. + * + * <p> + * Only relevant {@code text/plain} and {@code text/html} children are stored in this container + * class. + * </p> + */ + class Alternative implements Viewable { + private List<Viewable> mText; + private List<Viewable> mHtml; + + public Alternative(List<Viewable> text, List<Viewable> html) { + mText = text; + mHtml = html; + } + + public List<Viewable> getText() { + return mText; + } + + public List<Viewable> getHtml() { + return mHtml; + } + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/message/MessageHeaderParser.kt b/mail/common/src/main/java/com/fsck/k9/mail/message/MessageHeaderParser.kt new file mode 100644 index 0000000..ab055f9 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/message/MessageHeaderParser.kt @@ -0,0 +1,52 @@ +package com.fsck.k9.mail.message + +import com.fsck.k9.mail.MessagingException +import java.io.IOException +import java.io.InputStream +import org.apache.james.mime4j.MimeException +import org.apache.james.mime4j.parser.AbstractContentHandler +import org.apache.james.mime4j.parser.MimeStreamParser +import org.apache.james.mime4j.stream.Field +import org.apache.james.mime4j.stream.MimeConfig + +object MessageHeaderParser { + @Throws(MessagingException::class) + @JvmStatic + fun parse(headerInputStream: InputStream, collector: MessageHeaderCollector) { + val parser = createMimeStreamParser().apply { + setContentHandler(MessageHeaderParserContentHandler(collector)) + } + + try { + parser.parse(headerInputStream) + } catch (me: MimeException) { + throw MessagingException("Error parsing headers", me) + } catch (e: IOException) { + throw MessagingException("I/O error parsing headers", e) + } + } + + private fun createMimeStreamParser(): MimeStreamParser { + val parserConfig = MimeConfig.Builder() + .setMaxHeaderLen(-1) + .setMaxLineLen(-1) + .setMaxHeaderCount(-1) + .build() + + return MimeStreamParser(parserConfig) + } + + private class MessageHeaderParserContentHandler( + private val collector: MessageHeaderCollector + ) : AbstractContentHandler() { + override fun field(rawField: Field) { + val name = rawField.name + val raw = rawField.raw.toString() + collector.addRawHeader(name, raw) + } + } +} + +fun interface MessageHeaderCollector { + fun addRawHeader(name: String, raw: String) +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/oauth/OAuth2TokenProvider.java b/mail/common/src/main/java/com/fsck/k9/mail/oauth/OAuth2TokenProvider.java new file mode 100644 index 0000000..77ec7ec --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/oauth/OAuth2TokenProvider.java @@ -0,0 +1,29 @@ +package com.fsck.k9.mail.oauth; + + +import com.fsck.k9.mail.AuthenticationFailedException; + + +public interface OAuth2TokenProvider { + /** + * A default timeout value to use when fetching tokens. + */ + int OAUTH2_TIMEOUT = 30000; + + + /** + * Fetch a token. No guarantees are provided for validity. + */ + String getToken(long timeoutMillis) throws AuthenticationFailedException; + + /** + * Invalidate the token for this username. + * + * <p> + * Note that the token should always be invalidated on credential failure. However invalidating a token every + * single time is not recommended. + * <p> + * Invalidating a token and then failure with a new token should be treated as a permanent failure. + */ + void invalidateToken(); +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/oauth/XOAuth2ChallengeParser.java b/mail/common/src/main/java/com/fsck/k9/mail/oauth/XOAuth2ChallengeParser.java new file mode 100644 index 0000000..550bf4e --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/oauth/XOAuth2ChallengeParser.java @@ -0,0 +1,43 @@ +package com.fsck.k9.mail.oauth; + + +import java.io.IOException; + +import com.fsck.k9.logging.Timber; +import com.fsck.k9.mail.K9MailLib; +import com.fsck.k9.mail.filter.Base64; +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.Moshi; + + +/** + * Parses Google's Error/Challenge responses + * See: https://developers.google.com/gmail/xoauth2_protocol#error_response + */ +public class XOAuth2ChallengeParser { + public static final String BAD_RESPONSE = "400"; + + + public static boolean shouldRetry(String response, String host) { + String decodedResponse = Base64.decode(response); + + if (K9MailLib.isDebug()) { + Timber.v("Challenge response: %s", decodedResponse); + } + + try { + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter<XOAuth2Response> adapter = moshi.adapter(XOAuth2Response.class); + XOAuth2Response responseObject = adapter.fromJson(decodedResponse); + if (responseObject != null && responseObject.status != null && + !BAD_RESPONSE.equals(responseObject.status)) { + return false; + } + } catch (IOException | JsonDataException e) { + Timber.e(e, "Error decoding JSON response from: %s. Response was: %s", host, decodedResponse); + } + + return true; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/oauth/XOAuth2Response.java b/mail/common/src/main/java/com/fsck/k9/mail/oauth/XOAuth2Response.java new file mode 100644 index 0000000..40d9b04 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/oauth/XOAuth2Response.java @@ -0,0 +1,6 @@ +package com.fsck.k9.mail.oauth; + + +class XOAuth2Response { + public String status; +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/power/PowerManager.kt b/mail/common/src/main/java/com/fsck/k9/mail/power/PowerManager.kt new file mode 100644 index 0000000..ab347fb --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/power/PowerManager.kt @@ -0,0 +1,5 @@ +package com.fsck.k9.mail.power + +interface PowerManager { + fun newWakeLock(tag: String): WakeLock +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/power/WakeLock.kt b/mail/common/src/main/java/com/fsck/k9/mail/power/WakeLock.kt new file mode 100644 index 0000000..d0adba0 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/power/WakeLock.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.mail.power + +interface WakeLock { + fun acquire(timeout: Long) + fun acquire() + fun setReferenceCounted(counted: Boolean) + fun release() +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/ssl/KeyStoreDirectoryProvider.kt b/mail/common/src/main/java/com/fsck/k9/mail/ssl/KeyStoreDirectoryProvider.kt new file mode 100644 index 0000000..4e040ce --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/ssl/KeyStoreDirectoryProvider.kt @@ -0,0 +1,7 @@ +package com.fsck.k9.mail.ssl + +import java.io.File + +fun interface KeyStoreDirectoryProvider { + fun getDirectory(): File +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/ssl/LocalKeyStore.kt b/mail/common/src/main/java/com/fsck/k9/mail/ssl/LocalKeyStore.kt new file mode 100644 index 0000000..280a0d6 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/ssl/LocalKeyStore.kt @@ -0,0 +1,162 @@ +package com.fsck.k9.mail.ssl + +import com.fsck.k9.logging.Timber +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.security.KeyStore +import java.security.KeyStoreException +import java.security.NoSuchAlgorithmException +import java.security.cert.Certificate +import java.security.cert.CertificateException +import java.security.cert.X509Certificate + +private const val KEY_STORE_FILE_VERSION = 1 +private val PASSWORD = charArrayOf() + +class LocalKeyStore(private val directoryProvider: KeyStoreDirectoryProvider) { + private var keyStoreFile: File? = null + private val keyStoreDirectory: File by lazy { directoryProvider.getDirectory() } + private val keyStore: KeyStore? by lazy { initializeKeyStore() } + + @Synchronized + private fun initializeKeyStore(): KeyStore? { + upgradeKeyStoreFile() + + val file = getKeyStoreFile(KEY_STORE_FILE_VERSION) + if (file.length() == 0L) { + /* + * The file may be empty (e.g., if it was created with + * File.createTempFile). We can't pass an empty file to + * Keystore.load. Instead, we let it be created anew. + */ + if (file.exists() && !file.delete()) { + Timber.d("Failed to delete empty keystore file: %s", file.absolutePath) + } + } + + val fileInputStream = try { + FileInputStream(file) + } catch (e: FileNotFoundException) { + // If the file doesn't exist, that's fine, too + null + } + + return try { + keyStoreFile = file + + KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(fileInputStream, PASSWORD) + } + } catch (e: Exception) { + Timber.e(e, "Failed to initialize local key store") + + // Use of the local key store is effectively disabled. + keyStoreFile = null + null + } finally { + fileInputStream?.close() + } + } + + private fun upgradeKeyStoreFile() { + if (KEY_STORE_FILE_VERSION > 0) { + // Blow away version "0" because certificate aliases have changed. + val versionZeroFile = getKeyStoreFile(0) + if (versionZeroFile.exists() && !versionZeroFile.delete()) { + Timber.d("Failed to delete old key-store file: %s", versionZeroFile.absolutePath) + } + } + } + + @Synchronized + @Throws(CertificateException::class) + fun addCertificate(host: String, port: Int, certificate: X509Certificate?) { + val keyStore = this.keyStore + ?: throw CertificateException("Certificate not added because key store not initialized") + + try { + keyStore.setCertificateEntry(getCertKey(host, port), certificate) + } catch (e: KeyStoreException) { + throw CertificateException("Failed to add certificate to local key store", e) + } + + writeCertificateFile() + } + + private fun writeCertificateFile() { + val keyStore = requireNotNull(this.keyStore) + + FileOutputStream(keyStoreFile).use { keyStoreStream -> + try { + keyStore.store(keyStoreStream, PASSWORD) + } catch (e: FileNotFoundException) { + throw CertificateException("Unable to write KeyStore: ${e.message}", e) + } catch (e: CertificateException) { + throw CertificateException("Unable to write KeyStore: ${e.message}", e) + } catch (e: IOException) { + throw CertificateException("Unable to write KeyStore: ${e.message}", e) + } catch (e: NoSuchAlgorithmException) { + throw CertificateException("Unable to write KeyStore: ${e.message}", e) + } catch (e: KeyStoreException) { + throw CertificateException("Unable to write KeyStore: ${e.message}", e) + } + } + } + + @Synchronized + fun isValidCertificate(certificate: Certificate, host: String, port: Int): Boolean { + val keyStore = this.keyStore ?: return false + + return try { + val storedCert = keyStore.getCertificate(getCertKey(host, port)) + if (storedCert == null) { + Timber.v("Couldn't find a stored certificate for %s:%d", host, port) + false + } else if (storedCert != certificate) { + Timber.v( + "Stored certificate for %s:%d doesn't match.\nExpected:\n%s\nActual:\n%s", + host, + port, + storedCert, + certificate + ) + false + } else { + Timber.v("Stored certificate for %s:%d matches the server certificate", host, port) + true + } + } catch (e: KeyStoreException) { + Timber.w(e, "Error reading from KeyStore") + false + } + } + + @Synchronized + fun deleteCertificate(oldHost: String, oldPort: Int) { + val keyStore = this.keyStore ?: return + + try { + keyStore.deleteEntry(getCertKey(oldHost, oldPort)) + writeCertificateFile() + } catch (e: KeyStoreException) { + // Ignore: most likely there was no cert. found + } catch (e: CertificateException) { + Timber.e(e, "Error updating the local key store file") + } + } + + private fun getKeyStoreFile(version: Int): File { + return if (version < 1) { + File(keyStoreDirectory, "KeyStore.bks") + } else { + File(keyStoreDirectory, "KeyStore_v$version.bks") + } + } + + private fun getCertKey(host: String, port: Int): String { + return "$host:$port" + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/ssl/TrustManagerFactory.java b/mail/common/src/main/java/com/fsck/k9/mail/ssl/TrustManagerFactory.java new file mode 100644 index 0000000..36f2bd9 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/ssl/TrustManagerFactory.java @@ -0,0 +1,119 @@ + +package com.fsck.k9.mail.ssl; + + +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.HashMap; +import java.util.Map; + +import com.fsck.k9.logging.Timber; +import com.fsck.k9.mail.CertificateChainException; +import javax.net.ssl.SSLException; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier; + +public class TrustManagerFactory { + public static TrustManagerFactory createInstance(LocalKeyStore localKeyStore) { + TrustManagerFactory trustManagerFactory = new TrustManagerFactory(localKeyStore); + try { + trustManagerFactory.initialize(); + } catch (NoSuchAlgorithmException | KeyStoreException e) { + Timber.e(e, "Failed to initialize X509 Trust Manager!"); + throw new IllegalStateException(e); + } + return trustManagerFactory; + } + + + private X509TrustManager defaultTrustManager; + private LocalKeyStore keyStore; + private final Map<String, SecureX509TrustManager> cachedTrustManagers = new HashMap<>(); + + + private TrustManagerFactory(LocalKeyStore localKeyStore) { + this.keyStore = localKeyStore; + } + + private void initialize() throws KeyStoreException, NoSuchAlgorithmException { + javax.net.ssl.TrustManagerFactory tmf = javax.net.ssl.TrustManagerFactory.getInstance("X509"); + tmf.init((KeyStore) null); + + TrustManager[] tms = tmf.getTrustManagers(); + if (tms != null) { + for (TrustManager tm : tms) { + if (tm instanceof X509TrustManager) { + defaultTrustManager = (X509TrustManager) tm; + break; + } + } + } + } + + public X509TrustManager getTrustManagerForDomain(String host, int port) { + String key = host + ":" + port; + SecureX509TrustManager trustManager; + if (cachedTrustManagers.containsKey(key)) { + trustManager = cachedTrustManagers.get(key); + } else { + trustManager = new SecureX509TrustManager(host, port); + cachedTrustManagers.put(key, trustManager); + } + + return trustManager; + } + + private class SecureX509TrustManager implements X509TrustManager { + private final DefaultHostnameVerifier hostnameVerifier = new DefaultHostnameVerifier(); + + private final String mHost; + private final int mPort; + + private SecureX509TrustManager(String host, int port) { + mHost = host; + mPort = port; + } + + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + defaultTrustManager.checkClientTrusted(chain, authType); + } + + public void checkServerTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + String message; + X509Certificate certificate = chain[0]; + + Throwable cause; + + try { + defaultTrustManager.checkServerTrusted(chain, authType); + hostnameVerifier.verify(mHost, certificate); + return; + } catch (CertificateException e) { + // cert. chain can't be validated + message = e.getMessage(); + cause = e; + } catch (SSLException e) { + // host name doesn't match certificate + message = e.getMessage(); + cause = e; + } + + // Check the local key store if we couldn't verify the certificate using the global + // key store or if the host name doesn't match the certificate name + if (!keyStore.isValidCertificate(certificate, mHost, mPort)) { + throw new CertificateChainException(message, chain, cause); + } + } + + public X509Certificate[] getAcceptedIssuers() { + return defaultTrustManager.getAcceptedIssuers(); + } + + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/ssl/TrustedSocketFactory.java b/mail/common/src/main/java/com/fsck/k9/mail/ssl/TrustedSocketFactory.java new file mode 100644 index 0000000..c3ce808 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/ssl/TrustedSocketFactory.java @@ -0,0 +1,13 @@ +package com.fsck.k9.mail.ssl; + +import com.fsck.k9.mail.MessagingException; + +import java.io.IOException; +import java.net.Socket; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; + +public interface TrustedSocketFactory { + Socket createSocket(Socket socket, String host, int port, String clientCertificateAlias) + throws NoSuchAlgorithmException, KeyManagementException, MessagingException, IOException; +} diff --git a/mail/common/src/main/java/com/fsck/k9/mailstore/BinaryMemoryBody.java b/mail/common/src/main/java/com/fsck/k9/mailstore/BinaryMemoryBody.java new file mode 100644 index 0000000..a2de736 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mailstore/BinaryMemoryBody.java @@ -0,0 +1,48 @@ +package com.fsck.k9.mailstore; + + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.RawDataBody; +import com.fsck.k9.mail.internet.SizeAware; + + +public class BinaryMemoryBody implements Body, RawDataBody, SizeAware { + private final byte[] data; + private final String encoding; + + public BinaryMemoryBody(byte[] data, String encoding) { + this.data = data; + this.encoding = encoding; + } + + @Override + public String getEncoding() { + return encoding; + } + + @Override + public InputStream getInputStream() throws MessagingException { + return new ByteArrayInputStream(data); + } + + @Override + public void setEncoding(String encoding) throws MessagingException { + throw new RuntimeException("nope"); //FIXME + } + + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + out.write(data); + } + + @Override + public long getSize() { + return data.length; + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/sasl/OAuthBearer.kt b/mail/common/src/main/java/com/fsck/k9/sasl/OAuthBearer.kt new file mode 100644 index 0000000..531e5b6 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/sasl/OAuthBearer.kt @@ -0,0 +1,15 @@ +@file:JvmName("OAuthBearer") + +package com.fsck.k9.sasl + +import okio.ByteString.Companion.encodeUtf8 + +/** + * Builds an initial client response for the SASL `OAUTHBEARER` mechanism. + * + * See [RFC 7628](https://datatracker.ietf.org/doc/html/rfc7628). + */ +fun buildOAuthBearerInitialClientResponse(username: String, token: String): String { + val saslName = username.replace(",", "=2C").replace("=", "=3D") + return "n,a=$saslName,\u0001auth=Bearer $token\u0001\u0001".encodeUtf8().base64() +} |
