diff --git a/CHANGELOG.md b/CHANGELOG.md index a095a58233..9ae82d5803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * Enabled [sqlite analyze](https://sqlite.org/lang_analyze.html) * Added [linear back-off scheme](https://github.com/M66B/FairEmail/blob/master/FAQ.md#user-content-faq123) * Added option to sort reply templates by frequency of use +* Added basic DMARC report viewer * Small improvements and minor bug fixes * Updated translations diff --git a/app/src/amazon/AndroidManifest.xml b/app/src/amazon/AndroidManifest.xml index 8f3f5ff1f5..7c97d6745e 100644 --- a/app/src/amazon/AndroidManifest.xml +++ b/app/src/amazon/AndroidManifest.xml @@ -377,6 +377,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + . + + Copyright 2018-2022 by Marcel Bokhorst (M66B) +*/ + +import static androidx.webkit.WebSettingsCompat.FORCE_DARK_OFF; +import static androidx.webkit.WebSettingsCompat.FORCE_DARK_ON; + +import android.Manifest; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.method.ScrollingMovementMethod; +import android.text.style.ForegroundColorSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.constraintlayout.widget.Group; +import androidx.preference.PreferenceManager; +import androidx.webkit.WebSettingsCompat; +import androidx.webkit.WebViewFeature; + +import com.google.android.material.snackbar.Snackbar; + +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserFactory; + +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.text.DateFormat; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +public class ActivityDmarc extends ActivityBase { + private TextView tvDmarc; + private ContentLoadingProgressBar pbWait; + private Group grpReady; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setSubtitle("DMARC"); + + View view = LayoutInflater.from(this).inflate(R.layout.activity_dmarc, null); + setContentView(view); + + tvDmarc = findViewById(R.id.tvDmarc); + pbWait = findViewById(R.id.pbWait); + grpReady = findViewById(R.id.grpReady); + + // Initialize + FragmentDialogTheme.setBackground(this, view, false); + grpReady.setVisibility(View.GONE); + + load(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + load(); + } + + private void load() { + Intent intent = getIntent(); + Uri uri = intent.getData(); + long id = intent.getLongExtra("id", -1L); + Log.i("DMARC uri=" + uri + " id=" + id); + + Bundle args = new Bundle(); + args.putParcelable("uri", uri); + args.putLong("id", id); + + new SimpleTask() { + @Override + protected void onPreExecute(Bundle args) { + pbWait.setVisibility(View.VISIBLE); + } + + @Override + protected void onPostExecute(Bundle args) { + pbWait.setVisibility(View.GONE); + } + + @Override + protected Spanned onExecute(Context context, Bundle args) throws Throwable { + Uri uri = args.getParcelable("uri"); + + if (uri == null) + throw new FileNotFoundException(); + + if (!"content".equals(uri.getScheme()) && + !Helper.hasPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE)) { + Log.w("DMARC uri=" + uri); + throw new IllegalArgumentException(context.getString(R.string.title_no_stream)); + } + + DateFormat DTF = Helper.getDateTimeInstance(context, DateFormat.SHORT, DateFormat.SHORT); + int colorWarning = Helper.resolveColor(context, R.attr.colorWarning); + int colorSeparator = Helper.resolveColor(context, R.attr.colorSeparator); + float stroke = context.getResources().getDisplayMetrics().density; + SpannableStringBuilder ssb = new SpannableStringBuilderEx(); + + String data; + ContentResolver resolver = context.getContentResolver(); + try (InputStream is = resolver.openInputStream(uri)) { + data = Helper.readStream(is); + } + + XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); + XmlPullParser xml = factory.newPullParser(); + xml.setInput(new StringReader(data)); + + // https://tools.ietf.org/id/draft-kucherawy-dmarc-base-13.xml#xml_schema + boolean feedback = false; + boolean report_metadata = false; + boolean policy_published = false; + boolean record = false; + boolean row = false; + boolean policy_evaluated = false; + boolean identifiers = false; + boolean auth_results = false; + String result = null; + int eventType = xml.getEventType(); + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG) { + String name = xml.getName(); + switch (name) { + case "feedback": + feedback = true; + break; + case "report_metadata": + report_metadata = true; + break; + case "policy_published": + policy_published = true; + break; + case "record": + record = true; + break; + case "row": + row = true; + ssb.append("\uFFFC"); + ssb.setSpan(new LineSpan(colorSeparator, stroke, 0), ssb.length() - 1, ssb.length(), 0); + ssb.append("\n"); + break; + case "policy_evaluated": + policy_evaluated = true; + break; + case "identifiers": + identifiers = true; + break; + case "auth_results": + auth_results = true; + ssb.append("\n"); + break; + + case "org_name": + case "begin": + case "end": + if (feedback && report_metadata) { + eventType = xml.next(); + if (eventType == XmlPullParser.TEXT) { + String text = xml.getText(); + if ("begin".equals(name) || "end".equals(name)) { + text = text.trim(); + try { + ssb.append(name).append('=') + .append(DTF.format(Long.parseLong(text) * 1000)); + } catch (Throwable ex) { + Log.w(ex); + ssb.append(name).append('=') + .append(text); + } + } else + ssb.append(text); + ssb.append(' '); + } + } + break; + case "domain": + if (feedback && (policy_published || auth_results)) { + eventType = xml.next(); + if (eventType == XmlPullParser.TEXT) + ssb.append(xml.getText()).append(' '); + } + break; + case "adkim": + case "aspf": + case "p": + case "sp": + case "pct": + if (feedback && policy_published) { + eventType = xml.next(); + if (eventType == XmlPullParser.TEXT) + ssb.append(name).append('=') + .append(xml.getText()).append(' '); + } + break; + case "source_ip": + case "count": + if (feedback && record && row) { + eventType = xml.next(); + if (eventType == XmlPullParser.TEXT) { + ssb.append(name).append('=') + .append(xml.getText()).append(' '); + } + } + break; + case "disposition": // none, quarantine, reject + case "dkim": + case "spf": + case "header_from": + if (feedback && record) + if (policy_evaluated || identifiers) { + eventType = xml.next(); + if (eventType == XmlPullParser.TEXT) { + ssb.append(name).append('='); + int start = ssb.length(); + String text = xml.getText(); + ssb.append(text); + if (!"pass".equals(text) && + ("dkim".equals(name) || "spf".equals(name))) { + ssb.setSpan(new ForegroundColorSpan(colorWarning), start, ssb.length(), 0); + ssb.setSpan(new StyleSpan(Typeface.BOLD), start, ssb.length(), 0); + } + ssb.append(' '); + } + } else if (auth_results) + result = name; + break; + case "result": + if (feedback && auth_results) { + eventType = xml.next(); + if (eventType == XmlPullParser.TEXT) { + ssb.append(result == null ? "?" : result).append('='); + int start = ssb.length(); + String text = xml.getText(); + ssb.append(text); + if (!"pass".equals(text)) { + ssb.setSpan(new ForegroundColorSpan(colorWarning), start, ssb.length(), 0); + ssb.setSpan(new StyleSpan(Typeface.BOLD), start, ssb.length(), 0); + } + ssb.append(' '); + } + } + break; + case "selector": + case "scope": + if (feedback && auth_results) { + eventType = xml.next(); + if (eventType == XmlPullParser.TEXT) + ssb.append(name).append('=') + .append(xml.getText()).append(' '); + } + break; + } + + if ("report_metadata".equals(name) || + "policy_published".equals(name) || + "row".equals(name) || + "identifiers".equals(name) || + "auth_results".equals(name)) { + int start = ssb.length(); + ssb.append(name); + ssb.setSpan(new StyleSpan(Typeface.BOLD), start, ssb.length(), 0); + ssb.append("\n"); + } + + } else if (eventType == XmlPullParser.END_TAG) { + String name = xml.getName(); + switch (name) { + case "feedback": + feedback = false; + break; + case "report_metadata": + report_metadata = false; + if (feedback) + ssb.append("\n\n"); + break; + case "policy_published": + policy_published = false; + if (feedback) + ssb.append("\n\n"); + break; + case "record": + record = false; + break; + case "row": + row = false; + if (feedback) + ssb.append("\n\n"); + break; + case "policy_evaluated": + policy_evaluated = false; + break; + case "identifiers": + identifiers = false; + if (feedback) + ssb.append("\n"); + break; + case "auth_results": + auth_results = false; + if (feedback) + ssb.append("\n"); + break; + case "dkim": + case "spf": + if (feedback && auth_results) { + result = null; + ssb.append("\n"); + } + break; + } + } + + eventType = xml.next(); + } + + ssb.append("\uFFFC"); + ssb.setSpan(new LineSpan(colorSeparator, stroke, 0), ssb.length() - 1, ssb.length(), 0); + ssb.append("\n"); + + int start = ssb.length(); + ssb.append(data); + ssb.setSpan(new TypefaceSpan("monospace"), start, ssb.length(), 0); + ssb.setSpan(new RelativeSizeSpan(HtmlHelper.FONT_SMALL), start, ssb.length(), 0); + + return ssb; + } + + @Override + protected void onExecuted(Bundle args, Spanned dmarc) { + tvDmarc.setText(dmarc); + grpReady.setVisibility(View.VISIBLE); + } + + @Override + protected void onException(Bundle args, @NonNull Throwable ex) { + if (ex instanceof IllegalArgumentException && !BuildConfig.DEBUG) + Snackbar.make(findViewById(android.R.id.content), ex.getMessage(), Snackbar.LENGTH_LONG) + .setGestureInsetBottomIgnored(true).show(); + else + Log.unexpectedError(getSupportFragmentManager(), ex, false); + } + }.execute(this, args, "dmarc:decode"); + } +} diff --git a/app/src/main/java/eu/faircode/email/Helper.java b/app/src/main/java/eu/faircode/email/Helper.java index eb037a4027..6f03981a58 100644 --- a/app/src/main/java/eu/faircode/email/Helper.java +++ b/app/src/main/java/eu/faircode/email/Helper.java @@ -698,7 +698,8 @@ public class Helper { "message/delivery-status".equals(type) || "message/disposition-notification".equals(type) || "text/rfc822-headers".equals(type) || - "text/x-amp-html".equals(type))) + "text/x-amp-html".equals(type) || + "text/xml".equals(type) /* DMARC */)) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); if (!TextUtils.isEmpty(name)) diff --git a/app/src/main/res/layout/activity_dmarc.xml b/app/src/main/res/layout/activity_dmarc.xml new file mode 100644 index 0000000000..b001824478 --- /dev/null +++ b/app/src/main/res/layout/activity_dmarc.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml index dd0aa93119..aa441b4bbd 100644 --- a/app/src/play/AndroidManifest.xml +++ b/app/src/play/AndroidManifest.xml @@ -377,6 +377,26 @@ + + + + + + + + + + + + + + +