def __init__(self, subject, template_name, recipients, from_address, delta=None, message_id=None, notification_type=None, mail_controller_class=None, request=None, wrap=False, force_wrap=False): """Constructor. :param subject: A Python dict-replacement template for the subject line of the email. :param template: Name of the template to use for the message body. :param recipients: A dict of recipient to Subscription. :param from_address: The from_address to use on emails. :param delta: A Delta object with members "delta_values", "interface" and "new_values", such as BranchMergeProposalDelta. :param message_id: The Message-Id to use for generated emails. If not supplied, random message-ids will be used. :param mail_controller_class: The class of the mail controller to use to send the mails. Defaults to `MailController`. :param request: An optional `IErrorReportRequest` to use when logging OOPSes. :param wrap: Wrap body text using `MailWrapper`. :param force_wrap: See `MailWrapper.format`. """ # Running mail notifications with web security is too fragile: it's # easy to end up with subtle bugs due to such things as # subscriptions from private teams that are inaccessible to the user # with the current interaction. BaseMailer always sends one mail # per recipient and thus never leaks information to other users, so # it's safer to require a permissive security policy. # # When converting other notification code to BaseMailer, it may be # necessary to move notifications into jobs, to move unit tests to a # Zopeless-based layer, or to use the permissive_security_policy # context manager. assert getSecurityPolicy() == LaunchpadPermissiveSecurityPolicy, ( "BaseMailer may only be used with a permissive security policy.") self._subject_template = subject self._template_name = template_name self._recipients = NotificationRecipientSet() for recipient, reason in recipients.iteritems(): self._recipients.add(recipient, reason, reason.mail_header) self.from_address = from_address self.delta = delta self.message_id = message_id self.notification_type = notification_type self.logger = logging.getLogger('lp.services.mail.basemailer') if mail_controller_class is None: mail_controller_class = MailController self._mail_controller_class = mail_controller_class self.request = request self._wrap = wrap self._force_wrap = force_wrap
def test_send_message(self): self.factory.makePerson(email='*****@*****.**', name='me') user = self.factory.makePerson(email='*****@*****.**', name='him') subject = 'test subject' body = 'test body' recipients_set = NotificationRecipientSet() recipients_set.add(user, 'test reason', 'test rationale') pop_notifications() send_direct_contact_email('*****@*****.**', recipients_set, subject, body) notifications = pop_notifications() notification = notifications[0] self.assertEqual(1, len(notifications)) self.assertEqual('Me <*****@*****.**>', notification['From']) self.assertEqual('Him <*****@*****.**>', notification['To']) self.assertEqual(subject, notification['Subject']) self.assertEqual('test rationale', notification['X-Launchpad-Message-Rationale']) self.assertIs(None, notification['Precedence']) self.assertTrue('launchpad' in notification['Message-ID']) self.assertEqual( '\n'.join([ '%s' % body, '-- ', 'This message was sent from Launchpad by', 'Me (http://launchpad.dev/~me)', 'test reason.', 'For more information see', 'https://help.launchpad.net/YourAccount/ContactingPeople' ]), notification.get_payload())
def recipients(self): """See `IQuestionEmailJob`.""" term = QuestionRecipientSet.getTermByToken( self.metadata['recipient_set']) question_recipient_set = term.value if question_recipient_set == QuestionRecipientSet.ASKER: recipients = NotificationRecipientSet() owner = self.question.owner original_recipients = self.question.direct_recipients if owner in original_recipients: rationale, header = original_recipients.getReason(owner) recipients.add(owner, rationale, header) return recipients elif question_recipient_set == QuestionRecipientSet.SUBSCRIBER: recipients = self.question.getRecipients() if self.question.owner in recipients: recipients.remove(self.question.owner) return recipients elif question_recipient_set == QuestionRecipientSet.ASKER_SUBSCRIBER: return self.question.getRecipients() elif question_recipient_set == QuestionRecipientSet.CONTACT: return self.question.target.getAnswerContactRecipients(None) else: raise ValueError( 'Unsupported QuestionRecipientSet value: %s' % question_recipient_set)
def test_send_message(self): self.factory.makePerson(email='*****@*****.**', name='me') user = self.factory.makePerson(email='*****@*****.**', name='him') subject = 'test subject' body = 'test body' recipients_set = NotificationRecipientSet() recipients_set.add(user, 'test reason', 'test rationale') pop_notifications() send_direct_contact_email('*****@*****.**', recipients_set, subject, body) notifications = pop_notifications() notification = notifications[0] self.assertEqual(1, len(notifications)) self.assertEqual('Me <*****@*****.**>', notification['From']) self.assertEqual('Him <*****@*****.**>', notification['To']) self.assertEqual(subject, notification['Subject']) self.assertEqual( 'test rationale', notification['X-Launchpad-Message-Rationale']) self.assertIs(None, notification['Precedence']) self.assertTrue('launchpad' in notification['Message-ID']) self.assertEqual( '\n'.join([ '%s' % body, '-- ', 'This message was sent from Launchpad by', 'Me (http://launchpad.dev/~me)', 'test reason.', 'For more information see', 'https://help.launchpad.net/YourAccount/ContactingPeople']), notification.get_payload())
def test_add_doesnt_break_on_private_teams(self): # Since notifications are not exposed to UI, they should handle # protected preferred emails fine. email = self.factory.getUniqueEmailAddress() notified_team = self.factory.makeTeam( email=email, visibility=PersonVisibility.PRIVATE) recipients = NotificationRecipientSet() notifier = self.factory.makePerson() with person_logged_in(notifier): recipients.add([notified_team], 'some reason', 'some header') with celebrity_logged_in("admin"): self.assertEqual([notified_team], recipients.getRecipients())
def test_name_utf8_encoding(self): # Names are encoded in the From and To headers. self.factory.makePerson(email='*****@*****.**', displayname=u'sn\xefrf') user = self.factory.makePerson( email='*****@*****.**', displayname=u'pti\xedng') recipients_set = NotificationRecipientSet() recipients_set.add(user, 'test reason', 'test rationale') pop_notifications() send_direct_contact_email('*****@*****.**', recipients_set, 'test', 'test') notifications = pop_notifications() notification = notifications[0] self.assertEqual( '=?utf-8?b?c27Dr3Jm?= <*****@*****.**>', notification['From']) self.assertEqual( '=?utf-8?q?pti=C3=ADng?= <*****@*****.**>', notification['To'])
def test_name_utf8_encoding(self): # Names are encoded in the From and To headers. self.factory.makePerson(email='*****@*****.**', displayname=u'sn\xefrf') user = self.factory.makePerson(email='*****@*****.**', displayname=u'pti\xedng') recipients_set = NotificationRecipientSet() recipients_set.add(user, 'test reason', 'test rationale') pop_notifications() send_direct_contact_email('*****@*****.**', recipients_set, 'test', 'test') notifications = pop_notifications() notification = notifications[0] self.assertEqual('=?utf-8?b?c27Dr3Jm?= <*****@*****.**>', notification['From']) self.assertEqual('=?utf-8?q?pti=C3=ADng?= <*****@*****.**>', notification['To'])
def test_wrapping(self): self.factory.makePerson(email='*****@*****.**') user = self.factory.makePerson() recipients_set = NotificationRecipientSet() recipients_set.add(user, 'test reason', 'test rationale') pop_notifications() body = 'Can you help me? ' * 8 send_direct_contact_email('*****@*****.**', recipients_set, 'subject', body) notifications = pop_notifications() body, footer = notifications[0].get_payload().split('-- ') self.assertEqual( 'Can you help me? Can you help me? Can you help me? ' 'Can you help me? Can\n' 'you help me? Can you help me? Can you help me? ' 'Can you help me?\n', body)
def recipients(self): """See `IProductNotificationJob`.""" maintainer = self.product.owner if maintainer.is_team: team_name = maintainer.displayname role = "an admin of %s which is the maintainer" % team_name users = maintainer.adminmembers else: role = "the maintainer" users = maintainer reason = ( "You received this notification because you are %s of %s.\n%s" % (role, self.product.displayname, self.message_data['product_url'])) header = 'Maintainer' notification_set = NotificationRecipientSet() notification_set.add(users, reason, header) return notification_set
def recipients(self): """See `IQuestionEmailJob`.""" term = QuestionRecipientSet.getTermByToken( self.metadata['recipient_set']) question_recipient_set = term.value if question_recipient_set == QuestionRecipientSet.ASKER: recipients = NotificationRecipientSet() owner = self.question.owner original_recipients = self.question.direct_recipients for recipient in original_recipients: reason, header = original_recipients.getReason(recipient) if reason.subscriber == owner: recipients.add(recipient, reason, header) return recipients elif question_recipient_set == QuestionRecipientSet.SUBSCRIBER: recipients = self.question.getRecipients() owner = self.question.owner asker_recipients = [ recipient for recipient in recipients if recipients.getReason(recipient)[0].subscriber == owner ] recipients.remove(asker_recipients) return recipients elif question_recipient_set == QuestionRecipientSet.ASKER_SUBSCRIBER: return self.question.getRecipients() elif question_recipient_set == QuestionRecipientSet.CONTACT: return self.question.target.getAnswerContactRecipients(None) else: raise ValueError('Unsupported QuestionRecipientSet value: %s' % question_recipient_set)
def test_quota_reached_error(self): # An error is raised if the user has reached the daily quota. self.factory.makePerson(email='*****@*****.**', name='me') user = self.factory.makePerson(email='*****@*****.**', name='him') recipients_set = NotificationRecipientSet() old_message = self.factory.makeSignedMessage(email_address='*****@*****.**') authorization = IDirectEmailAuthorization(user) for action in xrange(authorization.message_quota): authorization.record(old_message) self.assertRaises(QuotaReachedError, send_direct_contact_email, '*****@*****.**', recipients_set, 'subject', 'body')
def __init__(self, subject, template_name, recipients, from_address, delta=None, message_id=None, notification_type=None, mail_controller_class=None): """Constructor. :param subject: A Python dict-replacement template for the subject line of the email. :param template: Name of the template to use for the message body. :param recipients: A dict of recipient to Subscription. :param from_address: The from_address to use on emails. :param delta: A Delta object with members "delta_values", "interface" and "new_values", such as BranchMergeProposalDelta. :param message_id: The Message-Id to use for generated emails. If not supplied, random message-ids will be used. :param mail_controller_class: The class of the mail controller to use to send the mails. Defaults to `MailController`. """ self._subject_template = subject self._template_name = template_name self._recipients = NotificationRecipientSet() for recipient, reason in recipients.iteritems(): self._recipients.add(recipient, reason, reason.mail_header) self.from_address = from_address self.delta = delta self.message_id = message_id self.notification_type = notification_type self.logger = logging.getLogger('lp.services.mail.basemailer') if mail_controller_class is None: mail_controller_class = MailController self._mail_controller_class = mail_controller_class
def test_empty_recipient_set(self): # The recipient set can be empty. No messages are sent and the # action does not count toward the daily quota. self.factory.makePerson(email='*****@*****.**', name='me') user = self.factory.makePerson(email='*****@*****.**', name='him') recipients_set = NotificationRecipientSet() old_message = self.factory.makeSignedMessage(email_address='*****@*****.**') authorization = IDirectEmailAuthorization(user) for action in xrange(authorization.message_quota - 1): authorization.record(old_message) pop_notifications() send_direct_contact_email('*****@*****.**', recipients_set, 'subject', 'body') notifications = pop_notifications() self.assertEqual(0, len(notifications)) self.assertTrue(authorization.is_allowed)
class BaseMailer: """Base class for notification mailers. Subclasses must provide getReason (or reimplement _getTemplateParameters or generateEmail). It is expected that subclasses may override _getHeaders, _getTemplateParams, and perhaps _getBody. """ app = None def __init__(self, subject, template_name, recipients, from_address, delta=None, message_id=None, notification_type=None, mail_controller_class=None, request=None, wrap=False, force_wrap=False): """Constructor. :param subject: A Python dict-replacement template for the subject line of the email. :param template: Name of the template to use for the message body. :param recipients: A dict of recipient to Subscription. :param from_address: The from_address to use on emails. :param delta: A Delta object with members "delta_values", "interface" and "new_values", such as BranchMergeProposalDelta. :param message_id: The Message-Id to use for generated emails. If not supplied, random message-ids will be used. :param mail_controller_class: The class of the mail controller to use to send the mails. Defaults to `MailController`. :param request: An optional `IErrorReportRequest` to use when logging OOPSes. :param wrap: Wrap body text using `MailWrapper`. :param force_wrap: See `MailWrapper.format`. """ # Running mail notifications with web security is too fragile: it's # easy to end up with subtle bugs due to such things as # subscriptions from private teams that are inaccessible to the user # with the current interaction. BaseMailer always sends one mail # per recipient and thus never leaks information to other users, so # it's safer to require a permissive security policy. # # When converting other notification code to BaseMailer, it may be # necessary to move notifications into jobs, to move unit tests to a # Zopeless-based layer, or to use the permissive_security_policy # context manager. assert getSecurityPolicy() == LaunchpadPermissiveSecurityPolicy, ( "BaseMailer may only be used with a permissive security policy.") self._subject_template = subject self._template_name = template_name self._recipients = NotificationRecipientSet() for recipient, reason in recipients.iteritems(): self._recipients.add(recipient, reason, reason.mail_header) self.from_address = from_address self.delta = delta self.message_id = message_id self.notification_type = notification_type self.logger = logging.getLogger('lp.services.mail.basemailer') if mail_controller_class is None: mail_controller_class = MailController self._mail_controller_class = mail_controller_class self.request = request self._wrap = wrap self._force_wrap = force_wrap def _getFromAddress(self, email, recipient): return self.from_address def _getToAddresses(self, email, recipient): return [format_address(recipient.displayname, email)] def generateEmail(self, email, recipient, force_no_attachments=False): """Generate the email for this recipient. :param email: Email address of the recipient to send to. :param recipient: The Person to send to. :return: (headers, subject, body) of the email. """ from_address = self._getFromAddress(email, recipient) to_addresses = self._getToAddresses(email, recipient) headers = self._getHeaders(email, recipient) subject = self._getSubject(email, recipient) body = self._getBody(email, recipient) expanded_footer = self._getExpandedFooter(headers, recipient) if expanded_footer: body = append_footer(body, expanded_footer) ctrl = self._mail_controller_class(from_address, to_addresses, subject, body, headers, envelope_to=[email]) if force_no_attachments: ctrl.addAttachment('Excessively large attachments removed.', content_type='text/plain', inline=True) else: self._addAttachments(ctrl, email) return ctrl def _getSubject(self, email, recipient): """The subject template expanded with the template params.""" return (self._subject_template % self._getTemplateParams(email, recipient)) def _getReplyToAddress(self, email, recipient): """Return the address to use for the reply-to header.""" return None def _getHeaders(self, email, recipient): """Return the mail headers to use.""" reason, rationale = self._recipients.getReason(email) headers = OrderedDict() headers['X-Launchpad-Message-Rationale'] = reason.mail_header if reason.subscriber.name is not None: headers['X-Launchpad-Message-For'] = reason.subscriber.name if self.notification_type is not None: headers['X-Launchpad-Notification-Type'] = self.notification_type reply_to = self._getReplyToAddress(email, recipient) if reply_to is not None: headers['Reply-To'] = reply_to if self.message_id is not None: headers['Message-Id'] = self.message_id return headers def _addAttachments(self, ctrl, email): """Add any appropriate attachments to a MailController. Default implementation does nothing. :param ctrl: The MailController to add attachments to. :param email: The email address of the recipient. """ pass def _getTemplateName(self, email, recipient): """Return the name of the template to use for this email body.""" return self._template_name def _getTemplateParams(self, email, recipient): """Return a dict of values to use in the body and subject.""" reason, rationale = self._recipients.getReason(email) params = {'reason': reason.getReason()} if self.delta is not None: params['delta'] = self.textDelta() return params def textDelta(self): """Return a textual version of the class delta.""" return text_delta(self.delta, self.delta.delta_values, self.delta.new_values, self.delta.interface) def _getBody(self, email, recipient): """Return the complete body to use for this email.""" template = get_email_template(self._getTemplateName(email, recipient), app=self.app) params = self._getTemplateParams(email, recipient) body = template % params if self._wrap: body = MailWrapper().format(body, force_wrap=self._force_wrap) + "\n" footer = self._getFooter(email, recipient, params) if footer is not None: body = append_footer(body, footer) return body def _getFooter(self, email, recipient, params): """Provide a footer to attach to the body, or None.""" return None def _getExpandedFooter(self, headers, recipient): """Provide an expanded footer for recipients who have requested it.""" if not recipient.expanded_notification_footers: return None lines = [] for key, value in headers.items(): if key.startswith('X-Launchpad-'): lines.append('%s: %s\n' % (key[2:], value)) return ''.join(lines) def sendOne(self, email, recipient): """Send notification to one recipient.""" # We never want SMTP errors to propagate from this function. ctrl = self.generateEmail(email, recipient) try: ctrl.send() except SMTPException: # If the initial sending failed, try again without # attachments. ctrl = self.generateEmail(email, recipient, force_no_attachments=True) try: ctrl.send() except SMTPException: error_utility = getUtility(IErrorReportingUtility) oops_vars = { "message_id": ctrl.headers.get("Message-Id"), "notification_type": self.notification_type, "recipient": ", ".join(ctrl.to_addrs), "subject": ctrl.subject, } with error_utility.oopsMessage(oops_vars): oops = error_utility.raising(sys.exc_info(), self.request) self.logger.info("Mail resulted in OOPS: %s" % oops.get("id")) def sendAll(self): """Send notifications to all recipients.""" for email, recipient in sorted(self._recipients.getRecipientPersons()): self.sendOne(email, recipient)
class BaseMailer: """Base class for notification mailers. Subclasses must provide getReason (or reimplement _getTemplateParameters or generateEmail). It is expected that subclasses may override _getHeaders, _getTemplateParams, and perhaps _getBody. """ app = None def __init__(self, subject, template_name, recipients, from_address, delta=None, message_id=None, notification_type=None, mail_controller_class=None): """Constructor. :param subject: A Python dict-replacement template for the subject line of the email. :param template: Name of the template to use for the message body. :param recipients: A dict of recipient to Subscription. :param from_address: The from_address to use on emails. :param delta: A Delta object with members "delta_values", "interface" and "new_values", such as BranchMergeProposalDelta. :param message_id: The Message-Id to use for generated emails. If not supplied, random message-ids will be used. :param mail_controller_class: The class of the mail controller to use to send the mails. Defaults to `MailController`. """ self._subject_template = subject self._template_name = template_name self._recipients = NotificationRecipientSet() for recipient, reason in recipients.iteritems(): self._recipients.add(recipient, reason, reason.mail_header) self.from_address = from_address self.delta = delta self.message_id = message_id self.notification_type = notification_type self.logger = logging.getLogger('lp.services.mail.basemailer') if mail_controller_class is None: mail_controller_class = MailController self._mail_controller_class = mail_controller_class def _getToAddresses(self, recipient, email): return [format_address(recipient.displayname, email)] def generateEmail(self, email, recipient, force_no_attachments=False): """Generate the email for this recipient. :param email: Email address of the recipient to send to. :param recipient: The Person to send to. :return: (headers, subject, body) of the email. """ to_addresses = self._getToAddresses(recipient, email) headers = self._getHeaders(email) subject = self._getSubject(email, recipient) body = self._getBody(email, recipient) ctrl = self._mail_controller_class(self.from_address, to_addresses, subject, body, headers, envelope_to=[email]) if force_no_attachments: ctrl.addAttachment('Excessively large attachments removed.', content_type='text/plain', inline=True) else: self._addAttachments(ctrl, email) return ctrl def _getSubject(self, email, recipient): """The subject template expanded with the template params.""" return (self._subject_template % self._getTemplateParams(email, recipient)) def _getReplyToAddress(self): """Return the address to use for the reply-to header.""" return None def _getHeaders(self, email): """Return the mail headers to use.""" reason, rationale = self._recipients.getReason(email) headers = {'X-Launchpad-Message-Rationale': reason.mail_header} if self.notification_type is not None: headers['X-Launchpad-Notification-Type'] = self.notification_type reply_to = self._getReplyToAddress() if reply_to is not None: headers['Reply-To'] = reply_to if self.message_id is not None: headers['Message-Id'] = self.message_id return headers def _addAttachments(self, ctrl, email): """Add any appropriate attachments to a MailController. Default implementation does nothing. :param ctrl: The MailController to add attachments to. :param email: The email address of the recipient. """ pass def _getTemplateParams(self, email, recipient): """Return a dict of values to use in the body and subject.""" reason, rationale = self._recipients.getReason(email) params = {'reason': reason.getReason()} if self.delta is not None: params['delta'] = self.textDelta() return params def textDelta(self): """Return a textual version of the class delta.""" return text_delta(self.delta, self.delta.delta_values, self.delta.new_values, self.delta.interface) def _getBody(self, email, recipient): """Return the complete body to use for this email.""" template = get_email_template(self._template_name, app=self.app) params = self._getTemplateParams(email, recipient) body = template % params footer = self._getFooter(params) if footer is not None: body = append_footer(body, footer) return body def _getFooter(self, params): """Provide a footer to attach to the body, or None.""" return None def sendAll(self): """Send notifications to all recipients.""" # We never want SMTP errors to propagate from this function. for email, recipient in self._recipients.getRecipientPersons(): try: ctrl = self.generateEmail(email, recipient) ctrl.send() except SMTPException as e: # If the initial sending failed, try again without # attachments. try: ctrl = self.generateEmail(email, recipient, force_no_attachments=True) ctrl.send() except SMTPException as e: # Don't want an entire stack trace, just some details. self.logger.warning('send failed for %s, %s' % (email, e))