Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,29 @@
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.android.lint)
}
val testCoverageEnabled: Boolean by extra
if (testCoverageEnabled) {
apply(plugin = "jacoco")
}
dependencies {
api(libs.jetbrains.annotations)
api(projects.core.logging.implLegacy)
implementation(projects.core.common)
implementation(libs.mime4j.core)
implementation(libs.mime4j.dom)
implementation(libs.okio)
implementation(libs.commons.io)
implementation(libs.moshi)
// We're only using this for its DefaultHostnameVerifier
implementation(libs.apache.httpclient5)
testImplementation(projects.core.logging.testing)
testImplementation(projects.mail.testing)
testImplementation(libs.icu4j.charset)
}

View file

@ -0,0 +1,17 @@
package com.fsck.k9.helper
import net.thunderbird.core.common.exception.rootCauseMassage
object ExceptionHelper {
@Deprecated(
message = "Use net.thunderbird.core.common.exception.rootCauseMassage extension property instead.",
replaceWith = ReplaceWith(
"throwable.rootCauseMassage",
"net.thunderbird.core.common.exception.rootCauseMassage",
),
)
@JvmStatic
fun getRootCauseMessage(throwable: Throwable): String {
return throwable.rootCauseMassage.orEmpty()
}
}

View file

@ -0,0 +1,331 @@
package com.fsck.k9.mail;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import net.thunderbird.core.logging.legacy.Log;
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 {
Log.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)) {
String name = TextUtils.isEmpty(token.getName()) ? null : token.getName();
addresses.add(new Address(token.getAddress(), name, 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) {
Log.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;
}
}
/**
* Returns true if either the localpart or the domain of this
* address contains any non-ASCII characters, and false if all
* characters used are within ASCII.
*
* Note that this returns false for an address such as "Naïve
* Assumption &lt;naive.assumption@example.com&gt;", because both
* localpart and domain are all-ASCII. There's an ï there, but
* it's not in either localpart or domain.
*/
public boolean needsUnicode() {
if (mAddress == null)
return false;
int i = mAddress.length()-1;
while (i >= 0 && mAddress.charAt(i) < 128)
i--;
return i >= 0;
}
}

View file

@ -0,0 +1,26 @@
package com.fsck.k9.mail
enum class 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,
NONE,
}

View file

@ -0,0 +1,95 @@
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;
import net.thunderbird.core.common.exception.MessagingException;
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();
}
}

View file

@ -0,0 +1,11 @@
package com.fsck.k9.mail
import net.thunderbird.core.common.exception.MessagingException
class AuthenticationFailedException @JvmOverloads constructor(
message: String,
throwable: Throwable? = null,
val messageFromServer: String? = null,
) : MessagingException(message, throwable) {
val isMessageFromServerAvailable = messageFromServer != null
}

View file

@ -0,0 +1,28 @@
package com.fsck.k9.mail;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import net.thunderbird.core.common.exception.MessagingException;
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;
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -0,0 +1,33 @@
package com.fsck.k9.mail
import java.security.SecureRandom
import org.jetbrains.annotations.VisibleForTesting
class BoundaryGenerator @VisibleForTesting internal constructor(private val random: SecureRandom) {
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(SecureRandom())
@JvmStatic
fun getInstance() = INSTANCE
}
}

View file

@ -0,0 +1,13 @@
package com.fsck.k9.mail
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
/**
* A [CertificateException] extension that provides access to the pertinent certificate chain.
*/
class CertificateChainException(
message: String?,
val certChain: Array<out X509Certificate>?,
cause: Throwable?,
) : CertificateException(message, cause)

View file

@ -0,0 +1,9 @@
package com.fsck.k9.mail
import java.security.cert.X509Certificate
import net.thunderbird.core.common.exception.MessagingException
class CertificateValidationException(
val certificateChain: List<X509Certificate>,
cause: Throwable?,
) : MessagingException(cause)

View file

