def test_sendmail_with_catch_all(self): recipients = ['*****@*****.**', 'Recipient-2 <*****@*****.**>', ('Recipient-3', '*****@*****.**')] ccs = ['*****@*****.**', 'CC-2 <*****@*****.**>', ('CC-3', '*****@*****.**')] bccs = ['*****@*****.**', 'BCC-2 <*****@*****.**>', ('BCC-3', '*****@*****.**')] with self.mail_config(catch_all='*****@*****.**'): with self.mail.connect() as conn: with mock.patch.object(conn, 'host') as host: msg = Message(subject="testing", sender="*****@*****.**", recipients=recipients, cc=ccs, bcc=bccs, body=u"Öö") conn.send(msg) args, kwargs = host.sendmail.call_args for mail in recipients + ccs + bccs: send_to = args[1] expected = sanitize_address(mail, catch_all='*****@*****.**') not_expected = sanitize_address(mail) self.assertIn(expected, send_to) self.assertNotIn(not_expected, send_to)
def send(self, message, envelope_from=None): """Verifies and sends message. :param message: Message instance. :param envelope_from: Email address to be used in MAIL FROM command. """ assert message.recipients, "No recipients have been added" assert message.sender, ( "The message does not specify a sender and a default sender " "has not been configured") if message.has_bad_headers(): raise BadHeaderError if message.date is None: message.date = time.time() if self.host: self.host.sendmail(sanitize_address(envelope_from or message.sender), message.send_to, message.as_string(), message.mail_options, message.rcpt_options) email_dispatched.send(message, app=current_app._get_current_object()) self.num_emails += 1 if self.num_emails == self.mail.max_emails: self.num_emails = 0 if self.host: self.host.quit() self.host = self.configure_host()
def send(self, job, parent=None): # TODO(dcramer): we should send a clipping of a relevant job log recipients = self.get_recipients(job) if not recipients: return event = try_create(Event, where={ 'type': EventType.email, 'item_id': job.build_id, 'data': { 'recipients': recipients, } }) if not event: # We've already sent out notifications for this build return context = self.get_context(job, parent) msg = Message(context['title'], recipients=recipients, extra_headers={ 'Reply-To': ', '.join(sanitize_address(r) for r in recipients), }) msg.body = render_template('listeners/mail/notification.txt', **context) msg.html = Markup(toronado.from_string( render_template('listeners/mail/notification.html', **context) )) mail.send(msg)
def get_msg(self, builds): # type: (List[Build]) -> Message context = build_context_lib.get_collection_context( builds) # type: CollectionContext if context.result == Result.passed: return None max_shown = current_app.config.get('MAX_SHOWN_ITEMS_PER_BUILD_MAIL', 3) context_dict = context._asdict() context_dict.update({ 'MAX_SHOWN_ITEMS_PER_BUILD': max_shown, 'showing_failing_tests_count': sum([ min(b['failing_tests_count'], max_shown) for b in context.builds ]) }) recipients = self.get_collection_recipients(context) msg = Message(context.title, recipients=recipients, extra_headers={ 'Reply-To': ', '.join(sanitize_address(r) for r in recipients), }) msg.body = render_template('listeners/mail/notification.txt', **context_dict) msg.html = Markup( toronado.from_string( render_template('listeners/mail/notification.html', **context_dict))) return msg
def send(self, job, parent=None): # TODO(dcramer): we should send a clipping of a relevant job log recipients = self.get_recipients(job) if not recipients: return event = try_create(Event, where={ 'type': EventType.email, 'item_id': job.build_id, 'data': { 'recipients': recipients, } }) if not event: # We've already sent out notifications for this build return context = self.get_context(job, parent) msg = Message(context['title'], recipients=recipients, extra_headers={ 'Reply-To': ', '.join(sanitize_address(r) for r in recipients), }) msg.body = render_template('listeners/mail/notification.txt', **context) msg.html = Markup( toronado.from_string( render_template('listeners/mail/notification.html', **context))) mail.send(msg)
def send(self, message, envelope_from=None): """Verifies and sends message. :param message: Message instance. :param envelope_from: Email address to be used in MAIL FROM command. """ assert message.recipients, "No recipients have been added" assert message.sender, ( "The message does not specify a sender and a default sender " "has not been configured") if message.has_bad_headers(): raise BadHeaderError if message.date is None: message.date = time.time() if self.host: self.host.sendmail( sanitize_address(envelope_from or message.sender), message.send_to, message.as_string(), message.mail_options, message.rcpt_options) email_dispatched.send(message, app=current_app._get_current_object()) self.num_emails += 1 if self.num_emails == self.mail.max_emails: self.num_emails = 0 if self.host: self.host.quit() self.host = self.configure_host()
def test_sanitize_addresse_with_catch_all(self): values = ( '*****@*****.**', ('Recipient', '*****@*****.**'), 'Recipient <*****@*****.**>', ) for value in values: self.assertIn(' <*****@*****.**>', sanitize_address(value, catch_all='*****@*****.**'))
def test_reply_to(self): msg = Message(subject="testing", recipients=["*****@*****.**"], sender="spammer <*****@*****.**>", reply_to="somebody <*****@*****.**>", body="testing") response = msg.as_string() h = Header("Reply-To: %s" % sanitize_address('somebody <*****@*****.**>')) self.assertIn(h.encode(), str(response))
def send_notification(job, recipients): # TODO(dcramer): we should send a clipping of a relevant job log test_failures = TestGroup.query.filter( TestGroup.job_id == job.id, TestGroup.result == Result.failed, TestGroup.num_leaves == 0, ).order_by(TestGroup.name.asc()) num_test_failures = test_failures.count() test_failures = test_failures[:25] build = job.build # TODO(dcramer): we should probably find a better way to do logs primary_log = LogSource.query.filter( LogSource.job_id == job.id, ).order_by(LogSource.date_created.asc()).first() if primary_log: log_clipping = get_log_clipping( primary_log, max_size=5000, max_lines=25) subject = u"Build {result} - {project} #{number} ({target})".format( number='{0}.{1}'.format(job.build.number, job.number), result=unicode(job.result), target=build.target or build.source.revision_sha or 'Unknown', project=job.project.name, ) for testgroup in test_failures: testgroup.uri = build_uri('/testgroups/{0}/'.format(testgroup.id.hex)) job.uri = build_uri('/jobs/{0}/'.format(job.id.hex)) build.uri = build_uri('/builds/{0}/'.format(build.id.hex)) context = { 'job': job, 'build': job.build, 'total_test_failures': num_test_failures, 'test_failures': test_failures, } if primary_log: context['build_log'] = { 'text': log_clipping, 'name': primary_log.name, 'link': '{0}logs/{1}/'.format(job.uri, primary_log.id.hex), } msg = Message(subject, recipients=recipients, extra_headers={ 'Reply-To': ', '.join(sanitize_address(r) for r in recipients), }) msg.body = render_template('listeners/mail/notification.txt', **context) msg.html = render_template('listeners/mail/notification.html', **context) mail.send(msg)
def get_msg(self, context): recipients = self.get_collection_recipients(context) msg = Message(context['title'], recipients=recipients, extra_headers={ 'Reply-To': ', '.join(sanitize_address(r) for r in recipients), }) msg.body = render_template('listeners/mail/notification.txt', **context) msg.html = Markup(toronado.from_string( render_template('listeners/mail/notification.html', **context) )) return msg
def test_unicode_headers(self): msg = Message(subject="subject", sender=u'ÄÜÖ → ✓ <*****@*****.**>', recipients=[u"Ä <*****@*****.**>", u"Ü <*****@*****.**>"], cc=[u"Ö <*****@*****.**>"]) response = msg.as_string() a1 = sanitize_address(u"Ä <*****@*****.**>") a2 = sanitize_address(u"Ü <*****@*****.**>") h1_a = Header("To: %s, %s" % (a1, a2)) h1_b = Header("To: %s, %s" % (a2, a1)) h2 = Header("From: %s" % sanitize_address(u"ÄÜÖ → ✓ <*****@*****.**>")) h3 = Header("Cc: %s" % sanitize_address(u"Ö <*****@*****.**>")) # Ugly, but there's no guaranteed order of the recipieints in the header try: self.assertIn(h1_a.encode(), response) except AssertionError: self.assertIn(h1_b.encode(), response) self.assertIn(h2.encode(), response) self.assertIn(h3.encode(), response)
def test_send_message_with_catch_all(self): recipients = [ '*****@*****.**', 'Recipient-2 <*****@*****.**>', ('Recipient-3', '*****@*****.**') ] ccs = [ '*****@*****.**', 'CC-2 <*****@*****.**>', ('CC-3', '*****@*****.**') ] # Not tested because BCCs are not serialized into headers # See: https://github.com/mattupstate/flask-mail/pull/29 bccs = [ '*****@*****.**', 'BCC-2 <*****@*****.**>', ('BCC-3', '*****@*****.**') ] sender = '*****@*****.**' reply_to = '*****@*****.**' catch_all = '*****@*****.**' with self.mail_config(catch_all=catch_all): with self.mail.record_messages() as outbox: self.mail.send_message(subject='testing', sender=sender, reply_to=reply_to, recipients=recipients, cc=ccs, bcc=bccs, body='test') self.assertEqual(len(outbox), 1) msg = outbox[0] response = msg.as_string() for value in recipients + ccs: expected = sanitize_address(value, catch_all=catch_all) self.assertIn(expected, response) for value in (sender, reply_to): not_expected = sanitize_address(value, catch_all=catch_all) self.assertNotIn(not_expected, response)
def send(self, job, parent=None): # TODO(dcramer): we should send a clipping of a relevant job log recipients = self.get_recipients(job) if not recipients: return context = self.get_context(job, parent) msg = Message(context['title'], recipients=recipients, extra_headers={ 'Reply-To': ', '.join(sanitize_address(r) for r in recipients), }) msg.body = render_template('listeners/mail/notification.txt', **context) msg.html = Markup(Pynliner().from_string( render_template('listeners/mail/notification.html', **context) ).run()) mail.send(msg)
def get_msg(self, builds): context = build_context_lib.get_collection_context(builds) if context['result'] == Result.passed: return None max_shown = current_app.config.get('MAX_SHOWN_ITEMS_PER_BUILD_MAIL', 3) context.update({ 'MAX_SHOWN_ITEMS_PER_BUILD': max_shown, 'showing_failing_tests_count': sum([min(b['failing_tests_count'], max_shown) for b in context['builds']]) }) recipients = self.get_collection_recipients(context) msg = Message(context['title'], recipients=recipients, extra_headers={ 'Reply-To': ', '.join(sanitize_address(r) for r in recipients), }) msg.body = render_template('listeners/mail/notification.txt', **context) msg.html = Markup(toronado.from_string( render_template('listeners/mail/notification.html', **context) )) return msg
def build_message(repository: Repository, reason: DeactivationReason) -> Message: recipients = [ e for e, in db.session.query(User.email).filter( User.id.in_( db.session.query(RepositoryAccess.user_id).filter( RepositoryAccess.repository_id == repository.id, RepositoryAccess.permission == Permission.admin, ))) ] if not recipients: current_app.logger.warn( "email.deactivated-repository.no-recipients repository_id=%s", repository.id) return context = { "title": "Repository Disabled", "settings_url": absolute_url("/settings/github/repos"), "repo": { "owner_name": repository.owner_name, "name": repository.name, "full_name": repository.get_full_name, }, "reason": reason, } msg = Message( "Repository Disabled - {}/{}".format(repository.owner_name, repository.name), recipients=recipients, extra_headers={ "Reply-To": ", ".join(sanitize_address(r) for r in recipients) }, ) msg.body = render_template("emails/deactivated-repository.txt", **context) msg.html = inline_css( render_template("emails/deactivated-repository.html", **context)) return msg
def send(self, job, parent=None): # TODO(dcramer): we should send a clipping of a relevant job log recipients = filter_recipients(self.get_recipients(job)) if not recipients: return event = try_create( Event, where={"type": EventType.email, "item_id": job.build_id, "data": {"recipients": recipients}} ) if not event: # We've already sent out notifications for this build return context = self.get_context(job, parent) msg = Message( context["title"], recipients=recipients, extra_headers={"Reply-To": ", ".join(sanitize_address(r) for r in recipients)}, ) msg.body = render_template("listeners/mail/notification.txt", **context) msg.html = Markup(toronado.from_string(render_template("listeners/mail/notification.html", **context))) mail.send(msg)
def build_message(build: Build, force=False) -> Message: author = Author.query.join( Source, Source.author_id == Author.id, ).filter(Source.id == build.source_id, ).first() if not author: current_app.logger.info('mail.missing-author', extra={ 'build_id': build.id, }) return users = find_linked_accounts(build) if not users and not force: current_app.logger.info('mail.no-linked-accounts', extra={ 'build_id': build.id, }) return elif not users: users = [auth.get_current_user()] # filter it down to the users that have notifications enabled user_options = dict( db.session.query(ItemOption.item_id, ItemOption.value).filter( ItemOption.item_id.in_([u.id for u in users]), ItemOption.name == 'mail.notify_author', )) users = [u for u in users if user_options.get(u.id, '1') == '1'] if not users: current_app.logger.info('mail.no-enabed-accounts', extra={ 'build_id': build.id, }) return source = Source.query.get(build.source_id) assert source repo = Repository.query.get(build.repository_id) assert repo revision = Revision.query.filter( Revision.sha == source.revision_sha, Revision.repository_id == build.repository_id, ).first() assert revision job_list = sorted(Job.query.filter(Job.build_id == build.id), key=lambda x: [x.result != Result.failed, x.number]) job_ids = [j.id for j in job_list] recipients = [u.email for u in users] subject = 'Build {} - {}/{} #{}'.format( str(build.result).title(), repo.owner_name, repo.name, build.number, ) if job_ids: failing_tests_query = TestCase.query.options( undefer('message')).filter( TestCase.job_id.in_(job_ids), TestCase.result == Result.failed, ) failing_tests_count = failing_tests_query.count() failing_tests = failing_tests_query.limit(10) style_violations_query = StyleViolation.query.filter( StyleViolation.job_id.in_(job_ids), ) style_violations_count = style_violations_query.count() style_violations = style_violations_query.limit(10) else: failing_tests = () failing_tests_count = 0 style_violations = () style_violations_count = 0 context = { 'title': subject, 'uri': '{proto}://{domain}/{repo}/builds/{build_no}'.format( proto='https' if current_app.config['SSL'] else 'http', domain=current_app.config['DOMAIN'], repo=repo.get_full_name(), build_no=build.number, ), 'build': { 'number': build.number, 'result': { 'id': str(build.result), 'name': str(build.result).title(), }, 'label': build.label, }, 'repo': { 'owner_name': repo.owner_name, 'name': repo.name, 'full_name': repo.get_full_name, }, 'author': { 'name': author.name, 'email': author.email, }, 'revision': { 'sha': revision.sha, 'short_sha': revision.sha[:7], 'message': revision.message, }, 'job_list': [{ 'number': job.number, 'result': { 'id': str(job.result), 'name': str(job.result).title(), }, 'url': job.url, 'label': job.label, } for job in job_list], 'job_failure_count': sum((1 for job in job_list if job.result == Result.failed)), 'date_created': build.date_created, 'failing_tests': [{ 'name': test.name } for test in failing_tests], 'failing_tests_count': failing_tests_count, 'style_violations': [{ 'message': violation.message, 'filename': violation.filename } for violation in style_violations], 'style_violations_count': style_violations_count, } msg = Message(subject, recipients=recipients, extra_headers={ 'Reply-To': ', '.join(sanitize_address(r) for r in recipients), }) msg.body = render_template('notifications/email.txt', **context) msg.html = inline_css( render_template('notifications/email.html', **context)) return msg
def build_message(build: Build, force=False) -> Message: author = Author.query.join(Source, Source.author_id == Author.id).filter( Source.id == build.source_id ).first() if not author: current_app.logger.info("mail.missing-author", extra={"build_id": build.id}) return emails = find_linked_emails(build) if not emails and not force: current_app.logger.info("mail.no-linked-accounts", extra={"build_id": build.id}) return elif not emails: current_user = auth.get_current_user() emails = [[current_user.id, current_user.email]] # filter it down to the users that have notifications enabled user_options = dict( db.session.query(ItemOption.item_id, ItemOption.value).filter( ItemOption.item_id.in_([uid for uid, _ in emails]), ItemOption.name == "mail.notify_author", ) ) emails = [r for r in emails if user_options.get(r[0], "1") == "1"] if not emails: current_app.logger.info("mail.no-enabed-accounts", extra={"build_id": build.id}) return source = Source.query.get(build.source_id) assert source repo = Repository.query.get(build.repository_id) assert repo revision = Revision.query.filter( Revision.sha == source.revision_sha, Revision.repository_id == build.repository_id, ).first() assert revision job_list = sorted( Job.query.filter(Job.build_id == build.id), key=lambda x: [x.result != Result.failed, x.number], ) job_ids = [j.id for j in job_list] recipients = [r[1] for r in emails] subject = "Build {} - {}/{} #{}".format( str(build.result).title(), repo.owner_name, repo.name, build.number ) if job_ids: failing_tests_query = TestCase.query.options(undefer("message")).filter( TestCase.job_id.in_(job_ids), TestCase.result == Result.failed ) failing_tests_count = failing_tests_query.count() failing_tests = failing_tests_query.limit(10) style_violations_query = StyleViolation.query.filter( StyleViolation.job_id.in_(job_ids) ).order_by( (StyleViolation.severity == Severity.error).desc(), StyleViolation.filename.asc(), StyleViolation.lineno.asc(), StyleViolation.colno.asc(), ) style_violations_count = style_violations_query.count() style_violations = style_violations_query.limit(10) else: failing_tests = () failing_tests_count = 0 style_violations = () style_violations_count = 0 context = { "title": subject, "uri": "{proto}://{domain}/{repo}/builds/{build_no}".format( proto="https" if current_app.config["SSL"] else "http", domain=current_app.config["DOMAIN"], repo=repo.get_full_name(), build_no=build.number, ), "build": { "number": build.number, "result": {"id": str(build.result), "name": str(build.result).title()}, "label": build.label, }, "repo": { "owner_name": repo.owner_name, "name": repo.name, "full_name": repo.get_full_name, }, "author": {"name": author.name, "email": author.email}, "revision": { "sha": revision.sha, "short_sha": revision.sha[:7], "message": revision.message, }, "job_list": [ { "number": job.number, "result": {"id": str(job.result), "name": str(job.result).title()}, "url": job.url, "label": job.label, } for job in job_list ], "job_failure_count": sum( (1 for job in job_list if job.result == Result.failed) ), "date_created": build.date_created, "failing_tests": [{"name": test.name} for test in failing_tests], "failing_tests_count": failing_tests_count, "style_violations": [ {"message": violation.message, "filename": violation.filename} for violation in style_violations ], "style_violations_count": style_violations_count, } msg = Message( subject, recipients=recipients, extra_headers={"Reply-To": ", ".join(sanitize_address(r) for r in recipients)}, ) msg.body = render_template("notifications/email.txt", **context) msg.html = inline_css(render_template("notifications/email.html", **context)) return msg
def _message(self): """Creates email as 'multipart/related' instead of 'multipart/mixed'""" ascii_attachments = current_app.extensions['mail'].ascii_attachments encoding = self.charset or 'utf-8' attachments = self.attachments or [] if len(attachments) == 0 and not self.html: # No html content and zero attachments means plain text msg = self._mimetext(self.body) elif len(attachments) > 0 and not self.html: # No html and at least one attachment means multipart msg = MIMEMultipart() msg.attach(self._mimetext(self.body)) else: # Anything else msg = MIMEMultipart( 'related') # This fixes embedded images in the html body alternative = MIMEMultipart('alternative') alternative.attach(self._mimetext(self.body, 'plain')) alternative.attach(self._mimetext(self.html, 'html')) msg.attach(alternative) if self.subject: msg['Subject'] = sanitize_subject(force_text(self.subject), encoding) msg['From'] = sanitize_address(self.sender, encoding) msg['To'] = ', '.join( list(set(sanitize_addresses(self.recipients, encoding)))) msg['Date'] = formatdate(self.date, localtime=True) # see RFC 5322 section 3.6.4. msg['Message-ID'] = self.msgId if self.cc: msg['Cc'] = ', '.join( list(set(sanitize_addresses(self.cc, encoding)))) if self.reply_to: msg['Reply-To'] = sanitize_address(self.reply_to, encoding) if self.extra_headers: for k, v in self.extra_headers.items(): msg[k] = v SPACES = re.compile(r'[\s]+', re.UNICODE) for attachment in attachments: f = MIMEBase(*attachment.content_type.split('/')) f.set_payload(attachment.data) encode_base64(f) filename = attachment.filename if filename and ascii_attachments: # force filename to ascii filename = unicodedata.normalize('NFKD', filename) filename = filename.encode('ascii', 'ignore').decode('ascii') filename = SPACES.sub(u' ', filename).strip() try: filename and filename.encode('ascii') except UnicodeEncodeError: filename = ('UTF8', '', filename) f.add_header('Content-Disposition', attachment.disposition, filename=filename) for key, value in attachment.headers: f.add_header(key, value) msg.attach(f) if message_policy: msg.policy = message_policy return msg
def as_string(self): """Creates the email""" attachments = self.attachments or [] if len(attachments) == 0 and not self.html: # No html content and zero attachments means plain text msg = self._mimetext(self.body) elif len(attachments) > 0 and not self.html: # No html and at least one attachment means multipart msg = MIMEMultipart() msg.attach(self._mimetext(self.body)) else: # Anything else msg = MIMEMultipart() alternative = MIMEMultipart('alternative') alternative.attach(self._mimetext(self.body, 'plain')) alternative.attach(self._mimetext(self.html, 'html')) msg.attach(alternative) msg['Subject'] = self.subject msg['From'] = sanitize_address(self.sender) msg['To'] = ', '.join(list(set(sanitize_addresses(self.recipients)))) msg['Date'] = formatdate(self.date, localtime=True) # see RFC 5322 section 3.6.4. msg['Message-ID'] = self.msgId if self.cc: msg['Cc'] = ', '.join(list(set(sanitize_addresses(self.cc)))) if self.reply_to: msg['Reply-To'] = sanitize_address(self.reply_to) if self.extra_headers: for k, v in self.extra_headers.items(): msg[k] = v for attachment in attachments: f = MIMEBase(*attachment.content_type.split('/')) f.set_payload(attachment.data) encode_base64(f) try: attachment.filename and attachment.filename.encode('ascii') except UnicodeEncodeError: filename = attachment.filename if not PY3: filename = filename.encode('utf8') f.add_header('Content-Disposition', attachment.disposition, filename=('UTF8', '', filename)) else: f.add_header('Content-Disposition', '%s;filename=%s' % (attachment.disposition, attachment.filename)) for key, value in attachment.headers: f.add_header(key, value) msg.attach(f) return msg.as_string()
def as_string(self): """Creates the email""" attachments = self.attachments or [] if len(attachments) == 0 and not self.html: # No html content and zero attachments means plain text msg = self._mimetext(self.body) elif len(attachments) > 0 and not self.html: # No html and at least one attachment means multipart msg = MIMEMultipart() msg.attach(self._mimetext(self.body)) else: # Anything else msg = MIMEMultipart() alternative = MIMEMultipart('alternative') alternative.attach(self._mimetext(self.body, 'plain')) alternative.attach(self._mimetext(self.html, 'html')) msg.attach(alternative) msg['Subject'] = self.subject msg['From'] = sanitize_address(self.sender) msg['To'] = ', '.join(list(set(sanitize_addresses(self.recipients)))) msg['Date'] = formatdate(self.date, localtime=True) # see RFC 5322 section 3.6.4. msg['Message-ID'] = self.msgId if self.cc: msg['Cc'] = ', '.join(list(set(sanitize_addresses(self.cc)))) if self.reply_to: msg['Reply-To'] = sanitize_address(self.reply_to) if self.extra_headers: for k, v in self.extra_headers.items(): msg[k] = v for attachment in attachments: f = MIMEBase(*attachment.content_type.split('/')) f.set_payload(attachment.data) encode_base64(f) try: attachment.filename and attachment.filename.encode('ascii') except UnicodeEncodeError: filename = attachment.filename if not PY3: filename = filename.encode('utf8') f.add_header('Content-Disposition', attachment.disposition, filename=('UTF8', '', filename)) else: f.add_header( 'Content-Disposition', '%s;filename=%s' % (attachment.disposition, attachment.filename)) for key, value in attachment.headers: f.add_header(key, value) msg.attach(f) return msg.as_string()
def sanitize_addresses(addresses): return map(lambda e: sanitize_address(e), addresses)