Commit | Line | Data |
---|---|---|
b4bddc06 CM |
1 | __copyright__ = """ |
2 | Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com> | |
3 | ||
4 | This program is free software; you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License version 2 as | |
6 | published by the Free Software Foundation. | |
7 | ||
8 | This program is distributed in the hope that it will be useful, | |
9 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
11 | GNU General Public License for more details. | |
12 | ||
13 | You should have received a copy of the GNU General Public License | |
14 | along with this program; if not, write to the Free Software | |
15 | Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA | |
16 | """ | |
17 | ||
18 | import sys, os, re, time, smtplib, email.Utils | |
19 | from optparse import OptionParser, make_option | |
20 | from time import gmtime, strftime | |
21 | ||
22 | from stgit.commands.common import * | |
23 | from stgit.utils import * | |
24 | from stgit import stack, git | |
25 | from stgit.config import config | |
26 | ||
27 | ||
28 | help = 'send a patch or series of patches by e-mail' | |
ddab48a5 | 29 | usage = """%prog [options] [<patch> [<patch2...]] |
26aab5b0 CM |
30 | |
31 | Send a patch or a range of patches (defaulting to the applied patches) | |
2bb96902 CM |
32 | by e-mail using the 'smtpserver' configuration option. The From |
33 | address and the e-mail format are generated from the template file | |
34 | passed as argument to '--template' (defaulting to .git/patchmail.tmpl | |
35 | or /usr/share/stgit/templates/patchmail.tmpl). The To/Cc/Bcc addresses | |
36 | can either be added to the template file or passed via the | |
37 | corresponding command line options. | |
38 | ||
e3e05587 CM |
39 | A preamble e-mail can be sent using the '--cover' and/or '--edit' |
40 | options. The first allows the user to specify a file to be used as a | |
41 | template. The latter option will invoke the editor on the specified | |
42 | file (defaulting to .git/covermail.tmpl or | |
43 | /usr/share/stgit/templates/covermail.tmpl). | |
44 | ||
45 | All the subsequent e-mails appear as replies to the first e-mail sent | |
46 | (either the preamble or the first patch). E-mails can be seen as | |
47 | replies to a different e-mail by using the '--refid' option. | |
26aab5b0 CM |
48 | |
49 | SMTP authentication is also possible with '--smtp-user' and | |
50 | '--smtp-password' options, also available as configuration settings: | |
51 | 'smtpuser' and 'smtppassword'. | |
52 | ||
53 | The template e-mail headers and body must be separated by | |
54 | '%(endofheaders)s' variable, which is replaced by StGIT with | |
55 | additional headers and a blank line. The patch e-mail template accepts | |
56 | the following variables: | |
57 | ||
58 | %(patch)s - patch name | |
dae0f0be | 59 | %(maintainer)s - 'authname <authemail>' as read from the config file |
26aab5b0 CM |
60 | %(shortdescr)s - the first line of the patch description |
61 | %(longdescr)s - the rest of the patch description, after the first line | |
62 | %(endofheaders)s - delimiter between e-mail headers and body | |
63 | %(diff)s - unified diff of the patch | |
64 | %(diffstat)s - diff statistics | |
65 | %(date)s - current date/time | |
d0d139a3 | 66 | %(version)s - ' version' string passed on the command line (or empty) |
26aab5b0 CM |
67 | %(patchnr)s - patch number |
68 | %(totalnr)s - total number of patches to be sent | |
b8d258e5 | 69 | %(number)s - empty if only one patch is sent or ' patchnr/totalnr' |
26aab5b0 CM |
70 | %(authname)s - author's name |
71 | %(authemail)s - author's email | |
72 | %(authdate)s - patch creation date | |
73 | %(commname)s - committer's name | |
74 | %(commemail)s - committer's e-mail | |
75 | ||
dae0f0be | 76 | For the preamble e-mail template, only the %(maintainer)s, %(date)s, |
d0d139a3 CM |
77 | %(endofheaders)s, %(version)s, %(patchnr)s, %(totalnr)s and %(number)s |
78 | variables are supported.""" | |
b4bddc06 | 79 | |
9a316368 CM |
80 | options = [make_option('-a', '--all', |
81 | help = 'e-mail all the applied patches', | |
82 | action = 'store_true'), | |
b4bddc06 CM |
83 | make_option('-r', '--range', |
84 | metavar = '[PATCH1][:[PATCH2]]', | |
85 | help = 'e-mail patches between PATCH1 and PATCH2'), | |
2bb96902 | 86 | make_option('--to', |
e83b3149 PO |
87 | help = 'add TO to the To: list', |
88 | action = 'append'), | |
2bb96902 | 89 | make_option('--cc', |
e83b3149 PO |
90 | help = 'add CC to the Cc: list', |
91 | action = 'append'), | |
2bb96902 | 92 | make_option('--bcc', |
e83b3149 PO |
93 | help = 'add BCC to the Bcc: list', |
94 | action = 'append'), | |
d0d139a3 CM |
95 | make_option('-v', '--version', metavar = 'VERSION', |
96 | help = 'add VERSION to the [PATCH ...] prefix'), | |
9a316368 CM |
97 | make_option('-t', '--template', metavar = 'FILE', |
98 | help = 'use FILE as the message template'), | |
e3e05587 CM |
99 | make_option('-c', '--cover', metavar = 'FILE', |
100 | help = 'send FILE as the cover message'), | |
101 | make_option('-e', '--edit', | |
102 | help = 'edit the cover message before sending', | |
103 | action = 'store_true'), | |
b4bddc06 CM |
104 | make_option('-s', '--sleep', type = 'int', metavar = 'SECONDS', |
105 | help = 'sleep for SECONDS between e-mails sending'), | |
106 | make_option('--refid', | |
d0d139a3 | 107 | help = 'use REFID as the reference id'), |
eb026d93 B |
108 | make_option('-u', '--smtp-user', metavar = 'USER', |
109 | help = 'username for SMTP authentication'), | |
110 | make_option('-p', '--smtp-password', metavar = 'PASSWORD', | |
2f7c8b0b CM |
111 | help = 'username for SMTP authentication'), |
112 | make_option('-b', '--branch', | |
113 | help = 'use BRANCH instead of the default one')] | |
b4bddc06 CM |
114 | |
115 | ||
dae0f0be CM |
116 | def __get_maintainer(): |
117 | """Return the 'authname <authemail>' string as read from the | |
118 | configuration file | |
119 | """ | |
120 | if config.has_option('stgit', 'authname') \ | |
121 | and config.has_option('stgit', 'authemail'): | |
122 | return '%s <%s>' % (config.get('stgit', 'authname'), | |
123 | config.get('stgit', 'authemail')) | |
124 | else: | |
125 | return None | |
126 | ||
7cc615f3 | 127 | def __parse_addresses(addresses): |
b4bddc06 CM |
128 | """Return a two elements tuple: (from, [to]) |
129 | """ | |
7cc615f3 CL |
130 | def __addr_list(addrs): |
131 | m = re.search('[^@\s<,]+@[^>\s,]+', addrs); | |
d60cd083 CM |
132 | if (m == None): |
133 | return [] | |
7cc615f3 | 134 | return [ m.group() ] + __addr_list(addrs[m.end():]) |
b4bddc06 CM |
135 | |
136 | from_addr_list = [] | |
137 | to_addr_list = [] | |
7cc615f3 | 138 | for line in addresses.split('\n'): |
b4bddc06 CM |
139 | if re.match('from:\s+', line, re.I): |
140 | from_addr_list += __addr_list(line) | |
141 | elif re.match('(to|cc|bcc):\s+', line, re.I): | |
142 | to_addr_list += __addr_list(line) | |
143 | ||
24aadb3f | 144 | if len(from_addr_list) == 0: |
b4bddc06 CM |
145 | raise CmdException, 'No "From" address' |
146 | if len(to_addr_list) == 0: | |
147 | raise CmdException, 'No "To/Cc/Bcc" addresses' | |
148 | ||
149 | return (from_addr_list[0], to_addr_list) | |
150 | ||
eb026d93 B |
151 | def __send_message(smtpserver, from_addr, to_addr_list, msg, sleep, |
152 | smtpuser, smtppassword): | |
b4bddc06 CM |
153 | """Send the message using the given SMTP server |
154 | """ | |
155 | try: | |
156 | s = smtplib.SMTP(smtpserver) | |
157 | except Exception, err: | |
158 | raise CmdException, str(err) | |
159 | ||
160 | s.set_debuglevel(0) | |
161 | try: | |
eb026d93 B |
162 | if smtpuser and smtppassword: |
163 | s.ehlo() | |
164 | s.login(smtpuser, smtppassword) | |
165 | ||
b4bddc06 CM |
166 | s.sendmail(from_addr, to_addr_list, msg) |
167 | # give recipients a chance of receiving patches in the correct order | |
168 | time.sleep(sleep) | |
169 | except Exception, err: | |
170 | raise CmdException, str(err) | |
171 | ||
172 | s.quit() | |
173 | ||
e83b3149 PO |
174 | def __build_address_headers(options): |
175 | headers_end = '' | |
176 | if options.to: | |
d60cd083 CM |
177 | headers_end += 'To: ' |
178 | for to in options.to: | |
179 | headers_end += '%s, ' % to | |
180 | headers_end = headers_end[:-2] + '\n' | |
e83b3149 | 181 | if options.cc: |
d60cd083 CM |
182 | headers_end += 'Cc: ' |
183 | for cc in options.cc: | |
184 | headers_end += '%s, ' % cc | |
185 | headers_end = headers_end[:-2] + '\n' | |
e83b3149 | 186 | if options.bcc: |
d60cd083 CM |
187 | headers_end += 'Bcc: ' |
188 | for bcc in options.bcc: | |
189 | headers_end += '%s, ' % bcc | |
190 | headers_end = headers_end[:-2] + '\n' | |
e83b3149 PO |
191 | return headers_end |
192 | ||
e3e05587 CM |
193 | def __build_cover(tmpl, total_nr, msg_id, options): |
194 | """Build the cover message (series description) to be sent via SMTP | |
b4bddc06 | 195 | """ |
dae0f0be CM |
196 | maintainer = __get_maintainer() |
197 | if not maintainer: | |
198 | maintainer = '' | |
199 | ||
e83b3149 | 200 | headers_end = __build_address_headers(options) |
2bb96902 CM |
201 | headers_end += 'Message-Id: %s\n' % msg_id |
202 | ||
d0d139a3 CM |
203 | if options.version: |
204 | version_str = ' %s' % options.version | |
ed5de0cc CM |
205 | else: |
206 | version_str = '' | |
d0d139a3 | 207 | |
b4bddc06 | 208 | total_nr_str = str(total_nr) |
b8d258e5 CM |
209 | patch_nr_str = '0'.zfill(len(total_nr_str)) |
210 | if total_nr > 1: | |
211 | number_str = ' %s/%s' % (patch_nr_str, total_nr_str) | |
212 | else: | |
213 | number_str = '' | |
b4bddc06 | 214 | |
dae0f0be CM |
215 | tmpl_dict = {'maintainer': maintainer, |
216 | 'endofheaders': headers_end, | |
b4bddc06 | 217 | 'date': email.Utils.formatdate(localtime = True), |
d0d139a3 | 218 | 'version': version_str, |
b8d258e5 CM |
219 | 'patchnr': patch_nr_str, |
220 | 'totalnr': total_nr_str, | |
221 | 'number': number_str} | |
b4bddc06 CM |
222 | |
223 | try: | |
224 | msg = tmpl % tmpl_dict | |
225 | except KeyError, err: | |
226 | raise CmdException, 'Unknown patch template variable: %s' \ | |
227 | % err | |
228 | except TypeError: | |
229 | raise CmdException, 'Only "%(name)s" variables are ' \ | |
230 | 'supported in the patch template' | |
231 | ||
e3e05587 CM |
232 | if options.edit: |
233 | fname = '.stgitmail.txt' | |
234 | ||
235 | # create the initial file | |
236 | f = file(fname, 'w+') | |
237 | f.write(msg) | |
238 | f.close() | |
239 | ||
240 | # the editor | |
241 | if config.has_option('stgit', 'editor'): | |
242 | editor = config.get('stgit', 'editor') | |
243 | elif 'EDITOR' in os.environ: | |
244 | editor = os.environ['EDITOR'] | |
245 | else: | |
246 | editor = 'vi' | |
247 | editor += ' %s' % fname | |
248 | ||
249 | print 'Invoking the editor: "%s"...' % editor, | |
250 | sys.stdout.flush() | |
251 | print 'done (exit code: %d)' % os.system(editor) | |
252 | ||
253 | # read the message back | |
254 | f = file(fname) | |
255 | msg = f.read() | |
256 | f.close() | |
257 | ||
b4bddc06 CM |
258 | return msg |
259 | ||
2bb96902 | 260 | def __build_message(tmpl, patch, patch_nr, total_nr, msg_id, ref_id, options): |
b4bddc06 CM |
261 | """Build the message to be sent via SMTP |
262 | """ | |
263 | p = crt_series.get_patch(patch) | |
264 | ||
265 | descr = p.get_description().strip() | |
266 | descr_lines = descr.split('\n') | |
267 | ||
268 | short_descr = descr_lines[0].rstrip() | |
269 | long_descr = reduce(lambda x, y: x + '\n' + y, | |
270 | descr_lines[1:], '').lstrip() | |
271 | ||
dae0f0be CM |
272 | maintainer = __get_maintainer() |
273 | if not maintainer: | |
274 | maintainer = '%s <%s>' % (p.get_commname(), p.get_commemail()) | |
275 | ||
e83b3149 | 276 | headers_end = __build_address_headers(options) |
2bb96902 | 277 | headers_end += 'Message-Id: %s\n' % msg_id |
b4bddc06 | 278 | if ref_id: |
2bb96902 CM |
279 | headers_end += "In-Reply-To: %s\n" % ref_id |
280 | headers_end += "References: %s\n" % ref_id | |
b4bddc06 | 281 | |
d0d139a3 CM |
282 | if options.version: |
283 | version_str = ' %s' % options.version | |
ed5de0cc CM |
284 | else: |
285 | version_str = '' | |
d0d139a3 | 286 | |
b4bddc06 CM |
287 | total_nr_str = str(total_nr) |
288 | patch_nr_str = str(patch_nr).zfill(len(total_nr_str)) | |
b8d258e5 CM |
289 | if total_nr > 1: |
290 | number_str = ' %s/%s' % (patch_nr_str, total_nr_str) | |
291 | else: | |
292 | number_str = '' | |
b4bddc06 CM |
293 | |
294 | tmpl_dict = {'patch': patch, | |
dae0f0be | 295 | 'maintainer': maintainer, |
b4bddc06 CM |
296 | 'shortdescr': short_descr, |
297 | 'longdescr': long_descr, | |
298 | 'endofheaders': headers_end, | |
299 | 'diff': git.diff(rev1 = git_id('%s/bottom' % patch), | |
300 | rev2 = git_id('%s/top' % patch)), | |
301 | 'diffstat': git.diffstat(rev1 = git_id('%s/bottom'%patch), | |
302 | rev2 = git_id('%s/top' % patch)), | |
303 | 'date': email.Utils.formatdate(localtime = True), | |
d0d139a3 | 304 | 'version': version_str, |
b4bddc06 CM |
305 | 'patchnr': patch_nr_str, |
306 | 'totalnr': total_nr_str, | |
b8d258e5 | 307 | 'number': number_str, |
b4bddc06 CM |
308 | 'authname': p.get_authname(), |
309 | 'authemail': p.get_authemail(), | |
310 | 'authdate': p.get_authdate(), | |
311 | 'commname': p.get_commname(), | |
312 | 'commemail': p.get_commemail()} | |
313 | for key in tmpl_dict: | |
314 | if not tmpl_dict[key]: | |
315 | tmpl_dict[key] = '' | |
316 | ||
317 | try: | |
318 | msg = tmpl % tmpl_dict | |
319 | except KeyError, err: | |
320 | raise CmdException, 'Unknown patch template variable: %s' \ | |
321 | % err | |
322 | except TypeError: | |
323 | raise CmdException, 'Only "%(name)s" variables are ' \ | |
324 | 'supported in the patch template' | |
325 | ||
326 | return msg | |
327 | ||
b4bddc06 CM |
328 | def func(parser, options, args): |
329 | """Send the patches by e-mail using the patchmail.tmpl file as | |
330 | a template | |
331 | """ | |
b4bddc06 CM |
332 | if not config.has_option('stgit', 'smtpserver'): |
333 | raise CmdException, 'smtpserver not defined' | |
334 | smtpserver = config.get('stgit', 'smtpserver') | |
335 | ||
eb026d93 B |
336 | smtpuser = None |
337 | smtppassword = None | |
338 | if config.has_option('stgit', 'smtpuser'): | |
339 | smtpuser = config.get('stgit', 'smtpuser') | |
340 | if config.has_option('stgit', 'smtppassword'): | |
341 | smtppassword = config.get('stgit', 'smtppassword') | |
342 | ||
b4bddc06 | 343 | applied = crt_series.get_applied() |
f2944a74 | 344 | unapplied = crt_series.get_unapplied() |
b4bddc06 | 345 | |
ddab48a5 PBG |
346 | if len(args) >= 1: |
347 | for patch in args: | |
f2944a74 | 348 | if patch in unapplied: |
ddab48a5 | 349 | raise CmdException, 'Patch "%s" not applied' % patch |
f2944a74 CL |
350 | if not patch in applied: |
351 | raise CmdException, 'Patch "%s" does not exist' % patch | |
352 | patches = args | |
9a316368 CM |
353 | elif options.all: |
354 | patches = applied | |
355 | elif options.range: | |
b4bddc06 CM |
356 | boundaries = options.range.split(':') |
357 | if len(boundaries) == 1: | |
358 | start = boundaries[0] | |
359 | stop = boundaries[0] | |
360 | elif len(boundaries) == 2: | |
361 | if boundaries[0] == '': | |
362 | start = applied[0] | |
363 | else: | |
364 | start = boundaries[0] | |
365 | if boundaries[1] == '': | |
366 | stop = applied[-1] | |
367 | else: | |
368 | stop = boundaries[1] | |
369 | else: | |
370 | raise CmdException, 'incorrect parameters to "--range"' | |
371 | ||
372 | if start in applied: | |
373 | start_idx = applied.index(start) | |
374 | else: | |
f2944a74 CL |
375 | if start in unapplied: |
376 | raise CmdException, 'Patch "%s" not applied' % start | |
377 | else: | |
378 | raise CmdException, 'Patch "%s" does not exist' % start | |
b4bddc06 CM |
379 | if stop in applied: |
380 | stop_idx = applied.index(stop) + 1 | |
381 | else: | |
f2944a74 CL |
382 | if stop in unapplied: |
383 | raise CmdException, 'Patch "%s" not applied' % stop | |
384 | else: | |
385 | raise CmdException, 'Patch "%s" does not exist' % stop | |
b4bddc06 CM |
386 | |
387 | if start_idx >= stop_idx: | |
388 | raise CmdException, 'Incorrect patch range order' | |
9a316368 CM |
389 | |
390 | patches = applied[start_idx:stop_idx] | |
b4bddc06 | 391 | else: |
9a316368 | 392 | raise CmdException, 'Incorrect options. Unknown patches to send' |
b4bddc06 | 393 | |
eb026d93 B |
394 | if options.smtp_password: |
395 | smtppassword = options.smtp_password | |
396 | ||
397 | if options.smtp_user: | |
398 | smtpuser = options.smtp_user | |
399 | ||
400 | if (smtppassword and not smtpuser): | |
401 | raise CmdException, 'SMTP password supplied, username needed' | |
402 | if (smtpuser and not smtppassword): | |
403 | raise CmdException, 'SMTP username supplied, password needed' | |
404 | ||
b4bddc06 | 405 | total_nr = len(patches) |
9a316368 CM |
406 | if total_nr == 0: |
407 | raise CmdException, 'No patches to send' | |
b4bddc06 CM |
408 | |
409 | ref_id = options.refid | |
410 | ||
411 | if options.sleep != None: | |
412 | sleep = options.sleep | |
413 | else: | |
414 | sleep = 2 | |
415 | ||
e3e05587 CM |
416 | # send the cover message (if any) |
417 | if options.cover or options.edit: | |
418 | # find the template file | |
419 | if options.cover: | |
420 | tfile_list = [options.cover] | |
421 | else: | |
bae29ddd | 422 | tfile_list = [os.path.join(git.get_base_dir(), 'covermail.tmpl'), |
e3e05587 CM |
423 | os.path.join(sys.prefix, |
424 | 'share/stgit/templates/covermail.tmpl')] | |
425 | ||
426 | tmpl = None | |
427 | for tfile in tfile_list: | |
428 | if os.path.isfile(tfile): | |
429 | tmpl = file(tfile).read() | |
430 | break | |
431 | if not tmpl: | |
432 | raise CmdException, 'No cover message template file found' | |
b4bddc06 CM |
433 | |
434 | msg_id = email.Utils.make_msgid('stgit') | |
e3e05587 | 435 | msg = __build_cover(tmpl, total_nr, msg_id, options) |
2bb96902 | 436 | from_addr, to_addr_list = __parse_addresses(msg) |
b4bddc06 CM |
437 | |
438 | # subsequent e-mails are seen as replies to the first one | |
439 | ref_id = msg_id | |
440 | ||
e3e05587 | 441 | print 'Sending the cover message...', |
b4bddc06 CM |
442 | sys.stdout.flush() |
443 | ||
eb026d93 B |
444 | __send_message(smtpserver, from_addr, to_addr_list, msg, sleep, |
445 | smtpuser, smtppassword) | |
b4bddc06 CM |
446 | |
447 | print 'done' | |
448 | ||
449 | # send the patches | |
450 | if options.template: | |
2bb96902 | 451 | tfile_list = [options.template] |
b4bddc06 | 452 | else: |
bae29ddd | 453 | tfile_list = [os.path.join(git.get_base_dir(), 'patchmail.tmpl'), |
e3e05587 CM |
454 | os.path.join(sys.prefix, |
455 | 'share/stgit/templates/patchmail.tmpl')] | |
2bb96902 CM |
456 | tmpl = None |
457 | for tfile in tfile_list: | |
458 | if os.path.isfile(tfile): | |
459 | tmpl = file(tfile).read() | |
460 | break | |
461 | if not tmpl: | |
e3e05587 | 462 | raise CmdException, 'No e-mail template file found' |
b4bddc06 CM |
463 | |
464 | for (p, patch_nr) in zip(patches, range(1, len(patches) + 1)): | |
465 | msg_id = email.Utils.make_msgid('stgit') | |
2bb96902 CM |
466 | msg = __build_message(tmpl, p, patch_nr, total_nr, msg_id, ref_id, |
467 | options) | |
468 | from_addr, to_addr_list = __parse_addresses(msg) | |
469 | ||
b4bddc06 CM |
470 | # subsequent e-mails are seen as replies to the first one |
471 | if not ref_id: | |
472 | ref_id = msg_id | |
473 | ||
474 | print 'Sending patch "%s"...' % p, | |
475 | sys.stdout.flush() | |
476 | ||
eb026d93 B |
477 | __send_message(smtpserver, from_addr, to_addr_list, msg, sleep, |
478 | smtpuser, smtppassword) | |
b4bddc06 CM |
479 | |
480 | print 'done' |