diff roundup/mailgw.py @ 7064:3359dc1dabb0

Add OAuth authentication to the mailgw script Now IMAPS can be used with OAuth as required by several large cloud providers. Move command line processing of the mailgw script to ``argparse``. Note that the command line options of the mailgw have changed, see upgrading.txt for details.
author Ralf Schlatterbeck <rsc@runtux.com>
date Wed, 23 Nov 2022 10:13:48 +0100
parents bd2c3b2010c3
children f918351a0fe6
line wrap: on
line diff
--- a/roundup/mailgw.py	Tue Nov 22 17:10:39 2022 -0500
+++ b/roundup/mailgw.py	Wed Nov 23 10:13:48 2022 +0100
@@ -107,6 +107,15 @@
 import time
 import traceback
 
+import mailbox
+import socket
+import getpass
+import poplib
+import imaplib
+try:
+    import requests
+except ImportError:
+    requests = None
 from email.generator import Generator
 
 import roundup.anypy.random_ as random_
@@ -120,6 +129,7 @@
 from roundup.hyperdb import iter_roles
 from roundup.i18n import _
 from roundup.mailer import Mailer
+from roundup.dehtml import dehtml
 
 try:
     import gpg, gpg.core, gpg.constants, gpg.constants.sigsum   # noqa: E401
@@ -719,10 +729,7 @@
         if classname:
             attempts.append(classname)
 
-        if self.mailgw.default_class:
-            attempts.append(self.mailgw.default_class)
-        else:
-            attempts.append(self.config['MAILGW_DEFAULT_CLASS'])
+        attempts.append(self.mailgw.default_class)
 
         # first valid class name wins
         self.cl = None
@@ -802,7 +809,7 @@
                     nodeid = None
                     # trigger Subject match
                     self.matches['refwd'] = True
