diff --git a/FAQ.md b/FAQ.md
index 75684d8ef8..ee2eece806 100644
--- a/FAQ.md
+++ b/FAQ.md
@@ -448,6 +448,7 @@ The low priority status bar notification shows the number of pending operations,
* *exists*: check if message exists
* *rule*: execute rule on body text
* *expunge*: permanently delete messages
+* *report*: process delivery or read receipt (experimental)
Operations are processed only when there is a connection to the email server or when manually synchronizing.
See also [this FAQ](#user-content-faq16).
@@ -3429,6 +3430,30 @@ Remarks:
+*Process delivery/read receipt (version 1.1797+)*
+
+On receiving a delivery or read receipt, the related message will be looked up in the sent messages folder
+and the following keywords will be set depending on the contents of the report:
+
+```
+$Delivered
+$NotDelivered
+$Displayed
+$NotDisplayed
+```
+
+* Delivered: action = *delivered*, *relayed*, or *expanded*, [see here](https://datatracker.ietf.org/doc/html/rfc3464#section-2.3.3)
+* Displayed: disposition = *displayed*, [see here](https://datatracker.ietf.org/doc/html/rfc3798#section-3.2.6)
+
+It is probably a good idea to enable *Show keywords in message header* in the display settings.
+
+Note that the email server needs to support IMAP flags (keywords) for this feature.
+
+Filter rules will be applied to the received receipt, so it is possible to move/archive the receipt.
+See [this FAQ](#user-content-faq71) for a header condition to recognize receipts.
+
+
+
**(126) Can message previews be sent to my wearable?**
diff --git a/app/src/main/java/eu/faircode/email/Core.java b/app/src/main/java/eu/faircode/email/Core.java
index 36294347e4..33d3138ee7 100644
--- a/app/src/main/java/eu/faircode/email/Core.java
+++ b/app/src/main/java/eu/faircode/email/Core.java
@@ -239,6 +239,7 @@ class Core {
if (message == null &&
!EntityOperation.FETCH.equals(op.name) &&
+ !EntityOperation.REPORT.equals(op.name) &&
!EntityOperation.SYNC.equals(op.name) &&
!EntityOperation.SUBSCRIBE.equals(op.name) &&
!EntityOperation.PURGE.equals(op.name) &&
@@ -346,6 +347,7 @@ class Core {
case EntityOperation.ANSWERED:
case EntityOperation.ADD:
+ case EntityOperation.REPORT:
// Do nothing
break;
@@ -445,6 +447,10 @@ class Core {
onExists(context, jargs, account, folder, message, op, (IMAPFolder) ifolder);
break;
+ case EntityOperation.REPORT:
+ onReport(context, jargs, folder, (IMAPStore) istore, (IMAPFolder) ifolder, state);
+ break;
+
case EntityOperation.SYNC:
Helper.gc();
onSynchronizeMessages(context, jargs, account, folder, (IMAPStore) istore, (IMAPFolder) ifolder, state);
@@ -1970,6 +1976,43 @@ class Core {
}
}
+ private static void onReport(Context context, JSONArray jargs, EntityFolder folder, IMAPStore istore, IMAPFolder ifolder, State state) throws JSONException, MessagingException {
+ String msgid = jargs.getString(0);
+ String keyword = jargs.getString(1);
+
+ if (TextUtils.isEmpty(msgid))
+ throw new IllegalArgumentException("msgid missing");
+
+ if (TextUtils.isEmpty(keyword))
+ throw new IllegalArgumentException("keyword missing");
+
+ if (folder.read_only)
+ throw new IllegalArgumentException(folder.name + " read-only");
+
+ if (!ifolder.getPermanentFlags().contains(Flags.Flag.USER))
+ throw new IllegalArgumentException(folder.name + " has no keywords");
+
+ Message[] imessages = ifolder.search(new MessageIDTerm(msgid));
+ if (imessages == null || imessages.length == 0)
+ throw new IllegalArgumentException(msgid + " not found");
+
+ for (Message imessage : imessages) {
+ long uid = ifolder.getUID(imessage);
+ Log.i("Report uid=" + uid + " keyword=" + keyword);
+
+ Flags flags = new Flags(keyword);
+ imessage.setFlags(flags, true);
+
+ try {
+ JSONArray fargs = new JSONArray();
+ fargs.put(uid);
+ onFetch(context, fargs, folder, istore, ifolder, state);
+ } catch (Throwable ex) {
+ Log.w(ex);
+ }
+ }
+ }
+
static void onSynchronizeFolders(
Context context, EntityAccount account, Store istore, State state,
boolean keep_alive, boolean force) throws MessagingException {
@@ -3564,6 +3607,7 @@ class Core {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
boolean download_headers = prefs.getBoolean("download_headers", false);
boolean notify_known = prefs.getBoolean("notify_known", false);
+ boolean experiments = prefs.getBoolean("experiments", false);
boolean pro = ActivityBilling.isPro(context);
long uid = ifolder.getUID(imessage);
@@ -3863,6 +3907,22 @@ class Core {
List headers = (needsHeaders ? helper.getAllHeaders() : null);
String body = (needsBody ? helper.getMessageParts().getHtml(context) : null);
+ if (experiments && helper.isReport())
+ try {
+ MessageHelper.Report r = parts.getReport();
+ EntityFolder s = db.folder().getFolderByType(folder.account, EntityFolder.SENT);
+ if (r != null && s != null) {
+ if (r.isDeliveryStatus())
+ EntityOperation.queue(context, s, EntityOperation.REPORT,
+ message.inreplyto, r.isDelivered() ? "$Delivered" : "$NotDelivered");
+ else if (r.isDispositionNotification())
+ EntityOperation.queue(context, s, EntityOperation.REPORT,
+ message.inreplyto, r.isDisplayed() ? "$Displayed" : "$NotDisplayed");
+ }
+ } catch (Throwable ex) {
+ Log.w(ex);
+ }
+
try {
db.beginTransaction();
diff --git a/app/src/main/java/eu/faircode/email/DaoOperation.java b/app/src/main/java/eu/faircode/email/DaoOperation.java
index c88bf90bfc..a57a3db98b 100644
--- a/app/src/main/java/eu/faircode/email/DaoOperation.java
+++ b/app/src/main/java/eu/faircode/email/DaoOperation.java
@@ -39,6 +39,7 @@ public interface DaoOperation {
// Other operations: add, delete, seen, answered, flag, keyword, label, subscribe, send
" WHEN operation.name = '" + EntityOperation.FETCH + "' THEN 2" +
" WHEN operation.name = '" + EntityOperation.EXISTS + "' THEN 3" +
+ " WHEN operation.name = '" + EntityOperation.REPORT + "' THEN 3" +
" WHEN operation.name = '" + EntityOperation.COPY + "' THEN 4" +
" WHEN operation.name = '" + EntityOperation.MOVE + "' THEN 5" +
" WHEN operation.name = '" + EntityOperation.PURGE + "' THEN 6" +
diff --git a/app/src/main/java/eu/faircode/email/EntityOperation.java b/app/src/main/java/eu/faircode/email/EntityOperation.java
index f706721236..bdb3dbffa1 100644
--- a/app/src/main/java/eu/faircode/email/EntityOperation.java
+++ b/app/src/main/java/eu/faircode/email/EntityOperation.java
@@ -101,6 +101,7 @@ public class EntityOperation {
static final String RULE = "rule";
static final String PURGE = "purge";
static final String EXPUNGE = "expunge";
+ static final String REPORT = "report";
private static final int MAX_FETCH = 100; // operations
private static final long FORCE_WITHIN = 30 * 1000; // milliseconds
diff --git a/app/src/main/java/eu/faircode/email/MessageHelper.java b/app/src/main/java/eu/faircode/email/MessageHelper.java
index 01e69fb6c6..e24145e911 100644
--- a/app/src/main/java/eu/faircode/email/MessageHelper.java
+++ b/app/src/main/java/eu/faircode/email/MessageHelper.java
@@ -2598,6 +2598,22 @@ public class MessageHelper {
return sb.toString();
}
+ Report getReport() throws MessagingException, IOException {
+ for (PartHolder h : extra)
+ if (h.isReport()) {
+ String result;
+ Object content = h.part.getContent();
+ if (content instanceof String)
+ result = (String) content;
+ else if (content instanceof InputStream)
+ result = Helper.readStream((InputStream) content);
+ else
+ result = content.toString();
+ return new Report(h.contentType.getBaseType(), result);
+ }
+ return null;
+ }
+
List getAttachmentParts() {
return attachments;
}
@@ -3789,6 +3805,14 @@ public class MessageHelper {
this.html = report.toString();
}
+ boolean isDeliveryStatus() {
+ return isDeliveryStatus(type);
+ }
+
+ boolean isDispositionNotification() {
+ return isDispositionNotification(type);
+ }
+
boolean isDelivered() {
return ("delivered".equals(action) || "relayed".equals(action) || "expanded".equals(action));
}