Send email action, step 1

Basic send-email functionality and unit tests
See comments in test_send_email_action on how to run with real smtpd.

Implements: blueprint mistral-std-send-email-action
Change-Id: Ib1d460d26ce54f4ab85d34b18947d858ab471dfd
This commit is contained in:
dzimine 2014-02-06 08:51:08 -08:00
parent 842d75713a
commit 9bca04c79e
2 changed files with 175 additions and 0 deletions
mistral
engine/actions
tests/unit/engine/actions

@ -16,6 +16,9 @@
from amqplib import client_0_8 as amqp
import requests
#TODO(dzimine):separate actions across different files/modules
import smtplib
from email.mime.text import MIMEText
from mistral.openstack.common import log as logging
@ -40,6 +43,7 @@ class BaseAction(object):
class RestAction(BaseAction):
def __init__(self, action_type, action_name, url, params={},
method="GET", headers={}, data={}):
super(RestAction, self).__init__(action_type, action_name)
@ -110,3 +114,49 @@ class OsloRPCAction(BaseAction):
def callback(self, msg):
#TODO (nmakhotkin) set status
self.status = None
class SendEmailAction(BaseAction):
def __init__(self, action_type, action_name, params, settings):
super(SendEmailAction, self).__init__(action_type, action_name)
#TODO(dzimine): validate parameters
# Task invocation parameters.
self.to = ', '.join(params['to'])
self.subject = params['subject']
self.body = params['body']
# Action provider settings.
self.smtp_server = settings['smtp_server']
self.sender = settings['from']
self.password = settings['password'] \
if 'password' in settings else None
def run(self):
LOG.info("Sending email message "
"[from=%s, to=%s, subject=%s, using smtp=%s, body=%s...]" %
(self.sender, self.to, self.subject,
self.smtp_server, self.body[:128]))
#TODO(dzimine): handle utf-8, http://stackoverflow.com/a/14506784
message = MIMEText(self.body)
message['Subject'] = self.subject
message['From'] = self.sender
message['To'] = self.to
try:
s = smtplib.SMTP(self.smtp_server)
if self.password is not None:
# Sequence to request TLS connection and log in (RFC-2487).
s.ehlo()
s.starttls()
s.ehlo()
s.login(self.sender, self.password)
s.sendmail(from_addr=self.sender,
to_addrs=self.to,
msg=message.as_string())
except (smtplib.SMTPException, IOError) as e:
LOG.error("Error sending email message: %s" % e)
#NOTE(DZ): Raise Misral exception instead re-throwing SMTP?
# For now just logging the error here and re-thorw the original
raise

@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
#
# Copyright 2013 - StackStorm, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from mistral.engine.actions import actions
from email.parser import Parser
import unittest2
from mock import patch, call
#TODO(dzimine): replace local definitions on next step
# from mistral.engine.actions import action_types
ACTION_TYPE = "MISTRAL_SEND_EMAIL"
ACTION_NAME = "TEMPORARY"
'''
To try against a real SNMP server:
1) set LOCAL_SMPTD = True
run debug snmpd on the local machine:
`sudo python -m smtpd -c DebuggingServer -n localhost:25`
Debugging server doesn't support password.
2) set REMOTE_SMPT = True
use external SNMP (like gmail), change the configuration,
provide actual username and password
self.settings = {
'host': 'smtp.gmail.com:587',
'from': "youraccount@gmail.com",
'password': "secret"
}
'''
LOCAL_SMTPD = False
REMOTE_SMTP = False
class SendEmailActionTest(unittest2.TestCase):
def setUp(self):
self.params = {
'to': ["dz@example.com, deg@example.com", "xyz@example.com"],
'subject': "Multi word subject с русскими буквами",
'body': "short multiline\nbody\nc русскими буквами",
}
self.settings = {
'smtp_server': 'mail.example.com:25',
'from': "bot@example.com",
}
self.to_addrs = ', '.join(self.params['to'])
@unittest2.skipIf(not LOCAL_SMTPD, "Setup local smtpd to run it")
def test_send_email_real(self):
action = actions.SendEmailAction(
ACTION_TYPE, ACTION_NAME, self.params, self.settings)
action.run()
@unittest2.skipIf(not REMOTE_SMTP, "Configure Remote SMTP to run it")
def test_with_password_real(self):
self.params['to'] = ["dz@stackstorm.com"]
self.settings = {
'smtp_server': 'smtp.gmail.com:587',
'from': "username@gmail.com",
}
self.settings['password'] = "secret"
action = actions.SendEmailAction(
ACTION_TYPE, ACTION_NAME, self.params, self.settings)
action.run()
@patch('smtplib.SMTP')
def test_send_email(self, smtp):
action = actions.SendEmailAction(
ACTION_TYPE, ACTION_NAME, self.params, self.settings)
action.run()
smtp.assert_called_once_with(self.settings['smtp_server'])
sendmail = smtp.return_value.sendmail
self.assertTrue(sendmail.called, "should call sendmail")
self.assertEqual(
sendmail.call_args[1]['from_addr'], self.settings['from'])
self.assertEqual(
sendmail.call_args[1]['to_addrs'], self.to_addrs)
message = Parser().parsestr(sendmail.call_args[1]['msg'])
self.assertEqual(message['from'], self.settings['from'])
self.assertEqual(message['to'], self.to_addrs)
self.assertEqual(message['subject'], self.params['subject'])
self.assertEqual(message.get_payload(), self.params['body'])
@patch('smtplib.SMTP')
def test_with_password(self, smtp):
self.settings['password'] = "secret"
action = actions.SendEmailAction(
ACTION_TYPE, ACTION_NAME, self.params, self.settings)
action.run()
smtpmock = smtp.return_value
calls = [call.ehlo(), call.starttls(), call.ehlo(),
call.login(self.settings['from'], self.settings['password'])]
smtpmock.assert_has_calls(calls)
self.assertTrue(smtpmock.sendmail.called, "should call sendmail")
@patch('mistral.engine.actions.actions.LOG')
def test_exception(self, log):
self.params['smtp_server'] = "wrong host"
action = actions.SendEmailAction(
ACTION_TYPE, ACTION_NAME, self.params, self.settings)
try:
action.run()
except IOError:
pass
else:
self.assertFalse("Must throw exception")
self.assertTrue(log.error.called)