comparison 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
comparison
equal deleted inserted replaced
7063:aca710a3b687 7064:3359dc1dabb0
105 import re 105 import re
106 import sys 106 import sys
107 import time 107 import time
108 import traceback 108 import traceback
109 109
110 import mailbox
111 import socket
112 import getpass
113 import poplib
114 import imaplib
115 try:
116 import requests
117 except ImportError:
118 requests = None
110 from email.generator import Generator 119 from email.generator import Generator
111 120
112 import roundup.anypy.random_ as random_ 121 import roundup.anypy.random_ as random_
113 import roundup.anypy.ssl_ as ssl_ 122 import roundup.anypy.ssl_ as ssl_
114 123
118 from roundup.anypy.my_input import my_input 127 from roundup.anypy.my_input import my_input
119 from roundup.anypy.strings import StringIO, b2s, u2s 128 from roundup.anypy.strings import StringIO, b2s, u2s
120 from roundup.hyperdb import iter_roles 129 from roundup.hyperdb import iter_roles
121 from roundup.i18n import _ 130 from roundup.i18n import _
122 from roundup.mailer import Mailer 131 from roundup.mailer import Mailer
132 from roundup.dehtml import dehtml
123 133
124 try: 134 try:
125 import gpg, gpg.core, gpg.constants, gpg.constants.sigsum # noqa: E401 135 import gpg, gpg.core, gpg.constants, gpg.constants.sigsum # noqa: E401
126 except ImportError: 136 except ImportError:
127 gpg = None 137 gpg = None
717 # back on the default 727 # back on the default
718 attempts = [] 728 attempts = []
719 if classname: 729 if classname:
720 attempts.append(classname) 730 attempts.append(classname)
721 731
722 if self.mailgw.default_class: 732 attempts.append(self.mailgw.default_class)
723 attempts.append(self.mailgw.default_class)
724 else:
725 attempts.append(self.config['MAILGW_DEFAULT_CLASS'])
726 733
727 # first valid class name wins 734 # first valid class name wins
728 self.cl = None 735 self.cl = None
729 for trycl in attempts: 736 for trycl in attempts:
730 try: 737 try:
800 # match. Not sure if this would work better in 807 # match. Not sure if this would work better in
801 # production, so not implementing now. 808 # production, so not implementing now.
802 nodeid = None 809 nodeid = None
803 # trigger Subject match 810 # trigger Subject match
804 self.matches['refwd'] = True 811 self.matches['refwd'] = True
805 812
806 # but we do need either a title or a nodeid... 813 # but we do need either a title or a nodeid...
807 if nodeid is None and not title: 814 if nodeid is None and not title:
808 raise MailUsageError(_(""" 815 raise MailUsageError(_("""
809 I cannot match your message to a node in the database - you need to either 816 I cannot match your message to a node in the database - you need to either
810 supply a full designator (with number, eg "[issue123]") or keep the 817 supply a full designator (with number, eg "[issue123]") or keep the
1087 encrypted.""")) 1094 encrypted."""))
1088 1095
1089 def get_content_and_attachments(self): 1096 def get_content_and_attachments(self):
1090 ''' get the attachments and first text part from the message 1097 ''' get the attachments and first text part from the message
1091 ''' 1098 '''
1092 from roundup.dehtml import dehtml
1093 html2text = dehtml(self.config['MAILGW_CONVERT_HTMLTOTEXT']).html2text 1099 html2text = dehtml(self.config['MAILGW_CONVERT_HTMLTOTEXT']).html2text
1094 1100
1095 ig = self.config.MAILGW_IGNORE_ALTERNATIVES 1101 ig = self.config.MAILGW_IGNORE_ALTERNATIVES
1096 self.message.instance = self.mailgw.instance 1102 self.message.instance = self.mailgw.instance
1097 self.content, self.attachments, html_part = \ 1103 self.content, self.attachments, html_part = \
1316 # To override the message parsing, derive your own class from 1322 # To override the message parsing, derive your own class from
1317 # parsedMessage and assign to parsed_message_class in a derived 1323 # parsedMessage and assign to parsed_message_class in a derived
1318 # class of MailGW 1324 # class of MailGW
1319 parsed_message_class = parsedMessage 1325 parsed_message_class = parsedMessage
1320 1326
1321 def __init__(self, instance, arguments=()): 1327 def __init__(self, instance, arguments):
1322 self.instance = instance 1328 self.instance = instance
1323 self.arguments = arguments 1329 self.arguments = arguments
1324 self.default_class = None 1330 self.default_class = self.arguments.default_class.strip()
1325 for option, value in self.arguments: 1331 if not self.default_class:
1326 if option == '-c': 1332 self.default_class = instance.config ['MAILGW_DEFAULT_CLASS']
1327 self.default_class = value.strip() 1333 self.props_by_class = {}
1334 self.parse_set_value()
1328 1335
1329 self.mailer = Mailer(instance.config) 1336 self.mailer = Mailer(instance.config)
1330 self.logger = logging.getLogger('roundup.mailgw') 1337 self.logger = logging.getLogger('roundup.mailgw')
1331 1338
1332 # should we trap exceptions (normal usage) or pass them through 1339 # should we trap exceptions (normal usage) or pass them through
1333 # (for testing) 1340 # (for testing)
1334 self.trapExceptions = 1 1341 self.trapExceptions = 1
1342
1343 def parse_set_value(self):
1344 """ Parse properties given with '-S' or --set-value option on
1345 command line
1346 """
1347 errors = []
1348 for v in self.arguments.set_value:
1349 try:
1350 n, r = v.split('=', 1)
1351 except ValueError:
1352 errors.append ('"%s" is not of the form "property=value"' % v)
1353 break
1354 try:
1355 classname, rv = n.split('.', 1)
1356 r = '='.join((rv, r))
1357 except ValueError:
1358 classname = 'msg'
1359 r = v
1360 if classname not in self.props_by_class:
1361 # We can only check later if this is a valid class
1362 self.props_by_class [classname] = []
1363 self.props_by_class [classname].append (r)
1364 if errors:
1365 mailadmin = self.instance.config['ADMIN_EMAIL']
1366 raise MailUsageError(_("""
1367 The mail gateway is not properly set up. Please contact
1368 %(mailadmin)s and have them fix the incorrect properties:
1369 %(errors)s
1370 """) % locals())
1335 1371
1336 def do_pipe(self): 1372 def do_pipe(self):
1337 """ Read a message from standard input and pass it to the mail handler. 1373 """ Read a message from standard input and pass it to the mail handler.
1338 1374
1339 Read into an internal structure that we can seek on (in case 1375 Read into an internal structure that we can seek on (in case
1353 1389
1354 def do_mailbox(self, filename): 1390 def do_mailbox(self, filename):
1355 """ Read a series of messages from the specified unix mailbox file and 1391 """ Read a series of messages from the specified unix mailbox file and
1356 pass each to the mail handler. 1392 pass each to the mail handler.
1357 """ 1393 """
1358 import mailbox
1359
1360 class mboxRoundupMessage(mailbox.mboxMessage, RoundupMessage): 1394 class mboxRoundupMessage(mailbox.mboxMessage, RoundupMessage):
1361 pass 1395 pass
1362 1396
1363 # The mailbox class constructs email.message.Message objects 1397 # The mailbox class constructs email.message.Message objects
1364 # using various email.message_from_* methods, without allowing 1398 # using various email.message_from_* methods, without allowing
1398 for method in orig_methods: 1432 for method in orig_methods:
1399 setattr(email, method, orig) 1433 setattr(email, method, orig)
1400 1434
1401 return 0 1435 return 0
1402 1436
1403 def do_imap(self, server, user='', password='', mailbox='', ssl=0, cram=0): 1437 def get_oauth_tokens(self, oauth_path):
1438 if not oauth_path:
1439 oauth_path = self.instance.config ['TRACKER_HOME']
1440 oauth_path = os.path.join(oauth_path, 'oauth')
1441 self.oauth_path = oauth_path
1442 with open(os.path.join(oauth_path, 'access_token'), 'r') as f:
1443 self.access_token = f.read().strip()
1444 with open(os.path.join(oauth_path, 'refresh_token'), 'r') as f:
1445 self.refresh_token = f.read().strip()
1446
1447 def write_token (self, tokenname):
1448 n = os.path.join(self.oauth_path, tokenname)
1449 tmp = n + '.tmp'
1450 old = n + '.old'
1451 with open(tmp, 'w') as f:
1452 f.write(getattr(self, tokenname))
1453 try:
1454 os.remove(old)
1455 except OSError:
1456 pass
1457 os.rename(n, old)
1458 os.rename(tmp, n)
1459
1460 def renew_oauth_tokens(self):
1461 """ Get new token(s) via refresh token
1462 """
1463 with open(os.path.join(self.oauth_path, 'client_secret'), 'r') as f:
1464 client_secret = f.read().strip()
1465 data = dict \
1466 ( client_id = self.oauth_client_id
1467 , client_secret = client_secret
1468 , refresh_token = self.refresh_token
1469 , grant_type = 'refresh_token'
1470 )
1471 session = requests.session()
1472 r = session.post(self.token_endpoint, data = data)
1473 if not 200 <= r.status_code <= 299:
1474 raise RuntimeError('Invalid get result: %s: %s\n %s'
1475 %(r.status_code, r.reason, r.text))
1476 d = r.json()
1477 if d ['refresh_token'] != self.refresh_token:
1478 self.refresh_token = d ['refresh_token']
1479 self.write_token ('refresh_token')
1480 if d ['access_token'] != self.access_token:
1481 self.access_token = d ['access_token']
1482 self.write_token ('access_token')
1483
1484 def do_imap(self, server, user='', password='', mailbox='', **kw):
1404 ''' Do an IMAP connection 1485 ''' Do an IMAP connection
1405 ''' 1486 '''
1406 import getpass, imaplib, socket # noqa: E401
1407 try: 1487 try:
1408 if not user: 1488 if not user:
1409 user = my_input('User: ') 1489 user = my_input('User: ')
1410 if not password: 1490 if not password and not kw.get('oauth'):
1411 password = getpass.getpass() 1491 password = getpass.getpass()
1412 except (KeyboardInterrupt, EOFError): 1492 except (KeyboardInterrupt, EOFError):
1413 # Ctrl C or D maybe also Ctrl Z under Windows. 1493 # Ctrl C or D maybe also Ctrl Z under Windows.
1414 print("\nAborted by user.") 1494 print("\nAborted by user.")
1415 return 1 1495 return 1
1416 # open a connection to the server and retrieve all messages 1496 # open a connection to the server and retrieve all messages
1417 try: 1497 try:
1418 if ssl: 1498 if kw.get('ssl'):
1419 self.logger.debug('Trying server %r with ssl' % server) 1499 self.logger.debug('Trying server %r with ssl' % server)
1420 server = imaplib.IMAP4_SSL(server) 1500 server = imaplib.IMAP4_SSL(server)
1421 else: 1501 else:
1422 self.logger.debug('Trying server %r without ssl' % server) 1502 self.logger.debug('Trying server %r without ssl' % server)
1423 server = imaplib.IMAP4(server) 1503 server = imaplib.IMAP4(server)
1424 except (imaplib.IMAP4.error, socket.error, ssl_.SSLError): 1504 except (imaplib.IMAP4.error, socket.error, ssl_.SSLError):
1425 self.logger.exception('IMAP server error') 1505 self.logger.exception('IMAP server error')
1426 return 1 1506 return 1
1427 1507
1428 try: 1508 if kw.get('oauth'):
1429 if cram: 1509 if requests is None:
1430 server.login_cram_md5(user, password) 1510 self.logger.error('For OAUTH, the requests library '
1431 else: 1511 'must be installed')
1432 server.login(user, password) 1512 return 1
1433 except imaplib.IMAP4.error as e: 1513 self.get_oauth_tokens(kw.get('oauth_path'))
1434 self.logger.exception('IMAP login failure: %s' % e) 1514 # The following are mandatory for oauth and are passed by
1435 return 1 1515 # the command-line handler:
1516 self.token_endpoint = kw ['token_endpoint']
1517 self.oauth_client_id = kw ['oauth_client_id']
1518 for k in range(2):
1519 t = self.access_token
1520 s = 'user=%s\1auth=Bearer %s\1\1' % (user, t)
1521 # Try twice, access token may be too old
1522 try:
1523 server.authenticate('XOAUTH2', lambda x: s)
1524 break
1525 except imaplib.IMAP4.error:
1526 if k:
1527 self.logger.exception('OAUTH authentication failed')
1528 return 1
1529 try:
1530 self.renew_oauth_tokens()
1531 except RuntimeError:
1532 self.logger.exception('OAUTH token renew failed')
1533 return 1
1534 else:
1535 try:
1536 if kw.get('cram'):
1537 server.login_cram_md5(user, password)
1538 else:
1539 server.login(user, password)
1540 except imaplib.IMAP4.error as e:
1541 self.logger.exception('IMAP login failure: %s' % e)
1542 return 1
1436 1543
1437 try: 1544 try:
1438 if not mailbox: 1545 if not mailbox:
1439 (typ, data) = server.select() 1546 (typ, data) = server.select()
1440 else: 1547 else:
1480 self._do_pop(server, user, password, False, ssl) 1587 self._do_pop(server, user, password, False, ssl)
1481 1588
1482 def _do_pop(self, server, user, password, apop, ssl): 1589 def _do_pop(self, server, user, password, apop, ssl):
1483 '''Read a series of messages from the specified POP server. 1590 '''Read a series of messages from the specified POP server.
1484 ''' 1591 '''
1485 import getpass, poplib, socket # noqa: E401
1486 # Monkey-patch poplib to have a large line-limit 1592 # Monkey-patch poplib to have a large line-limit
1487 # Seems that in python2.7 poplib applies a line-length limit not 1593 # Seems that in python2.7 poplib applies a line-length limit not
1488 # just to the lines that take care of the pop3 protocol but also 1594 # just to the lines that take care of the pop3 protocol but also
1489 # to all email content 1595 # to all email content
1490 # See, e.g., 1596 # See, e.g.,
1698 - 'file' specifies a file-type class 1804 - 'file' specifies a file-type class
1699 - 'msg' is the message-class 1805 - 'msg' is the message-class
1700 classname - the name of the current issue-type class 1806 classname - the name of the current issue-type class
1701 1807
1702 Parse the commandline arguments and retrieve the properties that 1808 Parse the commandline arguments and retrieve the properties that
1703 are relevant to the class_type. We now allow multiple -S options 1809 are relevant to the class_type.
1704 per class_type (-C option).
1705 ''' 1810 '''
1706 allprops = {} 1811 allprops = {}
1707 1812
1708 classname = classname or class_type 1813 classname = classname or class_type
1709 cls_lookup = {'issue': classname} 1814
1710 1815 # check if the classname is valid
1711 # Allow other issue-type classes -- take the real classname from
1712 # previous parsing-steps of the message:
1713 clsname = cls_lookup.get(class_type, class_type)
1714
1715 # check if the clsname is valid
1716 try: 1816 try:
1717 self.db.getclass(clsname) 1817 cls = self.db.getclass(classname)
1718 except KeyError: 1818 except KeyError:
1719 mailadmin = self.instance.config['ADMIN_EMAIL'] 1819 mailadmin = self.instance.config['ADMIN_EMAIL']
1720 raise MailUsageError(_(""" 1820 raise MailUsageError(_("""
1721 The mail gateway is not properly set up. Please contact 1821 The mail gateway is not properly set up. Please contact
1722 %(mailadmin)s and have them fix the incorrect class specified as: 1822 %(mailadmin)s and have them fix the incorrect class specified as:
1723 %(clsname)s 1823 %(classname)s
1724 """) % locals()) 1824 """) % locals())
1725 1825
1726 if self.arguments: 1826 if classname not in self.props_by_class:
1727 # The default type on the commandline is msg 1827 return {}
1728 if class_type == 'msg': 1828 for propstring in self.props_by_class[classname]:
1729 current_type = class_type 1829 errors, props = setPropArrayFromString(self, cls,
1730 else: 1830 propstring.strip())
1731 current_type = None 1831
1732 1832
1733 # Handle the arguments specified by the email gateway command line. 1833 if errors:
1734 # We do this by looping over the list of self.arguments looking for 1834 mailadmin = self.instance.config['ADMIN_EMAIL']
1735 # a -C to match the class we want, then use the -S setting string. 1835 raise MailUsageError(_("""
1736 for option, propstring in self.arguments:
1737 if option in ('-C', '--class'):
1738 current_type = propstring.strip()
1739
1740 if current_type != class_type:
1741 current_type = None
1742
1743 elif current_type and option in ('-S', '--set'):
1744 cls = cls_lookup.get(current_type, current_type)
1745 temp_cl = self.db.getclass(cls)
1746 errors, props = setPropArrayFromString(self,
1747 temp_cl,
1748 propstring.strip())
1749
1750 if errors:
1751 mailadmin = self.instance.config['ADMIN_EMAIL']
1752 raise MailUsageError(_("""
1753 The mail gateway is not properly set up. Please contact 1836 The mail gateway is not properly set up. Please contact
1754 %(mailadmin)s and have them fix the incorrect properties: 1837 %(mailadmin)s and have them fix the incorrect properties:
1755 %(errors)s 1838 %(errors)s
1756 """) % locals()) 1839 """) % locals())
1757 allprops.update(props) 1840 allprops.update(props)
1758 1841
1759 return allprops 1842 return allprops
1760 1843
1761 1844
1762 def setPropArrayFromString(self, cl, propString, nodeid=None): 1845 def setPropArrayFromString(self, cl, propString, nodeid=None):
1999 if not keep_body: 2082 if not keep_body:
2000 content = '\n\n'.join(kept_lines) 2083 content = '\n\n'.join(kept_lines)
2001 2084
2002 return summary, content 2085 return summary, content
2003 2086
2004 # vim: set filetype=python sts=4 sw=4 et si : 2087 # vim: set filetype=python sts=4 sw=4 et :

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