Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
29
mail/common/build.gradle.kts
Normal file
29
mail/common/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
331
mail/common/src/main/java/com/fsck/k9/mail/Address.java
Normal file
331
mail/common/src/main/java/com/fsck/k9/mail/Address.java
Normal 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 <naive.assumption@example.com>", 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;
|
||||
}
|
||||
}
|
||||
26
mail/common/src/main/java/com/fsck/k9/mail/AuthType.kt
Normal file
26
mail/common/src/main/java/com/fsck/k9/mail/AuthType.kt
Normal 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,
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
28
mail/common/src/main/java/com/fsck/k9/mail/Body.java
Normal file
28
mail/common/src/main/java/com/fsck/k9/mail/Body.java
Normal 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;
|
||||
}
|
||||
10
mail/common/src/main/java/com/fsck/k9/mail/BodyFactory.java
Normal file
10
mail/common/src/main/java/com/fsck/k9/mail/BodyFactory.java
Normal 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;
|
||||
}
|
||||
25
mail/common/src/main/java/com/fsck/k9/mail/BodyPart.java
Normal file
25
mail/common/src/main/java/com/fsck/k9/mail/BodyPart.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.fsck.k9.mail
|
||||
|
||||
enum class ConnectionSecurity {
|
||||
NONE,
|
||||
STARTTLS_REQUIRED,
|
||||
SSL_TLS_REQUIRED,
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
59
mail/common/src/main/java/com/fsck/k9/mail/FetchProfile.java
Normal file
59
mail/common/src/main/java/com/fsck/k9/mail/FetchProfile.java
Normal 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,
|
||||
}
|
||||
}
|
||||
70
mail/common/src/main/java/com/fsck/k9/mail/Flag.java
Normal file
70
mail/common/src/main/java/com/fsck/k9/mail/Flag.java
Normal 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,
|
||||
}
|
||||
20
mail/common/src/main/java/com/fsck/k9/mail/FolderType.kt
Normal file
20
mail/common/src/main/java/com/fsck/k9/mail/FolderType.kt
Normal 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,
|
||||
}
|
||||
3
mail/common/src/main/java/com/fsck/k9/mail/Header.kt
Normal file
3
mail/common/src/main/java/com/fsck/k9/mail/Header.kt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package com.fsck.k9.mail
|
||||
|
||||
data class Header(val name: String, val value: String)
|
||||
95
mail/common/src/main/java/com/fsck/k9/mail/K9MailLib.java
Normal file
95
mail/common/src/main/java/com/fsck/k9/mail/K9MailLib.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
188
mail/common/src/main/java/com/fsck/k9/mail/Message.java
Normal file
188
mail/common/src/main/java/com/fsck/k9/mail/Message.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.fsck.k9.mail
|
||||
|
||||
enum class MessageDownloadState {
|
||||
ENVELOPE,
|
||||
PARTIAL,
|
||||
FULL,
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
|
||||
package com.fsck.k9.mail;
|
||||
|
||||
|
||||
public interface MessageRetrievalListener<T extends Message> {
|
||||
void messageFinished(T message);
|
||||
}
|
||||
44
mail/common/src/main/java/com/fsck/k9/mail/MimeType.kt
Normal file
44
mail/common/src/main/java/com/fsck/k9/mail/MimeType.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
57
mail/common/src/main/java/com/fsck/k9/mail/Multipart.java
Normal file
57
mail/common/src/main/java/com/fsck/k9/mail/Multipart.java
Normal 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();
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.fsck.k9.mail
|
||||
|
||||
object NetworkTimeouts {
|
||||
const val SOCKET_CONNECT_TIMEOUT = 30000
|
||||
const val SOCKET_READ_TIMEOUT = 60000
|
||||
}
|
||||
47
mail/common/src/main/java/com/fsck/k9/mail/Part.java
Normal file
47
mail/common/src/main/java/com/fsck/k9/mail/Part.java
Normal 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);
|
||||
}
|
||||
41
mail/common/src/main/java/com/fsck/k9/mail/ServerSettings.kt
Normal file
41
mail/common/src/main/java/com/fsck/k9/mail/ServerSettings.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
785
mail/common/src/main/java/com/fsck/k9/mail/filter/Base64.java
Normal file
785
mail/common/src/main/java/com/fsck/k9/mail/filter/Base64.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
58
mail/common/src/main/java/com/fsck/k9/mail/filter/Hex.kt
Normal file
58
mail/common/src/main/java/com/fsck/k9/mail/filter/Hex.kt
Normal 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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package com.fsck.k9.mail.folders
|
||||
|
||||
@JvmInline
|
||||
value class FolderServerId(val serverId: String)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
/*
|
||||
* Copyright (C) 2008 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.fsck.k9.mail.helper;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* This class works as a Tokenizer for MultiAutoCompleteTextView for
|
||||
* address list fields, and also provides a method for converting
|
||||
* a string of addresses (such as might be typed into such a field)
|
||||
* into a series of Rfc822Tokens.
|
||||
*/
|
||||
public class Rfc822Tokenizer {
|
||||
|
||||
/**
|
||||
* This constructor will try to take a string like
|
||||
* "Foo Bar (something) <foo\@google.com>,
|
||||
* blah\@google.com (something)"
|
||||
* and convert it into one or more Rfc822Tokens, output into the supplied
|
||||
* collection.
|
||||
*
|
||||
* It does *not* decode MIME encoded-words; charset conversion
|
||||
* must already have taken place if necessary.
|
||||
* It will try to be tolerant of broken syntax instead of
|
||||
* returning an error.
|
||||
*
|
||||
*/
|
||||
public static void tokenize(CharSequence text, Collection<Rfc822Token> out) {
|
||||
StringBuilder name = new StringBuilder();
|
||||
StringBuilder address = new StringBuilder();
|
||||
StringBuilder comment = new StringBuilder();
|
||||
|
||||
int i = 0;
|
||||
int cursor = text.length();
|
||||
|
||||
while (i < cursor) {
|
||||
char c = text.charAt(i);
|
||||
|
||||
if (c == ',' || c == ';') {
|
||||
i++;
|
||||
|
||||
while (i < cursor && text.charAt(i) == ' ') {
|
||||
i++;
|
||||
}
|
||||
|
||||
crunch(name);
|
||||
|
||||
if (address.length() > 0) {
|
||||
out.add(new Rfc822Token(name.toString(),
|
||||
address.toString(),
|
||||
comment.toString()));
|
||||
} else if (name.length() > 0) {
|
||||
out.add(new Rfc822Token(null,
|
||||
name.toString(),
|
||||
comment.toString()));
|
||||
}
|
||||
|
||||
name.setLength(0);
|
||||
address.setLength(0);
|
||||
comment.setLength(0);
|
||||
} else if (c == '"') {
|
||||
i++;
|
||||
|
||||
while (i < cursor) {
|
||||
c = text.charAt(i);
|
||||
|
||||
if (c == '"') {
|
||||
i++;
|
||||
break;
|
||||
} else if (c == '\\') {
|
||||
if (i + 1 < cursor) {
|
||||
name.append(text.charAt(i + 1));
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
name.append(c);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else if (c == '(') {
|
||||
int level = 1;
|
||||
i++;
|
||||
|
||||
while (i < cursor && level > 0) {
|
||||
c = text.charAt(i);
|
||||
|
||||
if (c == ')') {
|
||||
if (level > 1) {
|
||||
comment.append(c);
|
||||
}
|
||||
|
||||
level--;
|
||||
i++;
|
||||
} else if (c == '(') {
|
||||
comment.append(c);
|
||||
level++;
|
||||
i++;
|
||||
} else if (c == '\\') {
|
||||
if (i + 1 < cursor) {
|
||||
comment.append(text.charAt(i + 1));
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
comment.append(c);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else if (c == '<') {
|
||||
i++;
|
||||
|
||||
while (i < cursor) {
|
||||
c = text.charAt(i);
|
||||
|
||||
if (c == '>') {
|
||||
i++;
|
||||
break;
|
||||
} else {
|
||||
address.append(c);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else if (c == ' ') {
|
||||
name.append('\0');
|
||||
i++;
|
||||
} else {
|
||||
name.append(c);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
crunch(name);
|
||||
|
||||
if (address.length() > 0) {
|
||||
out.add(new Rfc822Token(name.toString(),
|
||||
address.toString(),
|
||||
comment.toString()));
|
||||
} else if (name.length() > 0) {
|
||||
out.add(new Rfc822Token(null,
|
||||
name.toString(),
|
||||
comment.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will try to take a string like
|
||||
* "Foo Bar (something) <foo\@google.com>,
|
||||
* blah\@google.com (something)"
|
||||
* and convert it into one or more Rfc822Tokens.
|
||||
* It does *not* decode MIME encoded-words; charset conversion
|
||||
* must already have taken place if necessary.
|
||||
* It will try to be tolerant of broken syntax instead of
|
||||
* returning an error.
|
||||
*/
|
||||
public static Rfc822Token[] tokenize(CharSequence text) {
|
||||
ArrayList<Rfc822Token> out = new ArrayList<Rfc822Token>();
|
||||
tokenize(text, out);
|
||||
return out.toArray(new Rfc822Token[out.size()]);
|
||||
}
|
||||
|
||||
private static void crunch(StringBuilder sb) {
|
||||
int i = 0;
|
||||
int len = sb.length();
|
||||
|
||||
while (i < len) {
|
||||
char c = sb.charAt(i);
|
||||
|
||||
if (c == '\0') {
|
||||
if (i == 0 || i == len - 1 ||
|
||||
sb.charAt(i - 1) == ' ' ||
|
||||
sb.charAt(i - 1) == '\0' ||
|
||||
sb.charAt(i + 1) == ' ' ||
|
||||
sb.charAt(i + 1) == '\0') {
|
||||
sb.deleteCharAt(i);
|
||||
len--;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
for (i = 0; i < len; i++) {
|
||||
if (sb.charAt(i) == '\0') {
|
||||
sb.setCharAt(i, ' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public int findTokenStart(CharSequence text, int cursor) {
|
||||
/*
|
||||
* It's hard to search backward, so search forward until
|
||||
* we reach the cursor.
|
||||
*/
|
||||
|
||||
int best = 0;
|
||||
int i = 0;
|
||||
|
||||
while (i < cursor) {
|
||||
i = findTokenEnd(text, i);
|
||||
|
||||
if (i < cursor) {
|
||||
i++; // Skip terminating punctuation
|
||||
|
||||
while (i < cursor && text.charAt(i) == ' ') {
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i < cursor) {
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public int findTokenEnd(CharSequence text, int cursor) {
|
||||
int len = text.length();
|
||||
int i = cursor;
|
||||
|
||||
while (i < len) {
|
||||
char c = text.charAt(i);
|
||||
|
||||
if (c == ',' || c == ';') {
|
||||
return i;
|
||||
} else if (c == '"') {
|
||||
i++;
|
||||
|
||||
while (i < len) {
|
||||
c = text.charAt(i);
|
||||
|
||||
if (c == '"') {
|
||||
i++;
|
||||
break;
|
||||
} else if (c == '\\' && i + 1 < len) {
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else if (c == '(') {
|
||||
int level = 1;
|
||||
i++;
|
||||
|
||||
while (i < len && level > 0) {
|
||||
c = text.charAt(i);
|
||||
|
||||
if (c == ')') {
|
||||
level--;
|
||||
i++;
|
||||
} else if (c == '(') {
|
||||
level++;
|
||||
i++;
|
||||
} else if (c == '\\' && i + 1 < len) {
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else if (c == '<') {
|
||||
i++;
|
||||
|
||||
while (i < len) {
|
||||
c = text.charAt(i);
|
||||
|
||||
if (c == '>') {
|
||||
i++;
|
||||
break;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminates the specified address with a comma and space.
|
||||
* This assumes that the specified text already has valid syntax.
|
||||
* The Adapter subclass's convertToString() method must make that
|
||||
* guarantee.
|
||||
*/
|
||||
public CharSequence terminateToken(CharSequence text) {
|
||||
return text + ", ";
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
115
mail/common/src/main/java/com/fsck/k9/mail/helper/Utf8.kt
Normal file
115
mail/common/src/main/java/com/fsck/k9/mail/helper/Utf8.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>*/*: 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.fsck.k9.mail.internet;
|
||||
|
||||
|
||||
public interface SizeAware {
|
||||
long getSize();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.fsck.k9.mail.oauth
|
||||
|
||||
interface AuthStateStorage {
|
||||
fun getAuthorizationState(): String?
|
||||
fun updateAuthorizationState(authorizationState: String?)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.fsck.k9.mail.oauth;
|
||||
|
||||
|
||||
class XOAuth2Response {
|
||||
public String status;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.fsck.k9.mail.power
|
||||
|
||||
interface PowerManager {
|
||||
fun newWakeLock(tag: String): WakeLock
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.fsck.k9.mail.power
|
||||
|
||||
interface WakeLock {
|
||||
fun acquire(timeout: Long)
|
||||
fun acquire()
|
||||
fun setReferenceCounted(counted: Boolean)
|
||||
fun release()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.fsck.k9.mail.ssl
|
||||
|
||||
import java.io.File
|
||||
|
||||
fun interface KeyStoreDirectoryProvider {
|
||||
fun getDirectory(): File
|
||||
}
|
||||
162
mail/common/src/main/java/com/fsck/k9/mail/ssl/LocalKeyStore.kt
Normal file
162
mail/common/src/main/java/com/fsck/k9/mail/ssl/LocalKeyStore.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
15
mail/common/src/main/java/com/fsck/k9/sasl/OAuthBearer.kt
Normal file
15
mail/common/src/main/java/com/fsck/k9/sasl/OAuthBearer.kt
Normal 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()
|
||||
}
|
||||
164
mail/common/src/test/java/com/fsck/k9/mail/AddressTest.java
Normal file
164
mail/common/src/test/java/com/fsck/k9/mail/AddressTest.java
Normal 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
Loading…
Add table
Add a link
Reference in a new issue