Repository: zulip/zulip

Issue:
Replying to zulip email notification bounces
Today I replied to two Zulip notification (recurse.zulipchat.com) from a Cafe from Gmail for iPhone and all the emails bounced back. 

Here are two responses without message body

```
This is the mail system at host prod0.zulipchat.net.

I'm sorry to have to inform you that your message could not
be delivered to one or more recipients. It's attached below.

For further assistance, please send mail to postmaster.

If you do so, please include this problem report. You can
delete your own text from the attached returned message.

                   The mail system

<zulip@localhost> (expanded from
    <mm41cc49189e47a2571326153aa10edb0c@prod0.zulipchat.net>): Bad destination
    mailbox address: Bad or expired missed message address.

Final-Recipient: rfc822; zulip@localhost
Original-Recipient: rfc822;mm41cc49189e47a2571326153aa10edb0c@prod0.zulipchat.net
Action: failed
Status: 5.1.1
Diagnostic-Code: x-unix; Bad destination mailbox address: Bad or expired missed
    message address.

---------- Forwarded message ----------
From: Kracekumar Ramaraj <me@kracekumar.com>
To: mm41cc49189e47a2571326153aa10edb0c@streams.zulipchat.com
Cc: 
Date: Fri, 16 Dec 2016 20:12:56 +0000
Subject: <Stripped>
I am on the move, zulip should also work.
```

Second one was 8 days old Email.

```
This is the mail system at host prod0.zulipchat.net.

I'm sorry to have to inform you that your message could not
be delivered to one or more recipients. It's attached below.

For further assistance, please send mail to postmaster.

If you do so, please include this problem report. You can
delete your own text from the attached returned message.

                   The mail system

<zulip@localhost> (expanded from
    <mmebf1a6e73b9270d7f2de15c8bb917929@prod0.zulipchat.net>): Bad destination
    mailbox address: Bad or expired missed message address.

Final-Recipient: rfc822; zulip@localhost
Original-Recipient: rfc822;mmebf1a6e73b9270d7f2de15c8bb917929@prod0.zulipchat.net
Action: failed
Status: 5.1.1
Diagnostic-Code: x-unix; Bad destination mailbox address: Bad or expired missed
    message address.


---------- Forwarded message ----------
From: Kracekumar Ramaraj <me@kracekumar.com>
To: mmebf1a6e73b9270d7f2de15c8bb917929@streams.zulipchat.com
Cc: 
Date: Fri, 16 Dec 2016 20:08:12 +0000
Subject: <Stripped>
```

