Skip to content

Commit 148fa2f

Browse files
fix: decode header fields for alert msg (ietf-tools#8034)
* style: Black * chore: type hints * refactor: % to f-string * refactor: helper to decode header values * fix: decode header fields for pop-up msg * test: add tests * fix: use truthiness check We want to suppress empty strings, too... * refactor: use f-string for time formatting * test: clarify side_effect intention
1 parent f7e0a67 commit 148fa2f

2 files changed

Lines changed: 195 additions & 16 deletions

File tree

ietf/utils/mail.py

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
from email.header import Header, decode_header
2020
from email import message_from_bytes, message_from_string
2121
from email import charset as Charset
22+
from typing import Optional
2223

2324
from django.conf import settings
2425
from django.contrib import messages
2526
from django.core.exceptions import ImproperlyConfigured, ValidationError
2627
from django.core.validators import validate_email
28+
from django.http import HttpRequest
2729
from django.template.loader import render_to_string
2830
from django.template import Context,RequestContext
2931
from django.utils import timezone
@@ -64,6 +66,18 @@ def add_headers(msg):
6466
msg['From'] = settings.DEFAULT_FROM_EMAIL
6567
return msg
6668

69+
70+
def decode_header_value(value: str) -> str:
71+
"""Decode a header value
72+
73+
Easier-to-use wrapper around email.message.decode_header()
74+
"""
75+
return "".join(
76+
part.decode(charset if charset else "utf-8") if isinstance(part, bytes) else part
77+
for part, charset in decode_header(value)
78+
)
79+
80+
6781
class SMTPSomeRefusedRecipients(smtplib.SMTPException):
6882

6983
def __init__(self, message, original_msg, refusals):
@@ -251,8 +265,7 @@ def parseaddr(addr):
251265
252266
"""
253267

254-
addr = ''.join( [ ( s.decode(m) if m else s.decode()) if isinstance(s, bytes) else s for (s,m) in decode_header(addr) ] )
255-
name, addr = simple_parseaddr(addr)
268+
name, addr = simple_parseaddr(decode_header_value(addr))
256269
return name, addr
257270

258271
def excludeaddrs(addrlist, exlist):
@@ -330,18 +343,45 @@ def condition_message(to, frm, subject, msg, cc, extra):
330343
msg['Message-ID'] = make_msgid()
331344

332345

333-
def show_that_mail_was_sent(request,leadline,msg,bcc):
334-
if request and request.user:
335-
from ietf.ietfauth.utils import has_role
336-
if has_role(request.user,['Area Director','Secretariat','IANA','RFC Editor','ISE','IAD','IRTF Chair','WG Chair','RG Chair','WG Secretary','RG Secretary']):
337-
info = "%s at %s %s\n" % (leadline,timezone.now().strftime("%Y-%m-%d %H:%M:%S"),settings.TIME_ZONE)
338-
info += "Subject: %s\n" % force_str(msg.get('Subject','[no subject]'))
339-
info += "To: %s\n" % msg.get('To','[no to]')
340-
if msg.get('Cc'):
341-
info += "Cc: %s\n" % msg.get('Cc')
342-
if bcc:
343-
info += "Bcc: %s\n" % bcc
344-
messages.info(request,info,extra_tags='preformatted',fail_silently=True)
346+
def show_that_mail_was_sent(request: HttpRequest, leadline: str, msg: Message, bcc: Optional[str]):
347+
if request and request.user:
348+
from ietf.ietfauth.utils import has_role
349+
350+
if has_role(
351+
request.user,
352+
[
353+
"Area Director",
354+
"Secretariat",
355+
"IANA",
356+
"RFC Editor",
357+
"ISE",
358+
"IAD",
359+
"IRTF Chair",
360+
"WG Chair",
361+
"RG Chair",
362+
"WG Secretary",
363+
"RG Secretary",
364+
],
365+
):
366+
subject = decode_header_value(msg.get("Subject", "[no subject]"))
367+
_to = decode_header_value(msg.get("To", "[no to]"))
368+
info_lines = [
369+
f"{leadline} at {timezone.now():%Y-%m-%d %H:%M:%S %Z}",
370+
f"Subject: {subject}",
371+
f"To: {_to}",
372+
]
373+
cc = msg.get("Cc", None)
374+
if cc:
375+
info_lines.append(f"Cc: {decode_header_value(cc)}")
376+
if bcc:
377+
info_lines.append(f"Bcc: {decode_header_value(bcc)}")
378+
messages.info(
379+
request,
380+
"\n".join(info_lines),
381+
extra_tags="preformatted",
382+
fail_silently=True,
383+
)
384+
345385

346386
def save_as_message(request, msg, bcc):
347387
by = ((request and request.user and not request.user.is_anonymous and request.user.person)

ietf/utils/tests.py

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
import shutil
1212
import types
1313

14-
from mock import patch
14+
from mock import call, patch
1515
from pyquery import PyQuery
1616
from typing import Dict, List # pyflakes:ignore
1717

18+
from email.message import Message
1819
from email.mime.image import MIMEImage
1920
from email.mime.multipart import MIMEMultipart
2021
from email.mime.text import MIMEText
@@ -32,6 +33,7 @@
3233
from django.template.defaulttags import URLNode
3334
from django.template.loader import get_template, render_to_string
3435
from django.templatetags.static import StaticNode
36+
from django.test import RequestFactory
3537
from django.urls import reverse as urlreverse
3638

3739
import debug # pyflakes:ignore
@@ -42,7 +44,15 @@
4244
from ietf.utils.draft import PlaintextDraft, getmeta
4345
from ietf.utils.fields import SearchableField
4446
from ietf.utils.log import unreachable, assertion
45-
from ietf.utils.mail import send_mail_preformatted, send_mail_text, send_mail_mime, outbox, get_payload_text
47+
from ietf.utils.mail import (
48+
send_mail_preformatted,
49+
send_mail_text,
50+
send_mail_mime,
51+
outbox,
52+
get_payload_text,
53+
decode_header_value,
54+
show_that_mail_was_sent,
55+
)
4656
from ietf.utils.test_runner import get_template_paths, set_coverage_checking
4757
from ietf.utils.test_utils import TestCase, unicontent
4858
from ietf.utils.text import parse_unicode
@@ -109,6 +119,135 @@ def test_send_mail_preformatted(self):
109119
recv = outbox[-1]
110120
self.assertEqual(recv['Fuzz'], 'bucket, monger')
111121

122+
123+
class MailUtilsTests(TestCase):
124+
def test_decode_header_value(self):
125+
self.assertEqual(
126+
decode_header_value("cake"),
127+
"cake",
128+
"decodes simple string value",
129+
)
130+
self.assertEqual(
131+
decode_header_value("=?utf-8?b?8J+Ogg==?="),
132+
"\U0001f382",
133+
"decodes single utf-8-encoded part",
134+
)
135+
self.assertEqual(
136+
decode_header_value("=?utf-8?b?8J+Ogg==?= = =?macintosh?b?jYxrjg==?="),
137+
"\U0001f382 = çåké",
138+
"decodes a value with non-utf-8 encodings",
139+
)
140+
141+
# Patch in a side_effect so we can distinguish values that came from decode_header_value.
142+
@patch("ietf.utils.mail.decode_header_value", side_effect=lambda s: f"decoded-{s}")
143+
@patch("ietf.utils.mail.messages")
144+
def test_show_that_mail_was_sent(self, mock_messages, mock_decode_header_value):
145+
request = RequestFactory().get("/some/path")
146+
request.user = object() # just needs to exist
147+
msg = Message()
148+
msg["To"] = "to-value"
149+
msg["Subject"] = "subject-value"
150+
msg["Cc"] = "cc-value"
151+
with patch("ietf.ietfauth.utils.has_role", return_value=True):
152+
show_that_mail_was_sent(request, "mail was sent", msg, "bcc-value")
153+
self.assertCountEqual(
154+
mock_decode_header_value.call_args_list,
155+
[call("to-value"), call("subject-value"), call("cc-value"), call("bcc-value")],
156+
)
157+
self.assertEqual(mock_messages.info.call_args[0][0], request)
158+
self.assertIn("mail was sent", mock_messages.info.call_args[0][1])
159+
self.assertIn("decoded-subject-value", mock_messages.info.call_args[0][1])
160+
self.assertIn("decoded-to-value", mock_messages.info.call_args[0][1])
161+
self.assertIn("decoded-cc-value", mock_messages.info.call_args[0][1])
162+
self.assertIn("decoded-bcc-value", mock_messages.info.call_args[0][1])
163+
mock_messages.reset_mock()
164+
mock_decode_header_value.reset_mock()
165+
166+
# no bcc
167+
with patch("ietf.ietfauth.utils.has_role", return_value=True):
168+
show_that_mail_was_sent(request, "mail was sent", msg, None)
169+
self.assertCountEqual(
170+
mock_decode_header_value.call_args_list,
171+
[call("to-value"), call("subject-value"), call("cc-value")],
172+
)
173+
self.assertEqual(mock_messages.info.call_args[0][0], request)
174+
self.assertIn("mail was sent", mock_messages.info.call_args[0][1])
175+
self.assertIn("decoded-subject-value", mock_messages.info.call_args[0][1])
176+
self.assertIn("decoded-to-value", mock_messages.info.call_args[0][1])
177+
self.assertIn("decoded-cc-value", mock_messages.info.call_args[0][1])
178+
# Note: here and below - when using assertNotIn(), leaving off the "decoded-" prefix
179+
# proves that neither the original value nor the decoded value appear.
180+
self.assertNotIn("bcc-value", mock_messages.info.call_args[0][1])
181+
mock_messages.reset_mock()
182+
mock_decode_header_value.reset_mock()
183+
184+
# no cc
185+
del msg["Cc"]
186+
with patch("ietf.ietfauth.utils.has_role", return_value=True):
187+
show_that_mail_was_sent(request, "mail was sent", msg, None)
188+
self.assertCountEqual(
189+
mock_decode_header_value.call_args_list,
190+
[call("to-value"), call("subject-value")],
191+
)
192+
self.assertEqual(mock_messages.info.call_args[0][0], request)
193+
self.assertIn("mail was sent", mock_messages.info.call_args[0][1])
194+
self.assertIn("decoded-subject-value", mock_messages.info.call_args[0][1])
195+
self.assertIn("decoded-to-value", mock_messages.info.call_args[0][1])
196+
self.assertNotIn("cc-value", mock_messages.info.call_args[0][1])
197+
self.assertNotIn("bcc-value", mock_messages.info.call_args[0][1])
198+
mock_messages.reset_mock()
199+
mock_decode_header_value.reset_mock()
200+
201+
# no to
202+
del msg["To"]
203+
with patch("ietf.ietfauth.utils.has_role", return_value=True):
204+
show_that_mail_was_sent(request, "mail was sent", msg, None)
205+
self.assertCountEqual(
206+
mock_decode_header_value.call_args_list,
207+
[call("[no to]"), call("subject-value")],
208+
)
209+
self.assertEqual(mock_messages.info.call_args[0][0], request)
210+
self.assertIn("mail was sent", mock_messages.info.call_args[0][1])
211+
self.assertIn("decoded-subject-value", mock_messages.info.call_args[0][1])
212+
self.assertIn("decoded-[no to]", mock_messages.info.call_args[0][1])
213+
self.assertNotIn("to-value", mock_messages.info.call_args[0][1])
214+
self.assertNotIn("cc-value", mock_messages.info.call_args[0][1])
215+
self.assertNotIn("bcc-value", mock_messages.info.call_args[0][1])
216+
mock_messages.reset_mock()
217+
mock_decode_header_value.reset_mock()
218+
219+
# no subject
220+
del msg["Subject"]
221+
with patch("ietf.ietfauth.utils.has_role", return_value=True):
222+
show_that_mail_was_sent(request, "mail was sent", msg, None)
223+
self.assertCountEqual(
224+
mock_decode_header_value.call_args_list,
225+
[call("[no to]"), call("[no subject]")],
226+
)
227+
self.assertEqual(mock_messages.info.call_args[0][0], request)
228+
self.assertIn("mail was sent", mock_messages.info.call_args[0][1])
229+
self.assertIn("decoded-[no subject]", mock_messages.info.call_args[0][1])
230+
self.assertNotIn("subject-value", mock_messages.info.call_args[0][1])
231+
self.assertIn("decoded-[no to]", mock_messages.info.call_args[0][1])
232+
self.assertNotIn("to-value", mock_messages.info.call_args[0][1])
233+
self.assertNotIn("cc-value", mock_messages.info.call_args[0][1])
234+
self.assertNotIn("bcc-value", mock_messages.info.call_args[0][1])
235+
mock_messages.reset_mock()
236+
mock_decode_header_value.reset_mock()
237+
238+
# user does not have role
239+
with patch("ietf.ietfauth.utils.has_role", return_value=False):
240+
show_that_mail_was_sent(request, "mail was sent", msg, None)
241+
self.assertFalse(mock_messages.called)
242+
243+
# no user
244+
request.user = None
245+
with patch("ietf.ietfauth.utils.has_role", return_value=True) as mock_has_role:
246+
show_that_mail_was_sent(request, "mail was sent", msg, None)
247+
self.assertFalse(mock_messages.called)
248+
self.assertFalse(mock_has_role.called)
249+
250+
112251
class TestSMTPServer(TestCase):
113252

114253
def test_address_rejected(self):

0 commit comments

Comments
 (0)