Mercurial > p > roundup > code
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 :
