email: Set an envelope-from which may be different from the From: field.

The envelope-from is used by the MTA if the destination address is not
deliverable.  Route all such mail to the noreply address.
This commit is contained in:
Alex Vandiver 2021-01-25 19:20:36 -08:00 committed by Tim Abbott
parent 173d2dec3d
commit e53be6d043
10 changed files with 50 additions and 12 deletions

View File

@ -1422,7 +1422,8 @@ class StripeTest(StripeTestCase):
self.assertEqual(message.to[0], "desdemona+admin@zulip.com")
self.assertEqual(message.subject, "Sponsorship request (Open-source) for zulip")
self.assertEqual(message.reply_to, ["hamlet@zulip.com"])
self.assertIn("Zulip sponsorship <noreply-", message.from_email)
self.assertEqual(self.email_envelope_from(message), settings.NOREPLY_EMAIL_ADDRESS)
self.assertIn("Zulip sponsorship <noreply-", self.email_display_from(message))
self.assertIn("Requested by: King Hamlet (Member)", message.body)
self.assertIn(
"Support URL: http://zulip.testserver/activity/support?q=zulip", message.body

View File

@ -1,3 +1,6 @@
{% if from_email != envelope_from %}
<h4>Envelope-From: {{ envelope_from }}</h4>
{% endif %}
<h4>From: {{ from_email }}</h4>
{% if reply_to %}
<h4>Reply To:

View File

@ -148,7 +148,9 @@ def build_email(
if from_address == FromAddress.support_placeholder:
from_address = FromAddress.SUPPORT
from_email = str(Address(display_name=from_name, addr_spec=from_address))
# Set the "From" that is displayed separately from the envelope-from
extra_headers["From"] = str(Address(display_name=from_name, addr_spec=from_address))
reply_to = None
if reply_to_email is not None:
reply_to = [reply_to_email]
@ -158,8 +160,9 @@ def build_email(
elif from_address == FromAddress.NOREPLY:
reply_to = [FromAddress.NOREPLY]
envelope_from = FromAddress.NOREPLY
mail = EmailMultiAlternatives(
email_subject, message, from_email, to_emails, reply_to=reply_to, headers=extra_headers
email_subject, message, envelope_from, to_emails, reply_to=reply_to, headers=extra_headers
)
if html_message is not None:
mail.attach_alternative(html_message, "text/html")

View File

@ -13,6 +13,7 @@ import lxml.html
import orjson
from django.apps import apps
from django.conf import settings
from django.core.mail import EmailMessage
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from django.db.migrations.state import StateApps
@ -1160,6 +1161,25 @@ Output:
def ldap_password(self, uid: str) -> str:
return f"{uid}_ldap_password"
def email_display_from(self, email_message: EmailMessage) -> str:
"""
Returns the email address that will show in email clients as the
"From" field.
"""
# The extra_headers field may contain a "From" which is used
# for display in email clients, and appears in the RFC822
# header as `From`. The `.from_email` accessor is the
# "envelope from" address, used by mail transfer agents if
# the email bounces.
return email_message.extra_headers.get("From", email_message.from_email)
def email_envelope_from(self, email_message: EmailMessage) -> str:
"""
Returns the email address that will be used if the email bounces.
"""
# See email_display_from, above.
return email_message.from_email
class WebhookTestCase(ZulipTestCase):
"""

View File

@ -1,5 +1,6 @@
import datetime
from django.conf import settings
from django.core import mail
from django.utils.timezone import now
@ -116,8 +117,9 @@ class EmailChangeTestCase(ZulipTestCase):
)
body = email_message.body
self.assertIn("We received a request to change the email", body)
self.assertEqual(self.email_envelope_from(email_message), settings.NOREPLY_EMAIL_ADDRESS)
self.assertRegex(
email_message.from_email,
self.email_display_from(email_message),
fr"^Zulip Account Security <{self.TOKENIZED_NOREPLY_REGEX}>\Z",
)

View File

@ -311,7 +311,8 @@ class TestMissedMessages(ZulipTestCase):
self.assertEqual(len(mail.outbox), 1)
if send_as_user:
from_email = f'"{othello.full_name}" <{othello.email}>'
self.assertEqual(msg.from_email, from_email)
self.assertEqual(self.email_envelope_from(msg), settings.NOREPLY_EMAIL_ADDRESS)
self.assertEqual(self.email_display_from(msg), from_email)
self.assertEqual(msg.subject, email_subject)
self.assertEqual(len(msg.reply_to), 1)
self.assertIn(msg.reply_to[0], reply_to_emails)

View File

@ -377,8 +377,9 @@ class TestPasswordRestEmail(ZulipTestCase):
call_command(self.COMMAND_NAME, users=self.example_email("iago"))
from django.core.mail import outbox
self.assertEqual(self.email_envelope_from(outbox[0]), settings.NOREPLY_EMAIL_ADDRESS)
self.assertRegex(
outbox[0].from_email,
self.email_display_from(outbox[0]),
fr"^Zulip Account Security <{self.TOKENIZED_NOREPLY_REGEX}>\Z",
)
self.assertIn("reset your password", outbox[0].body)

View File

@ -282,8 +282,9 @@ class RealmTest(ZulipTestCase):
from django.core.mail import outbox
self.assertEqual(len(outbox), 1)
self.assertEqual(self.email_envelope_from(outbox[0]), settings.NOREPLY_EMAIL_ADDRESS)
self.assertRegex(
outbox[0].from_email,
self.email_display_from(outbox[0]),
fr"^Zulip Account Security <{self.TOKENIZED_NOREPLY_REGEX}>\Z",
)
self.assertIn("Reactivate your Zulip organization", outbox[0].subject)

View File

@ -349,8 +349,9 @@ class PasswordResetTest(ZulipTestCase):
from django.core.mail import outbox
[message] = outbox
self.assertEqual(self.email_envelope_from(message), settings.NOREPLY_EMAIL_ADDRESS)
self.assertRegex(
message.from_email,
self.email_display_from(message),
fr"^Zulip Account Security <{self.TOKENIZED_NOREPLY_REGEX}>\Z",
)
self.assertIn(f"{subdomain}.testserver", message.extra_headers["List-Id"])
@ -911,9 +912,12 @@ class InviteUserBase(ZulipTestCase):
return
if custom_from_name is not None:
self.assertIn(custom_from_name, outbox[0].from_email)
self.assertIn(custom_from_name, self.email_display_from(outbox[0]))
self.assertRegex(outbox[0].from_email, fr" <{self.TOKENIZED_NOREPLY_REGEX}>\Z")
self.assertEqual(self.email_envelope_from(outbox[0]), settings.NOREPLY_EMAIL_ADDRESS)
self.assertRegex(
self.email_display_from(outbox[0]), fr" <{self.TOKENIZED_NOREPLY_REGEX}>\Z"
)
self.assertEqual(outbox[0].extra_headers["List-Id"], "Zulip Dev <zulip.testserver>")
@ -1676,7 +1680,8 @@ so we didn't send them an invitation. We did send invitations to everyone else!"
for job in email_jobs_to_deliver:
deliver_email(job)
self.assertEqual(len(outbox), email_count + 1)
self.assertIn(FromAddress.NOREPLY, outbox[-1].from_email)
self.assertEqual(self.email_envelope_from(outbox[-1]), settings.NOREPLY_EMAIL_ADDRESS)
self.assertIn(FromAddress.NOREPLY, self.email_display_from(outbox[-1]))
# Now verify that signing up clears invite_reminder emails
with self.settings(EMAIL_BACKEND="django.core.mail.backends.console.EmailBackend"):

View File

@ -39,7 +39,8 @@ class EmailLogBackEnd(EmailBackend):
context = {
"subject": email.subject,
"from_email": email.from_email,
"envelope_from": email.from_email,
"from_email": email.extra_headers.get("From", email.from_email),
"reply_to": email.reply_to,
"recipients": email.to,
"body": email.body,