diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/Identity.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/Identity.kt index b35fe50da6b..6d16862d414 100644 --- a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/Identity.kt +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/Identity.kt @@ -10,11 +10,13 @@ data class Identity( val email: String? = null, val signature: String? = null, val signatureUse: Boolean = false, + val signatureIsHtml: Boolean = false, val replyTo: String? = null, ) : Parcelable { // TODO remove when callers are converted to Kotlin fun withName(name: String?) = copy(name = name) fun withSignature(signature: String?) = copy(signature = signature) fun withSignatureUse(signatureUse: Boolean) = copy(signatureUse = signatureUse) + fun withSignatureIsHtml(signatureIsHtml: Boolean) = copy(signatureIsHtml = signatureIsHtml) fun withEmail(email: String?) = copy(email = email) } diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountDto.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountDto.kt index 3018387a95d..40111a68997 100644 --- a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountDto.kt +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountDto.kt @@ -433,6 +433,15 @@ open class LegacyAccountDto( identities[0] = newIdentity } + @get:Synchronized + @set:Synchronized + var signatureIsHtml: Boolean + get() = identities[0].signatureIsHtml + set(signatureIsHtml) { + val newIdentity = identities[0].withSignatureIsHtml(signatureIsHtml) + identities[0] = newIdentity + } + @get:JvmName("shouldMigrateToOAuth") @get:Synchronized @set:Synchronized diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt index 8c8d0488a16..0d994597cd4 100644 --- a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt @@ -271,6 +271,7 @@ class LegacyAccountStorageHandler( val email = storage.getStringOrNull(keyGen.create("$IDENTITY_EMAIL_KEY.$ident")) val signatureUse = storage.getBoolean(keyGen.create("signatureUse.$ident"), false) val signature = storage.getStringOrNull(keyGen.create("signature.$ident")) + val signatureIsHtml = storage.getBoolean(keyGen.create("signatureIsHtml.$ident"), false) val description = storage.getStringOrNull(keyGen.create("$IDENTITY_DESCRIPTION_KEY.$ident")) val replyTo = storage.getStringOrNull(keyGen.create("replyTo.$ident")) if (email != null) { @@ -279,6 +280,7 @@ class LegacyAccountStorageHandler( email = email, signatureUse = signatureUse, signature = signature, + signatureIsHtml = signatureIsHtml, description = description, replyTo = replyTo, ) @@ -293,11 +295,13 @@ class LegacyAccountStorageHandler( val email = storage.getStringOrNull(keyGen.create("email")) val signatureUse = storage.getBoolean(keyGen.create("signatureUse"), false) val signature = storage.getStringOrNull(keyGen.create("signature")) + val signatureIsHtml = storage.getBoolean(keyGen.create("signatureIsHtml"), false) val identity = Identity( name = name, email = email, signatureUse = signatureUse, signature = signature, + signatureIsHtml = signatureIsHtml, description = email, ) newIdentities.add(identity) @@ -556,6 +560,7 @@ class LegacyAccountStorageHandler( editor.putString(keyGen.create("$IDENTITY_EMAIL_KEY.$ident"), identity.email) editor.putBoolean(keyGen.create("signatureUse.$ident"), identity.signatureUse) editor.putString(keyGen.create("signature.$ident"), identity.signature) + editor.putBoolean(keyGen.create("signatureIsHtml.$ident"), identity.signatureIsHtml) editor.putString(keyGen.create("$IDENTITY_DESCRIPTION_KEY.$ident"), identity.description) editor.putString(keyGen.create("replyTo.$ident"), identity.replyTo) ident++ @@ -577,6 +582,7 @@ class LegacyAccountStorageHandler( editor.remove(keyGen.create("$IDENTITY_EMAIL_KEY.$identityIndex")) editor.remove(keyGen.create("signatureUse.$identityIndex")) editor.remove(keyGen.create("signature.$identityIndex")) + editor.remove(keyGen.create("signatureIsHtml.$identityIndex")) editor.remove(keyGen.create("$IDENTITY_DESCRIPTION_KEY.$identityIndex")) editor.remove(keyGen.create("replyTo.$identityIndex")) gotOne = true diff --git a/legacy/core/src/main/java/com/fsck/k9/message/MessageBuilder.java b/legacy/core/src/main/java/com/fsck/k9/message/MessageBuilder.java index 0c077ccecdf..0b2032f0c42 100644 --- a/legacy/core/src/main/java/com/fsck/k9/message/MessageBuilder.java +++ b/legacy/core/src/main/java/com/fsck/k9/message/MessageBuilder.java @@ -351,6 +351,7 @@ private TextBody buildText(boolean isDraft, SimpleMessageFormat simpleMessageFor if (useSignature) { textBodyBuilder.setAppendSignature(true); textBodyBuilder.setSignature(signature); + textBodyBuilder.setSignatureIsHtml(identity.getSignatureIsHtml()); textBodyBuilder.setSignatureBeforeQuotedText(isSignatureBeforeQuotedText); } else { textBodyBuilder.setAppendSignature(false); diff --git a/legacy/core/src/main/java/com/fsck/k9/message/TextBodyBuilder.java b/legacy/core/src/main/java/com/fsck/k9/message/TextBodyBuilder.java index 5e066631263..6e125f2e57a 100644 --- a/legacy/core/src/main/java/com/fsck/k9/message/TextBodyBuilder.java +++ b/legacy/core/src/main/java/com/fsck/k9/message/TextBodyBuilder.java @@ -5,6 +5,7 @@ import com.fsck.k9.K9; import com.fsck.k9.message.html.HtmlConverter; +import com.fsck.k9.message.html.HtmlSignatureSanitizer; import com.fsck.k9.mail.Body; import com.fsck.k9.mail.internet.TextBody; import com.fsck.k9.message.quote.InsertableHtmlContent; @@ -20,6 +21,7 @@ class TextBodyBuilder { private boolean mSignatureBeforeQuotedText = false; private boolean mInsertSeparator = false; private boolean mAppendSignature = true; + private boolean mSignatureIsHtml = false; private String mMessageContent; private String mSignature; @@ -182,7 +184,10 @@ public TextBody buildTextPlain() { private String getSignature() { String signature = ""; if (!isEmpty(mSignature)) { - signature = "\r\n" + mSignature; + String plainSignature = mSignatureIsHtml + ? HtmlConverter.htmlToText(mSignature) + : mSignature; + signature = "\r\n" + plainSignature; } return signature; @@ -191,7 +196,9 @@ private String getSignature() { private String getSignatureHtml() { String signature = ""; if (!isEmpty(mSignature)) { - signature = HtmlConverter.textToHtmlFragment(mSignature); + signature = mSignatureIsHtml + ? HtmlSignatureSanitizer.sanitize(mSignature) + : HtmlConverter.textToHtmlFragment(mSignature); } return signature; } @@ -244,6 +251,10 @@ public void setAppendSignature(boolean appendSignature) { mAppendSignature = appendSignature; } + public void setSignatureIsHtml(boolean signatureIsHtml) { + mSignatureIsHtml = signatureIsHtml; + } + private static boolean isEmpty(String s) { return s == null || s.length() == 0; } diff --git a/legacy/core/src/main/java/com/fsck/k9/message/html/HtmlSignatureSanitizer.kt b/legacy/core/src/main/java/com/fsck/k9/message/html/HtmlSignatureSanitizer.kt new file mode 100644 index 00000000000..749a147ba33 --- /dev/null +++ b/legacy/core/src/main/java/com/fsck/k9/message/html/HtmlSignatureSanitizer.kt @@ -0,0 +1,26 @@ +package com.fsck.k9.message.html + +import org.jsoup.Jsoup +import org.jsoup.safety.Safelist + +/** + * Sanitizes user-supplied HTML signatures before they are inserted into outgoing mail. + * + * Uses a Jsoup [Safelist.relaxed] baseline (common formatting tags, images, links, tables) + * and tightens it so scripting constructs cannot survive a round-trip through the signature + * field. Specifically, all `on*` event-handler attributes and `javascript:` URLs are removed, + * and `""" + val builder = newBuilder().apply { + setSignatureIsHtml(true) + setSignature(htmlSignature) + } + + val body = builder.buildTextHtml().rawText + + assertThat(body).contains("
Hi
") + assertThat(body).doesNotContain("" + val result = HtmlSignatureSanitizer.sanitize(input) + assertThat(result).doesNotContain("script") + assertThat(result).doesNotContain("alert") + } + + @Test + fun `strips inline event handler attributes`() { + val input = """click""" + val result = HtmlSignatureSanitizer.sanitize(input) + assertThat(result).doesNotContain("onclick") + assertThat(result).doesNotContain("alert") + } + + @Test + fun `strips javascript urls from anchors`() { + val input = """click""" + val result = HtmlSignatureSanitizer.sanitize(input) + assertThat(result).doesNotContain("javascript") + assertThat(result).doesNotContain("alert") + } + + @Test + fun `strips iframe elements`() { + val input = """""" + assertThat(HtmlSignatureSanitizer.sanitize(input)).doesNotContain("iframe") + } + + @Test + fun `plain text passes through unchanged`() { + val input = "Just some text" + assertThat(HtmlSignatureSanitizer.sanitize(input)).isEqualTo("Just some text") + } +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt index 1bc6912efd8..6858c4f1429 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt @@ -30,6 +30,7 @@ class EditIdentity : BaseActivity() { private lateinit var replyTo: EditText private lateinit var signatureUse: MaterialCheckBox private lateinit var signature: EditText + private lateinit var signatureIsHtml: MaterialCheckBox private lateinit var signatureLayout: View private var identityIndex: Int = 0 @@ -65,6 +66,7 @@ class EditIdentity : BaseActivity() { replyTo = findViewById(R.id.reply_to) signatureUse = findViewById(R.id.signature_use) signature = findViewById(R.id.signature) + signatureIsHtml = findViewById(R.id.signature_is_html) signatureLayout = findViewById(R.id.signature_layout) description.setText(identity.description) @@ -88,6 +90,8 @@ class EditIdentity : BaseActivity() { signatureLayout.isVisible = false } + signatureIsHtml.isChecked = identity.signatureIsHtml + setTextChangedListeners() validateFields() } @@ -119,6 +123,7 @@ class EditIdentity : BaseActivity() { name = name.text.toString().takeUnless { it.isBlank() }, signatureUse = signatureUse.isChecked, signature = signature.text.toString(), + signatureIsHtml = signatureIsHtml.isChecked, replyTo = replyTo.text.toString().trim().takeUnless { it.isBlank() }, ) diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java index bc57aaeaf29..bfc9a0ccf9c 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java @@ -102,6 +102,7 @@ import com.fsck.k9.helper.Utility; import net.thunderbird.core.android.network.ConnectivityManager; import net.thunderbird.core.common.mail.Flag; +import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.Message.RecipientType; import net.thunderbird.core.common.exception.MessagingException; @@ -226,6 +227,8 @@ public class MessageCompose extends BaseActivity implements OnClickListener, private final MessagingController messagingController = DI.get(MessagingController.class); private final Preferences preferences = DI.get(Preferences.class); private final GeneralSettingsManager generalSettingsManager = DI.get(GeneralSettingsManager.class); + private final com.fsck.k9.view.WebViewConfigProvider webViewConfigProvider = + DI.get(com.fsck.k9.view.WebViewConfigProvider.class); private final IntentDataMapper indentDataMapper = DI.get(IntentDataMapper.class); @@ -284,6 +287,7 @@ public class MessageCompose extends BaseActivity implements OnClickListener, private MaterialTextView chooseIdentityView; private EditText subjectView; private EditText signatureView; + private com.fsck.k9.view.MessageWebView signatureHtmlPreview; private EditText messageContentView; private LinearLayout attachmentsView; @@ -372,6 +376,8 @@ public void onCreate(Bundle savedInstanceState) { EditText upperSignature = findViewById(R.id.upper_signature); EditText lowerSignature = findViewById(R.id.lower_signature); + com.fsck.k9.view.MessageWebView upperSignaturePreview = findViewById(R.id.upper_signature_html_preview); + com.fsck.k9.view.MessageWebView lowerSignaturePreview = findViewById(R.id.lower_signature_html_preview); QuotedMessageMvpView quotedMessageMvpView = new QuotedMessageMvpView(this); @@ -493,16 +499,34 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { if (account.isSignatureBeforeQuotedText()) { signatureView = upperSignature; + signatureHtmlPreview = upperSignaturePreview; lowerSignature.setVisibility(View.GONE); + lowerSignaturePreview.setVisibility(View.GONE); } else { signatureView = lowerSignature; + signatureHtmlPreview = lowerSignaturePreview; upperSignature.setVisibility(View.GONE); - } + upperSignaturePreview.setVisibility(View.GONE); + } + signatureHtmlPreview.configure(webViewConfigProvider.createForMessageCompose()); + // Override MessageWebView's inbound-mail defaults: the signature is the user's own + // content, so allow remote images, and render at natural device size rather than + // the 980px "wide viewport" that would shrink short signatures. + signatureHtmlPreview.blockNetworkData(false); + signatureHtmlPreview.getSettings().setUseWideViewPort(false); + signatureHtmlPreview.getSettings().setLoadWithOverviewMode(false); + signatureHtmlPreview.setWebViewClient(new android.webkit.WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(android.webkit.WebView view, String url) { + return true; + } + }); updateSignature(); signatureView.addTextChangedListener(signTextWatcher); if (!identity.getSignatureUse()) { signatureView.setVisibility(View.GONE); + signatureHtmlPreview.setVisibility(View.GONE); } requestReadReceipt = account.isMessageReadReceipt(); @@ -1087,9 +1111,25 @@ private void updateSignature() { if (identity.getSignatureUse()) { String signature = CrLfConverter.toLf(identity.getSignature()); signatureView.setText(signature); - signatureView.setVisibility(View.VISIBLE); + // The plain EditText can't render HTML, so for HTML signatures we hide it and + // show a rendered preview in a WebView instead. The EditText still holds the + // raw HTML so signatureView.getText() continues to feed the outgoing message. + if (identity.getSignatureIsHtml() && signature != null) { + signatureView.setVisibility(View.GONE); + String sanitized = com.fsck.k9.message.html.HtmlSignatureSanitizer.sanitize(signature); + String document = "" + + "" + + "" + + "" + sanitized + ""; + signatureHtmlPreview.displayHtmlContentWithInlineAttachments(document, null, null); + signatureHtmlPreview.setVisibility(View.VISIBLE); + } else { + signatureHtmlPreview.setVisibility(View.GONE); + signatureView.setVisibility(View.VISIBLE); + } } else { signatureView.setVisibility(View.GONE); + signatureHtmlPreview.setVisibility(View.GONE); } } @@ -1569,6 +1609,27 @@ private void processDraftMessage(MessageViewInfo messageViewInfo) { newIdentity = newIdentity.withEmail(identity.getEmail()); } + // The draft's identity header does not encode whether the signature is HTML, so + // inherit the flag from the matching account identity (looked up by email) so that + // multi-identity accounts preserve each identity's setting. If no match, fall back + // to the currently-loaded default identity. + Identity matchedIdentity = null; + String draftEmail = newIdentity.getEmail(); + if (draftEmail != null) { + try { + Address[] parsed = Address.parse(draftEmail); + if (parsed.length > 0) { + matchedIdentity = account.findIdentity(parsed[0]); + } + } catch (Exception e) { + // Ignore — fall back to default identity below. + } + } + boolean signatureIsHtml = matchedIdentity != null + ? matchedIdentity.getSignatureIsHtml() + : identity.getSignatureIsHtml(); + newIdentity = newIdentity.withSignatureIsHtml(signatureIsHtml); + if (k9identity.containsKey(IdentityField.ORIGINAL_MESSAGE)) { relatedMessageReference = null; String originalMessage = k9identity.get(IdentityField.ORIGINAL_MESSAGE); diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.kt index 1cfaa92797f..bb62b2138d2 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.kt @@ -28,6 +28,7 @@ class AccountSetupComposition : BaseActivity() { private lateinit var accountAlwaysBcc: EditText private lateinit var accountSenderName: EditText private lateinit var accountSignatureUse: MaterialCheckBox + private lateinit var accountSignatureIsHtml: MaterialCheckBox private lateinit var accountSignatureBeforeLocation: MaterialRadioButton private lateinit var accountSignatureAfterLocation: MaterialRadioButton private lateinit var accountSignatureLayout: LinearLayout @@ -49,6 +50,7 @@ class AccountSetupComposition : BaseActivity() { accountSignatureLayout = findViewById(R.id.account_signature_layout) accountSignatureUse = findViewById(R.id.account_signature_use) accountSignature = findViewById(R.id.account_signature) + accountSignatureIsHtml = findViewById(R.id.account_signature_is_html) accountSignatureBeforeLocation = findViewById(R.id.account_signature_location_before_quoted_text) accountSignatureAfterLocation = findViewById(R.id.account_signature_location_after_quoted_text) @@ -62,6 +64,7 @@ class AccountSetupComposition : BaseActivity() { if (isChecked) { accountSignatureLayout.isVisible = true accountSignature.setText(account.signature) + accountSignatureIsHtml.isChecked = account.signatureIsHtml val isSignatureBeforeQuotedText = account.isSignatureBeforeQuotedText accountSignatureBeforeLocation.isChecked = isSignatureBeforeQuotedText @@ -73,6 +76,7 @@ class AccountSetupComposition : BaseActivity() { if (useSignature) { accountSignature.setText(account.signature) + accountSignatureIsHtml.isChecked = account.signatureIsHtml val isSignatureBeforeQuotedText = account.isSignatureBeforeQuotedText accountSignatureBeforeLocation.setChecked(isSignatureBeforeQuotedText) @@ -131,6 +135,7 @@ class AccountSetupComposition : BaseActivity() { account.signatureUse = accountSignatureUse.isChecked if (accountSignatureUse.isChecked) { account.signature = accountSignature.text.toString() + account.signatureIsHtml = accountSignatureIsHtml.isChecked account.isSignatureBeforeQuotedText = accountSignatureBeforeLocation.isChecked } diff --git a/legacy/ui/legacy/src/main/res/layout/account_setup_composition.xml b/legacy/ui/legacy/src/main/res/layout/account_setup_composition.xml index 653d66031e8..3f3f762cdf8 100644 --- a/legacy/ui/legacy/src/main/res/layout/account_setup_composition.xml +++ b/legacy/ui/legacy/src/main/res/layout/account_setup_composition.xml @@ -113,6 +113,20 @@ android:inputType="textMultiLine|textAutoCorrect|textCapSentences" /> +