I am happy to furnish more information.
Replying more than once to a Zulip email notification bounces
One may reply via email to _Zulip notifications_ emails (but beware of #2755), but sending one reply seems to also invalidate the email address, and further replies (like you'd add more messages in the web UI) generate an email _Undelivered Mail Returned to Sender_ bounce message, because _Bad destination mailbox address: Missed message address out of uses_.  That's annoying.


Discussion:
Thanks for the bug report @kracekumar!  I think what's going on is that the current security policy for missed-message reply-to email addresses means they expire after some period that's I think a week.  

I wonder what the right strategy for these should be.  
I expected it to work like standard e-mail reply for GH issues (ignoring locked conversation). I wouldn't expect an expiry since I can reply to any old private message from the web UI.
I am curious to know the reason for expiry and security concern.

 
I think a reasonable approach is to either remove the expiry for these or mention it in both the email and the bounce notification so users know what's up.
This issue is due to ALLOWED_USES defined in `zerver/models.py` line 3234:

```
class MissedMessageEmailAddress(models.Model):
    EXPIRY_SECONDS = 60 * 60 * 24 * 5
    ALLOWED_USES = 1

    ...

    def is_usable(self) -> bool:
        not_expired = timezone_now() <= self.timestamp + timedelta(seconds=self.EXPIRY_SECONDS)
        has_uses_left = self.times_used < self.ALLOWED_USES
        return has_uses_left and not_expired

```

This would be easy enough to change.  I don't understand why this setting defaults to 1, or whether there is any risk/downside to increasing it.

Resolving patch:
diff --git a/zerver/lib/email_mirror.py b/zerver/lib/email_mirror.py
--- a/zerver/lib/email_mirror.py
+++ b/zerver/lib/email_mirror.py
@@ -6,8 +6,6 @@
 from typing import Dict, List, Match, Optional, Tuple
 
 from django.conf import settings
-from django.utils.timezone import now as timezone_now
-from django.utils.timezone import timedelta
 
 from zerver.actions.message_send import (
     check_send_message,
@@ -134,18 +132,9 @@ def get_missed_message_token_from_address(address: str) -> str:
 def get_usable_missed_message_address(address: str) -> MissedMessageEmailAddress:
     token = get_missed_message_token_from_address(address)
     try:
-        mm_address = MissedMessageEmailAddress.objects.select_related().get(
-            email_token=token,
-            timestamp__gt=timezone_now()
-            - timedelta(seconds=MissedMessageEmailAddress.EXPIRY_SECONDS),
-        )
+        mm_address = MissedMessageEmailAddress.objects.select_related().get(email_token=token)
     except MissedMessageEmailAddress.DoesNotExist:
-        raise ZulipEmailForwardUserError("Missed message address expired or doesn't exist.")
-
-    if not mm_address.is_usable():
-        # Technical, this also checks whether the event is expired,
-        # but that case is excluded by the logic above.
-        raise ZulipEmailForwardUserError("Missed message address out of uses.")
+        raise ZulipEmailForwardError("Zulip notification reply address is invalid.")
 
     return mm_address
 
diff --git a/zerver/models.py b/zerver/models.py
--- a/zerver/models.py
+++ b/zerver/models.py
@@ -4220,27 +4220,19 @@ def __str__(self) -> str:
 
 
 class MissedMessageEmailAddress(models.Model):
-    EXPIRY_SECONDS = 60 * 60 * 24 * 5
-    ALLOWED_USES = 1
-
     id: int = models.AutoField(auto_created=True, primary_key=True, verbose_name="ID")
     message: Message = models.ForeignKey(Message, on_delete=CASCADE)
     user_profile: UserProfile = models.ForeignKey(UserProfile, on_delete=CASCADE)
     email_token: str = models.CharField(max_length=34, unique=True, db_index=True)
 
     # Timestamp of when the missed message address generated.
-    # The address is valid until timestamp + EXPIRY_SECONDS.
     timestamp: datetime.datetime = models.DateTimeField(db_index=True, default=timezone_now)
+    # Number of times the missed message address has been used.
     times_used: int = models.PositiveIntegerField(default=0, db_index=True)
 
     def __str__(self) -> str:
         return settings.EMAIL_GATEWAY_PATTERN % (self.email_token,)
 
-    def is_usable(self) -> bool:
-        not_expired = timezone_now() <= self.timestamp + timedelta(seconds=self.EXPIRY_SECONDS)
-        has_uses_left = self.times_used < self.ALLOWED_USES
-        return has_uses_left and not_expired
-
     def increment_times_used(self) -> None:
         self.times_used += 1
         self.save(update_fields=["times_used"])


Test patch:
diff --git a/zerver/tests/test_email_mirror.py b/zerver/tests/test_email_mirror.py
--- a/zerver/tests/test_email_mirror.py
+++ b/zerver/tests/test_email_mirror.py
@@ -39,7 +39,6 @@
 from zerver.lib.test_helpers import mock_queue_publish, most_recent_message, most_recent_usermessage
 from zerver.models import (
     Attachment,
-    MissedMessageEmailAddress,
     Recipient,
     Stream,
     UserProfile,
@@ -1246,10 +1245,8 @@ def test_missed_message_email_multiple_responses(self) -> None:
         incoming_valid_message["To"] = mm_address
         incoming_valid_message["Reply-to"] = user_profile.delivery_email
 
-        for i in range(0, MissedMessageEmailAddress.ALLOWED_USES):
-            process_missed_message(mm_address, incoming_valid_message)
-
-        with self.assertRaises(ZulipEmailForwardError):
+        # there is no longer a usage limit.  Ensure we can send multiple times.
+        for i in range(0, 5):
             process_missed_message(mm_address, incoming_valid_message)
 
 
@@ -1502,22 +1499,18 @@ def test_success_to_private(self) -> None:
 
     def test_using_mm_address_multiple_times(self) -> None:
         mm_address = self.send_private_message()
-        for i in range(0, MissedMessageEmailAddress.ALLOWED_USES):
+        # there is no longer a usage limit.  Ensure we can send multiple times.
+        for i in range(0, 5):
             result = self.send_offline_message(mm_address, self.example_user("cordelia"))
             self.assert_json_success(result)
 
-        result = self.send_offline_message(mm_address, self.example_user("cordelia"))
-        self.assert_json_error(
-            result, "5.1.1 Bad destination mailbox address: Missed message address out of uses."
-        )
-
     def test_wrong_missed_email_private_message(self) -> None:
         self.send_private_message()
         mm_address = "mm" + ("x" * 32) + "@testserver"
         result = self.send_offline_message(mm_address, self.example_user("cordelia"))
         self.assert_json_error(
             result,
-            "5.1.1 Bad destination mailbox address: Missed message address expired or doesn't exist.",
+            "5.1.1 Bad destination mailbox address: Zulip notification reply address is invalid.",
         )