@ -0,0 +1,15 @@
package com.fsck.k9.mail
import net.thunderbird.core.common.exception.MessagingException
/**
* Thrown when there's a problem with the client certificate used with TLS.
*/
class ClientCertificateException(
val error: ClientCertificateError,
cause: Throwable,
) : MessagingException("Problem with client certificate: $error", true, cause)
enum class ClientCertificateError {
RetrievalFailure,
CertificateExpired,
}

View file

@ -0,0 +1,7 @@
package com.fsck.k9.mail
enum class ConnectionSecurity {
NONE,
STARTTLS_REQUIRED,
SSL_TLS_REQUIRED,
}

View file

@ -0,0 +1,38 @@
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 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 {
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);
}
}

View file

@ -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,
}
}

View file

@ -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,
}

View file

@ -0,0 +1,20 @@
package com.fsck.k9.mail
@Deprecated(
message = "Use net.thunderbird.feature.mail.folder.api.FolderType instead",
replaceWith = ReplaceWith(
expression = "FolderType",
imports = ["net.thunderbird.feature.mail.folder.api.FolderType"],
),
level = DeprecationLevel.WARNING,
)
enum class FolderType {
REGULAR,
INBOX,
OUTBOX,
DRAFTS,
SENT,
TRASH,
SPAM,
ARCHIVE,
}

View file

@ -0,0 +1,3 @@
package com.fsck.k9.mail
data class Header(val name: String, val value: String)

View file

@ -0,0 +1,95 @@
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;
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;
}
}
}

View file

@ -0,0 +1,188 @@
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 net.thunderbird.core.logging.legacy.Log;
import com.fsck.k9.mail.filter.CountingOutputStream;
import com.fsck.k9.mail.filter.EOLConvertingOutputStream;
import org.jetbrains.annotations.NotNull;
import net.thunderbird.core.common.exception.MessagingException;
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) {
Log.e(e, "Failed to calculate a message size");
}
return 0;
}
/*
* Returns true if any address in this message uses a non-ASCII
* character in either the localpart or the domain, and false if
* all addresses use only ASCII.
*/
public boolean usesAnyUnicodeAddresses() {
for (final Address a : getFrom())
if (a.needsUnicode())
return true;
for (RecipientType t : RecipientType.values())
for (final Address r : getRecipients(t))
if (r.needsUnicode())
return true;
return false;
}
}

View file

@ -0,0 +1,7 @@
package com.fsck.k9.mail
enum class MessageDownloadState {
ENVELOPE,
PARTIAL,
FULL,
}

View file

@ -0,0 +1,7 @@
package com.fsck.k9.mail;
public interface MessageRetrievalListener<T extends Message> {
void messageFinished(T message);
}

View file

@ -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
}
}
}
}

View file

@ -0,0 +1,6 @@
package com.fsck.k9.mail
import net.thunderbird.core.common.exception.MessagingException
class MissingCapabilityException(
val capabilityName: String,
) : MessagingException("Missing capability: $capabilityName", true)

View file

@ -0,0 +1,57 @@
package com.fsck.k9.mail;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.apache.james.mime4j.util.MimeUtil;
import net.thunderbird.core.common.exception.MessagingException;
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();
}

View file

@ -0,0 +1,6 @@
package com.fsck.k9.mail
object NetworkTimeouts {
const val SOCKET_CONNECT_TIMEOUT = 30000
const val SOCKET_READ_TIMEOUT = 60000
}

View file

@ -0,0 +1,47 @@
package com.fsck.k9.mail;
import java.io.IOException;
import java.io.OutputStream;
import org.jetbrains.annotations.NotNull;
import net.thunderbird.core.common.exception.MessagingException;
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);
}

View file

@ -0,0 +1,41 @@
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.NONE -> false
AuthType.EXTERNAL -> clientCertificateAlias == null
AuthType.XOAUTH2 -> username.isBlank()
else -> username.isBlank() || password.isNullOrBlank()
}
init {
require(type == type.lowercase()) { "type must be all lower case" }
require(username.contains(LINE_BREAK).not()) { "username must not contain line break" }
require(password?.contains(LINE_BREAK) != true) { "password must not contain line break" }
}
fun newPassword(newPassword: String?): ServerSettings {
return this.copy(password = newPassword)
}
fun newAuthenticationType(authType: AuthType): ServerSettings {
return this.copy(authenticationType = authType)
}
companion object {
private val LINE_BREAK = "[\\r\\n]".toRegex()
}
}

