From f196f9a8a92bb7206ef64e8bba151ba659c3aab6 Mon Sep 17 00:00:00 2001 From: wanghao Date: Fri, 21 Oct 2016 09:20:11 +0800 Subject: [PATCH] Subscription Confirmation Support-3 This patch is the third part of subscription confirmation feature. Support to send email to subscriber if confirmation is needed. Change-Id: I230f5c7fbc9d19554bbcf34ce9b2f3b14230321b Implements: blueprint subscription-confirmation-support (cherry picked from commit 4778f708fa9ec86b4137eefd63d07c40ad24296e) --- doc/source/subscription_confirm.rst | 81 +++++++++- ...mation-support-email-0c2a56cfedc5d1e2.yaml | 15 ++ .../html/confirmation_web_service_sample.py | 86 ++++++++++ samples/html/subscriptionConfirmation.html | 148 ++++++++++++++++++ samples/html/unsubscriptionConfirmation.html | 145 +++++++++++++++++ zaqar/common/configs.py | 33 ++++ zaqar/notification/notifier.py | 19 ++- zaqar/notification/tasks/mailto.py | 78 +++++++-- .../tests/unit/notification/test_notifier.py | 97 ++++++++++++ zaqar/transport/wsgi/v2_0/__init__.py | 3 +- zaqar/transport/wsgi/v2_0/subscriptions.py | 23 ++- 11 files changed, 706 insertions(+), 22 deletions(-) create mode 100644 releasenotes/notes/subscription-confirmation-support-email-0c2a56cfedc5d1e2.yaml create mode 100644 samples/html/confirmation_web_service_sample.py create mode 100644 samples/html/subscriptionConfirmation.html create mode 100644 samples/html/unsubscriptionConfirmation.html diff --git a/doc/source/subscription_confirm.rst b/doc/source/subscription_confirm.rst index 7042284c8..f8c4da445 100644 --- a/doc/source/subscription_confirm.rst +++ b/doc/source/subscription_confirm.rst @@ -15,9 +15,13 @@ The subscription Confirm Guide ============================== -The subscription confirm feature now only support webhook with mongoDB backend. +The subscription confirm feature now supports webhook and email with both +mongoDB and redis backend. This guide shows how to use this feature: +Webhook +------- + 1. Set the config option "require_confirmation" and add the policy to the policy.json file. Then restart Zaqar-wsgi service:: @@ -202,3 +206,78 @@ The response:: Then try to post a message. The subscriber will not receive the notification any more. + +Email +----- + +1. For the email confirmation way, also need to set the config option +"external_confirmation_url", "subscription_confirmation_email_template" and +"unsubscribe_confirmation_email_template". +The confirmation page url that will be used in email subscription confirmation +before notification, this page is not hosted in Zaqar server, user should +build their own web service to provide this web page. +The subscription_confirmation_email_template let user to customize the +subscription confirmation email content, including topic, body and sender. +The unsubscribe_confirmation_email_template let user to customize the +unsubscribe confirmation email content, including topic, body and sender too:: + + In the config file: + [notification] + require_confirmation = True + external_confirmation_url = http://web_service_url/ + subscription_confirmation_email_template = topic:Zaqar Notification - Subscription Confirmation,\ + body:'You have chosen to subscribe to the queue: {0}. This queue belongs to project: {1}. To confirm this subscription, click or visit this link below: {2}',\ + sender:Zaqar Notifications + unsubscribe_confirmation_email_template = topic: Zaqar Notification - Unsubscribe Confirmation,\ + body:'You have unsubscribed successfully to the queue: {0}. This queue belongs to project: {1}. To resubscribe this subscription, click or visit this link below: {2}',\ + sender:Zaqar Notifications + + In the policy.json file: + "subscription:confirm": "", + +2. Create a subscription. +For email confirmation, you should create a subscription like this:: + + curl -i -X POST http://10.229.47.217:8888/v2/queues/test/subscriptions \ + -H "Content-type: application/json" \ + -H "Client-ID: de305d54-75b4-431b-adb2-eb6b9e546014" \ + -H "X-Auth-Token: 440b677561454ea8a7f872201dd4e2c4" \ + -d '{"subscriber":"your email address", "ttl":3600, "options":{}}' + +The response:: + + HTTP/1.1 201 Created + content-length: 47 + content-type: application/json; charset=UTF-8 + location: http://10.229.47.217:8888/v2/queues/test/subscriptions + Connection: close + {"subscription_id": "576256b03990b480617b4063"} + +After the subscription created, Zaqar will send a email to the email address +of subscriber. The email specifies how to confirm the subscription. + +3. Click the confirmation page link in the email body + +4. The confirmation page will send the subscription confirmation request to +Zaqar server automatically. User also can choose to unsubscribe by clicking +the unsubscription link in this page, that will cause Zaqar to cancel this +subscription and send another email to notify this unsubscription action. +Zaqar providers two examples of those web pages that will help user to build +their own pages:: + + zaqar/sample/html/subscriptionConfirmation.html + zaqar/sample/html/unsubscriptionConfirmation.html + +User can place those pages in web server like Apache to access them by browser, +so the external_confirmation_url will be like this:: + http://127.0.0.1:8080/subscriptionConfirmation.html +For CORS, here used zaqar/samples/html/confirmation_web_service_sample.py +be a simple web service for example, it will relay the confirmation request to +Zaqar Server. So before Step 3, you should start the web service first. +The service could be started simply by the command:: + + python zaqar/samples/html/confirmation_web_service_sample.py +The service's default port is 5678. If you want to use a new port, the command +will be like:: + + python zaqar/samples/html/confirmation_web_service_sample.py new_port_number diff --git a/releasenotes/notes/subscription-confirmation-support-email-0c2a56cfedc5d1e2.yaml b/releasenotes/notes/subscription-confirmation-support-email-0c2a56cfedc5d1e2.yaml new file mode 100644 index 000000000..fffc24a43 --- /dev/null +++ b/releasenotes/notes/subscription-confirmation-support-email-0c2a56cfedc5d1e2.yaml @@ -0,0 +1,15 @@ +--- +features: + - This feature is the third part of subscription confirmation feature. + Support to send email to subscriber if confirmation is needed. + To use this feature, user need to set the config option + "external_confirmation_url", "subscription_confirmation_email_template" + and "unsubscribe_confirmation_email_template". + The confirmation page url that will be used in email subscription + confirmation before notification, this page is not hosted in Zaqar server, + user should build their own web service to provide this web page. + The subscription_confirmation_email_template let user to customize the + subscription confimation email content, including topic, body and + sender. The unsubscribe_confirmation_email_template let user to customize + the unsubscribe confimation email content, including topic, body and + sender too. diff --git a/samples/html/confirmation_web_service_sample.py b/samples/html/confirmation_web_service_sample.py new file mode 100644 index 000000000..360949996 --- /dev/null +++ b/samples/html/confirmation_web_service_sample.py @@ -0,0 +1,86 @@ +# 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. +import json +import logging +import requests +import sys +import uuid + +try: + import SimpleHTTPServer + import SocketServer +except Exception: + from http import server as SimpleHTTPServer + import socketserver as SocketServer + + +if len(sys.argv) > 2: + PORT = int(sys.argv[2]) +elif len(sys.argv) > 1: + PORT = int(sys.argv[1]) +else: + PORT = 5678 + + +class ServerHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): + """This is the sample service for email subscription confirmation. + + """ + + def do_OPTIONS(self): + logging.warning('=================== OPTIONS =====================') + self.send_response(200) + self.send_header('Access-Control-Allow-Origin', self.headers['origin']) + self.send_header('Access-Control-Allow-Methods', 'PUT') + self.send_header('Access-Control-Allow-Headers', + 'client-id,confirmation-url,content-type,url-expires,' + 'url-methods,url-paths,url-signature,x-project-id,' + 'confirm') + self.end_headers() + logging.warning(self.headers) + return + + def do_PUT(self): + logging.warning('=================== PUT =====================') + self._send_confirm_request() + self.send_response(200) + self.send_header('Access-Control-Allow-Origin', self.headers['origin']) + self.end_headers() + message = "{\"message\": \"ok\"}" + self.wfile.write(message) + logging.warning(self.headers) + return + + def _send_confirm_request(self): + url = self.headers['confirmation-url'] + confirmed_value = True + try: + if self.headers['confirm'] == "false": + confirmed_value = False + except KeyError: + pass + headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Project-ID': self.headers['x-project-id'], + 'Client-ID': str(uuid.uuid4()), + 'URL-Methods': self.headers['url-methods'], + 'URL-Signature': self.headers['url-signature'], + 'URL-Paths': self.headers['url-paths'], + 'URL-Expires': self.headers['url-expires'], + } + data = {'confirmed': confirmed_value} + requests.put(url=url, data=json.dumps(data), headers=headers) + +Handler = ServerHandler +httpd = SocketServer.TCPServer(("", PORT), Handler) +httpd.serve_forever() diff --git a/samples/html/subscriptionConfirmation.html b/samples/html/subscriptionConfirmation.html new file mode 100644 index 000000000..f04ca77ed --- /dev/null +++ b/samples/html/subscriptionConfirmation.html @@ -0,0 +1,148 @@ + + + + + + + + +
+ +
+

