Merge lp:~julian-edwards/launchpad/utf-codes-in-emails-bug-817106 into lp:launchpad

Proposed by Julian Edwards
Status: Merged
Approved by: Julian Edwards
Approved revision: no longer in the source branch.
Merged at revision: 13558
Proposed branch: lp:~julian-edwards/launchpad/utf-codes-in-emails-bug-817106
Merge into: lp:launchpad
Diff against target: 286 lines (+110/-37)
2 files modified
lib/lp/soyuz/adapters/notification.py (+42/-17)
lib/lp/soyuz/adapters/tests/test_notification.py (+68/-20)
To merge this branch: bzr merge lp:~julian-edwards/launchpad/utf-codes-in-emails-bug-817106
Reviewer Review Type Date Requested Status
Abel Deuring (community) code Approve
Review via email: mp+69758@code.launchpad.net

Commit message

[r=adeuring][bug=817106] Make notification emails cope with UTF8 in the message.

Description of the change

= Summary =
Make notification emails cope with UTF8.

== Proposed fix ==
Look at https://lists.ubuntu.com/archives/oneiric-changes/2011-July/005553.html
which was the first ever sync done in production - the notification email
has mangled the Changed-By: in the body of the email. We need to fix that!

== Implementation details ==
Apart from Python being an utter PITA when dealing with unicode, the fix was
mostly straightforward apart from changing the fetch_information() function
to return a dictionary instead of that crazy tuple.

Using a dict makes it easier to add more returned data without affecting all
the callsites that don't care about that extra data.

The basic problem was that fetch_information() was assuming that the email
addresses it was returning would only ever be used in the email headers and
encoding them accordingly. I've added extra keys in the dict that contain
the email addresses in unicode strings instead of escaped ascii for headers,
then the whole email body is converted to utf8 before sending.

== Tests ==
bin/test -cvv test_notification

== Demo and Q/A ==
I'll be doing some syncs on staging/dogfood and examining the emails in
the staging inbox.

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/soyuz/adapters/notification.py
  lib/lp/soyuz/adapters/tests/test_notification.py

To post a comment you must log in.
Revision history for this message
Abel Deuring (adeuring) wrote :

nice branch!

Just a remark:

> +# -*- coding: utf-8 -*-
> +# NOTE: The first line above must stay first; do not move the copyright
> +# notice to the top. See http://www.python.org/dev/peps/pep-0263/.
[...]
> + RANDOM_UNICODE = u"Loïc"

I think we avoided to add the "coding: utf-8" line up to now and used string constants like u'Lo\u1234c' instead. But I think it is safe to assume that all editors we use work properly with utf-8 encoded files.

review: Approve (code)
Revision history for this message
Julian Edwards (julian-edwards) wrote :

Thanks Abel.

test_ppauploadprocessor.py does this trick too, I think I prefer seeing the real unicode compared to the crazy escape chars! And as you say, I would hope most editors deal with that now.

Revision history for this message
Robert Collins (lifeless) wrote :