View file

@ -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 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);
}
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,80 @@
package com.fsck.k9.mail.filter
import java.io.IOException
import java.io.InputStream
import java.util.Locale
import org.apache.commons.io.IOUtils
/**
* 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.
*/
class FixedLengthInputStream(
private val inputStream: InputStream,
private val length: Int,
) : InputStream() {
private var numberOfBytesRead = 0
// TODO: Call available() on underlying InputStream if remainingBytes() > 0
@Throws(IOException::class)
override fun available(): Int {
return remainingBytes()
}
@Throws(IOException::class)
override fun read(): Int {
if (remainingBytes() == 0) {
return -1
}
val byte = inputStream.read()
if (byte != -1) {
numberOfBytesRead++
}
return byte
}
@Throws(IOException::class)
override fun read(b: ByteArray, offset: Int, length: Int): Int {
if (remainingBytes() == 0) {
return -1
}
val byte = inputStream.read(b, offset, length.coerceAtMost(remainingBytes()))
if (byte != -1) {
numberOfBytesRead += byte
}
return byte
}
@Throws(IOException::class)
override fun read(b: ByteArray): Int {
return read(b, 0, b.size)
}
@Throws(IOException::class)
override fun skip(n: Long): Long {
val numberOfSkippedBytes = inputStream.skip(n.coerceAtMost(remainingBytes().toLong()))
if (numberOfSkippedBytes > 0) {
numberOfBytesRead += numberOfSkippedBytes.toInt()
}
return numberOfSkippedBytes
}
@Throws(IOException::class)
fun skipRemaining() {
IOUtils.skipFully(this, remainingBytes().toLong())
}
private fun remainingBytes(): Int {
return length - numberOfBytesRead
}
override fun toString(): String {
return String.format(Locale.ROOT, "FixedLengthInputStream(in=%s, length=%d)", inputStream.toString(), length)
}
}

View file

