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
=== modified file 'lib/lp/soyuz/adapters/notification.py'
--- lib/lp/soyuz/adapters/notification.py 2011-07-28 16:06:14 +0000
+++ lib/lp/soyuz/adapters/notification.py 2011-07-29 09:21:46 +0000
@@ -13,6 +13,7 @@
1313
14from email.mime.multipart import MIMEMultipart14from email.mime.multipart import MIMEMultipart
15from email.mime.text import MIMEText15from email.mime.text import MIMEText
16from email.utils import formataddr
16import os17import os
1718
18from zope.component import getUtility19from zope.component import getUtility
@@ -218,6 +219,7 @@
218 body = assemble_body(219 body = assemble_body(
219 blamer, spr, bprs, archive, distroseries, summarystring, changes,220 blamer, spr, bprs, archive, distroseries, summarystring, changes,
220 action)221 action)
222 body = body.encode("utf8")
221 send_mail(223 send_mail(
222 spr, archive, recipients, subject, body, dry_run,224 spr, archive, recipients, subject, body, dry_run,
223 changesfile_content=changesfile_content,225 changesfile_content=changesfile_content,
@@ -226,8 +228,8 @@
226228
227 build_and_send_mail(action, recipients)229 build_and_send_mail(action, recipients)
228230
229 (changesfile, date, from_addr, maintainer) = fetch_information(231 info = fetch_information(spr, bprs, changes)
230 spr, bprs, changes)232 from_addr = info['changedby']
231 if announce_from_person is not None:233 if announce_from_person is not None:
232 email = announce_from_person.preferredemail234 email = announce_from_person.preferredemail
233 if email:235 if email:
@@ -261,13 +263,12 @@
261 """Assemble the e-mail notification body."""263 """Assemble the e-mail notification body."""
262 if changes is None:264 if changes is None:
263 changes = {}265 changes = {}
264 (changesfile, date, changedby, maintainer) = fetch_information(266 info = fetch_information(spr, bprs, changes)
265 spr, bprs, changes)
266 information = {267 information = {
267 'STATUS': ACTION_DESCRIPTIONS[action],268 'STATUS': ACTION_DESCRIPTIONS[action],
268 'SUMMARY': summary,269 'SUMMARY': summary,
269 'DATE': 'Date: %s' % date,270 'DATE': 'Date: %s' % info['date'],
270 'CHANGESFILE': changesfile,271 'CHANGESFILE': info['changesfile'],
271 'DISTRO': distroseries.distribution.title,272 'DISTRO': distroseries.distribution.title,
272 'ANNOUNCE': 'No announcement sent',273 'ANNOUNCE': 'No announcement sent',
273 'CHANGEDBY': '',274 'CHANGEDBY': '',
@@ -279,8 +280,9 @@
279 }280 }
280 if spr:281 if spr:
281 information['SPR_URL'] = canonical_url(spr)282 information['SPR_URL'] = canonical_url(spr)
282 if changedby:283 changedby_displayname = info['changedby_displayname']
283 information['CHANGEDBY'] = '\nChanged-By: %s' % changedby284 if changedby_displayname:
285 information['CHANGEDBY'] = '\nChanged-By: %s' % changedby_displayname
284 origin = changes.get('Origin')286 origin = changes.get('Origin')
285 if origin:287 if origin:
286 information['ORIGIN'] = '\nOrigin: %s' % origin288 information['ORIGIN'] = '\nOrigin: %s' % origin
@@ -291,7 +293,7 @@
291 information['ANNOUNCE'] = "Announcing to %s" % (293 information['ANNOUNCE'] = "Announcing to %s" % (
292 distroseries.changeslist)294 distroseries.changeslist)
293 try:295 try:
294 changedby_person = email_to_person(changedby)296 changedby_person = email_to_person(info['changedby'])
295 except ParseMaintError:297 except ParseMaintError:
296 # Some syncs (e.g. from Debian) will involve packages whose298 # Some syncs (e.g. from Debian) will involve packages whose
297 # changed-by person was auto-created in LP and hence does not299 # changed-by person was auto-created in LP and hence does not
@@ -299,9 +301,11 @@
299 changedby_person = None301 changedby_person = None
300 if blamer is not None and blamer != changedby_person:302 if blamer is not None and blamer != changedby_person:
301 signer_signature = person_to_email(blamer)303 signer_signature = person_to_email(blamer)
302 if signer_signature != changedby:304 if signer_signature != info['changedby']:
303 information['SIGNER'] = '\nSigned-By: %s' % signer_signature305 information['SIGNER'] = '\nSigned-By: %s' % signer_signature
304 # Add maintainer if present and different from changed-by.306 # Add maintainer if present and different from changed-by.
307 maintainer = info['maintainer']
308 changedby = info['changedby']
305 if maintainer and maintainer != changedby:309 if maintainer and maintainer != changedby:
306 information['MAINTAINER'] = '\nMaintainer: %s' % maintainer310 information['MAINTAINER'] = '\nMaintainer: %s' % maintainer
307 return get_template(archive, action) % information311 return get_template(archive, action) % information
@@ -443,20 +447,19 @@
443 """Return a list of recipients for notification emails."""447 """Return a list of recipients for notification emails."""
444 candidate_recipients = []448 candidate_recipients = []
445 debug(logger, "Building recipients list.")449 debug(logger, "Building recipients list.")
446 (changesfile, date, changedby, maint) = fetch_information(450 info = fetch_information(spr, bprs, changes)
447 spr, bprs, changes)
448451
449 if changedby:452 if info['changedby']:
450 try:453 try:
451 changer = email_to_person(changedby)454 changer = email_to_person(info['changedby'])
452 except ParseMaintError:455 except ParseMaintError:
453 changer = None456 changer = None
454 else:457 else:
455 changer = None458 changer = None
456459
457 if maint:460 if info['maintainer']:
458 try:461 try:
459 maintainer = email_to_person(maint)462 maintainer = email_to_person(info['maintainer'])
460 except ParseMaintError:463 except ParseMaintError:
461 maintainer = None464 maintainer = None
462 else:465 else:
@@ -567,6 +570,7 @@
567570
568def person_to_email(person):571def person_to_email(person):
569 """Return a string of full name <e-mail address> given an IPerson."""572 """Return a string of full name <e-mail address> given an IPerson."""
573 # This will use email.Header to encode any unicode.
570 if person and person.preferredemail:574 if person and person.preferredemail:
571 return format_address_for_person(person)575 return format_address_for_person(person)
572576
@@ -590,13 +594,18 @@
590594
591def fetch_information(spr, bprs, changes):595def fetch_information(spr, bprs, changes):
592 changedby = None596 changedby = None
597 changedby_displayname = None
593 maintainer = None598 maintainer = None
599 maintainer_displayname = None
600
594 if changes:601 if changes:
595 changesfile = ChangesFile.formatChangesComment(602 changesfile = ChangesFile.formatChangesComment(
596 sanitize_string(changes.get('Changes')))603 sanitize_string(changes.get('Changes')))
597 date = changes.get('Date')604 date = changes.get('Date')
598 changedby = sanitize_string(changes.get('Changed-By'))605 changedby = sanitize_string(changes.get('Changed-By'))
599 maintainer = sanitize_string(changes.get('Maintainer'))606 maintainer = sanitize_string(changes.get('Maintainer'))
607 changedby_displayname = changedby
608 maintainer_displayname = maintainer
600 elif spr or bprs:609 elif spr or bprs:
601 if not spr and bprs:610 if not spr and bprs:
602 spr = bprs[0].build.source_package_release611 spr = bprs[0].build.source_package_release
@@ -604,9 +613,25 @@
604 date = spr.dateuploaded613 date = spr.dateuploaded
605 changedby = person_to_email(spr.creator)614 changedby = person_to_email(spr.creator)
606 maintainer = person_to_email(spr.maintainer)615 maintainer = person_to_email(spr.maintainer)
616 if changedby:
617 addr = formataddr((spr.creator.displayname,
618 spr.creator.preferredemail.email))
619 changedby_displayname = sanitize_string(addr)
620 if maintainer:
621 addr = formataddr((spr.maintainer.displayname,
622 spr.maintainer.preferredemail.email))
623 maintainer_displayname = sanitize_string(addr)
607 else:624 else:
608 changesfile = date = None625 changesfile = date = None
609 return (changesfile, date, changedby, maintainer)626
627 return {
628 'changesfile': changesfile,
629 'date': date,
630 'changedby': changedby,
631 'changedby_displayname': changedby_displayname,
632 'maintainer': maintainer,
633 'maintainer_displayname': maintainer_displayname,
634 }
610635
611636
612class LanguagePackEncountered(Exception):637class LanguagePackEncountered(Exception):
613638
=== modified file 'lib/lp/soyuz/adapters/tests/test_notification.py'
--- lib/lp/soyuz/adapters/tests/test_notification.py 2011-07-28 14:02:39 +0000
+++ lib/lp/soyuz/adapters/tests/test_notification.py 2011-07-29 09:21:46 +0000
@@ -1,6 +1,11 @@
1# -*- coding: utf-8 -*-
2# NOTE: The first line above must stay first; do not move the copyright
3# notice to the top. See http://www.python.org/dev/peps/pep-0263/.
4#
1# Copyright 2011 Canonical Ltd. This software is licensed under the5# Copyright 2011 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).6# GNU Affero General Public License version 3 (see the file LICENSE).
37
8from email.utils import formataddr
4from storm.store import Store9from storm.store import Store
5from zope.component import getUtility10from zope.component import getUtility
6from zope.security.proxy import removeSecurityProxy11from zope.security.proxy import removeSecurityProxy
@@ -37,6 +42,27 @@
3742
38 layer = LaunchpadZopelessLayer43 layer = LaunchpadZopelessLayer
3944
45 def test_notify_from_unicode_names(self):
46 # People with unicode in their names should appear correctly in the
47 # email and not get smashed to ASCII or otherwise transliterated.
48 RANDOM_UNICODE = u"Loïc"
49 creator = self.factory.makePerson(displayname=RANDOM_UNICODE)
50 spr = self.factory.makeSourcePackageRelease(creator=creator)
51 self.factory.makeSourcePackageReleaseFile(sourcepackagerelease=spr)
52 archive = self.factory.makeArchive(purpose=ArchivePurpose.PRIMARY)
53 pocket = PackagePublishingPocket.RELEASE
54 distroseries = self.factory.makeDistroSeries()
55 distroseries.changeslist = "blah@example.com"
56 blamer = self.factory.makePerson()
57 notify(
58 blamer, spr, [], [], archive, distroseries, pocket,
59 action='accepted')
60 notifications = pop_notifications()
61 self.assertEqual(2, len(notifications))
62 msg = notifications[1].get_payload(0)
63 body = msg.get_payload(decode=True)
64 self.assertIn("Loïc", body)
65
40 def test_calculate_subject_customfile(self):66 def test_calculate_subject_customfile(self):
41 lfa = self.factory.makeLibraryFileAlias()67 lfa = self.factory.makeLibraryFileAlias()
42 package_upload = self.factory.makePackageUpload()68 package_upload = self.factory.makePackageUpload()
@@ -87,33 +113,56 @@
87 'Maintainer': 'Foo Bar <foo.bar@canonical.com>',113 'Maintainer': 'Foo Bar <foo.bar@canonical.com>',
88 'Changes': ' * Foo!',114 'Changes': ' * Foo!',
89 }115 }
90 (changesfile, date, changedby, maintainer) = fetch_information(116 info = fetch_information(
91 None, None, changes)117 None, None, changes)
92 self.assertEqual('2001-01-01', date)118 self.assertEqual('2001-01-01', info['date'])
93 self.assertEqual(' * Foo!', changesfile)119 self.assertEqual(' * Foo!', info['changesfile'])
94 for field in (changedby, maintainer):120 fields = [
121 info['changedby'],
122 info['maintainer'],
123 info['changedby_displayname'],
124 info['maintainer_displayname'],
125 ]
126 for field in fields:
95 self.assertEqual('Foo Bar <foo.bar@canonical.com>', field)127 self.assertEqual('Foo Bar <foo.bar@canonical.com>', field)
96128
97 def test_fetch_information_spr(self):129 def test_fetch_information_spr(self):
98 spr = self.factory.makeSourcePackageRelease()130 creator = self.factory.makePerson(displayname=u"foø")
99 (changesfile, date, changedby, maintainer) = fetch_information(131 maintainer = self.factory.makePerson(displayname=u"bær")
100 spr, None, None)132 spr = self.factory.makeSourcePackageRelease(
101 self.assertEqual(date, spr.dateuploaded)133 creator=creator, maintainer=maintainer)
102 self.assertEqual(changesfile, spr.changelog_entry)134 info = fetch_information(spr, None, None)
103 self.assertEqual(changedby, format_address_for_person(spr.creator))135 self.assertEqual(info['date'], spr.dateuploaded)
104 self.assertEqual(136 self.assertEqual(info['changesfile'], spr.changelog_entry)
105 maintainer, format_address_for_person(spr.maintainer))137 self.assertEqual(
138 info['changedby'], format_address_for_person(spr.creator))
139 self.assertEqual(
140 info['maintainer'], format_address_for_person(spr.maintainer))
141 self.assertEqual(
142 u"foø <%s>" % spr.creator.preferredemail.email,
143 info['changedby_displayname'])
144 self.assertEqual(
145 u"bær <%s>" % spr.maintainer.preferredemail.email,
146 info['maintainer_displayname'])
106147
107 def test_fetch_information_bprs(self):148 def test_fetch_information_bprs(self):
108 bpr = self.factory.makeBinaryPackageRelease()149 bpr = self.factory.makeBinaryPackageRelease()
109 (changesfile, date, changedby, maintainer) = fetch_information(150 info = fetch_information(None, [bpr], None)
110 None, [bpr], None)
111 spr = bpr.build.source_package_release151 spr = bpr.build.source_package_release
112 self.assertEqual(date, spr.dateuploaded)152 self.assertEqual(info['date'], spr.dateuploaded)
113 self.assertEqual(changesfile, spr.changelog_entry)153 self.assertEqual(info['changesfile'], spr.changelog_entry)
114 self.assertEqual(changedby, format_address_for_person(spr.creator))154 self.assertEqual(
115 self.assertEqual(155 info['changedby'], format_address_for_person(spr.creator))
116 maintainer, format_address_for_person(spr.maintainer))156 self.assertEqual(
157 info['maintainer'], format_address_for_person(spr.maintainer))
158 self.assertEqual(
159 info['changedby_displayname'],
160 formataddr((spr.creator.displayname,
161 spr.creator.preferredemail.email)))
162 self.assertEqual(
163 info['maintainer_displayname'],
164 formataddr((spr.maintainer.displayname,
165 spr.maintainer.preferredemail.email)))
117166
118 def test_calculate_subject_spr(self):167 def test_calculate_subject_spr(self):
119 spr = self.factory.makeSourcePackageRelease()168 spr = self.factory.makeSourcePackageRelease()
@@ -291,4 +340,3 @@
291 result = is_auto_sync_upload(340 result = is_auto_sync_upload(
292 spr=None, bprs=None, pocket=None, changed_by_email=None)341 spr=None, bprs=None, pocket=None, changed_by_email=None)
293 self.assertFalse(result)342 self.assertFalse(result)
294