FTR I'm totally fine with utf8 file encoding; I think our docs /
standards say otherwise somewhere - care to have a quick look on the
wiki / in tree and refresh them with this change?

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/soyuz/adapters/notification.py'
2--- lib/lp/soyuz/adapters/notification.py 2011-07-28 16:06:14 +0000
3+++ lib/lp/soyuz/adapters/notification.py 2011-07-29 09:21:46 +0000
4@@ -13,6 +13,7 @@
5
6 from email.mime.multipart import MIMEMultipart
7 from email.mime.text import MIMEText
8+from email.utils import formataddr
9 import os
10
11 from zope.component import getUtility
12@@ -218,6 +219,7 @@
13 body = assemble_body(
14 blamer, spr, bprs, archive, distroseries, summarystring, changes,
15 action)
16+ body = body.encode("utf8")
17 send_mail(
18 spr, archive, recipients, subject, body, dry_run,
19 changesfile_content=changesfile_content,
20@@ -226,8 +228,8 @@
21
22 build_and_send_mail(action, recipients)
23
24- (changesfile, date, from_addr, maintainer) = fetch_information(
25- spr, bprs, changes)
26+ info = fetch_information(spr, bprs, changes)
27+ from_addr = info['changedby']
28 if announce_from_person is not None:
29 email = announce_from_person.preferredemail
30 if email:
31@@ -261,13 +263,12 @@
32 """Assemble the e-mail notification body."""
33 if changes is None:
34 changes = {}
35- (changesfile, date, changedby, maintainer) = fetch_information(
36- spr, bprs, changes)
37+ info = fetch_information(spr, bprs, changes)
38 information = {
39 'STATUS': ACTION_DESCRIPTIONS[action],
40 'SUMMARY': summary,
41- 'DATE': 'Date: %s' % date,
42- 'CHANGESFILE': changesfile,
43+ 'DATE': 'Date: %s' % info['date'],
44+ 'CHANGESFILE': info['changesfile'],
45 'DISTRO': distroseries.distribution.title,
46 'ANNOUNCE': 'No announcement sent',
47 'CHANGEDBY': '',
48@@ -279,8 +280,9 @@
49 }
50 if spr:
51 information['SPR_URL'] = canonical_url(spr)
52- if changedby:
53- information['CHANGEDBY'] = '\nChanged-By: %s' % changedby
54+ changedby_displayname = info['changedby_displayname']
55+ if changedby_displayname:
56+ information['CHANGEDBY'] = '\nChanged-By: %s' % changedby_displayname
57 origin = changes.get('Origin')
58 if origin:
59 information['ORIGIN'] = '\nOrigin: %s' % origin
60@@ -291,7 +293,7 @@
61 information['ANNOUNCE'] = "Announcing to %s" % (
62 distroseries.changeslist)
63 try:
64- changedby_person = email_to_person(changedby)
65+ changedby_person = email_to_person(info['changedby'])
66 except ParseMaintError:
67 # Some syncs (e.g. from Debian) will involve packages whose
68 # changed-by person was auto-created in LP and hence does not
69@@ -299,9 +301,11 @@
70 changedby_person = None
71 if blamer is not None and blamer != changedby_person:
72 signer_signature = person_to_email(blamer)
73- if signer_signature != changedby:
74+ if signer_signature != info['changedby']:
75 information['SIGNER'] = '\nSigned-By: %s' % signer_signature
76 # Add maintainer if present and different from changed-by.
77+ maintainer = info['maintainer']
78+ changedby = info['changedby']
79 if maintainer and maintainer != changedby:
80 information['MAINTAINER'] = '\nMaintainer: %s' % maintainer
81 return get_template(archive, action) % information
82@@ -443,20 +447,19 @@
83 """Return a list of recipients for notification emails."""
84 candidate_recipients = []
85 debug(logger, "Building recipients list.")
86- (changesfile, date, changedby, maint) = fetch_information(
87- spr, bprs, changes)
88+ info = fetch_information(spr, bprs, changes)
89
90- if changedby:
91+ if info['changedby']:
92 try:
93- changer = email_to_person(changedby)
94+ changer = email_to_person(info['changedby'])
95 except ParseMaintError:
96 changer = None
97 else:
98 changer = None
99
100- if maint:
101+ if info['maintainer']:
102 try:
103- maintainer = email_to_person(maint)
104+ maintainer = email_to_person(info['maintainer'])
105 except ParseMaintError:
106 maintainer = None
107 else:
108@@ -567,6 +570,7 @@
109
110 def person_to_email(person):
111 """Return a string of full name <e-mail address> given an IPerson."""
112+ # This will use email.Header to encode any unicode.
113 if person and person.preferredemail:
114 return format_address_for_person(person)
115
116@@ -590,13 +594,18 @@
117
118 def fetch_information(spr, bprs, changes):
119 changedby = None
120+ changedby_displayname = None
121 maintainer = None
122+ maintainer_displayname = None
123+
124 if changes:
125 changesfile = ChangesFile.formatChangesComment(
126 sanitize_string(changes.get('Changes')))
127 date = changes.get('Date')
128 changedby = sanitize_string(changes.get('Changed-By'))
129 maintainer = sanitize_string(changes.get('Maintainer'))
130+ changedby_displayname = changedby
131+ maintainer_displayname = maintainer
132 elif spr or bprs:
133 if not spr and bprs:
134 spr = bprs[0].build.source_package_release
135@@ -604,9 +613,25 @@
136 date = spr.dateuploaded
137 changedby = person_to_email(spr.creator)
138 maintainer = person_to_email(spr.maintainer)
139+ if changedby:
140+ addr = formataddr((spr.creator.displayname,
141+ spr.creator.preferredemail.email))
142+ changedby_displayname = sanitize_string(addr)
143+ if maintainer:
144+ addr = formataddr((spr.maintainer.displayname,
145+ spr.maintainer.preferredemail.email))
146+ maintainer_displayname = sanitize_string(addr)
147 else:
148 changesfile = date = None
149- return (changesfile, date, changedby, maintainer)
150+
151+ return {
152+ 'changesfile': changesfile,
153+ 'date': date,
154+ 'changedby': changedby,
155+ 'changedby_displayname': changedby_displayname,
156+ 'maintainer': maintainer,
157+ 'maintainer_displayname': maintainer_displayname,
158+ }
159
160
161 class LanguagePackEncountered(Exception):
162
163=== modified file 'lib/lp/soyuz/adapters/tests/test_notification.py'
164--- lib/lp/soyuz/adapters/tests/test_notification.py 2011-07-28 14:02:39 +0000
165+++ lib/lp/soyuz/adapters/tests/test_notification.py 2011-07-29 09:21:46 +0000
166@@ -1,6 +1,11 @@
167+# -*- coding: utf-8 -*-
168+# NOTE: The first line above must stay first; do not move the copyright
169+# notice to the top. See http://www.python.org/dev/peps/pep-0263/.
170+#
171 # Copyright 2011 Canonical Ltd. This software is licensed under the
172 # GNU Affero General Public License version 3 (see the file LICENSE).
173
174+from email.utils import formataddr
175 from storm.store import Store
176 from zope.component import getUtility
177 from zope.security.proxy import removeSecurityProxy
178@@ -37,6 +42,27 @@
179
180 layer = LaunchpadZopelessLayer
181
182+ def test_notify_from_unicode_names(self):
183+ # People with unicode in their names should appear correctly in the
184+ # email and not get smashed to ASCII or otherwise transliterated.
185+ RANDOM_UNICODE = u"Loïc"
186+ creator = self.factory.makePerson(displayname=RANDOM_UNICODE)
187+ spr = self.factory.makeSourcePackageRelease(creator=creator)
188+ self.factory.makeSourcePackageReleaseFile(sourcepackagerelease=spr)
189+ archive = self.factory.makeArchive(purpose=ArchivePurpose.PRIMARY)
190+ pocket = PackagePublishingPocket.RELEASE
191+ distroseries = self.factory.makeDistroSeries()
192+ distroseries.changeslist = "blah@example.com"
193+ blamer = self.factory.makePerson()
194+ notify(
195+ blamer, spr, [], [], archive, distroseries, pocket,
196+ action='accepted')
197+ notifications = pop_notifications()
198+ self.assertEqual(2, len(notifications))
199+ msg = notifications[1].get_payload(0)
200+ body = msg.get_payload(decode=True)
201+ self.assertIn("Loïc", body)
202+
203 def test_calculate_subject_customfile(self):
204 lfa = self.factory.makeLibraryFileAlias()
205 package_upload = self.factory.makePackageUpload()
206@@ -87,33 +113,56 @@
207 'Maintainer': 'Foo Bar <foo.bar@canonical.com>',
208 'Changes': ' * Foo!',
209 }
210- (changesfile, date, changedby, maintainer) = fetch_information(
211+ info = fetch_information(
212 None, None, changes)
213- self.assertEqual('2001-01-01', date)
214- self.assertEqual(' * Foo!', changesfile)
215- for field in (changedby, maintainer):
216+ self.assertEqual('2001-01-01', info['date'])
217+ self.assertEqual(' * Foo!', info['changesfile'])
218+ fields = [
219+ info['changedby'],
220+ info['maintainer'],
221+ info['changedby_displayname'],
222+ info['maintainer_displayname'],
223+ ]
224+ for field in fields:
225 self.assertEqual('Foo Bar <foo.bar@canonical.com>', field)
226
227 def test_fetch_information_spr(self):
228- spr = self.factory.makeSourcePackageRelease()
229- (changesfile, date, changedby, maintainer) = fetch_information(
230- spr, None, None)
231- self.assertEqual(date, spr.dateuploaded)
232- self.assertEqual(changesfile, spr.changelog_entry)
233- self.assertEqual(changedby, format_address_for_person(spr.creator))
234- self.assertEqual(
235- maintainer, format_address_for_person(spr.maintainer))
236+ creator = self.factory.makePerson(displayname=u"foø")
237+ maintainer = self.factory.makePerson(displayname=u"bær")
238+ spr = self.factory.makeSourcePackageRelease(
239+ creator=creator, maintainer=maintainer)
240+ info = fetch_information(spr, None, None)
241+ self.assertEqual(info['date'], spr.dateuploaded)
242+ self.assertEqual(info['changesfile'], spr.changelog_entry)
243+ self.assertEqual(
244+ info['changedby'], format_address_for_person(spr.creator))
245+ self.assertEqual(
246+ info['maintainer'], format_address_for_person(spr.maintainer))
247+ self.assertEqual(
248+ u"foø <%s>" % spr.creator.preferredemail.email,
249+ info['changedby_displayname'])
250+ self.assertEqual(
251+ u"bær <%s>" % spr.maintainer.preferredemail.email,
252+ info['maintainer_displayname'])
253
254 def test_fetch_information_bprs(self):
255 bpr = self.factory.makeBinaryPackageRelease()
256- (changesfile, date, changedby, maintainer) = fetch_information(
257- None, [bpr], None)
258+ info = fetch_information(None, [bpr], None)
259 spr = bpr.build.source_package_release
260- self.assertEqual(date, spr.dateuploaded)
261- self.assertEqual(changesfile, spr.changelog_entry)
262- self.assertEqual(changedby, format_address_for_person(spr.creator))
263- self.assertEqual(
264- maintainer, format_address_for_person(spr.maintainer))
265+ self.assertEqual(info['date'], spr.dateuploaded)
266+ self.assertEqual(info['changesfile'], spr.changelog_entry)
267+ self.assertEqual(
268+ info['changedby'], format_address_for_person(spr.creator))
269+ self.assertEqual(
270+ info['maintainer'], format_address_for_person(spr.maintainer))
271+ self.assertEqual(
272+ info['changedby_displayname'],
273+ formataddr((spr.creator.displayname,
274+ spr.creator.preferredemail.email)))
275+ self.assertEqual(
276+ info['maintainer_displayname'],
277+ formataddr((spr.maintainer.displayname,
278+ spr.maintainer.preferredemail.email)))
279
280 def test_calculate_subject_spr(self):
281 spr = self.factory.makeSourcePackageRelease()
282@@ -291,4 +340,3 @@
283 result = is_auto_sync_upload(
284 spr=None, bprs=None, pocket=None, changed_by_email=None)
285 self.assertFalse(result)
286-