@ -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])
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,94 @@
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.
*/
class PeekableInputStream(private val inputStream: InputStream) : FilterInputStream(inputStream) {
private var peeked = false
private var peekedByte = 0
@Throws(IOException::class)
override fun read(): Int {
return if (!peeked) {
inputStream.read()
} else {
peeked = false
peekedByte
}
}
@Throws(IOException::class)
fun peek(): Int {
if (!peeked) {
peekedByte = inputStream.read()
peeked = true
}
return peekedByte
}
@Throws(IOException::class)
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
return if (!peeked) {
inputStream.read(buffer, offset, length)
} else {
buffer[offset] = peekedByte.toByte()
peeked = false
val numberOfBytesRead = inputStream.read(buffer, offset + 1, length - 1)
if (numberOfBytesRead == -1) {
1
} else {
numberOfBytesRead + 1
}
}
}
@Throws(IOException::class)
override fun read(buffer: ByteArray): Int {
return read(buffer, 0, buffer.size)
}
@Throws(IOException::class)
override fun skip(n: Long): Long {
return if (!peeked) {
inputStream.skip(n)
} else if (n > 0) {
peeked = false
inputStream.skip(n - 1)
} else {
0
}
}
@Throws(IOException::class)
override fun available(): Int {
return if (!peeked) {
inputStream.available()
} else {
1 + inputStream.available()
}
}
override fun markSupported(): Boolean {
// We're not using this mechanism. So it's not worth the effort to add support for mark() and reset().
return false
}
override fun toString(): String {
return "PeekableInputStream(in=%s, peeked=%b, peekedByte=%d)".format(
Locale.ROOT,
inputStream.toString(),
peeked,
peekedByte,
)
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,31 @@
package com.fsck.k9.mail.filter
import java.io.FilterOutputStream
import java.io.IOException
import java.io.OutputStream
class SmtpDataStuffing(out: OutputStream?) : FilterOutputStream(out) {
private var state: Int = STATE_CRLF
@Throws(IOException::class)
override fun write(oneByte: Int) {
if (oneByte == '\r'.code) {
state = STATE_CR
} else if ((state == STATE_CR) && (oneByte == '\n'.code)) {
state = STATE_CRLF
} else if ((state == STATE_CRLF) && (oneByte == '.'.code)) {
// Read <CR><LF><DOT> so this line needs an additional period.
super.write('.'.code)
state = STATE_NORMAL
} else {
state = STATE_NORMAL
}
super.write(oneByte)
}
companion object {
private const val STATE_NORMAL = 0
private const val STATE_CR = 1
private const val STATE_CRLF = 2
}
}

View file

@ -0,0 +1,16 @@
package com.fsck.k9.mail.folders
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mail.oauth.AuthStateStorage
/**
* Fetches the list of folders from a server.
*
* @throws FolderFetcherException in case of an error
*/
fun interface FolderFetcher {
fun getFolders(
serverSettings: ServerSettings,
authStateStorage: AuthStateStorage?,
): List<RemoteFolder>
}

View file

@ -0,0 +1,9 @@
package com.fsck.k9.mail.folders
/**
* Thrown by [FolderFetcher] in case of an error.
*/
class FolderFetcherException(
cause: Throwable,
val messageFromServer: String? = null,
) : RuntimeException(cause.message, cause)

View file

@ -0,0 +1,4 @@
package com.fsck.k9.mail.folders
@JvmInline
value class FolderServerId(val serverId: String)

View file

@ -0,0 +1,9 @@
package com.fsck.k9.mail.folders
import com.fsck.k9.mail.FolderType
data class RemoteFolder(
val serverId: FolderServerId,
val displayName: String,
val type: FolderType,
)

View file

@ -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)
}
}
}

View file

@ -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));
}
}

View file

@ -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 + ", ";
}
}

View file

@ -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)
}
}

View file

@ -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");
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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 net.thunderbird.core.logging.legacy.Log;
import net.thunderbird.core.common.exception.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 {
Log.d("Deleting temporary binary file: %s", mFile.getName());
boolean fileSuccessfullyDeleted = mFile.delete();
if (!fileSuccessfullyDeleted) {
Log.i("Failed to delete temporary binary file: %s", mFile.getName());
}
}
}
public void closeWithoutDeleting() throws IOException {
super.close();
}
}
}

View file

@ -0,0 +1,25 @@
package com.fsck.k9.mail.internet;
import com.fsck.k9.mail.Body;
import net.thunderbird.core.common.exception.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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,202 @@
package com.fsck.k9.mail.internet
import com.fsck.k9.mail.Message
import java.io.ByteArrayInputStream
import java.io.IOException
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.core.logging.legacy.Log
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()) {
Log.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 {
Log.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,
}
}

View file

@ -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;
}
}

View file

@ -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
}
}
}
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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();
}
}
}

View file

@ -0,0 +1,112 @@
package com.fsck.k9.mail.internet;
import com.fsck.k9.mail.Address;
import com.fsck.k9.mail.Message;
import net.thunderbird.core.common.exception.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);
}
}

View file

@ -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 net.thunderbird.core.logging.legacy.Log;
import com.fsck.k9.mail.Body;
import com.fsck.k9.mail.BodyPart;
import com.fsck.k9.mail.Message;
import net.thunderbird.core.common.exception.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) {
Log.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);
}
Log.w("Provided non-text part: %s", mimeType);
} catch (IOException | MessagingException e) {
Log.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;
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -0,0 +1,186 @@
package com.fsck.k9.mail.internet;
import com.fsck.k9.mail.Body;
import com.fsck.k9.mail.BodyPart;
import net.thunderbird.core.common.exception.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);
}
}

View file

@ -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 }

View file