Confirming subscription...

+
+ +
+
+
+ + + + + diff --git a/samples/html/unsubscriptionConfirmation.html b/samples/html/unsubscriptionConfirmation.html new file mode 100644 index 000000000..320a95e73 --- /dev/null +++ b/samples/html/unsubscriptionConfirmation.html @@ -0,0 +1,145 @@ + + + + + + + + +
+ +
+

Removing subscription...

+
+ +
+
+
+ + + + + diff --git a/zaqar/common/configs.py b/zaqar/common/configs.py index 04a2ea8a4..c8886de3b 100644 --- a/zaqar/common/configs.py +++ b/zaqar/common/configs.py @@ -67,6 +67,39 @@ _NOTIFICATION_OPTIONS = ( cfg.BoolOpt('require_confirmation', default=False, help='Whether the http/https/email subscription need to be ' 'confirmed before notification.'), + cfg.StrOpt('external_confirmation_url', + help='The confirmation page url that will be used in email ' + 'subscription confirmation before notification.'), + cfg.DictOpt("subscription_confirmation_email_template", + default={'topic': 'Zaqar Notification - Subscription ' + 'Confirmation', + 'body': 'You have chosen to subscribe to the ' + 'queue: {0}. This queue belongs to ' + 'project: {1}. ' + 'To confirm this subscription, ' + 'click or visit this link below: {2}', + 'sender': 'Zaqar Notifications ' + ''}, + help="Defines the set of subscription confirmation email " + "content, including topic, body and sender. There is " + "a mapping is {0} -> queue name, {1} ->project id, " + "{2}-> confirm url in body string. User can use any of " + "the three value. But they can't use more than three."), + cfg.DictOpt("unsubscribe_confirmation_email_template", + default={'topic': 'Zaqar Notification - ' + 'Unsubscribe Confirmation', + 'body': 'You have unsubscribed successfully to the ' + 'queue: {0}. This queue belongs to ' + 'project: {1}. ' + 'To resubscribe this subscription, ' + 'click or visit this link below: {2}', + 'sender': 'Zaqar Notifications ' + ''}, + help="Defines the set of unsubscribe confirmation email " + "content, including topic, body and sender. There is " + "a mapping is {0} -> queue name, {1} ->project id, " + "{2}-> confirm url in body string. User can use any of " + "the three value. But they can't use more than three."), ) _NOTIFICATION_GROUP = 'notification' diff --git a/zaqar/notification/notifier.py b/zaqar/notification/notifier.py index a9ca4b940..81782b501 100644 --- a/zaqar/notification/notifier.py +++ b/zaqar/notification/notifier.py @@ -81,7 +81,7 @@ class NotifierDriver(object): def send_confirm_notification(self, queue, subscription, conf, project=None, expires=None, - api_version=None): + api_version=None, is_unsubscribed=False): # NOTE(flwang): If the confirmation feature isn't enabled, just do # nothing. Here we're getting the require_confirmation from conf # object instead of using self.require_confirmation, because the @@ -100,7 +100,15 @@ class NotifierDriver(object): subscription['id']) pre_url = urls.create_signed_url(key, [url], project=project, expires=expires, methods=['PUT']) - message_type = MessageType.SubscriptionConfirmation.name + message = None + if is_unsubscribed: + message_type = MessageType.UnsubscribeConfirmation.name + message = ('You have unsubscribed successfully to the queue: %s, ' + 'you can resubscribe it by using confirmed=True.' + % queue) + else: + message_type = MessageType.SubscriptionConfirmation.name + message = 'You have chosen to subscribe to the queue: %s' % queue messages = {} endpoint_dict = auth.get_public_endpoint() @@ -116,8 +124,7 @@ class NotifierDriver(object): websocket_endpoint, url) messages['WebSocketSubscribeURL'] = websocket_subscribe_url messages.update({'Message_Type': message_type, - 'Message': 'You have chosen to subscribe to the ' - 'queue: %s' % queue, + 'Message': message, 'URL-Signature': pre_url['signature'], 'URL-Methods': pre_url['methods'][0], 'URL-Paths': pre_url['paths'][0], @@ -126,8 +133,8 @@ class NotifierDriver(object): 'SubscribeBody': {'confirmed': True}, 'UnsubscribeBody': {'confirmed': False}}) s_type = urllib_parse.urlparse(subscription['subscriber']).scheme - LOG.info(_LI('Begin to send %(type)s confirm notification. The request' - 'body is %(messages)s'), + LOG.info(_LI('Begin to send %(type)s confirm/unsubscribe notification.' + ' The request body is %(messages)s'), {'type': s_type, 'messages': messages}) self._execute(s_type, subscription, [messages], conf) diff --git a/zaqar/notification/tasks/mailto.py b/zaqar/notification/tasks/mailto.py index 1b7a5169a..86818d6c1 100644 --- a/zaqar/notification/tasks/mailto.py +++ b/zaqar/notification/tasks/mailto.py @@ -20,32 +20,86 @@ import subprocess from oslo_log import log as logging -from zaqar.i18n import _LE +from zaqar.i18n import _, _LE +from zaqar.notification.notifier import MessageType LOG = logging.getLogger(__name__) class MailtoTask(object): + def _make_confirm_string(self, conf_n, message, queue_name): + confirm_url = conf_n.external_confirmation_url + if confirm_url is None: + msg = _("Can't make confirmation email body, need a valid " + "confirm url.") + LOG.error(msg) + raise Exception(msg) + param_string_signature = '?Signature=' + message.get('URL-Signature', + '') + param_string_methods = '&Methods=' + message.get('URL-Methods', '') + param_string_paths = '&Paths=' + message.get('URL-Paths', '') + param_string_project = '&Project=' + message.get('X-Project-ID', '') + param_string_expires = '&Expires=' + message.get('URL-Expires', '') + param_string_confirm_url = '&Url=' + message.get('WSGISubscribeURL', + '') + param_string_queue = '&Queue=' + queue_name + confirm_url_string = (confirm_url + param_string_signature + + param_string_methods + param_string_paths + + param_string_project + param_string_expires + + param_string_confirm_url + param_string_queue) + return confirm_url_string + + def _make_confirmation_email(self, body, subscription, message, conf_n): + queue_name = subscription['source'] + confirm_url = self._make_confirm_string(conf_n, message, + queue_name) + email_body = "" + if body is not None: + email_body = body.format(queue_name, message['X-Project-ID'], + confirm_url) + return text.MIMEText(email_body) + def execute(self, subscription, messages, **kwargs): subscriber = urllib_parse.urlparse(subscription['subscriber']) params = urllib_parse.parse_qs(subscriber.query) params = dict((k.lower(), v) for k, v in params.items()) - conf = kwargs.get('conf') + conf_n = kwargs.get('conf').notification try: for message in messages: - p = subprocess.Popen(conf.notification.smtp_command.split(' '), + p = subprocess.Popen(conf_n.smtp_command.split(' '), stdin=subprocess.PIPE) - # NOTE(Eva-i): Unfortunately this will add 'queue_name' key to - # our original messages(dicts) which will be later consumed in - # the storage controller. It seems safe though. - message['queue_name'] = subscription['source'] - msg = text.MIMEText(json.dumps(message)) - msg["to"] = subscriber.path - msg["from"] = subscription['options'].get('from', '') - subject_opt = subscription['options'].get('subject', '') - msg["subject"] = params.get('subject', subject_opt) + # Send confirmation email to subscriber. + if (message.get('Message_Type') == + MessageType.SubscriptionConfirmation.name): + content = conf_n.subscription_confirmation_email_template + msg = self._make_confirmation_email(content['body'], + subscription, + message, conf_n) + msg["to"] = subscriber.path + msg["from"] = content['sender'] + msg["subject"] = content['topic'] + elif (message.get('Message_Type') == + MessageType.UnsubscribeConfirmation.name): + content = conf_n.unsubscribe_confirmation_email_template + msg = self._make_confirmation_email(content['body'], + subscription, + message, conf_n) + msg["to"] = subscriber.path + msg["from"] = content['sender'] + msg["subject"] = content['topic'] + else: + # NOTE(Eva-i): Unfortunately this will add 'queue_name' key + # to our original messages(dicts) which will be later + # consumed in the storage controller. It seems safe though. + message['queue_name'] = subscription['source'] + msg = text.MIMEText(json.dumps(message)) + msg["to"] = subscriber.path + msg["from"] = subscription['options'].get('from', '') + subject_opt = subscription['options'].get('subject', '') + msg["subject"] = params.get('subject', subject_opt) p.communicate(msg.as_string()) + LOG.debug("Send mail successfully: %s", msg.as_string()) except OSError as err: LOG.exception(_LE('Failed to create process for sendmail, ' 'because %s.') % str(err)) diff --git a/zaqar/tests/unit/notification/test_notifier.py b/zaqar/tests/unit/notification/test_notifier.py index d00b7ca04..608c16f3f 100644 --- a/zaqar/tests/unit/notification/test_notifier.py +++ b/zaqar/tests/unit/notification/test_notifier.py @@ -16,6 +16,7 @@ import json import uuid +import ddt import mock from zaqar.common import urls @@ -23,6 +24,7 @@ from zaqar.notification import notifier from zaqar import tests as testing +@ddt.ddt class NotifierTest(testing.TestBase): def setUp(self): @@ -314,3 +316,98 @@ class NotifierTest(testing.TestBase): str(self.project), self.api_version) self.assertFalse(mock_create_signed_url.called) + + def _make_confirm_string(self, conf, message, queue_name): + confirmation_url = conf.notification.external_confirmation_url + param_string_signature = '?Signature=' + message.get('signature') + param_string_methods = '&Methods=' + message.get('methods')[0] + param_string_paths = '&Paths=' + message.get('paths')[0] + param_string_project = '&Project=' + message.get('project') + param_string_expires = '&Expires=' + message.get('expires') + param_string_confirm_url = '&Url=' + message.get('WSGISubscribeURL', + '') + param_string_queue = '&Queue=' + queue_name + confirm_url_string = (confirmation_url + param_string_signature + + param_string_methods + param_string_paths + + param_string_project + param_string_expires + + param_string_confirm_url + param_string_queue) + return confirm_url_string + + @mock.patch('zaqar.common.urls.create_signed_url') + @mock.patch('subprocess.Popen') + def _send_confirm_notification_with_email(self, mock_popen, + mock_signed_url, + is_unsubscribed=False): + subscription = {'id': '5760c9fb3990b42e8b7c20bd', + 'subscriber': 'mailto:aaa@example.com', + 'source': 'test_queue', + 'options': {'subject': 'Hello', + 'from': 'zaqar@example.com'} + } + driver = notifier.NotifierDriver(require_confirmation=True) + self.conf.signed_url.secret_key = 'test_key' + self.conf.notification.external_confirmation_url = 'http://127.0.0.1' + self.conf.notification.require_confirmation = True + + message = {'methods': ['PUT'], + 'paths': ['/v2/queues/test_queue/subscriptions/' + '5760c9fb3990b42e8b7c20bd/confirm'], + 'project': str(self.project), + 'expires': '2016-12-20T02:01:23', + 'signature': 'e268676368c235dbe16e0e9ac40f2829a92c948288df' + '36e1cbabd9de73f698df', + } + confirm_url = self._make_confirm_string(self.conf, message, + 'test_queue') + msg = ('Content-Type: text/plain; charset="us-ascii"\n' + 'MIME-Version: 1.0\nContent-Transfer-Encoding: 7bit\nto:' + ' %(to)s\nfrom: %(from)s\nsubject: %(subject)s\n\n%(body)s') + if is_unsubscribed: + e = self.conf.notification.unsubscribe_confirmation_email_template + body = e['body'] + topic = e['topic'] + sender = e['sender'] + else: + e = self.conf.notification.subscription_confirmation_email_template + body = e['body'] + topic = e['topic'] + sender = e['sender'] + body = body.format(subscription['source'], str(self.project), + confirm_url) + mail1 = msg % {'to': subscription['subscriber'][7:], + 'from': sender, + 'subject': topic, + 'body': body} + + called = set() + + def _communicate(msg): + called.add(msg) + + mock_process = mock.Mock() + attrs = {'communicate': _communicate} + mock_process.configure_mock(**attrs) + mock_popen.return_value = mock_process + mock_signed_url.return_value = message + driver.send_confirm_notification('test_queue', subscription, self.conf, + str(self.project), + api_version=self.api_version, + is_unsubscribed=is_unsubscribed) + driver.executor.shutdown() + + self.assertEqual(1, mock_popen.call_count) + options, body = mail1.split('\n\n') + expec_options = [options] + expect_body = [body] + called_options = [] + called_bodies = [] + for call in called: + options, body = call.split('\n\n') + called_options.append(options) + called_bodies.append(body) + self.assertEqual(expec_options, called_options) + self.assertEqual(expect_body, called_bodies) + + @ddt.data(False, True) + def test_send_confirm_notification_with_email(self, is_unsub): + self._send_confirm_notification_with_email(is_unsubscribed=is_unsub) diff --git a/zaqar/transport/wsgi/v2_0/__init__.py b/zaqar/transport/wsgi/v2_0/__init__.py index a1604fe41..cc4b5b0ce 100644 --- a/zaqar/transport/wsgi/v2_0/__init__.py +++ b/zaqar/transport/wsgi/v2_0/__init__.py @@ -114,7 +114,8 @@ def public_endpoints(driver, conf): ('/queues/{queue_name}/subscriptions/{subscription_id}/confirm', subscriptions.ConfirmResource(driver._validate, - subscription_controller)), + subscription_controller, + conf)), # Pre-Signed URL Endpoint ('/queues/{queue_name}/share', urls.Resource(driver)), diff --git a/zaqar/transport/wsgi/v2_0/subscriptions.py b/zaqar/transport/wsgi/v2_0/subscriptions.py index 2c59ab4b6..4e79e887a 100644 --- a/zaqar/transport/wsgi/v2_0/subscriptions.py +++ b/zaqar/transport/wsgi/v2_0/subscriptions.py @@ -248,11 +248,14 @@ class CollectionResource(object): class ConfirmResource(object): - __slots__ = ('_subscription_controller', '_validate') + __slots__ = ('_subscription_controller', '_validate', '_notification', + '_conf') - def __init__(self, validate, subscription_controller): + def __init__(self, validate, subscription_controller, conf): self._subscription_controller = subscription_controller self._validate = validate + self._notification = notifier.NotifierDriver() + self._conf = conf @decorators.TransportLog("Subscription confirmation item") @acl.enforce("subscription:confirm") @@ -268,6 +271,22 @@ class ConfirmResource(object): self._subscription_controller.confirm(queue_name, subscription_id, project=project_id, confirmed=confirmed) + if confirmed is False: + now = timeutils.utcnow_ts() + now_dt = datetime.datetime.utcfromtimestamp(now) + ttl = self._conf.transport.default_subscription_ttl + expires = now_dt + datetime.timedelta(seconds=ttl) + api_version = req.path.split('/')[1] + sub = self._subscription_controller.get(queue_name, + subscription_id, + project=project_id) + self._notification.send_confirm_notification(queue_name, + sub, + self._conf, + project_id, + str(expires), + api_version, + True) resp.status = falcon.HTTP_204 resp.location = req.path except storage_errors.SubscriptionDoesNotExist as ex: