From f36b430c1bef7ecf9d2e15b7a971743c1e8450a1 Mon Sep 17 00:00:00 2001 From: M66B Date: Wed, 27 Feb 2019 13:03:17 +0000 Subject: [PATCH] Use dedicated server for sending messages --- app/src/main/AndroidManifest.xml | 2 + .../eu/faircode/email/EntityOperation.java | 4 + .../main/java/eu/faircode/email/Helper.java | 40 +- .../eu/faircode/email/ReceiverAutostart.java | 10 + .../java/eu/faircode/email/ServiceSend.java | 378 ++++++++++++++++++ .../eu/faircode/email/ServiceSynchronize.java | 318 +-------------- app/src/main/res/values/strings.xml | 1 + 7 files changed, 438 insertions(+), 315 deletions(-) create mode 100644 app/src/main/java/eu/faircode/email/ServiceSend.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 27b1528399..3a590494f7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -162,6 +162,8 @@ + + diff --git a/app/src/main/java/eu/faircode/email/EntityOperation.java b/app/src/main/java/eu/faircode/email/EntityOperation.java index 1ab4d243e9..39050a6ee0 100644 --- a/app/src/main/java/eu/faircode/email/EntityOperation.java +++ b/app/src/main/java/eu/faircode/email/EntityOperation.java @@ -20,6 +20,7 @@ package eu.faircode.email; */ import android.content.Context; +import android.content.Intent; import android.content.SharedPreferences; import android.preference.PreferenceManager; @@ -221,6 +222,9 @@ public class EntityOperation { } else if (DELETE.equals(name)) db.message().setMessageUiHide(message.id, true); + + else if (SEND.equals(name)) + context.startService(new Intent(context, ServiceSend.class)); } catch (JSONException ex) { Log.e(ex); } diff --git a/app/src/main/java/eu/faircode/email/Helper.java b/app/src/main/java/eu/faircode/email/Helper.java index 75c1f5ea0d..5f8dbd35dc 100644 --- a/app/src/main/java/eu/faircode/email/Helper.java +++ b/app/src/main/java/eu/faircode/email/Helper.java @@ -21,6 +21,8 @@ package eu.faircode.email; import android.accounts.Account; import android.accounts.AccountManager; +import android.app.Notification; +import android.app.PendingIntent; import android.app.usage.UsageStatsManager; import android.content.ActivityNotFoundException; import android.content.Context; @@ -100,6 +102,7 @@ import javax.net.ssl.SSLException; import androidx.annotation.NonNull; import androidx.browser.customtabs.CustomTabsIntent; +import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleOwner; @@ -109,7 +112,8 @@ import static androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_C public class Helper { static final int NOTIFICATION_SYNCHRONIZE = 1; - static final int NOTIFICATION_EXTERNAL = 2; + static final int NOTIFICATION_SEND = 2; + static final int NOTIFICATION_EXTERNAL = 3; static final int JOB_DAILY = 1001; @@ -1035,4 +1039,38 @@ public class Helper { static String sanitizeFilename(String name) { return (name == null ? null : name.replaceAll("[^a-zA-Z0-9\\.\\-]", "_")); } + + static NotificationCompat.Builder getNotificationError(Context context, String title, Throwable ex) { + return getNotificationError(context, "error", title, ex, true); + } + + static NotificationCompat.Builder getNotificationError(Context context, String channel, String title, Throwable ex, boolean debug) { + // Build pending intent + Intent intent = new Intent(context, ActivitySetup.class); + if (debug) + intent.setAction("error"); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + PendingIntent pi = PendingIntent.getActivity( + context, ActivitySetup.REQUEST_ERROR, intent, PendingIntent.FLAG_UPDATE_CURRENT); + + // Build notification + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channel); + + builder + .setSmallIcon(R.drawable.baseline_warning_white_24) + .setContentTitle(context.getString(R.string.title_notification_failed, title)) + .setContentText(Helper.formatThrowable(ex)) + .setContentIntent(pi) + .setAutoCancel(false) + .setShowWhen(true) + .setPriority(Notification.PRIORITY_MAX) + .setOnlyAlertOnce(true) + .setCategory(Notification.CATEGORY_ERROR) + .setVisibility(NotificationCompat.VISIBILITY_SECRET); + + builder.setStyle(new NotificationCompat.BigTextStyle() + .bigText(Helper.formatThrowable(ex, false, "\n"))); + + return builder; + } } diff --git a/app/src/main/java/eu/faircode/email/ReceiverAutostart.java b/app/src/main/java/eu/faircode/email/ReceiverAutostart.java index 0cb690a4fa..5d89fb5f2e 100644 --- a/app/src/main/java/eu/faircode/email/ReceiverAutostart.java +++ b/app/src/main/java/eu/faircode/email/ReceiverAutostart.java @@ -33,6 +33,7 @@ public class ReceiverAutostart extends BroadcastReceiver { if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction()) || Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction())) { EntityLog.log(context, intent.getAction()); + ServiceSynchronize.init(context, true); Thread thread = new Thread(new Runnable() { @@ -40,9 +41,18 @@ public class ReceiverAutostart extends BroadcastReceiver { public void run() { try { DB db = DB.getInstance(context); + List messages = db.message().getSnoozed(); for (EntityMessage message : messages) EntityMessage.snooze(context, message.id, message.ui_snoozed); + + EntityFolder outbox = db.folder().getOutbox(); + if (outbox == null) + return; + + if (db.operation().getOperations(outbox.id).size() > 0) + context.startService(new Intent(context, ServiceSend.class)); + } catch (Throwable ex) { Log.e(ex); } diff --git a/app/src/main/java/eu/faircode/email/ServiceSend.java b/app/src/main/java/eu/faircode/email/ServiceSend.java new file mode 100644 index 0000000000..ba7b8ca7b2 --- /dev/null +++ b/app/src/main/java/eu/faircode/email/ServiceSend.java @@ -0,0 +1,378 @@ +package eu.faircode.email; + +/* + This file is part of FairEmail. + + FairEmail is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + FairEmail is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with FairEmail. If not, see . + + Copyright 2018-2019 by Marcel Bokhorst (M66B) +*/ + +import android.app.Notification; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkInfo; +import android.net.NetworkRequest; +import android.os.PowerManager; +import android.text.TextUtils; + +import java.io.File; +import java.io.IOException; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Properties; + +import javax.mail.Address; +import javax.mail.AuthenticationFailedException; +import javax.mail.Message; +import javax.mail.MessageRemovedException; +import javax.mail.MessagingException; +import javax.mail.SendFailedException; +import javax.mail.Session; +import javax.mail.Transport; +import javax.mail.internet.InternetAddress; +import javax.mail.internet.MimeMessage; + +import androidx.core.app.NotificationCompat; +import androidx.lifecycle.LifecycleService; + +public class ServiceSend extends LifecycleService { + private static final int IDENTITY_ERROR_AFTER = 30; // minutes + + @Override + public void onCreate() { + Log.i("Service send create"); + super.onCreate(); + + ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkRequest.Builder builder = new NetworkRequest.Builder(); + builder.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); + cm.registerNetworkCallback(builder.build(), networkCallback); + } + + @Override + public void onDestroy() { + Log.i("Service send destroy"); + + ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + cm.unregisterNetworkCallback(networkCallback); + + stopForeground(true); + + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + nm.cancel(Helper.NOTIFICATION_SEND); + + super.onDestroy(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + // Build notification + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, "service"); + + builder + .setSmallIcon(R.drawable.baseline_send_24) + .setContentTitle(getString(R.string.title_notification_sending)) + .setAutoCancel(false) + .setShowWhen(false) + .setPriority(Notification.PRIORITY_MIN) + .setCategory(Notification.CATEGORY_STATUS) + .setVisibility(NotificationCompat.VISIBILITY_SECRET); + + startForeground(Helper.NOTIFICATION_SEND, builder.build()); + + super.onStartCommand(intent, flags, startId); + + return START_STICKY; + } + + ConnectivityManager.NetworkCallback networkCallback = new ConnectivityManager.NetworkCallback() { + private Thread thread = null; + + @Override + public void onAvailable(Network network) { + Log.i("Service send available=" + network); + + if (thread == null || !thread.isAlive()) { + thread = new Thread(new Runnable() { + @Override + public void run() { + PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); + PowerManager.WakeLock wl = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":send"); + try { + wl.acquire(); + + DB db = DB.getInstance(ServiceSend.this); + EntityFolder outbox = db.folder().getOutbox(); + try { + db.folder().setFolderError(outbox.id, null); + db.folder().setFolderSyncState(outbox.id, "syncing"); + + ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + + List ops = db.operation().getOperations(outbox.id); + Log.i(outbox.name + " pending operations=" + ops.size()); + for (EntityOperation op : ops) { + EntityMessage message = db.message().getMessage(op.message); + try { + Log.i(outbox.name + + " start op=" + op.id + "/" + op.name + + " msg=" + op.message + + " args=" + op.args); + + if (message == null) + throw new MessageRemovedException(); + + send(message); + + db.operation().deleteOperation(op.id); + } catch (Throwable ex) { + Log.e(ex); + + if (message != null) + db.message().setMessageError(message.id, Helper.formatThrowable(ex)); + + if (ex instanceof SendFailedException) + db.operation().deleteOperation(op.id); + else + throw ex; + } finally { + Log.i(outbox.name + " end op=" + op.id + "/" + op.name); + } + + NetworkInfo ni = cm.getActiveNetworkInfo(); + if (ni == null || !ni.isConnected()) + break; + } + + if (db.operation().getOperations(outbox.id).size() == 0) + stopSelf(); + } catch (Throwable ex) { + Log.e(outbox.name, ex); + db.folder().setFolderError(outbox.id, Helper.formatThrowable(ex, true)); + } finally { + db.folder().setFolderSyncState(outbox.id, null); + } + } finally { + wl.release(); + } + } + }); + thread.start(); + } + } + }; + + private void send(EntityMessage message) throws MessagingException, IOException { + DB db = DB.getInstance(this); + + // Mark attempt + if (message.last_attempt == null) { + message.last_attempt = new Date().getTime(); + db.message().setMessageLastAttempt(message.id, message.last_attempt); + } + + EntityIdentity ident = db.identity().getIdentity(message.identity); + + // Get properties + Properties props = MessageHelper.getSessionProperties(ident.auth_type, ident.realm, ident.insecure); + InetAddress ip = (ident.use_ip ? Helper.getLocalIp(this) : null); + if (ip == null) { + EntityLog.log(this, "Send local host=" + ident.host); + if (ident.starttls) + props.put("mail.smtp.localhost", ident.host); + else + props.put("mail.smtps.localhost", ident.host); + } else { + InetAddress localhost = InetAddress.getLocalHost(); + String haddr = "[" + (localhost instanceof Inet6Address ? "IPv6:" : "") + localhost.getHostAddress() + "]"; + EntityLog.log(this, "Send local address=" + haddr); + if (ident.starttls) + props.put("mail.smtp.localhost", haddr); + else + props.put("mail.smtps.localhost", haddr); + } + + // Create session + final Session isession = Session.getInstance(props, null); + isession.setDebug(true); + + // Create message + MimeMessage imessage = MessageHelper.from(this, message, isession, ident.plain_only); + + // Add reply to + if (ident.replyto != null) + imessage.setReplyTo(new Address[]{new InternetAddress(ident.replyto)}); + + // Add bcc + if (ident.bcc != null) { + List
bcc = new ArrayList<>(); + Address[] existing = imessage.getRecipients(Message.RecipientType.BCC); + if (existing != null) + bcc.addAll(Arrays.asList(existing)); + bcc.add(new InternetAddress(ident.bcc)); + imessage.setRecipients(Message.RecipientType.BCC, bcc.toArray(new Address[0])); + } + + // defacto standard + if (ident.delivery_receipt) + imessage.addHeader("Return-Receipt-To", ident.replyto == null ? ident.email : ident.replyto); + + // https://tools.ietf.org/html/rfc3798 + if (ident.read_receipt) + imessage.addHeader("Disposition-Notification-To", ident.replyto == null ? ident.email : ident.replyto); + + // Create transport + // TODO: cache transport? + try (Transport itransport = isession.getTransport(ident.getProtocol())) { + // Connect transport + db.identity().setIdentityState(ident.id, "connecting"); + try { + itransport.connect(ident.host, ident.port, ident.user, ident.password); + } catch (AuthenticationFailedException ex) { + if (ident.auth_type == Helper.AUTH_TYPE_GMAIL) { + EntityAccount account = db.account().getAccount(ident.account); + ident.password = Helper.refreshToken(this, "com.google", ident.user, account.password); + DB.getInstance(this).identity().setIdentityPassword(ident.id, ident.password); + itransport.connect(ident.host, ident.port, ident.user, ident.password); + } else + throw ex; + } + + db.identity().setIdentityState(ident.id, "connected"); + + // Send message + Address[] to = imessage.getAllRecipients(); + itransport.sendMessage(imessage, to); + EntityLog.log(this, "Sent via " + ident.host + "/" + ident.user + + " to " + TextUtils.join(", ", to)); + + // Append replied/forwarded text + StringBuilder sb = new StringBuilder(); + sb.append(Helper.readText(EntityMessage.getFile(this, message.id))); + File refFile = EntityMessage.getRefFile(this, message.id); + if (refFile.exists()) + sb.append(Helper.readText(refFile)); + Helper.writeText(EntityMessage.getFile(this, message.id), sb.toString()); + + try { + db.beginTransaction(); + + db.message().setMessageSent(message.id, imessage.getSentDate().getTime()); + db.message().setMessageSeen(message.id, true); + db.message().setMessageUiSeen(message.id, true); + db.message().setMessageError(message.id, null); + + EntityFolder sent = db.folder().getFolderByType(message.account, EntityFolder.SENT); + if (ident.store_sent && sent != null) { + db.message().setMessageFolder(message.id, sent.id); + message.folder = sent.id; + EntityOperation.queue(this, db, message, EntityOperation.ADD); + } else + db.message().setMessageUiHide(message.id, true); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + if (refFile.exists()) + refFile.delete(); + + if (message.inreplyto != null) { + List replieds = db.message().getMessageByMsgId(message.account, message.inreplyto); + for (EntityMessage replied : replieds) + EntityOperation.queue(this, db, replied, EntityOperation.ANSWERED, true); + } + + db.identity().setIdentityConnected(ident.id, new Date().getTime()); + db.identity().setIdentityError(ident.id, null); + + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + nm.cancel("send", message.identity.intValue()); + + if (message.to != null) + for (Address recipient : message.to) { + String email = ((InternetAddress) recipient).getAddress(); + String name = ((InternetAddress) recipient).getPersonal(); + List contacts = db.contact().getContacts(EntityContact.TYPE_TO, email); + if (contacts.size() == 0) { + EntityContact contact = new EntityContact(); + contact.type = EntityContact.TYPE_TO; + contact.email = email; + contact.name = name; + db.contact().insertContact(contact); + Log.i("Inserted recipient contact=" + contact); + } else { + EntityContact contact = contacts.get(0); + if (name != null && !name.equals(contact.name)) { + contact.name = name; + db.contact().updateContact(contact); + Log.i("Updated recipient contact=" + contact); + } + } + } + } catch (MessagingException ex) { + if (ex instanceof SendFailedException) { + SendFailedException sfe = (SendFailedException) ex; + + StringBuilder sb = new StringBuilder(); + + sb.append(sfe.getMessage()); + + sb.append(' ').append(getString(R.string.title_address_sent)); + sb.append(' ').append(MessageHelper.formatAddresses(sfe.getValidSentAddresses())); + + sb.append(' ').append(getString(R.string.title_address_unsent)); + sb.append(' ').append(MessageHelper.formatAddresses(sfe.getValidUnsentAddresses())); + + sb.append(' ').append(getString(R.string.title_address_invalid)); + sb.append(' ').append(MessageHelper.formatAddresses(sfe.getInvalidAddresses())); + + ex = new SendFailedException( + sb.toString(), + sfe.getNextException(), + sfe.getValidSentAddresses(), + sfe.getValidUnsentAddresses(), + sfe.getInvalidAddresses()); + } + + db.identity().setIdentityError(ident.id, Helper.formatThrowable(ex)); + + EntityLog.log(this, ident.name + " last attempt: " + new Date(message.last_attempt)); + + long now = new Date().getTime(); + long delayed = now - message.last_attempt; + if (delayed > IDENTITY_ERROR_AFTER * 60 * 1000L) { + Log.i("Reporting send error after=" + delayed); + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + nm.notify("send", message.identity.intValue(), + Helper.getNotificationError(this, ident.name, ex).build()); + } + + throw ex; + } finally { + db.identity().setIdentityState(ident.id, null); + } + } +} diff --git a/app/src/main/java/eu/faircode/email/ServiceSynchronize.java b/app/src/main/java/eu/faircode/email/ServiceSynchronize.java index 077127104e..e2355e347b 100644 --- a/app/src/main/java/eu/faircode/email/ServiceSynchronize.java +++ b/app/src/main/java/eu/faircode/email/ServiceSynchronize.java @@ -71,8 +71,6 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.net.Inet6Address; -import java.net.InetAddress; import java.net.SocketException; import java.net.SocketTimeoutException; import java.net.UnknownHostException; @@ -108,7 +106,6 @@ import javax.mail.SendFailedException; import javax.mail.Session; import javax.mail.Store; import javax.mail.StoreClosedException; -import javax.mail.Transport; import javax.mail.UIDFolder; import javax.mail.event.ConnectionAdapter; import javax.mail.event.ConnectionEvent; @@ -153,7 +150,6 @@ public class ServiceSynchronize extends LifecycleService { private static final long RECONNECT_BACKOFF = 90 * 1000L; // milliseconds private static final int ACCOUNT_ERROR_AFTER = 90; // minutes private static final int BACKOFF_ERROR_AFTER = 16; // seconds - private static final int IDENTITY_ERROR_AFTER = 30; // minutes private static final long STOP_DELAY = 5000L; // milliseconds private static final long YIELD_DURATION = 200L; // milliseconds @@ -717,40 +713,6 @@ public class ServiceSynchronize extends LifecycleService { return notifications; } - private NotificationCompat.Builder getNotificationError(String title, Throwable ex) { - return getNotificationError("error", title, ex, true); - } - - private NotificationCompat.Builder getNotificationError(String channel, String title, Throwable ex, boolean debug) { - // Build pending intent - Intent intent = new Intent(this, ActivitySetup.class); - if (debug) - intent.setAction("error"); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - PendingIntent pi = PendingIntent.getActivity( - this, ActivitySetup.REQUEST_ERROR, intent, PendingIntent.FLAG_UPDATE_CURRENT); - - // Build notification - NotificationCompat.Builder builder = new NotificationCompat.Builder(this, channel); - - builder - .setSmallIcon(R.drawable.baseline_warning_white_24) - .setContentTitle(getString(R.string.title_notification_failed, title)) - .setContentText(Helper.formatThrowable(ex)) - .setContentIntent(pi) - .setAutoCancel(false) - .setShowWhen(true) - .setPriority(Notification.PRIORITY_MAX) - .setOnlyAlertOnce(true) - .setCategory(Notification.CATEGORY_ERROR) - .setVisibility(NotificationCompat.VISIBILITY_SECRET); - - builder.setStyle(new NotificationCompat.BigTextStyle() - .bigText(Helper.formatThrowable(ex, false, "\n"))); - - return builder; - } - private void reportError(EntityAccount account, EntityFolder folder, Throwable ex) { // FolderClosedException: can happen when no connectivity @@ -778,7 +740,7 @@ public class ServiceSynchronize extends LifecycleService { if ((ex instanceof SendFailedException) || (ex instanceof AlertException)) { NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - nm.notify(tag, 1, getNotificationError(title, ex).build()); + nm.notify(tag, 1, Helper.getNotificationError(this, title, ex).build()); } // connection failure: Too many simultaneous connections @@ -798,7 +760,7 @@ public class ServiceSynchronize extends LifecycleService { !(ex instanceof MessagingException && ex.getCause() instanceof SSLException) && !(ex instanceof MessagingException && "connection failure".equals(ex.getMessage()))) { NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - nm.notify(tag, 1, getNotificationError(title, ex).build()); + nm.notify(tag, 1, Helper.getNotificationError(this, title, ex).build()); } } @@ -934,7 +896,8 @@ public class ServiceSynchronize extends LifecycleService { .format((account.last_connected))), ex); NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); nm.notify("receive", account.id.intValue(), - getNotificationError("warning", account.name, warning, false).build()); + Helper.getNotificationError(this, "warning", account.name, warning, false) + .build()); } } @@ -1511,10 +1474,6 @@ public class ServiceSynchronize extends LifecycleService { doDelete(folder, (IMAPFolder) ifolder, message, jargs, db); break; - case EntityOperation.SEND: - doSend(message, db); - break; - case EntityOperation.HEADERS: doHeaders(folder, (IMAPFolder) ifolder, message, db); break; @@ -1559,7 +1518,6 @@ public class ServiceSynchronize extends LifecycleService { if (ex instanceof MessageRemovedException || ex instanceof FolderNotFoundException || - ex instanceof SendFailedException || ex instanceof IllegalArgumentException) { Log.w("Unrecoverable", ex); @@ -1905,197 +1863,6 @@ public class ServiceSynchronize extends LifecycleService { db.message().deleteMessage(message.id); } - private void doSend(EntityMessage message, DB db) throws MessagingException, IOException { - // Send message - EntityIdentity ident = db.identity().getIdentity(message.identity); - - // Mark attempt - if (message.last_attempt == null) { - message.last_attempt = new Date().getTime(); - db.message().setMessageLastAttempt(message.id, message.last_attempt); - } - - // Get properties - Properties props = MessageHelper.getSessionProperties(ident.auth_type, ident.realm, ident.insecure); - InetAddress ip = (ident.use_ip ? Helper.getLocalIp(ServiceSynchronize.this) : null); - if (ip == null) { - EntityLog.log(ServiceSynchronize.this, "Send local host=" + ident.host); - if (ident.starttls) - props.put("mail.smtp.localhost", ident.host); - else - props.put("mail.smtps.localhost", ident.host); - } else { - InetAddress localhost = InetAddress.getLocalHost(); - String haddr = "[" + (localhost instanceof Inet6Address ? "IPv6:" : "") + localhost.getHostAddress() + "]"; - EntityLog.log(ServiceSynchronize.this, "Send local address=" + haddr); - if (ident.starttls) - props.put("mail.smtp.localhost", haddr); - else - props.put("mail.smtps.localhost", haddr); - } - - // Create session - final Session isession = Session.getInstance(props, null); - isession.setDebug(true); - - // Create message - MimeMessage imessage = MessageHelper.from(this, message, isession, ident.plain_only); - - // Add reply to - if (ident.replyto != null) - imessage.setReplyTo(new Address[]{new InternetAddress(ident.replyto)}); - - // Add bcc - if (ident.bcc != null) { - List
bcc = new ArrayList<>(); - Address[] existing = imessage.getRecipients(Message.RecipientType.BCC); - if (existing != null) - bcc.addAll(Arrays.asList(existing)); - bcc.add(new InternetAddress(ident.bcc)); - imessage.setRecipients(Message.RecipientType.BCC, bcc.toArray(new Address[0])); - } - - // defacto standard - if (ident.delivery_receipt) - imessage.addHeader("Return-Receipt-To", ident.replyto == null ? ident.email : ident.replyto); - - // https://tools.ietf.org/html/rfc3798 - if (ident.read_receipt) - imessage.addHeader("Disposition-Notification-To", ident.replyto == null ? ident.email : ident.replyto); - - // Create transport - // TODO: cache transport? - try (Transport itransport = isession.getTransport(ident.getProtocol())) { - // Connect transport - db.identity().setIdentityState(ident.id, "connecting"); - try { - itransport.connect(ident.host, ident.port, ident.user, ident.password); - } catch (AuthenticationFailedException ex) { - if (ident.auth_type == Helper.AUTH_TYPE_GMAIL) { - EntityAccount account = db.account().getAccount(ident.account); - ident.password = Helper.refreshToken(this, "com.google", ident.user, account.password); - DB.getInstance(this).identity().setIdentityPassword(ident.id, ident.password); - itransport.connect(ident.host, ident.port, ident.user, ident.password); - } else - throw ex; - } - - db.identity().setIdentityState(ident.id, "connected"); - - // Send message - Address[] to = imessage.getAllRecipients(); - itransport.sendMessage(imessage, to); - EntityLog.log(this, "Sent via " + ident.host + "/" + ident.user + - " to " + TextUtils.join(", ", to)); - - // Append replied/forwarded text - StringBuilder sb = new StringBuilder(); - sb.append(Helper.readText(EntityMessage.getFile(this, message.id))); - File refFile = EntityMessage.getRefFile(this, message.id); - if (refFile.exists()) - sb.append(Helper.readText(refFile)); - Helper.writeText(EntityMessage.getFile(this, message.id), sb.toString()); - - try { - db.beginTransaction(); - - db.message().setMessageSent(message.id, imessage.getSentDate().getTime()); - db.message().setMessageSeen(message.id, true); - db.message().setMessageUiSeen(message.id, true); - db.message().setMessageError(message.id, null); - - EntityFolder sent = db.folder().getFolderByType(message.account, EntityFolder.SENT); - if (ident.store_sent && sent != null) { - db.message().setMessageFolder(message.id, sent.id); - message.folder = sent.id; - EntityOperation.queue(this, db, message, EntityOperation.ADD); - } else - db.message().setMessageUiHide(message.id, true); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - if (refFile.exists()) - refFile.delete(); - - if (message.inreplyto != null) { - List replieds = db.message().getMessageByMsgId(message.account, message.inreplyto); - for (EntityMessage replied : replieds) - EntityOperation.queue(this, db, replied, EntityOperation.ANSWERED, true); - } - - db.identity().setIdentityConnected(ident.id, new Date().getTime()); - db.identity().setIdentityError(ident.id, null); - - NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - nm.cancel("send", message.identity.intValue()); - - if (message.to != null) - for (Address recipient : message.to) { - String email = ((InternetAddress) recipient).getAddress(); - String name = ((InternetAddress) recipient).getPersonal(); - List contacts = db.contact().getContacts(EntityContact.TYPE_TO, email); - if (contacts.size() == 0) { - EntityContact contact = new EntityContact(); - contact.type = EntityContact.TYPE_TO; - contact.email = email; - contact.name = name; - db.contact().insertContact(contact); - Log.i("Inserted recipient contact=" + contact); - } else { - EntityContact contact = contacts.get(0); - if (name != null && !name.equals(contact.name)) { - contact.name = name; - db.contact().updateContact(contact); - Log.i("Updated recipient contact=" + contact); - } - } - } - } catch (MessagingException ex) { - if (ex instanceof SendFailedException) { - SendFailedException sfe = (SendFailedException) ex; - - StringBuilder sb = new StringBuilder(); - - sb.append(sfe.getMessage()); - - sb.append(' ').append(getString(R.string.title_address_sent)); - sb.append(' ').append(MessageHelper.formatAddresses(sfe.getValidSentAddresses())); - - sb.append(' ').append(getString(R.string.title_address_unsent)); - sb.append(' ').append(MessageHelper.formatAddresses(sfe.getValidUnsentAddresses())); - - sb.append(' ').append(getString(R.string.title_address_invalid)); - sb.append(' ').append(MessageHelper.formatAddresses(sfe.getInvalidAddresses())); - - ex = new SendFailedException( - sb.toString(), - sfe.getNextException(), - sfe.getValidSentAddresses(), - sfe.getValidUnsentAddresses(), - sfe.getInvalidAddresses()); - } - - db.identity().setIdentityError(ident.id, Helper.formatThrowable(ex)); - - EntityLog.log(this, ident.name + " last attempt: " + new Date(message.last_attempt)); - - long now = new Date().getTime(); - long delayed = now - message.last_attempt; - if (delayed > IDENTITY_ERROR_AFTER * 60 * 1000L) { - Log.i("Reporting send error after=" + delayed); - NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - nm.notify("send", message.identity.intValue(), getNotificationError(ident.name, ex).build()); - } - - throw ex; - } finally { - db.identity().setIdentityState(ident.id, null); - } - } - private void doHeaders(EntityFolder folder, IMAPFolder ifolder, EntityMessage message, DB db) throws MessagingException { if (message.headers != null) return; @@ -3115,76 +2882,6 @@ public class ServiceSynchronize extends LifecycleService { Log.w("main backoff " + ex.toString()); } - // Start monitoring outbox - Handler handler = null; - final EntityFolder outbox = db.folder().getOutbox(); - if (outbox != null) { - db.folder().setFolderError(outbox.id, null); - - handler = new Handler(getMainLooper()) { - private List handling = new ArrayList<>(); - private LiveData> liveOperations; - - @Override - public void handleMessage(android.os.Message msg) { - Log.i(outbox.name + " observe=" + msg.what); - if (msg.what == 0) { - liveOperations.removeObserver(observer); - handling.clear(); - } else { - liveOperations = db.operation().liveOperations(outbox.id); - liveOperations.observe(ServiceSynchronize.this, observer); - } - } - - private Observer> observer = new Observer>() { - private ExecutorService executor = Executors.newSingleThreadExecutor(Helper.backgroundThreadFactory); - PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); - PowerManager.WakeLock wl = pm.newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":outbox"); - - @Override - public void onChanged(final List operations) { - boolean process = false; - List current = new ArrayList<>(); - for (EntityOperation op : operations) { - if (!handling.contains(op.id)) - process = true; - current.add(op.id); - } - handling = current; - - if (handling.size() > 0 && process) { - Log.i(outbox.name + " operations=" + operations.size()); - executor.submit(new Runnable() { - @Override - public void run() { - try { - wl.acquire(); - Log.i(outbox.name + " process"); - - db.folder().setFolderSyncState(outbox.id, "syncing"); - processOperations(null, outbox, null, null, null, state); - db.folder().setFolderError(outbox.id, null); - } catch (Throwable ex) { - Log.e(outbox.name, ex); - reportError(null, outbox, ex); - db.folder().setFolderError(outbox.id, Helper.formatThrowable(ex, true)); - } finally { - db.folder().setFolderSyncState(outbox.id, null); - wl.release(); - EntityLog.log(ServiceSynchronize.this, "Outbox wake lock=" + wl.isHeld()); - } - } - }); - } - } - }; - }; - handler.sendEmptyMessage(1); - db.folder().setFolderState(outbox.id, "connected"); - } - // Start monitoring accounts List accounts = db.account().getSynchronizingAccounts(false); for (final EntityAccount account : accounts) { @@ -3230,13 +2927,6 @@ public class ServiceSynchronize extends LifecycleService { astate.join(); threadState.clear(); - // Stop monitoring outbox - if (outbox != null) { - Log.i(outbox.name + " unlisten operations"); - handler.sendEmptyMessage(0); - db.folder().setFolderState(outbox.id, null); - } - EntityLog.log(ServiceSynchronize.this, "Main exited"); } catch (Throwable ex) { // Fail-safe diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 40935c26d7..985c7d7e17 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -59,6 +59,7 @@ Report message from %1$s as spam? + Sending messages \'%1$s\' failed Templates