##// END OF EJS Templates
patchbomb: prompt only once for SMTP password...
Matt Mackall -
r5866:dc6ed273 default
parent child Browse files
Show More
@@ -1,464 +1,467
1 1 # Command for sending a collection of Mercurial changesets as a series
2 2 # of patch emails.
3 3 #
4 4 # The series is started off with a "[PATCH 0 of N]" introduction,
5 5 # which describes the series as a whole.
6 6 #
7 7 # Each patch email has a Subject line of "[PATCH M of N] ...", using
8 8 # the first line of the changeset description as the subject text.
9 9 # The message contains two or three body parts:
10 10 #
11 11 # The remainder of the changeset description.
12 12 #
13 13 # [Optional] If the diffstat program is installed, the result of
14 14 # running diffstat on the patch.
15 15 #
16 16 # The patch itself, as generated by "hg export".
17 17 #
18 18 # Each message refers to all of its predecessors using the In-Reply-To
19 19 # and References headers, so they will show up as a sequence in
20 20 # threaded mail and news readers, and in mail archives.
21 21 #
22 22 # For each changeset, you will be prompted with a diffstat summary and
23 23 # the changeset summary, so you can be sure you are sending the right
24 24 # changes.
25 25 #
26 26 # To enable this extension:
27 27 #
28 28 # [extensions]
29 29 # hgext.patchbomb =
30 30 #
31 31 # To configure other defaults, add a section like this to your hgrc
32 32 # file:
33 33 #
34 34 # [email]
35 35 # from = My Name <my@email>
36 36 # to = recipient1, recipient2, ...
37 37 # cc = cc1, cc2, ...
38 38 # bcc = bcc1, bcc2, ...
39 39 #
40 40 # Then you can use the "hg email" command to mail a series of changesets
41 41 # as a patchbomb.
42 42 #
43 43 # To avoid sending patches prematurely, it is a good idea to first run
44 44 # the "email" command with the "-n" option (test only). You will be
45 45 # prompted for an email recipient address, a subject an an introductory
46 46 # message describing the patches of your patchbomb. Then when all is
47 47 # done, patchbomb messages are displayed. If PAGER environment variable
48 48 # is set, your pager will be fired up once for each patchbomb message, so
49 49 # you can verify everything is alright.
50 50 #
51 51 # The "-m" (mbox) option is also very useful. Instead of previewing
52 52 # each patchbomb message in a pager or sending the messages directly,
53 53 # it will create a UNIX mailbox file with the patch emails. This
54 54 # mailbox file can be previewed with any mail user agent which supports
55 55 # UNIX mbox files, i.e. with mutt:
56 56 #
57 57 # % mutt -R -f mbox
58 58 #
59 59 # When you are previewing the patchbomb messages, you can use `formail'
60 60 # (a utility that is commonly installed as part of the procmail package),
61 61 # to send each message out:
62 62 #
63 63 # % formail -s sendmail -bm -t < mbox
64 64 #
65 65 # That should be all. Now your patchbomb is on its way out.
66 66
67 67 import os, errno, socket, tempfile
68 68 import email.MIMEMultipart, email.MIMEText, email.MIMEBase
69 69 import email.Utils, email.Encoders
70 70 from mercurial import cmdutil, commands, hg, mail, ui, patch, util
71 71 from mercurial.i18n import _
72 72 from mercurial.node import *
73 73
74 74 def patchbomb(ui, repo, *revs, **opts):
75 75 '''send changesets by email
76 76
77 77 By default, diffs are sent in the format generated by hg export,
78 78 one per message. The series starts with a "[PATCH 0 of N]"
79 79 introduction, which describes the series as a whole.
80 80
81 81 Each patch email has a Subject line of "[PATCH M of N] ...", using
82 82 the first line of the changeset description as the subject text.
83 83 The message contains two or three body parts. First, the rest of
84 84 the changeset description. Next, (optionally) if the diffstat
85 85 program is installed, the result of running diffstat on the patch.
86 86 Finally, the patch itself, as generated by "hg export".
87 87
88 88 With --outgoing, emails will be generated for patches not
89 89 found in the destination repository (or only those which are
90 90 ancestors of the specified revisions if any are provided)
91 91
92 92 With --bundle, changesets are selected as for --outgoing,
93 93 but a single email containing a binary Mercurial bundle as an
94 94 attachment will be sent.
95 95
96 96 Examples:
97 97
98 98 hg email -r 3000 # send patch 3000 only
99 99 hg email -r 3000 -r 3001 # send patches 3000 and 3001
100 100 hg email -r 3000:3005 # send patches 3000 through 3005
101 101 hg email 3000 # send patch 3000 (deprecated)
102 102
103 103 hg email -o # send all patches not in default
104 104 hg email -o DEST # send all patches not in DEST
105 105 hg email -o -r 3000 # send all ancestors of 3000 not in default
106 106 hg email -o -r 3000 DEST # send all ancestors of 3000 not in DEST
107 107
108 108 hg email -b # send bundle of all patches not in default
109 109 hg email -b DEST # send bundle of all patches not in DEST
110 110 hg email -b -r 3000 # bundle of all ancestors of 3000 not in default
111 111 hg email -b -r 3000 DEST # bundle of all ancestors of 3000 not in DEST
112 112
113 113 Before using this command, you will need to enable email in your hgrc.
114 114 See the [email] section in hgrc(5) for details.
115 115 '''
116 116
117 117 def prompt(prompt, default = None, rest = ': ', empty_ok = False):
118 118 if not ui.interactive:
119 119 return default
120 120 if default:
121 121 prompt += ' [%s]' % default
122 122 prompt += rest
123 123 while True:
124 124 r = ui.prompt(prompt, default=default)
125 125 if r:
126 126 return r
127 127 if default is not None:
128 128 return default
129 129 if empty_ok:
130 130 return r
131 131 ui.warn(_('Please enter a valid value.\n'))
132 132
133 133 def confirm(s, denial):
134 134 if not prompt(s, default = 'y', rest = '? ').lower().startswith('y'):
135 135 raise util.Abort(denial)
136 136
137 137 def cdiffstat(summary, patchlines):
138 138 s = patch.diffstat(patchlines)
139 139 if s:
140 140 if summary:
141 141 ui.write(summary, '\n')
142 142 ui.write(s, '\n')
143 143 confirm(_('Does the diffstat above look okay'),
144 144 _('diffstat rejected'))
145 145 elif s is None:
146 146 ui.warn(_('No diffstat information available.\n'))
147 147 s = ''
148 148 return s
149 149
150 150 def makepatch(patch, idx, total):
151 151 desc = []
152 152 node = None
153 153 body = ''
154 154 for line in patch:
155 155 if line.startswith('#'):
156 156 if line.startswith('# Node ID'):
157 157 node = line.split()[-1]
158 158 continue
159 159 if line.startswith('diff -r') or line.startswith('diff --git'):
160 160 break
161 161 desc.append(line)
162 162 if not node:
163 163 raise ValueError
164 164
165 165 if opts['attach']:
166 166 body = ('\n'.join(desc[1:]).strip() or
167 167 'Patch subject is complete summary.')
168 168 body += '\n\n\n'
169 169
170 170 if opts.get('plain'):
171 171 while patch and patch[0].startswith('# '):
172 172 patch.pop(0)
173 173 if patch:
174 174 patch.pop(0)
175 175 while patch and not patch[0].strip():
176 176 patch.pop(0)
177 177 if opts.get('diffstat'):
178 178 body += cdiffstat('\n'.join(desc), patch) + '\n\n'
179 179 if opts.get('attach') or opts.get('inline'):
180 180 msg = email.MIMEMultipart.MIMEMultipart()
181 181 if body:
182 182 msg.attach(email.MIMEText.MIMEText(body, 'plain'))
183 183 p = email.MIMEText.MIMEText('\n'.join(patch), 'x-patch')
184 184 binnode = bin(node)
185 185 # if node is mq patch, it will have patch file name as tag
186 186 patchname = [t for t in repo.nodetags(binnode)
187 187 if t.endswith('.patch') or t.endswith('.diff')]
188 188 if patchname:
189 189 patchname = patchname[0]
190 190 elif total > 1:
191 191 patchname = cmdutil.make_filename(repo, '%b-%n.patch',
192 192 binnode, idx, total)
193 193 else:
194 194 patchname = cmdutil.make_filename(repo, '%b.patch', binnode)
195 195 disposition = 'inline'
196 196 if opts['attach']:
197 197 disposition = 'attachment'
198 198 p['Content-Disposition'] = disposition + '; filename=' + patchname
199 199 msg.attach(p)
200 200 else:
201 201 body += '\n'.join(patch)
202 202 msg = email.MIMEText.MIMEText(body)
203 203
204 204 subj = desc[0].strip().rstrip('. ')
205 205 if total == 1:
206 206 subj = '[PATCH] ' + (opts.get('subject') or subj)
207 207 else:
208 208 tlen = len(str(total))
209 209 subj = '[PATCH %0*d of %d] %s' % (tlen, idx, total, subj)
210 210 msg['Subject'] = subj
211 211 msg['X-Mercurial-Node'] = node
212 212 return msg
213 213
214 214 def outgoing(dest, revs):
215 215 '''Return the revisions present locally but not in dest'''
216 216 dest = ui.expandpath(dest or 'default-push', dest or 'default')
217 217 revs = [repo.lookup(rev) for rev in revs]
218 218 other = hg.repository(ui, dest)
219 219 ui.status(_('comparing with %s\n') % dest)
220 220 o = repo.findoutgoing(other)
221 221 if not o:
222 222 ui.status(_("no changes found\n"))
223 223 return []
224 224 o = repo.changelog.nodesbetween(o, revs or None)[0]
225 225 return [str(repo.changelog.rev(r)) for r in o]
226 226
227 227 def getbundle(dest):
228 228 tmpdir = tempfile.mkdtemp(prefix='hg-email-bundle-')
229 229 tmpfn = os.path.join(tmpdir, 'bundle')
230 230 try:
231 231 commands.bundle(ui, repo, tmpfn, dest, **opts)
232 232 return open(tmpfn, 'rb').read()
233 233 finally:
234 234 try:
235 235 os.unlink(tmpfn)
236 236 except:
237 237 pass
238 238 os.rmdir(tmpdir)
239 239
240 240 if not (opts.get('test') or opts.get('mbox')):
241 241 # really sending
242 242 mail.validateconfig(ui)
243 243
244 244 if not (revs or opts.get('rev')
245 245 or opts.get('outgoing') or opts.get('bundle')):
246 246 raise util.Abort(_('specify at least one changeset with -r or -o'))
247 247
248 248 cmdutil.setremoteconfig(ui, opts)
249 249 if opts.get('outgoing') and opts.get('bundle'):
250 250 raise util.Abort(_("--outgoing mode always on with --bundle;"
251 251 " do not re-specify --outgoing"))
252 252
253 253 if opts.get('outgoing') or opts.get('bundle'):
254 254 if len(revs) > 1:
255 255 raise util.Abort(_("too many destinations"))
256 256 dest = revs and revs[0] or None
257 257 revs = []
258 258
259 259 if opts.get('rev'):
260 260 if revs:
261 261 raise util.Abort(_('use only one form to specify the revision'))
262 262 revs = opts.get('rev')
263 263
264 264 if opts.get('outgoing'):
265 265 revs = outgoing(dest, opts.get('rev'))
266 266 if opts.get('bundle'):
267 267 opts['revs'] = revs
268 268
269 269 # start
270 270 if opts.get('date'):
271 271 start_time = util.parsedate(opts.get('date'))
272 272 else:
273 273 start_time = util.makedate()
274 274
275 275 def genmsgid(id):
276 276 return '<%s.%s@%s>' % (id[:20], int(start_time[0]), socket.getfqdn())
277 277
278 278 def getdescription(body, sender):
279 279 if opts.get('desc'):
280 280 body = open(opts.get('desc')).read()
281 281 else:
282 282 ui.write(_('\nWrite the introductory message for the '
283 283 'patch series.\n\n'))
284 284 body = ui.edit(body, sender)
285 285 return body
286 286
287 287 def getexportmsgs():
288 288 patches = []
289 289
290 290 class exportee:
291 291 def __init__(self, container):
292 292 self.lines = []
293 293 self.container = container
294 294 self.name = 'email'
295 295
296 296 def write(self, data):
297 297 self.lines.append(data)
298 298
299 299 def close(self):
300 300 self.container.append(''.join(self.lines).split('\n'))
301 301 self.lines = []
302 302
303 303 commands.export(ui, repo, *revs, **{'output': exportee(patches),
304 304 'switch_parent': False,
305 305 'text': None,
306 306 'git': opts.get('git')})
307 307
308 308 jumbo = []
309 309 msgs = []
310 310
311 311 ui.write(_('This patch series consists of %d patches.\n\n')
312 312 % len(patches))
313 313
314 314 for p, i in zip(patches, xrange(len(patches))):
315 315 jumbo.extend(p)
316 316 msgs.append(makepatch(p, i + 1, len(patches)))
317 317
318 318 if len(patches) > 1:
319 319 tlen = len(str(len(patches)))
320 320
321 321 subj = '[PATCH %0*d of %d] %s' % (
322 322 tlen, 0, len(patches),
323 323 opts.get('subject') or
324 324 prompt('Subject:',
325 325 rest=' [PATCH %0*d of %d] ' % (tlen, 0, len(patches))))
326 326
327 327 body = ''
328 328 if opts.get('diffstat'):
329 329 d = cdiffstat(_('Final summary:\n'), jumbo)
330 330 if d:
331 331 body = '\n' + d
332 332
333 333 body = getdescription(body, sender)
334 334 msg = email.MIMEText.MIMEText(body)
335 335 msg['Subject'] = subj
336 336
337 337 msgs.insert(0, msg)
338 338 return msgs
339 339
340 340 def getbundlemsgs(bundle):
341 341 subj = (opts.get('subject')
342 342 or prompt('Subject:', default='A bundle for your repository'))
343 343
344 344 body = getdescription('', sender)
345 345 msg = email.MIMEMultipart.MIMEMultipart()
346 346 if body:
347 347 msg.attach(email.MIMEText.MIMEText(body, 'plain'))
348 348 datapart = email.MIMEBase.MIMEBase('application', 'x-mercurial-bundle')
349 349 datapart.set_payload(bundle)
350 350 datapart.add_header('Content-Disposition', 'attachment',
351 351 filename='bundle.hg')
352 352 email.Encoders.encode_base64(datapart)
353 353 msg.attach(datapart)
354 354 msg['Subject'] = subj
355 355 return [msg]
356 356
357 357 sender = (opts.get('from') or ui.config('email', 'from') or
358 358 ui.config('patchbomb', 'from') or
359 359 prompt('From', ui.username()))
360 360
361 361 if opts.get('bundle'):
362 362 msgs = getbundlemsgs(getbundle(dest))
363 363 else:
364 364 msgs = getexportmsgs()
365 365
366 366 def getaddrs(opt, prpt, default = None):
367 367 addrs = opts.get(opt) or (ui.config('email', opt) or
368 368 ui.config('patchbomb', opt) or
369 369 prompt(prpt, default = default)).split(',')
370 370 return [a.strip() for a in addrs if a.strip()]
371 371
372 372 to = getaddrs('to', 'To')
373 373 cc = getaddrs('cc', 'Cc', '')
374 374
375 375 bcc = opts.get('bcc') or (ui.config('email', 'bcc') or
376 376 ui.config('patchbomb', 'bcc') or '').split(',')
377 377 bcc = [a.strip() for a in bcc if a.strip()]
378 378
379 379 ui.write('\n')
380 380
381 381 parent = None
382 382
383 383 sender_addr = email.Utils.parseaddr(sender)[1]
384 sendmail = None
384 385 for m in msgs:
385 386 try:
386 387 m['Message-Id'] = genmsgid(m['X-Mercurial-Node'])
387 388 except TypeError:
388 389 m['Message-Id'] = genmsgid('patchbomb')
389 390 if parent:
390 391 m['In-Reply-To'] = parent
391 392 else:
392 393 parent = m['Message-Id']
393 394 m['Date'] = util.datestr(date=start_time,
394 395 format="%a, %d %b %Y %H:%M:%S", timezone=True)
395 396
396 397 start_time = (start_time[0] + 1, start_time[1])
397 398 m['From'] = sender
398 399 m['To'] = ', '.join(to)
399 400 if cc:
400 401 m['Cc'] = ', '.join(cc)
401 402 if bcc:
402 403 m['Bcc'] = ', '.join(bcc)
403 404 if opts.get('test'):
404 405 ui.status('Displaying ', m['Subject'], ' ...\n')
405 406 ui.flush()
406 407 if 'PAGER' in os.environ:
407 408 fp = os.popen(os.environ['PAGER'], 'w')
408 409 else:
409 410 fp = ui
410 411 try:
411 412 fp.write(m.as_string(0))
412 413 fp.write('\n')
413 414 except IOError, inst:
414 415 if inst.errno != errno.EPIPE:
415 416 raise
416 417 if fp is not ui:
417 418 fp.close()
418 419 elif opts.get('mbox'):
419 420 ui.status('Writing ', m['Subject'], ' ...\n')
420 421 fp = open(opts.get('mbox'),
421 422 m.has_key('In-Reply-To') and 'ab+' or 'wb+')
422 423 date = util.datestr(date=start_time,
423 424 format='%a %b %d %H:%M:%S %Y', timezone=False)
424 425 fp.write('From %s %s\n' % (sender_addr, date))
425 426 fp.write(m.as_string(0))
426 427 fp.write('\n\n')
427 428 fp.close()
428 429 else:
430 if not sendmail:
431 sendmail = mail.connect(ui)
429 432 ui.status('Sending ', m['Subject'], ' ...\n')
430 433 # Exim does not remove the Bcc field
431 434 del m['Bcc']
432 mail.sendmail(ui, sender, to + bcc + cc, m.as_string(0))
435 sendmail(ui, sender, to + bcc + cc, m.as_string(0))
433 436
434 437 cmdtable = {
435 438 "email":
436 439 (patchbomb,
437 440 [('a', 'attach', None, _('send patches as attachments')),
438 441 ('i', 'inline', None, _('send patches as inline attachments')),
439 442 ('', 'bcc', [], _('email addresses of blind copy recipients')),
440 443 ('c', 'cc', [], _('email addresses of copy recipients')),
441 444 ('d', 'diffstat', None, _('add diffstat output to messages')),
442 445 ('', 'date', '', _('use the given date as the sending date')),
443 446 ('', 'desc', '', _('use the given file as the series description')),
444 447 ('g', 'git', None, _('use git extended diff format')),
445 448 ('f', 'from', '', _('email address of sender')),
446 449 ('', 'plain', None, _('omit hg patch header')),
447 450 ('n', 'test', None, _('print messages that would be sent')),
448 451 ('m', 'mbox', '',
449 452 _('write messages to mbox file instead of sending them')),
450 453 ('o', 'outgoing', None,
451 454 _('send changes not found in the target repository')),
452 455 ('b', 'bundle', None,
453 456 _('send changes not in target as a binary bundle')),
454 457 ('r', 'rev', [], _('a revision to send')),
455 458 ('s', 'subject', '',
456 459 _('subject of first message (intro or single patch)')),
457 460 ('t', 'to', [], _('email addresses of recipients')),
458 461 ('', 'force', None,
459 462 _('run even when remote repository is unrelated (with -b)')),
460 463 ('', 'base', [],
461 464 _('a base changeset to specify instead of a destination (with -b)')),
462 465 ] + commands.remoteopts,
463 466 _('hg email [OPTION]... [DEST]...'))
464 467 }
@@ -1,90 +1,94
1 1 # mail.py - mail sending bits for mercurial
2 2 #
3 3 # Copyright 2006 Matt Mackall <mpm@selenic.com>
4 4 #
5 5 # This software may be used and distributed according to the terms
6 6 # of the GNU General Public License, incorporated herein by reference.
7 7
8 8 from i18n import _
9 9 import os, smtplib, templater, util, socket
10 10
11 11 def _smtp(ui):
12 12 '''send mail using smtp.'''
13 13
14 14 local_hostname = ui.config('smtp', 'local_hostname')
15 15 s = smtplib.SMTP(local_hostname=local_hostname)
16 16 mailhost = ui.config('smtp', 'host')
17 17 if not mailhost:
18 18 raise util.Abort(_('no [smtp]host in hgrc - cannot send mail'))
19 19 mailport = int(ui.config('smtp', 'port', 25))
20 20 ui.note(_('sending mail: smtp host %s, port %s\n') %
21 21 (mailhost, mailport))
22 22 s.connect(host=mailhost, port=mailport)
23 23 if ui.configbool('smtp', 'tls'):
24 24 if not hasattr(socket, 'ssl'):
25 25 raise util.Abort(_("can't use TLS: Python SSL support "
26 26 "not installed"))
27 27 ui.note(_('(using tls)\n'))
28 28 s.ehlo()
29 29 s.starttls()
30 30 s.ehlo()
31 31 username = ui.config('smtp', 'username')
32 32 password = ui.config('smtp', 'password')
33 33 if username and not password:
34 34 password = ui.getpass()
35 35 if username and password:
36 36 ui.note(_('(authenticating to mail server as %s)\n') %
37 37 (username))
38 38 s.login(username, password)
39 39 return s
40 40
41 class _sendmail(object):
41 def _sendmail(ui, sender, recipients, msg):
42 42 '''send mail using sendmail.'''
43
44 def __init__(self, ui, program):
45 self.ui = ui
46 self.program = program
47
48 def sendmail(self, sender, recipients, msg):
49 cmdline = '%s -f %s %s' % (
50 self.program, templater.email(sender),
43 program = ui.config('email', 'method')
44 cmdline = '%s -f %s %s' % (program, templater.email(sender),
51 45 ' '.join(map(templater.email, recipients)))
52 self.ui.note(_('sending mail: %s\n') % cmdline)
46 ui.note(_('sending mail: %s\n') % cmdline)
53 47 fp = os.popen(cmdline, 'w')
54 48 fp.write(msg)
55 49 ret = fp.close()
56 50 if ret:
57 51 raise util.Abort('%s %s' % (
58 os.path.basename(self.program.split(None, 1)[0]),
52 os.path.basename(program.split(None, 1)[0]),
59 53 util.explain_exit(ret)[0]))
60 54
61 55 def connect(ui):
62 '''make a mail connection. object returned has one method, sendmail.
56 '''make a mail connection. return a function to send mail.
63 57 call as sendmail(sender, list-of-recipients, msg).'''
64 58
65 method = ui.config('email', 'method', 'smtp')
66 if method == 'smtp':
67 return _smtp(ui)
59 func = _sendmail
60 if ui.config('email', 'method', 'smtp') == 'smtp':
61 func = _smtp(ui)
68 62
69 return _sendmail(ui, method)
63 def send(ui, sender, recipients, msg):
64 try:
65 return func.sendmail(sender, recipients, msg)
66 except smtplib.SMTPRecipientsRefused, inst:
67 recipients = [r[1] for r in inst.recipients.values()]
68 raise util.Abort('\n' + '\n'.join(recipients))
69 except smtplib.SMTPException, inst:
70 raise util.Abort(inst)
71
72 return send
70 73
71 74 def sendmail(ui, sender, recipients, msg):
72 75 try:
73 return connect(ui).sendmail(sender, recipients, msg)
76 send = connect(ui)
77 return send(sender, recipients, msg)
74 78 except smtplib.SMTPRecipientsRefused, inst:
75 79 recipients = [r[1] for r in inst.recipients.values()]
76 80 raise util.Abort('\n' + '\n'.join(recipients))
77 81 except smtplib.SMTPException, inst:
78 82 raise util.Abort(inst)
79 83
80 84 def validateconfig(ui):
81 85 '''determine if we have enough config data to try sending email.'''
82 86 method = ui.config('email', 'method', 'smtp')
83 87 if method == 'smtp':
84 88 if not ui.config('smtp', 'host'):
85 89 raise util.Abort(_('smtp specified as email transport, '
86 90 'but no smtp host configured'))
87 91 else:
88 92 if not util.find_exe(method):
89 93 raise util.Abort(_('%r specified as email transport, '
90 94 'but not in PATH') % method)
General Comments 0
You need to be logged in to leave comments. Login now