@ -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
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -0,0 +1,34 @@
package com.fsck.k9.mail.internet
import org.apache.james.mime4j.util.MimeUtil
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.
// Number of characters already used up on the first line (header field name + colon + space)
val usedCharacters = name.length + COLON_PLUS_SPACE_LENGTH
return if (hasToBeEncoded(value, usedCharacters)) {
MimeUtil.fold(EncoderUtil.encodeEncodedWord(value), usedCharacters)
} else {
value
}
}
private fun hasToBeEncoded(value: String, usedCharacters: Int): Boolean {
return exceedsRecommendedLineLength(value, usedCharacters) || charactersNeedEncoding(value)
}
private fun exceedsRecommendedLineLength(value: String, usedCharacters: Int): Boolean {
return usedCharacters + value.length > RECOMMENDED_MAX_LINE_LENGTH
}
private fun charactersNeedEncoding(text: String): Boolean {
return text.any { !it.isVChar() && !it.isWspOrCrlf() }
}
private const val COLON_PLUS_SPACE_LENGTH = 2
}

View file

@ -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)

View file

@ -0,0 +1,649 @@
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 net.thunderbird.core.logging.legacy.Log;
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 net.thunderbird.core.common.exception.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;
import net.thunderbird.core.common.exception.MessagingException;
/**
* 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) {
Log.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 net.thunderbird.core.common.exception.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());
}
}

View file

@ -0,0 +1,56 @@
package com.fsck.k9.mail.internet;
import com.fsck.k9.mail.Body;
import com.fsck.k9.mail.Message;
import net.thunderbird.core.common.exception.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);
}
}

View file

@ -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 net.thunderbird.core.common.exception.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();
}
}

View file

@ -0,0 +1,311 @@
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?,
)

View file

@ -0,0 +1,218 @@
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''"
private const val FOLDING_SPACE_LENGTH = 1
private const val EQUAL_SIGN_LENGTH = 1
private const val SEMICOLON_LENGTH = 1
private const val QUOTES_LENGTH = 2
private const val ASTERISK_LENGTH = 1
/**
* 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 = FOLDING_SPACE_LENGTH + name.length + EQUAL_SIGN_LENGTH + SEMICOLON_LENGTH
val unencodedValueFitsOnSingleLine = fixedCostLength + value.length <= MAX_LINE_LENGTH
val quotedValueMightFitOnSingleLine = fixedCostLength + value.length + QUOTES_LENGTH <= 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 = FOLDING_SPACE_LENGTH + name.length + ASTERISK_LENGTH + EQUAL_SIGN_LENGTH +
ENCODED_VALUE_PREFIX.length + value.rfc2231EncodedLength() + SEMICOLON_LENGTH
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) - SEMICOLON_LENGTH
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 = QUOTES_LENGTH
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
}
}

View file

@ -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 net.thunderbird.core.logging.legacy.Log;
import com.fsck.k9.mail.Body;
import com.fsck.k9.mail.BodyPart;
import com.fsck.k9.mail.Message;
import net.thunderbird.core.common.exception.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 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 {
Log.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);
}
}

View file

@ -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,
)

View file

@ -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)

View file

@ -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
}

View file

@ -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();
}

View file

@ -0,0 +1,6 @@
package com.fsck.k9.mail.internet;
public interface SizeAware {
long getSize();
}

View file

@ -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 net.thunderbird.core.common.exception.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;
}
}

View file

@ -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;
}
}
}

View file

@ -0,0 +1,52 @@
package com.fsck.k9.mail.message
import java.io.IOException
import java.io.InputStream
import net.thunderbird.core.common.exception.MessagingException
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)
}

View file

@ -0,0 +1,6 @@
package com.fsck.k9.mail.oauth
interface AuthStateStorage {
fun getAuthorizationState(): String?
fun updateAuthorizationState(authorizationState: String?)
}

View file

@ -0,0 +1,41 @@
package com.fsck.k9.mail.oauth
import com.fsck.k9.mail.AuthenticationFailedException
interface OAuth2TokenProvider {
companion object {
/**
* A default timeout value to use when fetching tokens.
*/
const val OAUTH2_TIMEOUT: Int = 30000
}
/**
* Fetch the primary email found in the id_token additional claims,
* if it is available.
*
* > Some providers, like Microsoft, require this as they need the primary account email to be the username,
* not the email the user entered
*
* @return the primary email present in the id_token, otherwise null.
*/
val primaryEmail: String?
@Throws(AuthenticationFailedException::class)
get
/**
* Fetch a token. No guarantees are provided for validity.
*/
@Throws(AuthenticationFailedException::class)
fun getToken(timeoutMillis: Long): String
/**
* Invalidate the token for this username.
*
* Note that the token should always be invalidated on credential failure. However invalidating a token every
* single time is not recommended.
*
* Invalidating a token and then failure with a new token should be treated as a permanent failure.
*/
fun invalidateToken()
}

