comparison roundup/scripts/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 c7a9f9c1801d
children 27c2d7295ba2
comparison
equal deleted inserted replaced
7063:aca710a3b687 7064:3359dc1dabb0
21 21
22 22
23 # --- patch sys.path to make sure 'import roundup' finds correct version 23 # --- patch sys.path to make sure 'import roundup' finds correct version
24 import sys 24 import sys
25 import os.path as osp 25 import os.path as osp
26 from argparse import ArgumentParser, RawDescriptionHelpFormatter
26 27
27 thisdir = osp.dirname(osp.abspath(__file__)) 28 thisdir = osp.dirname(osp.abspath(__file__))
28 rootdir = osp.dirname(osp.dirname(thisdir)) 29 rootdir = osp.dirname(osp.dirname(thisdir))
29 if (osp.exists(thisdir + '/__init__.py') and 30 if (osp.exists(thisdir + '/__init__.py') and
30 osp.exists(rootdir + '/roundup/__init__.py')): 31 osp.exists(rootdir + '/roundup/__init__.py')):
41 42
42 from roundup import mailgw 43 from roundup import mailgw
43 from roundup.i18n import _ 44 from roundup.i18n import _
44 45
45 46
46 def usage(args, message=None): 47 usage_epilog = """
47 if message is not None:
48 print(message)
49 print(_(
50 """Usage: %(program)s [-v] [-c class] [[-C class] -S field=value]* [instance home] [mail source [specification]]
51
52 Options:
53 -v: print version and exit
54 -c: default class of item to create (else the tracker's MAIL_DEFAULT_CLASS)
55 -C / -S: see below
56
57 The roundup mail gateway may be called in one of the following ways: 48 The roundup mail gateway may be called in one of the following ways:
58 . without arguments. Then the env var ROUNDUP_INSTANCE will be tried. 49 . without arguments. Then the env var ROUNDUP_INSTANCE will be tried.
59 . with an instance home as the only argument, 50 . with an instance home as the only argument,
60 . with both an instance home and a mail spool file, 51 . with both an instance home and a mail spool file, or
61 . with an instance home, a mail source type and its specification. 52 . with an instance home, a mail source type and its specification.
62 53
63 It also supports optional -C and -S arguments that allows you to set a 54 It also supports optional -S (or --set-value) arguments that allows you
64 fields for a class created by the roundup-mailgw. The default class if 55 to set fields for a class created by the roundup-mailgw. The format for
65 not specified is msg, but the other classes: issue, file, user can 56 this option is [class.]property=value where class can be omitted and
66 also be used. The -S or --set options uses the same 57 defaults to msg. The -S options uses the same
67 property=value[;property=value] notation accepted by the command line 58 property=value[;property=value] notation accepted by the command line
68 roundup command or the commands that can be given on the Subject line 59 roundup command or the commands that can be given on the Subject line of
69 of an email message. 60 an email message (if you're using multiple properties delimited with a
70 61 semicolon the class must be specified only once in the beginning).
71 It can let you set the type of the message on a per email address basis. 62
63 It can let you set the type of the message on a per email address basis
64 by calling roundup-mailgw with different email addresses and other
65 settings.
72 66
73 PIPE: 67 PIPE:
74 If there is no mail source specified, 68 If there is no mail source specified, the mail gateway reads a single
75 the mail gateway reads a single message from the standard input 69 message from the standard input and submits the message to the
76 and submits the message to the roundup.mailgw module. 70 roundup.mailgw module.
77 71
78 Mail source "mailbox": 72 Mail source "mailbox":
79 In this case, the gateway reads all messages from the UNIX mail spool 73 In this case, the gateway reads all messages from the UNIX mail spool
80 file and submits each in turn to the roundup.mailgw module. The file is 74 file and submits each in turn to the roundup.mailgw module. The file is
81 emptied once all messages have been successfully handled. The file is 75 emptied once all messages have been successfully handled. The file is
82 specified as: 76 specified as:
83 mailbox /path/to/mailbox 77 mailbox /path/to/mailbox
84 78
85 In all of the following mail source type the username and password 79 In all of the following mail source types, the username and password
86 can be stored in a ~/.netrc file. If done so case only the server name 80 can be stored in a ~/.netrc file. If done so, only the server name
87 need to be specified on the command-line. 81 needs to be specified on the command-line.
88 82
89 The username and/or password will be prompted for if not supplied on 83 The username and/or password will be prompted for if not supplied on
90 the command-line or in ~/.netrc. 84 the command-line or in ~/.netrc.
91 85
92 POP: 86 POP:
93 For the mail source "pop", the gateway reads all messages from the POP server 87 For the mail source "pop", the gateway reads all messages from the POP
94 specified and submits each in turn to the roundup.mailgw module. The 88 server specified and submits each in turn to the roundup.mailgw module.
95 server is specified as: 89 The server is specified as:
96 pop username:password@server 90 pop username:password@server
97 Alternatively, one can omit one or both of username and password: 91 The username and password may be omitted:
98 pop username@server 92 pop username@server
99 pop server 93 pop server
100 are both valid. 94 are both valid.
101 95
102 POPS: 96 POPS:
103 Connect to a POP server over ssl. This requires python 2.4 or later. 97 Connect to a POP server over ssl.
104 This supports the same notation as POP. 98 This supports the same notation as POP.
105 99
106 APOP: 100 APOP:
107 Same as POP, but using Authenticated POP: 101 Same as POP, but using Authenticated POP:
108 apop username:password@server 102 apop username:password@server
123 IMAPS_CRAM: 117 IMAPS_CRAM:
124 Connect to an IMAP server over ssl using CRAM-MD5 authentication. 118 Connect to an IMAP server over ssl using CRAM-MD5 authentication.
125 This supports the same notation as IMAP. 119 This supports the same notation as IMAP.
126 imaps_cram username:password@server [mailbox] 120 imaps_cram username:password@server [mailbox]
127 121
128 """) % {'program': args[0]}) 122 IMAPS_OAUTH:
129 return 1 123 Connect to an IMAP server over ssl using OAUTH authentication.
130 124 Note that this does not support a password in imaps URLs.
125 Instead it uses only the user and server and a command-line option for
126 the directory with the files 'access_token', 'refresh_token', and
127 'client_secret'.
128 By default this directory is 'oauth' in your tracker home directory. The
129 access token is tried first and, if expired, the refresh token together
130 with the client secret is used to retrieve a new access token. Note that
131 both token files need to be *writeable*, the access token is
132 continuously replaced and some cloud providers may also renew the
133 refresh token from time to time:
134 imaps_oauth username@server [mailbox]
135 Note that you also have to specify the OAuth client id with the
136 ``--oauth-client-id`` option on the command line. The refresh and
137 access tokens (the latter can be left empty) and the client secret need
138 to be retrieved via cloud provider specific protocols or websites.
139
140
141
142 """
143
144 def parse_arguments(argv):
145 '''Handle the arguments to the program
146 '''
147 # take the argv array and parse it leaving the non-option
148 # arguments in the list 'args'.
149 cmd = ArgumentParser(epilog=usage_epilog,
150 formatter_class=RawDescriptionHelpFormatter)
151 cmd.add_argument('args', nargs='*')
152 cmd.add_argument('-v', '--version', action='store_true',
153 help='print version and exit')
154 cmd.add_argument('-c', '--default_class', default='',
155 help="Default class of item to create (else the tracker's "
156 "MAILGW_DEFAULT_CLASS)")
157 cmd.add_argument('-I', '--oauth-client-id',
158 help='ID for OAUTH token refresh')
159 cmd.add_argument('-O', '--oauth-directory',
160 help='Directory with OAUTH credentials, default "oauth" in '
161 'tracker home')
162 cmd.add_argument('-S', '--set-value', action='append',
163 help="Set additional properties on some classes", default=[])
164 cmd.add_argument('-T', '--oauth-token-endpoint',
165 help="OAUTH token endpoint for access_token renew, default=%(default)s",
166 default='https://login.microsoftonline.com/'
167 'organizations/oauth2/v2.0/token')
168 return cmd, cmd.parse_args(argv)
131 169
132 def main(argv): 170 def main(argv):
133 '''Handle the arguments to the program and initialise environment. 171 '''Handle the arguments to the program and initialise environment.
134 ''' 172 '''
135 # take the argv array and parse it leaving the non-option 173 cmd, args = parse_arguments(argv)
136 # arguments in the args array. 174 if args.version:
137 try: 175 print('%s (python %s)' % (roundup_version, sys.version.split()[0]))
138 optionsList, args = getopt.getopt(argv[1:], 'vc:C:S:', ['set=', 176 return
139 'class='])
140 except getopt.GetoptError:
141 # print help information and exit:
142 usage(argv)
143 sys.exit(2)
144
145 for (opt, _arg) in optionsList:
146 if opt == '-v':
147 print('%s (python %s)' % (roundup_version, sys.version.split()[0]))
148 return
149 177
150 # figure the instance home 178 # figure the instance home
151 if len(args) > 0: 179 if len(args.args) > 0:
152 instance_home = args[0] 180 instance_home = args.args[0]
153 else: 181 else:
154 instance_home = os.environ.get('ROUNDUP_INSTANCE', '') 182 instance_home = os.environ.get('ROUNDUP_INSTANCE', '')
155 if not (instance_home and os.path.isdir(instance_home)): 183 if not (instance_home and os.path.isdir(instance_home)):
156 return usage(argv) 184 cmd.print_help(sys.stderr)
185 return _('\nError: The instance home must be specified')
157 186
158 # get the instance 187 # get the instance
159 import roundup.instance 188 import roundup.instance
160 instance = roundup.instance.open(instance_home) 189 instance = roundup.instance.open(instance_home)
161 190
162 if hasattr(instance, 'MailGW'): 191 if hasattr(instance, 'MailGW'):
163 handler = instance.MailGW(instance, optionsList) 192 handler = instance.MailGW(instance, args)
164 else: 193 else:
165 handler = mailgw.MailGW(instance, optionsList) 194 handler = mailgw.MailGW(instance, args)
166 195
167 # if there's no more arguments, read a single message from stdin 196 # if there's no more arguments, read a single message from stdin
168 if len(args) == 1: 197 if len(args.args) == 1:
169 return handler.do_pipe() 198 return handler.do_pipe()
170 199
171 # otherwise, figure what sort of mail source to handle 200 # otherwise, figure what sort of mail source to handle
172 if len(args) < 3: 201 if len(args.args) < 3:
173 return usage(argv, _( 202 cmd.print_help(sys.stderr)
174 'Error: not enough source specification information')) 203 return _('\nError: not enough source specification information')
175 source, specification = args[1:3] 204
205 source, specification = args.args[1:3]
176 206
177 # time out net connections after a minute if we can 207 # time out net connections after a minute if we can
178 if source not in ('mailbox', 'imaps', 'imaps_cram'): 208 if source not in ('mailbox', 'imaps', 'imaps_cram', 'imaps_oauth'):
179 if hasattr(socket, 'setdefaulttimeout'): 209 if hasattr(socket, 'setdefaulttimeout'):
180 socket.setdefaulttimeout(60) 210 socket.setdefaulttimeout(60)
181 211
182 if source == 'mailbox': 212 if source == 'mailbox':
183 return handler.do_mailbox(specification) 213 return handler.do_mailbox(specification)
207 ssl = source.endswith('s') 237 ssl = source.endswith('s')
208 return handler.do_pop(server, username, password, ssl) 238 return handler.do_pop(server, username, password, ssl)
209 elif source == 'apop': 239 elif source == 'apop':
210 return handler.do_apop(server, username, password) 240 return handler.do_apop(server, username, password)
211 elif source.startswith('imap'): 241 elif source.startswith('imap'):
212 ssl = cram = 0 242 d = {}
213 if source.endswith('s'): 243 if source.endswith('s'):
214 ssl = 1 244 d.update(ssl = 1)
215 elif source.endswith('s_cram'): 245 elif source.endswith('s_cram'):
216 ssl = cram = 1 246 d.update(ssl = 1, cram = 1)
247 elif source == 'imaps_oauth':
248 d.update(ssl = 1, oauth = 1, oauth_path = args.oauth_directory)
249 d.update(token_endpoint = args.oauth_token_endpoint)
250 d.update(oauth_client_id = args.oauth_client_id)
217 mailbox = '' 251 mailbox = ''
218 if len(args) > 3: 252 if len(args.args) > 3:
219 mailbox = args[3] 253 mailbox = args.args[3]
220 return handler.do_imap(server, username, password, mailbox, ssl, 254 return handler.do_imap(server, username, password, mailbox, **d)
221 cram)
222 255
223 return usage(argv, _('Error: The source must be either "mailbox",' 256 return usage(argv, _('Error: The source must be either "mailbox",'
224 ' "pop", "pops", "apop", "imap", "imaps" or' 257 ' "pop", "pops", "apop", "imap", "imaps" or'
225 ' "imaps_cram')) 258 ' "imaps_cram'))
226 259
227 260
228 def run(): 261 def run():
229 sys.exit(main(sys.argv)) 262 sys.exit(main(sys.argv [1:]))
230 263
231 264
232 # call main 265 # call main
233 if __name__ == '__main__': 266 if __name__ == '__main__':
234 run() 267 run()

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