← Back to team overview

launchpad-reviewers team mailing list archive

Re: [Merge] lp:~cjwatson/launchpad/upload-mail into lp:launchpad

 


Diff comments:

> 
> === renamed file 'lib/lp/soyuz/adapters/notification.py' => 'lib/lp/soyuz/mail/packageupload.py'
> --- lib/lp/soyuz/adapters/notification.py	2015-07-29 06:58:37 +0000
> +++ lib/lp/soyuz/mail/packageupload.py	2015-08-25 23:28:10 +0000
> @@ -28,67 +24,175 @@
>  from lp.registry.interfaces.pocket import PackagePublishingPocket
>  from lp.services.config import config
>  from lp.services.encoding import guess as guess_encoding
> -from lp.services.mail.helpers import get_email_template
> +from lp.services.mail.basemailer import (
> +    BaseMailer,
> +    RecipientReason,
> +    )
> +from lp.services.mail.mailwrapper import MailWrapper
> +from lp.services.mail.notificationrecipientset import StubPerson
>  from lp.services.mail.sendmail import (
>      format_address,
>      format_address_for_person,
> -    sendmail,
>      )
>  from lp.services.webapp import canonical_url
>  from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
>  
>  
> -def reject_changes_file(blamer, changes_file_path, changes, archive,
> -                        distroseries, reason, logger=None):
> -    """Notify about a rejection where all of the details are not known.
> -
> -    :param blamer: The `IPerson` that is to blame for this notification.
> -    :param changes_file_path: The path to the changes file.
> -    :param changes: A dictionary of the parsed changes file.
> -    :param archive: The `IArchive` the notification is regarding.
> -    :param distroseries: The `IDistroSeries` the notification is regarding.
> -    :param reason: The reason for the rejection.
> +class PackageUploadRecipientReason(RecipientReason):
> +
> +    @classmethod
> +    def forRequester(cls, requester, recipient):
> +        header = cls.makeRationale("Requester", requester)
> +        # This is a little vague - copies may end up here too - but it's
> +        # close enough.
> +        reason = "You are receiving this email because you made this upload."

I think we could clean that kind of thing up later with some refactoring elsewhere, but this branch was large enough ...

> +        return cls(requester, recipient, header, reason)
> +
> +    @classmethod
> +    def forMaintainer(cls, maintainer, recipient):
> +        header = cls.makeRationale("Maintainer", maintainer)
> +        reason = (
> +            "You are receiving this email because you are listed as this "
> +            "package's maintainer.")
> +        return cls(maintainer, recipient, header, reason)
> +
> +    @classmethod
> +    def forChangedBy(cls, changed_by, recipient):
> +        header = cls.makeRationale("Changed-By", changed_by)
> +        reason = (
> +            "You are receiving this email because you are the most recent "
> +            "person listed in this package's changelog.")
> +        return cls(changed_by, recipient, header, reason)
> +
> +    @classmethod
> +    def forPPAUploader(cls, uploader, recipient):
> +        header = cls.makeRationale("PPA Uploader", uploader)
> +        reason = (
> +            "You are receiving this email because you have upload permissions "
> +            "to this PPA.")
> +        return cls(uploader, recipient, header, reason)
> +
> +    @classmethod
> +    def forAnnouncement(cls, recipient):
> +        return cls(recipient, recipient, "Announcement", "")
> +
> +    def getReason(self):
> +        """See `RecipientReason`."""
> +        return MailWrapper(width=72).format(
> +            super(PackageUploadRecipientReason, self).getReason())
> +
> +
> +def debug(logger, msg, *args, **kwargs):
> +    """Shorthand debug notation."""
> +    if logger is not None:
> +        logger.debug(msg, *args, **kwargs)
> +
> +
> +def sanitize_string(s):
> +    """Make sure string does not trigger 'ascii' codec errors.
> +
> +    Convert string to unicode if needed so that characters outside
> +    the (7-bit) ASCII range do not cause errors like these:
> +
> +        'ascii' codec can't decode byte 0xc4 in position 21: ordinal
> +        not in range(128)
>      """
> -    ignored, filename = os.path.split(changes_file_path)
> -    information = {
> -        'SUMMARY': reason,
> -        'CHANGESFILE': '',
> -        'DATE': '',
> -        'CHANGEDBY': '',
> -        'MAINTAINER': '',
> -        'SIGNER': '',
> -        'ORIGIN': '',
> -        'ARCHIVE_URL': '',
> -        'USERS_ADDRESS': config.launchpad.users_address,
> -    }
> -    subject = '%s rejected' % filename
> -    if archive:
> -        subject = '[%s] %s' % (archive.reference, subject)
> -        information['ARCHIVE_URL'] = '\n%s' % canonical_url(archive)
> -    template = get_template(archive, 'rejected')
> -    body = template % information
> -    to_addrs = get_upload_notification_recipients(
> -        blamer, archive, distroseries, logger, changes=changes)
> -    debug(logger, "Sending rejection email.")
> -    if not to_addrs:
> -        debug(logger, "No recipients have a preferred email.")
> +    if isinstance(s, unicode):
> +        return s
> +    else:
> +        return guess_encoding(s)
> +
> +
> +def add_recipient(recipients, person, reason_factory, logger=None):
> +    # Circular import.
> +    from lp.registry.model.person import get_recipients
> +
> +    if person is None:
>          return
> -    send_mail(None, archive, to_addrs, subject, body, False, logger=logger)
> -
> -
> -def get_template(archive, action):
> -    """Return the appropriate email template."""
> -    template_name = 'upload-'
> -    if action in ('new', 'accepted', 'announcement'):
> -        template_name += action
> -    elif action == 'unapproved':
> -        template_name += 'accepted'
> -    elif action == 'rejected':
> -        template_name += 'rejection'
> -    if archive.is_ppa:
> -        template_name = 'ppa-%s' % template_name
> -    template_name += '.txt'
> -    return get_email_template(template_name, app='soyuz')
> +    for recipient in get_recipients(person):
> +        if recipient not in recipients:
> +            debug(
> +                logger, "Adding recipient: '%s'" % format_address_for_person(
> +                    recipient))
> +            reason = reason_factory(person, recipient)
> +            recipients[recipient] = reason
> +
> +
> +def fetch_information(spr, bprs, changes, previous_version=None):
> +    changelog = date = changedby = maintainer = None
> +
> +    if changes:
> +        changelog = ChangesFile.formatChangesComment(
> +            sanitize_string(changes.get('Changes')))
> +        date = changes.get('Date')
> +        try:
> +            changedby = parse_maintainer_bytes(
> +                changes.get('Changed-By'), 'Changed-By')
> +        except ParseMaintError:
> +            pass
> +        try:
> +            maintainer = parse_maintainer_bytes(
> +                changes.get('Maintainer'), 'Maintainer')
> +        except ParseMaintError:
> +            pass
> +    elif spr or bprs:
> +        if not spr and bprs:
> +            spr = bprs[0].build.source_package_release
> +        changelog = spr.aggregate_changelog(previous_version)
> +        date = spr.dateuploaded
> +        if spr.creator and spr.creator.preferredemail:
> +            changedby = (
> +                spr.creator.displayname, spr.creator.preferredemail.email)
> +        if spr.maintainer and spr.maintainer.preferredemail:
> +            maintainer = (
> +                spr.maintainer.displayname,
> +                spr.maintainer.preferredemail.email)
> +
> +    return {
> +        'changelog': changelog,
> +        'date': date,
> +        'changedby': changedby,
> +        'maintainer': maintainer,
> +        }
> +
> +
> +def addr_to_person(addr):
> +    """Return an `IPerson` given a name and email address.
> +
> +    :param addr: (name, email) tuple. The name is ignored.
> +    :return: `IPerson` with the given email address.  None if there
> +        isn't one, or if `addr` is None.
> +    """
> +    if addr is None:
> +        return None
> +    return getUtility(IPersonSet).getByEmail(addr[1])
> +
> +
> +def is_valid_uploader(person, distribution):
> +    """Is `person` an uploader for `distribution`?
> +
> +    A `None` person is not an uploader.
> +    """
> +    if person is None:
> +        return None
> +    else:
> +        return not getUtility(IArchivePermissionSet).componentsForUploader(
> +            distribution.main_archive, person).is_empty()
> +
> +
> +def is_auto_sync_upload(spr, bprs, pocket, changed_by):
> +    """Return True if this is a (Debian) auto sync upload.
> +
> +    Sync uploads are source-only, unsigned and not targeted to
> +    the security pocket. The Changed-By field is also the Katie
> +    user (archive@xxxxxxxxxx).
> +    """
> +    changed_by = addr_to_person(changed_by)
> +    return (
> +        spr and
> +        not bprs and
> +        changed_by == getUtility(ILaunchpadCelebrities).katie and
> +        pocket != PackagePublishingPocket.SECURITY)
>  
>  
>  ACTION_DESCRIPTIONS = {
> @@ -555,70 +273,336 @@
>      return summary
>  
>  
> -def addr_to_person(addr):
> -    """Return an `IPerson` given a name and email address.
> -
> -    :param addr: (name, email) tuple. The name is ignored.
> -    :return: `IPerson` with the given email address.  None if there
> -        isn't one, or if `addr` is None.
> -    """
> -    if addr is None:
> -        return None
> -    return getUtility(IPersonSet).getByEmail(addr[1])
> -
> -
> -def is_auto_sync_upload(spr, bprs, pocket, changed_by):
> -    """Return True if this is a (Debian) auto sync upload.
> -
> -    Sync uploads are source-only, unsigned and not targeted to
> -    the security pocket. The Changed-By field is also the Katie
> -    user (archive@xxxxxxxxxx).
> -    """
> -    changed_by = addr_to_person(changed_by)
> -    return (
> -        spr and
> -        not bprs and
> -        changed_by == getUtility(ILaunchpadCelebrities).katie and
> -        pocket != PackagePublishingPocket.SECURITY)
> -
> -
> -def fetch_information(spr, bprs, changes, previous_version=None):
> -    changelog = date = changedby = maintainer = None
> -
> -    if changes:
> -        changelog = ChangesFile.formatChangesComment(
> -            sanitize_string(changes.get('Changes')))
> -        date = changes.get('Date')
> -        try:
> -            changedby = parse_maintainer_bytes(
> -                changes.get('Changed-By'), 'Changed-By')
> -        except ParseMaintError:
> -            pass
> -        try:
> -            maintainer = parse_maintainer_bytes(
> -                changes.get('Maintainer'), 'Maintainer')
> -        except ParseMaintError:
> -            pass
> -    elif spr or bprs:
> -        if not spr and bprs:
> -            spr = bprs[0].build.source_package_release
> -        changelog = spr.aggregate_changelog(previous_version)
> -        date = spr.dateuploaded
> -        if spr.creator and spr.creator.preferredemail:
> -            changedby = (
> -                spr.creator.displayname, spr.creator.preferredemail.email)
> -        if spr.maintainer and spr.maintainer.preferredemail:
> -            maintainer = (
> -                spr.maintainer.displayname,
> -                spr.maintainer.preferredemail.email)
> -
> -    return {
> -        'changelog': changelog,
> -        'date': date,
> -        'changedby': changedby,
> -        'maintainer': maintainer,
> -        }
> -
> -
> -class LanguagePackEncountered(Exception):
> -    """Thrown when not wanting to email notifications for language packs."""
> +class PackageUploadMailer(BaseMailer):
> +
> +    app = 'soyuz'
> +
> +    @classmethod
> +    def getRecipientsForAction(cls, action, info, blamee, spr, bprs, archive,
> +                               distroseries, pocket, announce_from_person=None,
> +                               logger=None):
> +        # If this is a binary or mixed upload, we don't send *any* emails
> +        # provided it's not a rejection or a security upload:
> +        if (
> +            bprs and action != 'rejected' and
> +            pocket != PackagePublishingPocket.SECURITY):
> +            debug(logger, "Not sending email; upload is from a build.")
> +            return {}, ''
> +
> +        if spr and spr.source_package_recipe_build and action == 'accepted':
> +            debug(logger, "Not sending email; upload is from a recipe.")
> +            return {}, ''
> +
> +        if spr and spr.section.name == 'translations':
> +            debug(
> +                logger,
> +                "Skipping acceptance and announcement for language packs.")
> +            return {}, ''
> +
> +        debug(logger, "Building recipients list.")
> +        recipients = OrderedDict()
> +
> +        add_recipient(
> +            recipients, blamee, PackageUploadRecipientReason.forRequester,
> +            logger=logger)
> +
> +        changer = addr_to_person(info['changedby'])
> +        maintainer = addr_to_person(info['maintainer'])
> +
> +        if blamee is None and not archive.is_copy:
> +            debug(
> +                logger,
> +                "Changes file is unsigned; adding changer as recipient.")
> +            add_recipient(
> +                recipients, changer, PackageUploadRecipientReason.forChangedBy,
> +                logger=logger)
> +
> +        if archive.is_ppa:
> +            # For PPAs, any person or team mentioned explicitly in the
> +            # ArchivePermissions as uploaders for the archive will also get
> +            # emailed.
> +            for permission in archive.getUploadersForComponent():
> +                add_recipient(
> +                    recipients, permission.person,
> +                    PackageUploadRecipientReason.forPPAUploader, logger=logger)
> +        elif archive.is_copy:
> +            # For copy archives, notifying anyone else will probably only
> +            # confuse them.
> +            pass
> +        else:
> +            # If this is not a PPA, we also consider maintainer and changed-by.
> +            if blamee is not None:
> +                if is_valid_uploader(maintainer, distroseries.distribution):
> +                    debug(logger, "Adding maintainer to recipients")
> +                    add_recipient(
> +                        recipients, maintainer,
> +                        PackageUploadRecipientReason.forMaintainer,
> +                        logger=logger)
> +
> +                if is_valid_uploader(changer, distroseries.distribution):
> +                    debug(logger, "Adding changed-by to recipients")
> +                    add_recipient(
> +                        recipients, changer,
> +                        PackageUploadRecipientReason.forChangedBy,
> +                        logger=logger)
> +
> +        if announce_from_person is not None:
> +            announce_from_addr = (
> +                announce_from_person.displayname,
> +                announce_from_person.preferredemail.email)
> +        else:
> +            announce_from_addr = info['changedby']
> +
> +        # If we're sending an acceptance notification for a non-PPA upload,
> +        # announce if possible. Avoid announcing backports, binary-only
> +        # security uploads, or autosync uploads.
> +        if (action == 'accepted' and distroseries.changeslist
> +                and not archive.is_ppa
> +                and pocket != PackagePublishingPocket.BACKPORTS
> +                and not (
> +                    pocket == PackagePublishingPocket.SECURITY and spr is None)
> +                and not is_auto_sync_upload(
> +                    spr, bprs, pocket, announce_from_addr)):
> +            recipient = StubPerson(distroseries.changeslist)
> +            recipients[recipient] = (
> +                PackageUploadRecipientReason.forAnnouncement(recipient))
> +
> +        if announce_from_addr is not None:
> +            announce_from_address = format_address(*announce_from_addr)
> +        else:
> +            announce_from_address = None
> +        return recipients, announce_from_address
> +
> +    @classmethod
> +    def forAction(cls, action, blamee, spr, bprs, customfiles, archive,
> +                  distroseries, pocket, changes=None, changesfile_object=None,
> +                  announce_from_person=None, previous_version=None,
> +                  logger=None, **kwargs):
> +        info = fetch_information(
> +            spr, bprs, changes, previous_version=previous_version)
> +        recipients, announce_from_address = cls.getRecipientsForAction(
> +            action, info, blamee, spr, bprs, archive, distroseries, pocket,
> +            announce_from_person=announce_from_person, logger=logger)
> +        subject = calculate_subject(
> +            spr, bprs, customfiles, archive, distroseries, pocket, action,
> +            changesfile_object=changesfile_object)
> +        if subject is None:
> +            # We don't even have enough information to build a minimal
> +            # subject, so do nothing.
> +            recipients = {}
> +        template_name = "upload-"
> +        if action in ("new", "accepted", "announcement"):
> +            template_name += action
> +        elif action == "unapproved":
> +            template_name += "accepted"
> +        elif action == "rejected":
> +            template_name += "rejection"
> +        if archive.is_ppa:
> +            template_name = "ppa-%s" % template_name
> +        template_name += ".txt"
> +        from_address = format_address(
> +            config.uploader.default_sender_name,
> +            config.uploader.default_sender_address)
> +        return cls(
> +            subject, template_name, recipients, from_address, action, info,
> +            blamee, spr, bprs, customfiles, archive, distroseries, pocket,
> +            changes=changes, announce_from_address=announce_from_address,
> +            logger=logger, **kwargs)
> +
> +    def __init__(self, subject, template_name, recipients, from_address,
> +                 action, info, blamee, spr, bprs, customfiles, archive,
> +                 distroseries, pocket, summary_text=None, changes=None,
> +                 changesfile_content=None, dry_run=False,
> +                 announce_from_address=None, previous_version=None,
> +                 logger=None):
> +        super(PackageUploadMailer, self).__init__(
> +            subject, template_name, recipients, from_address,
> +            notification_type="package-upload")
> +        self.action = action
> +        self.info = info
> +        self.blamee = blamee
> +        self.spr = spr
> +        self.bprs = bprs
> +        self.customfiles = customfiles
> +        self.archive = archive
> +        self.distroseries = distroseries
> +        self.pocket = pocket
> +        self.changes = changes
> +        self.changesfile_content = changesfile_content
> +        self.dry_run = dry_run
> +        self.logger = logger
> +        self.announce_from_address = announce_from_address
> +        self.previous_version = previous_version
> +
> +        if action == 'rejected':
> +            self.summarystring = summary_text
> +        else:
> +            files = build_uploaded_files_list(spr, bprs, customfiles, logger)
> +            summary = build_summary(spr, files, action)
> +            if summary_text:
> +                summary.append(summary_text)
> +            self.summarystring = "\n".join(summary)
> +
> +    def _getFromAddress(self, email, recipient):
> +        """See `BaseMailer`."""
> +        if (zope_isinstance(recipient, StubPerson) and
> +                self.announce_from_address is not None):
> +            return self.announce_from_address

I went for the AnnouncementStubPerson approach you suggested in a subsequent comment, which is indeed clearer.

> +        else:
> +            return super(PackageUploadMailer, self)._getFromAddress(
> +                email, recipient)
> +
> +    def _getHeaders(self, email, recipient):
> +        """See `BaseMailer`."""
> +        headers = super(PackageUploadMailer, self)._getHeaders(
> +            email, recipient)
> +        headers['X-Katie'] = 'Launchpad actually'
> +        headers['X-Launchpad-Archive'] = self.archive.reference
> +
> +        # The deprecated PPA reference header is included for Ubuntu PPAs to
> +        # avoid breaking existing consumers.
> +        if self.archive.is_ppa and self.archive.distribution.name == u'ubuntu':
> +            headers['X-Launchpad-PPA'] = get_ppa_reference(self.archive)
> +
> +        # Include a 'X-Launchpad-Component' header with the component and
> +        # the section of the source package uploaded in order to facilitate
> +        # filtering on the part of the email recipients.
> +        if self.spr:
> +            headers['X-Launchpad-Component'] = 'component=%s, section=%s' % (
> +                self.spr.component.name, self.spr.section.name)
> +
> +        # All emails from here have a Bcc to the default recipient.
> +        bcc_text = format_address(
> +            config.uploader.default_recipient_name,
> +            config.uploader.default_recipient_address)
> +        if zope_isinstance(recipient, StubPerson):
> +            name = None
> +            if self.spr:
> +                name = self.spr.name
> +            elif self.bprs:
> +                name = self.bprs[0].build.source_package_release.name
> +            if name:
> +                distribution = self.distroseries.distribution
> +                email_base = distribution.package_derivatives_email
> +                if email_base:
> +                    bcc_text += ", " + email_base.format(package_name=name)
> +        headers['Bcc'] = bcc_text
> +
> +        return headers
> +
> +    def _addAttachments(self, ctrl, email):
> +        """See `BaseMailer`."""
> +        if not self.archive.is_ppa:
> +            if self.changesfile_content is not None:
> +                changesfile_text = sanitize_string(self.changesfile_content)
> +            else:
> +                changesfile_text = "Sorry, changesfile not available."
> +            ctrl.addAttachment(
> +                changesfile_text, content_type='text/plain',
> +                filename='changesfile', charset='utf-8')
> +
> +    def _getTemplateName(self, email, recipient):
> +        """See `BaseMailer`."""
> +        if zope_isinstance(recipient, StubPerson):
> +            return "upload-announcement.txt"
> +        else:
> +            return self._template_name
> +
> +    def _getTemplateParams(self, email, recipient):
> +        """See `BaseMailer`."""
> +        params = super(PackageUploadMailer, self)._getTemplateParams(
> +            email, recipient)
> +        params.update({
> +            'STATUS': ACTION_DESCRIPTIONS[self.action],
> +            'SUMMARY': self.summarystring,
> +            'DATE': '',
> +            'CHANGESFILE': '',
> +            'DISTRO': self.distroseries.distribution.title,
> +            'ANNOUNCE': 'No announcement sent',
> +            'CHANGEDBY': '',
> +            'MAINTAINER': '',
> +            'ORIGIN': '',
> +            'SIGNER': '',
> +            'SPR_URL': '',
> +            'ARCHIVE_URL': canonical_url(self.archive),
> +            'USERS_ADDRESS': config.launchpad.users_address,
> +            })
> +        changes = self.changes
> +        if changes is None:
> +            changes = {}
> +
> +        if self.info['date'] is not None:
> +            params['DATE'] = 'Date: %s' % self.info['date']
> +        if self.info['changelog'] is not None:
> +            params['CHANGESFILE'] = self.info['changelog']
> +        if self.spr:
> +            params['SPR_URL'] = canonical_url(
> +                self.distroseries.distribution.getSourcePackageRelease(
> +                    self.spr))
> +
> +        # Some syncs (e.g. from Debian) will involve packages whose
> +        # changed-by person was auto-created in LP and hence does not have a
> +        # preferred email address set.  We'll get a None here.
> +        changedby_person = addr_to_person(self.info['changedby'])
> +        if self.info['changedby']:
> +            params['CHANGEDBY'] = '\nChanged-By: %s' % rfc822_encode_address(
> +                *self.info['changedby'])
> +        if (self.blamee is not None and self.blamee != changedby_person
> +                and self.blamee.preferredemail):
> +            params['SIGNER'] = '\nSigned-By: %s' % rfc822_encode_address(
> +                self.blamee.displayname, self.blamee.preferredemail.email)
> +        if (self.info['maintainer']
> +                and self.info['maintainer'] != self.info['changedby']):
> +            params['MAINTAINER'] = '\nMaintainer: %s' % rfc822_encode_address(
> +                *self.info['maintainer'])
> +
> +        origin = changes.get('Origin')
> +        if origin:
> +            params['ORIGIN'] = '\nOrigin: %s' % origin
> +        if self.action == 'unapproved':
> +            params['SUMMARY'] += (
> +                "\nThis upload awaits approval by a distro manager\n")
> +        if self.distroseries.changeslist:
> +            params['ANNOUNCE'] = "Announcing to %s" % (
> +                self.distroseries.changeslist)
> +
> +        return params
> +
> +    def _getFooter(self, email, recipient, params):
> +        """See `BaseMailer`."""
> +        if zope_isinstance(recipient, StubPerson):
> +            return None
> +        else:
> +            footer_lines = []
> +            if self.archive.is_ppa:
> +                footer_lines.append("%(ARCHIVE_URL)s\n")
> +            footer_lines.append("%(reason)s\n")
> +            return "".join(footer_lines) % params
> +
> +    def generateEmail(self, email, recipient, force_no_attachments=False):
> +        """See `BaseMailer`."""
> +        ctrl = super(PackageUploadMailer, self).generateEmail(
> +            email, recipient, force_no_attachments=force_no_attachments)
> +        if self.dry_run:
> +            debug(self.logger, "Would have sent a mail:")
> +        else:
> +            debug(self.logger, "Sent a mail:")
> +        debug(self.logger, "  Subject: %s" % ctrl.subject)
> +        debug(self.logger, "  Sender: %s" % ctrl.from_addr)
> +        debug(self.logger, "  Recipients: %s" % ", ".join(ctrl.to_addrs))
> +        if 'Bcc' in ctrl.headers:
> +            debug(self.logger, "  Bcc: %s" % ctrl.headers['Bcc'])
> +        debug(self.logger, "  Body:")
> +        for line in ctrl.body.splitlines():
> +            if isinstance(line, bytes):
> +                line = line.decode('utf-8', 'replace')
> +            debug(self.logger, line)
> +        return ctrl
> +
> +    def sendOne(self, email, recipient):
> +        """See `BaseMailer`."""
> +        if self.dry_run:
> +            # Just generate the email for the sake of debugging output.
> +            self.generateEmail(email, recipient)
> +        else:
> +            super(PackageUploadMailer, self).sendOne(email, recipient)


-- 
https://code.launchpad.net/~cjwatson/launchpad/upload-mail/+merge/269066
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.


References