39
|
1 # -*- coding: utf-8 -*-
|
|
2 #
|
|
3 # Copyright (C) 2003-2006 Edgewall Software
|
|
4 # Copyright (C) 2003-2005 Daniel Lundin <daniel@edgewall.com>
|
|
5 # Copyright (C) 2005-2006 Emmanuel Blot <emmanuel.blot@free.fr>
|
|
6 # All rights reserved.
|
|
7 #
|
|
8 # This software is licensed as described in the file COPYING, which
|
|
9 # you should have received as part of this distribution. The terms
|
|
10 # are also available at http://trac.edgewall.com/license.html.
|
|
11 #
|
|
12 # This software consists of voluntary contributions made by many
|
|
13 # individuals. For the exact contribution history, see the revision
|
|
14 # history and logs, available at http://projects.edgewall.com/trac/.
|
|
15 #
|
|
16
|
|
17 import time
|
|
18 import smtplib
|
|
19 import re
|
|
20
|
|
21 from trac import __version__
|
|
22 from trac.config import BoolOption, IntOption, Option
|
|
23 from trac.core import *
|
|
24 from trac.util.text import CRLF, wrap
|
|
25 from trac.web.chrome import Chrome
|
|
26 from trac.web.clearsilver import HDFWrapper
|
|
27 from trac.web.main import populate_hdf
|
|
28
|
|
29 MAXHEADERLEN = 76
|
|
30
|
|
31
|
|
32 class NotificationSystem(Component):
|
|
33
|
|
34 smtp_enabled = BoolOption('notification', 'smtp_enabled', 'false',
|
|
35 """Enable SMTP (email) notification.""")
|
|
36
|
|
37 smtp_server = Option('notification', 'smtp_server', 'localhost',
|
|
38 """SMTP server hostname to use for email notifications.""")
|
|
39
|
|
40 smtp_port = IntOption('notification', 'smtp_port', 25,
|
|
41 """SMTP server port to use for email notification.""")
|
|
42
|
|
43 smtp_user = Option('notification', 'smtp_user', '',
|
|
44 """Username for SMTP server. (''since 0.9'').""")
|
|
45
|
|
46 smtp_password = Option('notification', 'smtp_password', '',
|
|
47 """Password for SMTP server. (''since 0.9'').""")
|
|
48
|
|
49 smtp_from = Option('notification', 'smtp_from', 'trac@localhost',
|
|
50 """Sender address to use in notification emails.""")
|
|
51
|
|
52 smtp_replyto = Option('notification', 'smtp_replyto', 'trac@localhost',
|
|
53 """Reply-To address to use in notification emails.""")
|
|
54
|
|
55 smtp_always_cc = Option('notification', 'smtp_always_cc', '',
|
|
56 """Email address(es) to always send notifications to,
|
|
57 addresses can be see by all recipients (Cc:).""")
|
|
58
|
|
59 smtp_always_bcc = Option('notification', 'smtp_always_bcc', '',
|
|
60 """Email address(es) to always send notifications to,
|
|
61 addresses do not appear publicly (Bcc:). (''since 0.10'').""")
|
|
62
|
|
63 smtp_default_domain = Option('notification', 'smtp_default_domain', '',
|
|
64 """Default host/domain to append to address that do not specify one""")
|
|
65
|
|
66 mime_encoding = Option('notification', 'mime_encoding', 'base64',
|
|
67 """Specifies the MIME encoding scheme for emails.
|
|
68
|
|
69 Valid options are 'base64' for Base64 encoding, 'qp' for
|
|
70 Quoted-Printable, and 'none' for no encoding. Note that the no encoding
|
|
71 means that non-ASCII characters in text are going to cause problems
|
|
72 with notifications (''since 0.10'').""")
|
|
73
|
|
74 use_public_cc = BoolOption('notification', 'use_public_cc', 'false',
|
|
75 """Recipients can see email addresses of other CC'ed recipients.
|
|
76
|
|
77 If this option is disabled (the default), recipients are put on BCC
|
|
78 (''since 0.10'').""")
|
|
79
|
|
80 use_short_addr = BoolOption('notification', 'use_short_addr', 'false',
|
|
81 """Permit email address without a host/domain (i.e. username only)
|
|
82
|
|
83 The SMTP server should accept those addresses, and either append
|
|
84 a FQDN or use local delivery (''since 0.10'').""")
|
|
85
|
|
86 use_tls = BoolOption('notification', 'use_tls', 'false',
|
|
87 """Use SSL/TLS to send notifications (''since 0.10'').""")
|
|
88
|
|
89
|
|
90 class Notify(object):
|
|
91 """Generic notification class for Trac.
|
|
92
|
|
93 Subclass this to implement different methods.
|
|
94 """
|
|
95
|
|
96 def __init__(self, env):
|
|
97 self.env = env
|
|
98 self.config = env.config
|
|
99 self.db = env.get_db_cnx()
|
|
100
|
|
101 loadpaths = Chrome(self.env).get_all_templates_dirs()
|
|
102 self.hdf = HDFWrapper(loadpaths)
|
|
103 populate_hdf(self.hdf, env)
|
|
104
|
|
105 def notify(self, resid):
|
|
106 (torcpts, ccrcpts) = self.get_recipients(resid)
|
|
107 self.begin_send()
|
|
108 self.send(torcpts, ccrcpts)
|
|
109 self.finish_send()
|
|
110
|
|
111 def get_recipients(self, resid):
|
|
112 """Return a pair of list of subscribers to the resource 'resid'.
|
|
113
|
|
114 First list represents the direct recipients (To:), second list
|
|
115 represents the recipients in carbon copy (Cc:).
|
|
116 """
|
|
117 raise NotImplementedError
|
|
118
|
|
119 def begin_send(self):
|
|
120 """Prepare to send messages.
|
|
121
|
|
122 Called before sending begins.
|
|
123 """
|
|
124
|
|
125 def send(self, torcpts, ccrcpts):
|
|
126 """Send message to recipients."""
|
|
127 raise NotImplementedError
|
|
128
|
|
129 def finish_send(self):
|
|
130 """Clean up after sending all messages.
|
|
131
|
|
132 Called after sending all messages.
|
|
133 """
|
|
134
|
|
135
|
|
136 class NotifyEmail(Notify):
|
|
137 """Baseclass for notification by email."""
|
|
138
|
|
139 smtp_server = 'localhost'
|
|
140 smtp_port = 25
|
|
141 from_email = 'trac+tickets@localhost'
|
|
142 subject = ''
|
|
143 server = None
|
|
144 email_map = None
|
|
145 template_name = None
|
|
146 addrfmt = r"[\w\d_\.\-\+=]+\@(([\w\d\-])+\.)+([\w\d]{2,4})+"
|
|
147 shortaddr_re = re.compile(addrfmt)
|
|
148 longaddr_re = re.compile(r"^\s*(.*)\s+<(" + addrfmt + ")>\s*$");
|
|
149 nodomaddr_re = re.compile(r"[\w\d_\.\-]+")
|
|
150 addrsep_re = re.compile(r"[;\s,]+")
|
|
151
|
|
152 def __init__(self, env):
|
|
153 Notify.__init__(self, env)
|
|
154
|
|
155 self._use_tls = self.env.config.getbool('notification', 'use_tls')
|
|
156 self._init_pref_encoding()
|
|
157 # Get the email addresses of all known users
|
|
158 self.email_map = {}
|
|
159 for username, name, email in self.env.get_known_users(self.db):
|
|
160 if email:
|
|
161 self.email_map[username] = email
|
|
162
|
|
163 def _init_pref_encoding(self):
|
|
164 from email.Charset import Charset, QP, BASE64
|
|
165 self._charset = Charset()
|
|
166 self._charset.input_charset = 'utf-8'
|
|
167 pref = self.env.config.get('notification', 'mime_encoding').lower()
|
|
168 if pref == 'base64':
|
|
169 self._charset.header_encoding = BASE64
|
|
170 self._charset.body_encoding = BASE64
|
|
171 self._charset.output_charset = 'utf-8'
|
|
172 self._charset.input_codec = 'utf-8'
|
|
173 self._charset.output_codec = 'utf-8'
|
|
174 elif pref in ['qp', 'quoted-printable']:
|
|
175 self._charset.header_encoding = QP
|
|
176 self._charset.body_encoding = QP
|
|
177 self._charset.output_charset = 'utf-8'
|
|
178 self._charset.input_codec = 'utf-8'
|
|
179 self._charset.output_codec = 'utf-8'
|
|
180 elif pref == 'none':
|
|
181 self._charset.header_encoding = None
|
|
182 self._charset.body_encoding = None
|
|
183 self._charset.input_codec = None
|
|
184 self._charset.output_charset = 'ascii'
|
|
185 else:
|
|
186 raise TracError, 'Invalid email encoding setting: %s' % pref
|
|
187
|
|
188 def notify(self, resid, subject):
|
|
189 self.subject = subject
|
|
190
|
|
191 if not self.config.getbool('notification', 'smtp_enabled'):
|
|
192 return
|
|
193 self.smtp_server = self.config['notification'].get('smtp_server')
|
|
194 self.smtp_port = self.config['notification'].getint('smtp_port')
|
|
195 self.from_email = self.config['notification'].get('smtp_from')
|
|
196 self.replyto_email = self.config['notification'].get('smtp_replyto')
|
|
197 self.from_email = self.from_email or self.replyto_email
|
|
198 if not self.from_email and not self.replyto_email:
|
|
199 raise TracError(Markup('Unable to send email due to identity '
|
|
200 'crisis.<p>Neither <b>notification.from</b> '
|
|
201 'nor <b>notification.reply_to</b> are '
|
|
202 'specified in the configuration.</p>'),
|
|
203 'SMTP Notification Error')
|
|
204
|
|
205 # Authentication info (optional)
|
|
206 self.user_name = self.config['notification'].get('smtp_user')
|
|
207 self.password = self.config['notification'].get('smtp_password')
|
|
208
|
|
209 Notify.notify(self, resid)
|
|
210
|
|
211 def format_header(self, key, name, email=None):
|
|
212 from email.Header import Header
|
|
213 maxlength = MAXHEADERLEN-(len(key)+2)
|
|
214 # Do not sent ridiculous short headers
|
|
215 if maxlength < 10:
|
|
216 raise TracError, "Header length is too short"
|
|
217 try:
|
|
218 tmp = name.encode('ascii')
|
|
219 header = Header(tmp, 'ascii', maxlinelen=maxlength)
|
|
220 except UnicodeEncodeError:
|
|
221 header = Header(name, self._charset, maxlinelen=maxlength)
|
|
222 if not email:
|
|
223 return header
|
|
224 else:
|
|
225 return "\"%s\" <%s>" % (header, email)
|
|
226
|
|
227 def add_headers(self, msg, headers):
|
|
228 for h in headers:
|
|
229 msg[h] = self.encode_header(h, headers[h])
|
|
230
|
|
231 def get_smtp_address(self, address):
|
|
232 if not address:
|
|
233 return None
|
|
234 if address.find('@') == -1:
|
|
235 if address == 'anonymous':
|
|
236 return None
|
|
237 if self.email_map.has_key(address):
|
|
238 address = self.email_map[address]
|
|
239 elif NotifyEmail.nodomaddr_re.match(address):
|
|
240 if self.config.getbool('notification', 'use_short_addr'):
|
|
241 return address
|
|
242 domain = self.config.get('notification', 'smtp_default_domain')
|
|
243 if domain:
|
|
244 address = "%s@%s" % (address, domain)
|
|
245 else:
|
|
246 self.env.log.info("Email address w/o domain: %s" % address)
|
|
247 return None
|
|
248 mo = NotifyEmail.shortaddr_re.search(address)
|
|
249 if mo:
|
|
250 return mo.group(0)
|
|
251 mo = NotifyEmail.longaddr_re.search(address)
|
|
252 if mo:
|
|
253 return mo.group(2)
|
|
254 self.env.log.info("Invalid email address: %s" % address)
|
|
255 return None
|
|
256
|
|
257 def encode_header(self, key, value):
|
|
258 if isinstance(value, tuple):
|
|
259 return self.format_header(key, value[0], value[1])
|
|
260 if isinstance(value, list):
|
|
261 items = []
|
|
262 for v in value:
|
|
263 items.append(self.encode_header(v))
|
|
264 return ',\n\t'.join(items)
|
|
265 mo = NotifyEmail.longaddr_re.match(value)
|
|
266 if mo:
|
|
267 return self.format_header(key, mo.group(1), mo.group(2))
|
|
268 return self.format_header(key, value)
|
|
269
|
|
270 def begin_send(self):
|
|
271 self.server = smtplib.SMTP(self.smtp_server, self.smtp_port)
|
|
272 # self.server.set_debuglevel(True)
|
|
273 if self._use_tls:
|
|
274 self.server.ehlo()
|
|
275 if not self.server.esmtp_features.has_key('starttls'):
|
|
276 raise TracError, "TLS enabled but server does not support TLS"
|
|
277 self.server.starttls()
|
|
278 self.server.ehlo()
|
|
279 if self.user_name:
|
|
280 self.server.login(self.user_name, self.password)
|
|
281
|
|
282 def send(self, torcpts, ccrcpts, mime_headers={}):
|
|
283 from email.MIMEText import MIMEText
|
|
284 from email.Utils import formatdate, formataddr
|
|
285 body = self.hdf.render(self.template_name)
|
|
286 projname = self.config.get('project', 'name')
|
|
287 public_cc = self.config.getbool('notification', 'use_public_cc')
|
|
288 headers = {}
|
|
289 headers['X-Mailer'] = 'Trac %s, by Edgewall Software' % __version__
|
|
290 headers['X-Trac-Version'] = __version__
|
|
291 headers['X-Trac-Project'] = projname
|
|
292 headers['X-URL'] = self.config.get('project', 'url')
|
|
293 headers['Subject'] = self.subject
|
|
294 headers['From'] = (projname, self.from_email)
|
|
295 headers['Sender'] = self.from_email
|
|
296 headers['Reply-To'] = self.replyto_email
|
|
297
|
|
298 def build_addresses(rcpts):
|
|
299 """Format and remove invalid addresses"""
|
|
300 return filter(lambda x: x, \
|
|
301 [self.get_smtp_address(addr) for addr in rcpts])
|
|
302
|
|
303 def remove_dup(rcpts, all):
|
|
304 """Remove duplicates"""
|
|
305 tmp = []
|
|
306 for rcpt in rcpts:
|
|
307 if not rcpt in all:
|
|
308 tmp.append(rcpt)
|
|
309 all.append(rcpt)
|
|
310 return (tmp, all)
|
|
311
|
|
312 toaddrs = build_addresses(torcpts)
|
|
313 ccaddrs = build_addresses(ccrcpts)
|
|
314 accparam = self.config.get('notification', 'smtp_always_cc')
|
|
315 accaddrs = accparam and \
|
|
316 build_addresses(accparam.replace(',', ' ').split()) or []
|
|
317 bccparam = self.config.get('notification', 'smtp_always_bcc')
|
|
318 bccaddrs = bccparam and \
|
|
319 build_addresses(bccparam.replace(',', ' ').split()) or []
|
|
320
|
|
321 recipients = []
|
|
322 (toaddrs, recipients) = remove_dup(toaddrs, recipients)
|
|
323 (ccaddrs, recipients) = remove_dup(ccaddrs, recipients)
|
|
324 (accaddrs, recipients) = remove_dup(accaddrs, recipients)
|
|
325 (bccaddrs, recipients) = remove_dup(bccaddrs, recipients)
|
|
326
|
|
327 # if there is not valid recipient, leave immediately
|
|
328 if len(recipients) < 1:
|
|
329 return
|
|
330
|
|
331 pcc = accaddrs
|
|
332 if public_cc:
|
|
333 pcc += ccaddrs
|
|
334 if toaddrs:
|
|
335 headers['To'] = ', '.join(toaddrs)
|
|
336 if pcc:
|
|
337 headers['Cc'] = ', '.join(pcc)
|
|
338 headers['Date'] = formatdate()
|
|
339 # sanity check
|
|
340 if not self._charset.body_encoding:
|
|
341 try:
|
|
342 dummy = body.encode('ascii')
|
|
343 except UnicodeDecodeError:
|
|
344 raise TracError, "Ticket contains non-Ascii chars. " \
|
|
345 "Please change encoding setting"
|
|
346 msg = MIMEText(body, 'plain')
|
|
347 # Message class computes the wrong type from MIMEText constructor,
|
|
348 # which does not take a Charset object as initializer. Reset the
|
|
349 # encoding type to force a new, valid evaluation
|
|
350 del msg['Content-Transfer-Encoding']
|
|
351 msg.set_charset(self._charset)
|
|
352 self.add_headers(msg, headers);
|
|
353 self.add_headers(msg, mime_headers);
|
|
354 self.env.log.debug("Sending SMTP notification to %s on port %d to %s"
|
|
355 % (self.smtp_server, self.smtp_port, recipients))
|
|
356 msgtext = msg.as_string()
|
|
357 # Ensure the message complies with RFC2822: use CRLF line endings
|
|
358 recrlf = re.compile("\r?\n")
|
|
359 msgtext = "\r\n".join(recrlf.split(msgtext))
|
|
360 self.server.sendmail(msg['From'], recipients, msgtext)
|
|
361
|
|
362 def finish_send(self):
|
|
363 if self._use_tls:
|
|
364 # avoid false failure detection when the server closes
|
|
365 # the SMTP connection with TLS enabled
|
|
366 import socket
|
|
367 try:
|
|
368 self.server.quit()
|
|
369 except socket.sslerror:
|
|
370 pass
|
|
371 else:
|
|
372 self.server.quit()
|