View file

@ -0,0 +1,9 @@
package com.fsck.k9.mail.oauth
/**
* Creates an instance of [OAuth2TokenProvider] that uses a given [AuthStateStorage] to retrieve and store the
* (implementation-specific) authorization state.
*/
fun interface OAuth2TokenProviderFactory {
fun create(authStateStorage: AuthStateStorage): OAuth2TokenProvider
}

View file

@ -0,0 +1,43 @@
package com.fsck.k9.mail.oauth;
import java.io.IOException;
import net.thunderbird.core.logging.legacy.Log;
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()) {
Log.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) {
Log.e(e, "Error decoding JSON response from: %s. Response was: %s", host, decodedResponse);
}
return true;
}
}

View file

@ -0,0 +1,6 @@
package com.fsck.k9.mail.oauth;
class XOAuth2Response {
public String status;
}

View file

@ -0,0 +1,5 @@
package com.fsck.k9.mail.power
interface PowerManager {
fun newWakeLock(tag: String): WakeLock
}

View file

@ -0,0 +1,8 @@
package com.fsck.k9.mail.power
interface WakeLock {
fun acquire(timeout: Long)
fun acquire()
fun setReferenceCounted(counted: Boolean)
fun release()
}

View file

@ -0,0 +1,60 @@
package com.fsck.k9.mail.server
import com.fsck.k9.mail.ServerSettings
import java.io.IOException
import java.security.cert.X509Certificate
/**
* Result type for [ServerSettingsValidator].
*/
sealed interface ServerSettingsValidationResult {
/**
* The given [ServerSettings] were successfully used to connect to the server and log in.
*/
object Success : ServerSettingsValidationResult
/**
* A network error occurred while checking the server settings.
*/
data class NetworkError(val exception: IOException) : ServerSettingsValidationResult
/**
* A certificate error occurred while checking the server settings.
*/
data class CertificateError(val certificateChain: List<X509Certificate>) : ServerSettingsValidationResult
/**
* There's a problem with the client certificate.
*/
sealed interface ClientCertificateError : ServerSettingsValidationResult {
/**
* The client certificate couldn't be retrieved.
*/
data object ClientCertificateRetrievalFailure : ClientCertificateError
/**
* The client certificate (or another one in the chain) has expired.
*/
data object ClientCertificateExpired : ClientCertificateError
}
/**
* Authentication failed while checking the server settings.
*/
data class AuthenticationError(val serverMessage: String?) : ServerSettingsValidationResult
/**
* The server returned an error while checking the server settings.
*/
data class ServerError(val serverMessage: String?) : ServerSettingsValidationResult
/**
* The server is missing a capability that is required by the current server settings.
*/
data class MissingServerCapabilityError(val capabilityName: String) : ServerSettingsValidationResult
/**
* An unknown error occurred while checking the server settings.
*/
data class UnknownError(val exception: Exception) : ServerSettingsValidationResult
}

View file

@ -0,0 +1,14 @@
package com.fsck.k9.mail.server
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mail.oauth.AuthStateStorage
/**
* Validate [ServerSettings] by trying to connect to the server and log in.
*/
fun interface ServerSettingsValidator {
fun checkServerSettings(
serverSettings: ServerSettings,
authStateStorage: AuthStateStorage?,
): ServerSettingsValidationResult
}