-                
+
         # but we do need either a title or a nodeid...
         if nodeid is None and not title:
             raise MailUsageError(_("""
@@ -1089,7 +1096,6 @@
     def get_content_and_attachments(self):
         ''' get the attachments and first text part from the message
         '''
-        from roundup.dehtml import dehtml
         html2text = dehtml(self.config['MAILGW_CONVERT_HTMLTOTEXT']).html2text
 
         ig = self.config.MAILGW_IGNORE_ALTERNATIVES
@@ -1318,13 +1324,14 @@
     # class of MailGW
     parsed_message_class = parsedMessage
 
-    def __init__(self, instance, arguments=()):
+    def __init__(self, instance, arguments):
         self.instance = instance
         self.arguments = arguments
-        self.default_class = None
-        for option, value in self.arguments:
-            if option == '-c':
-                self.default_class = value.strip()
+        self.default_class = self.arguments.default_class.strip()
+        if not self.default_class:
+            self.default_class = instance.config ['MAILGW_DEFAULT_CLASS']
+        self.props_by_class = {}
+        self.parse_set_value()
 
         self.mailer = Mailer(instance.config)
         self.logger = logging.getLogger('roundup.mailgw')
@@ -1333,6 +1340,35 @@
         # (for testing)
         self.trapExceptions = 1
 
+    def parse_set_value(self):
+        """ Parse properties given with '-S' or --set-value option on
+            command line
+        """
+        errors = []
+        for v in self.arguments.set_value:
+            try:
+                n, r = v.split('=', 1)
+            except ValueError:
+                errors.append ('"%s" is not of the form "property=value"' % v)
+                break
+            try:
+                classname, rv = n.split('.', 1)
+                r = '='.join((rv, r))
+            except ValueError:
+                classname = 'msg'
+                r = v
+            if classname not in self.props_by_class:
+                # We can only check later if this is a valid class
+                self.props_by_class [classname] = []
+            self.props_by_class [classname].append (r)
+        if errors:
+            mailadmin = self.instance.config['ADMIN_EMAIL']
+            raise MailUsageError(_("""
+The mail gateway is not properly set up. Please contact
+%(mailadmin)s and have them fix the incorrect properties:
+  %(errors)s
+""") % locals())
+
     def do_pipe(self):
         """ Read a message from standard input and pass it to the mail handler.
 
@@ -1355,8 +1391,6 @@
         """ Read a series of messages from the specified unix mailbox file and
             pass each to the mail handler.
         """
-        import mailbox
-
         class mboxRoundupMessage(mailbox.mboxMessage, RoundupMessage):
             pass
 
@@ -1400,14 +1434,60 @@
 
         return 0
 
-    def do_imap(self, server, user='', password='', mailbox='', ssl=0, cram=0):
+    def get_oauth_tokens(self, oauth_path):
+        if not oauth_path:
+            oauth_path = self.instance.config ['TRACKER_HOME']
+            oauth_path = os.path.join(oauth_path, 'oauth')
+        self.oauth_path = oauth_path
+        with open(os.path.join(oauth_path, 'access_token'), 'r') as f:
+            self.access_token = f.read().strip()
+        with open(os.path.join(oauth_path, 'refresh_token'), 'r') as f:
+            self.refresh_token = f.read().strip()
+
+    def write_token (self, tokenname):
+        n = os.path.join(self.oauth_path, tokenname)
+        tmp = n + '.tmp'
+        old = n + '.old'
+        with open(tmp, 'w') as f:
+            f.write(getattr(self, tokenname))
+        try:
+            os.remove(old)
+        except OSError:
+            pass
+        os.rename(n, old)
+        os.rename(tmp, n)
+
+    def renew_oauth_tokens(self):
+        """ Get new token(s) via refresh token
+        """
+        with open(os.path.join(self.oauth_path, 'client_secret'), 'r') as f:
+            client_secret = f.read().strip()
+        data = dict \
+            ( client_id = self.oauth_client_id
+            , client_secret = client_secret
+            , refresh_token = self.refresh_token
+            , grant_type = 'refresh_token'
+            )
+        session = requests.session()
+        r = session.post(self.token_endpoint, data = data)
+        if not 200 <= r.status_code <= 299:
+            raise RuntimeError('Invalid get result: %s: %s\n %s'
+                %(r.status_code, r.reason, r.text))
+        d = r.json()
+        if d ['refresh_token'] != self.refresh_token:
+            self.refresh_token = d ['refresh_token']
+            self.write_token ('refresh_token')
+        if d ['access_token'] != self.access_token:
+            self.access_token = d ['access_token']
+            self.write_token ('access_token')
+
+    def do_imap(self, server, user='', password='', mailbox='', **kw):
         ''' Do an IMAP connection
         '''
-        import getpass, imaplib, socket   # noqa: E401
         try:
             if not user:
                 user = my_input('User: ')
-            if not password:
+            if not password and not kw.get('oauth'):
                 password = getpass.getpass()
         except (KeyboardInterrupt, EOFError):
             # Ctrl C or D maybe also Ctrl Z under Windows.
@@ -1415,7 +1495,7 @@
             return 1
         # open a connection to the server and retrieve all messages
         try:
-            if ssl:
+            if kw.get('ssl'):
                 self.logger.debug('Trying server %r with ssl' % server)
                 server = imaplib.IMAP4_SSL(server)
             else:
@@ -1425,14 +1505,41 @@
             self.logger.exception('IMAP server error')
             return 1
 
-        try:
-            if cram:
-                server.login_cram_md5(user, password)
-            else:
-                server.login(user, password)
-        except imaplib.IMAP4.error as e:
-            self.logger.exception('IMAP login failure: %s' % e)
-            return 1
+        if kw.get('oauth'):
+            if requests is None:
+                self.logger.error('For OAUTH, the requests library '
+                    'must be installed')
+                return 1
+            self.get_oauth_tokens(kw.get('oauth_path'))
+            # The following are mandatory for oauth and are passed by
+            # the command-line handler:
+            self.token_endpoint = kw ['token_endpoint']
+            self.oauth_client_id = kw ['oauth_client_id']
+            for k in range(2):
+                t = self.access_token
+                s = 'user=%s\1auth=Bearer %s\1\1' % (user, t)
+                # Try twice, access token may be too old
+                try:
+                    server.authenticate('XOAUTH2', lambda x: s)
+                    break
+                except imaplib.IMAP4.error:
+                    if k:
+                        self.logger.exception('OAUTH authentication failed')
+                        return 1
+                try:
+                    self.renew_oauth_tokens()
+                except RuntimeError:
+                    self.logger.exception('OAUTH token renew failed')
+                    return 1
+        else:
+            try:
+                if kw.get('cram'):
+                    server.login_cram_md5(user, password)
+                else:
+                    server.login(user, password)
+            except imaplib.IMAP4.error as e:
+                self.logger.exception('IMAP login failure: %s' % e)
+                return 1
 
         try:
             if not mailbox:
@@ -1482,7 +1589,6 @@
     def _do_pop(self, server, user, password, apop, ssl):
         '''Read a series of messages from the specified POP server.
         '''
-        import getpass, poplib, socket   # noqa: E401
         # Monkey-patch poplib to have a large line-limit
         # Seems that in python2.7 poplib applies a line-length limit not
         # just to the lines that take care of the pop3 protocol but also
@@ -1700,61 +1806,38 @@
             classname - the name of the current issue-type class
 
         Parse the commandline arguments and retrieve the properties that
-        are relevant to the class_type. We now allow multiple -S options
-        per class_type (-C option).
+        are relevant to the class_type.
         '''
         allprops = {}
 
         classname = classname or class_type
-        cls_lookup = {'issue': classname}
 
-        # Allow other issue-type classes -- take the real classname from
-        # previous parsing-steps of the message:
-        clsname = cls_lookup.get(class_type, class_type)
-
-        # check if the clsname is valid
+        # check if the classname is valid
         try:
-            self.db.getclass(clsname)
+            cls = self.db.getclass(classname)
         except KeyError:
             mailadmin = self.instance.config['ADMIN_EMAIL']
             raise MailUsageError(_("""
 The mail gateway is not properly set up. Please contact
 %(mailadmin)s and have them fix the incorrect class specified as:
-  %(clsname)s
+  %(classname)s
 """) % locals())
 
-        if self.arguments:
-            # The default type on the commandline is msg
-            if class_type == 'msg':
-                current_type = class_type
-            else:
-                current_type = None
-
-            # Handle the arguments specified by the email gateway command line.
-            # We do this by looping over the list of self.arguments looking for
-            # a -C to match the class we want, then use the -S setting string.
-            for option, propstring in self.arguments:
-                if option in ('-C', '--class'):
-                    current_type = propstring.strip()
+        if classname not in self.props_by_class:
+            return {}
+        for propstring in self.props_by_class[classname]:
+            errors, props = setPropArrayFromString(self, cls,
+                propstring.strip())
 
-                    if current_type != class_type:
-                        current_type = None
 
-                elif current_type and option in ('-S', '--set'):
-                    cls = cls_lookup.get(current_type, current_type)
-                    temp_cl = self.db.getclass(cls)
-                    errors, props = setPropArrayFromString(self,
-                                                           temp_cl,
-                                                           propstring.strip())
-
-                    if errors:
-                        mailadmin = self.instance.config['ADMIN_EMAIL']
-                        raise MailUsageError(_("""
+            if errors:
+                mailadmin = self.instance.config['ADMIN_EMAIL']
+                raise MailUsageError(_("""
 The mail gateway is not properly set up. Please contact
 %(mailadmin)s and have them fix the incorrect properties:
   %(errors)s
 """) % locals())
-                    allprops.update(props)
+            allprops.update(props)
 
         return allprops
 
@@ -2001,4 +2084,4 @@
 
     return summary, content
 
-# vim: set filetype=python sts=4 sw=4 et si :
+# vim: set filetype=python sts=4 sw=4 et :

Roundup Issue Tracker: http://roundup-tracker.org/