summaryrefslogtreecommitdiff
path: root/mail/common/src/main/java
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /mail/common/src/main/java
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'mail/common/src/main/java')
-rw-r--r--mail/common/src/main/java/com/fsck/k9/helper/EmailHelper.kt15
-rw-r--r--mail/common/src/main/java/com/fsck/k9/helper/ExceptionHelper.java28
-rw-r--r--mail/common/src/main/java/com/fsck/k9/logging/Logger.kt26
-rw-r--r--mail/common/src/main/java/com/fsck/k9/logging/NoOpLogger.kt36
-rw-r--r--mail/common/src/main/java/com/fsck/k9/logging/Timber.kt83
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/Address.java311
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/AuthType.java34
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/Authentication.java93
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/AuthenticationFailedException.kt9
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/Body.java26
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/BodyFactory.java10
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/BodyPart.java25
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/BoundaryGenerator.kt33
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/CertificateChainException.java27
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/CertificateValidationException.java127
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/ConnectionSecurity.java7
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/DefaultBodyFactory.java43
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/FetchProfile.java59
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/Flag.java70
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/FolderClass.java5
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/FolderType.java12
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/Header.kt3
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/K9MailLib.java101
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/Message.java171
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/MessageDownloadState.kt7
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/MessageRetrievalListener.java7
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/MessagingException.java35
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/MimeType.kt44
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/Multipart.java56
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/NetworkTimeouts.kt6
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/Part.java46
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/ServerSettings.kt33
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/filter/Base64.java785
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/filter/Base64OutputStream.java183
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/filter/CountingOutputStream.java34
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/filter/EOLConvertingOutputStream.java64
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/filter/FixedLengthInputStream.java78
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/filter/Hex.kt58
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/filter/LineWrapOutputStream.java85
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/filter/PeekableInputStream.java68
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/filter/SignSafeOutputStream.java213
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/filter/SmtpDataStuffing.java33
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/helper/FetchProfileHelper.kt11
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/helper/Rfc822Token.java205
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/helper/Rfc822Tokenizer.java313
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/helper/TextUtils.kt11
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/helper/UrlEncodingHelper.java28
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/helper/Utf8.kt115
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/AddressHeaderBuilder.kt35
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileBody.java149
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileMessageBody.java25
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/CharsetSupport.java1096
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/DecoderUtil.kt201
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/EncoderUtil.java184
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/FlowedMessageUtils.kt83
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/FormatFlowedHelper.kt28
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/Headers.kt36
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/Iso2022JpToShiftJisInputStream.java76
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/JisSupport.java112
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MessageExtractor.java456
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MessageIdGenerator.kt33
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MessageIdParser.kt205
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MimeBodyPart.java186
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MimeExtensions.kt63
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeader.kt146
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderChecker.kt115
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderEncoder.kt26
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderParser.kt187
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMessage.java648
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMessageHelper.java56
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMultipart.java111
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterDecoder.kt310
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterEncoder.kt211
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MimeUtility.java226
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/MimeValue.kt8
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/ParameterSection.kt32
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/PartExtensions.kt25
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/RawDataBody.java12
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/SizeAware.java6
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/TextBody.java133
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/internet/Viewable.java104
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/message/MessageHeaderParser.kt52
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/oauth/OAuth2TokenProvider.java29
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/oauth/XOAuth2ChallengeParser.java43
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/oauth/XOAuth2Response.java6
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/power/PowerManager.kt5
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/power/WakeLock.kt8
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/ssl/KeyStoreDirectoryProvider.kt7
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/ssl/LocalKeyStore.kt162
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/ssl/TrustManagerFactory.java119
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mail/ssl/TrustedSocketFactory.java13
-rw-r--r--mail/common/src/main/java/com/fsck/k9/mailstore/BinaryMemoryBody.java48
-rw-r--r--mail/common/src/main/java/com/fsck/k9/sasl/OAuthBearer.kt15
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) &lt;foo\@google.com&gt;,
+ * 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) &lt;foo\@google.com&gt;,
+ * 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>*&#47;*: 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()
+}