View file

@ -0,0 +1,23 @@
package com.fsck.k9.mail.ssl
import com.fsck.k9.mail.CertificateChainException
import java.security.cert.X509Certificate
/**
* Checks if an exception chain contains a [CertificateChainException] and if so, extracts the certificate chain from it
*/
object CertificateChainExtractor {
@JvmStatic
fun extract(throwable: Throwable): List<X509Certificate>? {
return findCertificateChainException(throwable)?.certChain?.toList()
}
private tailrec fun findCertificateChainException(throwable: Throwable): CertificateChainException? {
val cause = throwable.cause
return when {
throwable is CertificateChainException -> throwable
cause == null -> null
else -> findCertificateChainException(cause)
}
}
}

View file

@ -0,0 +1,7 @@
package com.fsck.k9.mail.ssl
import java.io.File
fun interface KeyStoreDirectoryProvider {
fun getDirectory(): File
}

View file

@ -0,0 +1,162 @@
package com.fsck.k9.mail.ssl
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
import net.thunderbird.core.logging.legacy.Log
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()) {
Log.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) {
Log.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()) {
Log.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) {
Log.v("Couldn't find a stored certificate for %s:%d", host, port)
false
} else if (storedCert != certificate) {
Log.v(
"Stored certificate for %s:%d doesn't match.\nExpected:\n%s\nActual:\n%s",
host,
port,
storedCert,
certificate,
)
false
} else {
Log.v("Stored certificate for %s:%d matches the server certificate", host, port)
true
}
} catch (e: KeyStoreException) {
Log.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) {
Log.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"
}
}

View file

@ -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 net.thunderbird.core.logging.legacy.Log;
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) {
Log.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();
}
}
}

View file

@ -0,0 +1,17 @@
package com.fsck.k9.mail.ssl
import java.io.IOException
import java.net.Socket
import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import net.thunderbird.core.common.exception.MessagingException
interface TrustedSocketFactory {
@Throws(
NoSuchAlgorithmException::class,
KeyManagementException::class,
MessagingException::class,
IOException::class,
)
fun createSocket(socket: Socket?, host: String, port: Int, clientCertificateAlias: String?): Socket
}

View file

@ -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 net.thunderbird.core.common.exception.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;
}
}

View file

@ -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("=", "=3D").replace(",", "=2C")
return "n,a=$saslName,\u0001auth=Bearer $token\u0001\u0001".encodeUtf8().base64()
}

View file

@ -0,0 +1,164 @@
package com.fsck.k9.mail;
import net.thunderbird.core.logging.legacy.Log;
import net.thunderbird.core.logging.testing.TestLogger;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class AddressTest {
@Before
public void setUp() {
Log.logger = new TestLogger();
}
/**
* test the possibility to parse "From:" fields with no email.
* for example: From: News for Vector Limited - Google Finance
* http://code.google.com/p/k9mail/issues/detail?id=3814
*/
@Test
public void parse_withMissingEmail__shouldSetPersonal() {
Address[] addresses = Address.parse("NAME ONLY");
assertEquals(0, addresses.length);
}
/**
* test name + valid email
*/
@Test
public void parse_withValidEmailAndPersonal_shouldSetBoth() {
Address[] addresses = Address.parse("Max Mustermann <maxmuster@mann.com>");
assertEquals(1, addresses.length);
assertEquals("maxmuster@mann.com", addresses[0].getAddress());
assertEquals("Max Mustermann", addresses[0].getPersonal());
}
@Test
public void parse_withUnusualEmails_shouldSetAddress() {
String[] testEmails = new String [] {
"prettyandsimple@example.com",
"very.common@example.com",
"disposable.style.email.with+symbol@example.com",
"other.email-with-dash@example.com",
//TODO: Handle addresses with quotes
/*
"\"much.more unusual\"@example.com",
"\"very.unusual.@.unusual.com\"@example.com",
//"very.(),:;<>[]\".VERY.\"very@\\ \"very\".unusual"@strange.example.com
"\"very.(),:;<>[]\\\".VERY.\\\"very@\\\\ \\\"very\\\".unusual\"@strange.example.com",
"\"()<>[]:,;@\\\\\\\"!#$%&'*+-/=?^_`{}| ~.a\"@example.org",
"\" \"@example.org",
*/
"admin@mailserver1",
"#!$%&'*+-/=?^_`{}|~@example.org",
"example@localhost",
"example@s.solutions",
"user@com",
"user@localserver",
"user@[IPv6:2001:db8::1]"
};
for(String testEmail: testEmails) {
Address[] addresses = Address.parse("Anonymous <"+testEmail+">");
assertEquals(testEmail, 1, addresses.length);
assertEquals(testEmail, testEmail, addresses[0].getAddress());
}
}
@Test
public void parse_withEncodedPersonal_shouldDecode() {
Address[] addresses = Address.parse(
"=?UTF-8?B?WWFob28h44OA44Kk44Os44Kv44OI44Kq44OV44Kh44O8?= <directoffer-master@mail.yahoo.co.jp>");
assertEquals("Yahoo!ダイレクトオファー", addresses[0].getPersonal());
assertEquals("directoffer-master@mail.yahoo.co.jp", addresses[0].getAddress());
}
@Test
public void parse_withQuotedEncodedPersonal_shouldDecode() {
Address[] addresses = Address.parse(
"\"=?UTF-8?B?WWFob28h44OA44Kk44Os44Kv44OI44Kq44OV44Kh44O8?= \"<directoffer-master@mail.yahoo.co.jp>");
assertEquals("Yahoo!ダイレクトオファー ", addresses[0].getPersonal());
assertEquals("directoffer-master@mail.yahoo.co.jp", addresses[0].getAddress());
}
/**
* test with multi email addresses
*/
@Test
public void parse_withMultipleEmails_shouldDecodeBoth() {
Address[] addresses = Address.parse("lorem@ipsum.us,mark@twain.com");
assertEquals(2, addresses.length);
assertEquals("lorem@ipsum.us", addresses[0].getAddress());
assertEquals(null, addresses[0].getPersonal());
assertEquals("mark@twain.com", addresses[1].getAddress());
assertEquals(null, addresses[1].getPersonal());
}
@Test
public void stringQuotationShouldCorrectlyQuote() {
assertEquals("\"sample\"", Address.quoteString("sample"));
assertEquals("\"\"sample\"\"", Address.quoteString("\"\"sample\"\""));
assertEquals("\"sample\"", Address.quoteString("\"sample\""));
assertEquals("\"sa\"mp\"le\"", Address.quoteString("sa\"mp\"le"));
assertEquals("\"sa\"mp\"le\"", Address.quoteString("\"sa\"mp\"le\""));
assertEquals("\"\"\"", Address.quoteString("\""));
}
@Test
public void hashCode_withoutAddress() throws Exception {
Address[] addresses = Address.parse("name only");
assertEquals(0, addresses.length);
}
@Test
public void hashCode_withoutPersonal() throws Exception {
Address address = Address.parse("alice@example.org")[0];
assertNull(address.getPersonal());
address.hashCode();
}
@Test
public void equals_withoutPersonal_matchesSame() throws Exception {
Address address = Address.parse("alice@example.org")[0];
Address address2 = Address.parse("alice@example.org")[0];
assertNull(address.getPersonal());
boolean result = address.equals(address2);
assertTrue(result);
}
@Test
public void equals_withoutPersonal_doesNotMatchWithAddress() throws Exception {
Address address = Address.parse("alice@example.org")[0];
Address address2 = Address.parse("Alice <alice@example.org>")[0];
boolean result = address.equals(address2);
assertFalse(result);
}
@Test
public void handlesInvalidBase64Encoding() throws Exception {
Address address = Address.parse("=?utf-8?b?invalid#?= <oops@example.com>")[0];
assertEquals("oops@example.com", address.getAddress());
}
}

Some files were not shown because too many files have changed in this diff Show more