| 1 | from trac.core import Component, implements, ExtensionPoint |
|---|
| 2 | from trac.util.compat import set, sorted |
|---|
| 3 | from trac.config import Option, BoolOption, IntOption, OrderedExtensionsOption |
|---|
| 4 | from trac.util import get_pkginfo |
|---|
| 5 | from trac.util.translation import _ |
|---|
| 6 | |
|---|
| 7 | from announcerplugin.api import IAnnouncementDistributor |
|---|
| 8 | from announcerplugin.api import IAnnouncementFormatter |
|---|
| 9 | from announcerplugin.api import IAnnouncementPreferenceProvider |
|---|
| 10 | from announcerplugin.api import IAnnouncementAddressResolver |
|---|
| 11 | from announcerplugin.api import AnnouncementSystem |
|---|
| 12 | import announcerplugin, trac |
|---|
| 13 | |
|---|
| 14 | from email.MIMEMultipart import MIMEMultipart |
|---|
| 15 | from email.MIMEText import MIMEText |
|---|
| 16 | from email.Utils import formatdate |
|---|
| 17 | try: |
|---|
| 18 | from email.header import Header |
|---|
| 19 | except: |
|---|
| 20 | from email.Header import Header |
|---|
| 21 | import time, Queue, threading, smtplib |
|---|
| 22 | |
|---|
| 23 | class DeliveryThread(threading.Thread): |
|---|
| 24 | def __init__(self, queue, sender): |
|---|
| 25 | threading.Thread.__init__(self) |
|---|
| 26 | self._sender = sender |
|---|
| 27 | self._queue = queue |
|---|
| 28 | self.setDaemon(True) |
|---|
| 29 | |
|---|
| 30 | def run(self): |
|---|
| 31 | while 1: |
|---|
| 32 | sendfrom, recipients, message = self._queue.get() |
|---|
| 33 | |
|---|
| 34 | self._sender(sendfrom, recipients, message) |
|---|
| 35 | |
|---|
| 36 | class EmailDistributor(Component): |
|---|
| 37 | |
|---|
| 38 | implements(IAnnouncementDistributor, IAnnouncementPreferenceProvider) |
|---|
| 39 | |
|---|
| 40 | formatters = ExtensionPoint(IAnnouncementFormatter) |
|---|
| 41 | resolvers = OrderedExtensionsOption('announcer', 'email_address_resolvers', |
|---|
| 42 | IAnnouncementAddressResolver, 'SessionEmailResolver', |
|---|
| 43 | ) |
|---|
| 44 | |
|---|
| 45 | smtp_enabled = BoolOption('announcer', 'smtp_enabled', 'false', |
|---|
| 46 | """Enable SMTP (email) notification.""") |
|---|
| 47 | |
|---|
| 48 | smtp_server = Option('announcer', 'smtp_server', 'localhost', |
|---|
| 49 | """SMTP server hostname to use for email notifications.""") |
|---|
| 50 | |
|---|
| 51 | smtp_port = IntOption('announcer', 'smtp_port', 25, |
|---|
| 52 | """SMTP server port to use for email notification.""") |
|---|
| 53 | |
|---|
| 54 | smtp_user = Option('announcer', 'smtp_user', '', |
|---|
| 55 | """Username for SMTP server. (''since 0.9'').""") |
|---|
| 56 | |
|---|
| 57 | smtp_password = Option('announcer', 'smtp_password', '', |
|---|
| 58 | """Password for SMTP server. (''since 0.9'').""") |
|---|
| 59 | |
|---|
| 60 | smtp_from = Option('announcer', 'smtp_from', 'trac@localhost', |
|---|
| 61 | """Sender address to use in notification emails.""") |
|---|
| 62 | |
|---|
| 63 | smtp_from_name = Option('announcer', 'smtp_from_name', '', |
|---|
| 64 | """Sender name to use in notification emails.""") |
|---|
| 65 | |
|---|
| 66 | smtp_replyto = Option('announcer', 'smtp_replyto', 'trac@localhost', |
|---|
| 67 | """Reply-To address to use in notification emails.""") |
|---|
| 68 | |
|---|
| 69 | smtp_always_cc = Option('announcer', 'smtp_always_cc', '', |
|---|
| 70 | """Email address(es) to always send notifications to, |
|---|
| 71 | addresses can be see by all recipients (Cc:).""") |
|---|
| 72 | |
|---|
| 73 | smtp_always_bcc = Option('announcer', 'smtp_always_bcc', '', |
|---|
| 74 | """Email address(es) to always send notifications to, |
|---|
| 75 | addresses do not appear publicly (Bcc:). (''since 0.10'').""") |
|---|
| 76 | |
|---|
| 77 | ignore_domains = Option('announcer', 'ignore_domains', '', |
|---|
| 78 | """Comma-separated list of domains that should not be considered |
|---|
| 79 | part of email addresses (for usernames with Kerberos domains)""") |
|---|
| 80 | |
|---|
| 81 | admit_domains = Option('announcer', 'admit_domains', '', |
|---|
| 82 | """Comma-separated list of domains that should be considered as |
|---|
| 83 | valid for email addresses (such as localdomain)""") |
|---|
| 84 | |
|---|
| 85 | mime_encoding = Option('announcer', 'mime_encoding', 'base64', |
|---|
| 86 | """Specifies the MIME encoding scheme for emails. |
|---|
| 87 | |
|---|
| 88 | Valid options are 'base64' for Base64 encoding, 'qp' for |
|---|
| 89 | Quoted-Printable, and 'none' for no encoding. Note that the no encoding |
|---|
| 90 | means that non-ASCII characters in text are going to cause problems |
|---|
| 91 | with notifications (''since 0.10'').""") |
|---|
| 92 | |
|---|
| 93 | use_public_cc = BoolOption('announcer', 'use_public_cc', 'false', |
|---|
| 94 | """Recipients can see email addresses of other CC'ed recipients. |
|---|
| 95 | |
|---|
| 96 | If this option is disabled (the default), recipients are put on BCC |
|---|
| 97 | (''since 0.10'').""") |
|---|
| 98 | |
|---|
| 99 | use_short_addr = BoolOption('announcer', 'use_short_addr', 'false', |
|---|
| 100 | """Permit email address without a host/domain (i.e. username only) |
|---|
| 101 | |
|---|
| 102 | The SMTP server should accept those addresses, and either append |
|---|
| 103 | a FQDN or use local delivery (''since 0.10'').""") |
|---|
| 104 | |
|---|
| 105 | use_tls = BoolOption('announcer', 'use_tls', 'false', |
|---|
| 106 | """Use SSL/TLS to send notifications (''since 0.10'').""") |
|---|
| 107 | |
|---|
| 108 | smtp_subject_prefix = Option('announcer', 'smtp_subject_prefix', |
|---|
| 109 | '__default__', |
|---|
| 110 | """Text to prepend to subject line of notification emails. |
|---|
| 111 | |
|---|
| 112 | If the setting is not defined, then the [$project_name] prefix. |
|---|
| 113 | If no prefix is desired, then specifying an empty option |
|---|
| 114 | will disable it.(''since 0.10.1'').""") |
|---|
| 115 | smtp_to = Option('announcer', 'smtp_to', None, 'Default To: field') |
|---|
| 116 | |
|---|
| 117 | use_threaded_delivery = BoolOption('announcer', 'use_threaded_delivery', False, |
|---|
| 118 | """If true, the actual delivery of the message will occur in a separate thread. |
|---|
| 119 | |
|---|
| 120 | Enabling this will improve responsiveness for requests that end up with an |
|---|
| 121 | announcement being sent over email. It requires building Python with threading |
|---|
| 122 | support enabled-- which is usually the case. To test, start Python and type |
|---|
| 123 | 'import threading' to see if it raises an error.""") |
|---|
| 124 | |
|---|
| 125 | default_email_format = Option('announcer', 'default_email_format', 'text/plain') |
|---|
| 126 | |
|---|
| 127 | def __init__(self): |
|---|
| 128 | self.delivery_queue = None |
|---|
| 129 | |
|---|
| 130 | def get_delivery_queue(self): |
|---|
| 131 | if not self.delivery_queue: |
|---|
| 132 | self.delivery_queue = Queue.Queue() |
|---|
| 133 | thread = DeliveryThread(self.delivery_queue, self._transmit) |
|---|
| 134 | thread.start() |
|---|
| 135 | |
|---|
| 136 | return self.delivery_queue |
|---|
| 137 | |
|---|
| 138 | # IAnnouncementDistributor |
|---|
| 139 | def get_distribution_transport(self): |
|---|
| 140 | return "email" |
|---|
| 141 | |
|---|
| 142 | def distribute(self, transport, recipients, event): |
|---|
| 143 | if not self.smtp_enabled: |
|---|
| 144 | return |
|---|
| 145 | public_cc = self.config.getbool('announcer', 'use_public_cc') |
|---|
| 146 | to = self.config.get('announcer', 'smtp_to') |
|---|
| 147 | if transport == self.get_distribution_transport(): |
|---|
| 148 | formats = {} |
|---|
| 149 | |
|---|
| 150 | for f in self.formatters: |
|---|
| 151 | if f.get_format_transport() == transport: |
|---|
| 152 | if event.realm in f.get_format_realms(transport): |
|---|
| 153 | styles = f.get_format_styles(transport, event.realm) |
|---|
| 154 | for style in styles: |
|---|
| 155 | formats[style] = f |
|---|
| 156 | |
|---|
| 157 | self.log.debug( |
|---|
| 158 | "EmailDistributor has found the following formats capable " |
|---|
| 159 | "of handling '%s' of '%s': %s" % ( |
|---|
| 160 | transport, event.realm, ', '.join(formats.keys()) |
|---|
| 161 | ) |
|---|
| 162 | ) |
|---|
| 163 | |
|---|
| 164 | if not formats: |
|---|
| 165 | self.log.error( |
|---|
| 166 | "EmailDistributor is unable to continue without supporting formatters." |
|---|
| 167 | ) |
|---|
| 168 | return |
|---|
| 169 | |
|---|
| 170 | messages = {} |
|---|
| 171 | |
|---|
| 172 | for name, authenticated, address in recipients: |
|---|
| 173 | if name: |
|---|
| 174 | format = self._get_preferred_format(event.realm, name, authenticated) |
|---|
| 175 | else: |
|---|
| 176 | format = self._get_default_format() |
|---|
| 177 | |
|---|
| 178 | if format not in messages: |
|---|
| 179 | messages[format] = set() |
|---|
| 180 | |
|---|
| 181 | if name and not address: |
|---|
| 182 | for resolver in self.resolvers: |
|---|
| 183 | address = resolver.get_address_for_name(name, authenticated) |
|---|
| 184 | if address: |
|---|
| 185 | self.log.debug("EmailDistributor found the address '%s' for '%s (%s)' via: %s" % ( |
|---|
| 186 | address, name, authenticated and 'authenticated' or 'not authenticated', |
|---|
| 187 | resolver.__class__.__name__ |
|---|
| 188 | ) |
|---|
| 189 | ) |
|---|
| 190 | break |
|---|
| 191 | |
|---|
| 192 | if address: |
|---|
| 193 | messages[format].add((name, authenticated, address)) |
|---|
| 194 | else: |
|---|
| 195 | self.log.debug("EmailDistributor was unable to find an address for: %s (%s)" % ( |
|---|
| 196 | name, authenticated and 'authenticated' or 'not authenticated' |
|---|
| 197 | ) |
|---|
| 198 | ) |
|---|
| 199 | |
|---|
| 200 | for format in messages.keys(): |
|---|
| 201 | if messages[format]: |
|---|
| 202 | self.log.debug( |
|---|
| 203 | "EmailDistributor is sending event as '%s' to: %s" % ( |
|---|
| 204 | format, ', '.join(x[2] for x in messages[format]) |
|---|
| 205 | ) |
|---|
| 206 | ) |
|---|
| 207 | self._do_send(transport, event, format, messages[format], formats[format], None, to, public_cc) |
|---|
| 208 | |
|---|
| 209 | def _get_default_format(self): |
|---|
| 210 | return self.default_email_format |
|---|
| 211 | |
|---|
| 212 | def _get_preferred_format(self, realm, sid, authenticated): |
|---|
| 213 | db = self.env.get_db_cnx() |
|---|
| 214 | cursor = db.cursor() |
|---|
| 215 | |
|---|
| 216 | cursor.execute(""" |
|---|
| 217 | SELECT value |
|---|
| 218 | FROM session_attribute |
|---|
| 219 | WHERE sid=%s |
|---|
| 220 | AND authenticated=%s |
|---|
| 221 | AND name=%s |
|---|
| 222 | """, (sid, int(authenticated), 'announcer_email_format_%s' % realm)) |
|---|
| 223 | |
|---|
| 224 | result = cursor.fetchone() |
|---|
| 225 | if result: |
|---|
| 226 | chosen = result[0] |
|---|
| 227 | self.log.debug("EmailDistributor determined the preferred format for '%s (%s)' is: %s" % ( |
|---|
| 228 | sid, authenticated and 'authenticated' or 'not authenticated', chosen |
|---|
| 229 | ) |
|---|
| 230 | ) |
|---|
| 231 | return chosen |
|---|
| 232 | else: |
|---|
| 233 | return self._get_default_format() |
|---|
| 234 | |
|---|
| 235 | def _do_send(self, transport, event, format, recipients, formatter, backup=None, to=None, public_cc=False): |
|---|
| 236 | output = formatter.format(transport, event.realm, format, event) |
|---|
| 237 | subject = formatter.format_subject(transport, event.realm, format, event) |
|---|
| 238 | |
|---|
| 239 | charset = self.env.config.get('trac', 'default_charset', 'utf-8') |
|---|
| 240 | alternate_format = formatter.get_format_alternative(transport, event.realm, format) |
|---|
| 241 | if alternate_format: |
|---|
| 242 | alternate_output = formatter.format(transport, event.realm, alternate_format, event) |
|---|
| 243 | else: |
|---|
| 244 | alternate_output = None |
|---|
| 245 | |
|---|
| 246 | rootMessage = MIMEMultipart("related") |
|---|
| 247 | trac_version = get_pkginfo(trac.core).get('version', trac.__version__) |
|---|
| 248 | announcer_version = get_pkginfo(announcerplugin).get('version', 'Undefined') |
|---|
| 249 | |
|---|
| 250 | rootMessage['X-Mailer'] = 'AnnouncerPlugin v%s on Trac v%s' % (announcer_version, trac_version) |
|---|
| 251 | rootMessage['X-Trac-Version'] = trac_version |
|---|
| 252 | rootMessage['X-Announcer-Version'] = announcer_version |
|---|
| 253 | rootMessage['X-Trac-Project'] = self.env.project_name |
|---|
| 254 | rootMessage['Precedence'] = 'bulk' |
|---|
| 255 | rootMessage['Auto-Submitted'] = 'auto-generated' |
|---|
| 256 | |
|---|
| 257 | provided_headers = formatter.format_headers(transport, event.realm, format, event) |
|---|
| 258 | for key in provided_headers: |
|---|
| 259 | rootMessage['X-Announcement-%s' % key.capitalize()] = str(provided_headers[key]) |
|---|
| 260 | |
|---|
| 261 | rootMessage['Date'] = formatdate() |
|---|
| 262 | rootMessage['Subject'] = Header(subject, charset) |
|---|
| 263 | rootMessage['From'] = self.smtp_from |
|---|
| 264 | if to: |
|---|
| 265 | rootMessage['To'] = '"%s"'%(to) |
|---|
| 266 | if public_cc: |
|---|
| 267 | rootMessage['Cc'] = ', '.join([x[2] for x in recipients if x]) |
|---|
| 268 | rootMessage['Reply-To'] = self.smtp_replyto |
|---|
| 269 | rootMessage.preamble = 'This is a multi-part message in MIME format.' |
|---|
| 270 | |
|---|
| 271 | if alternate_output: |
|---|
| 272 | parentMessage = MIMEMultipart('alternative') |
|---|
| 273 | rootMessage.attach(parentMessage) |
|---|
| 274 | else: |
|---|
| 275 | parentMessage = rootMessage |
|---|
| 276 | |
|---|
| 277 | if alternate_output: |
|---|
| 278 | msgText = MIMEText(alternate_output, 'html' in alternate_format and 'html' or 'plain', str(charset)) |
|---|
| 279 | parentMessage.attach(msgText) |
|---|
| 280 | |
|---|
| 281 | msgText = MIMEText(output, 'html' in format and 'html' or 'plain', str(charset)) |
|---|
| 282 | parentMessage.attach(msgText) |
|---|
| 283 | |
|---|
| 284 | start = time.time() |
|---|
| 285 | |
|---|
| 286 | package = (self.smtp_from, [x[2] for x in recipients if x], rootMessage.as_string() ) |
|---|
| 287 | if self.use_threaded_delivery: |
|---|
| 288 | self.get_delivery_queue().put(package) |
|---|
| 289 | else: |
|---|
| 290 | self._transmit(*package) |
|---|
| 291 | |
|---|
| 292 | stop = time.time() |
|---|
| 293 | self.log.debug("EmailDistributor took %s seconds to send." % (round(stop-start,2))) |
|---|
| 294 | |
|---|
| 295 | def _transmit(self, smtpfrom, addresses, message): |
|---|
| 296 | smtp = smtplib.SMTP(self.smtp_server, self.smtp_port) |
|---|
| 297 | if self.use_tls: |
|---|
| 298 | smtp.ehlo() |
|---|
| 299 | if not smtp.esmtp_features.has_key('starttls'): |
|---|
| 300 | raise TracError(_("TLS enabled but server does not support " \ |
|---|
| 301 | "TLS")) |
|---|
| 302 | smtp.starttls() |
|---|
| 303 | smtp.ehlo() |
|---|
| 304 | if self.smtp_user: |
|---|
| 305 | smtp.login(self.smtp_user, self.smtp_password) |
|---|
| 306 | smtp.sendmail(smtpfrom, addresses, message) |
|---|
| 307 | smtp.quit() |
|---|
| 308 | |
|---|
| 309 | # IAnnouncementDistributor |
|---|
| 310 | def get_announcement_preference_boxes(self, req): |
|---|
| 311 | yield "email", "E-Mail Format" |
|---|
| 312 | |
|---|
| 313 | def render_announcement_preference_box(self, req, panel): |
|---|
| 314 | cfg = self.config |
|---|
| 315 | sess = req.session |
|---|
| 316 | transport = self.get_distribution_transport() |
|---|
| 317 | |
|---|
| 318 | supported_realms = {} |
|---|
| 319 | for formatter in self.formatters: |
|---|
| 320 | if formatter.get_format_transport() == transport: |
|---|
| 321 | for realm in formatter.get_format_realms(transport): |
|---|
| 322 | if realm not in supported_realms: |
|---|
| 323 | supported_realms[realm] = set() |
|---|
| 324 | |
|---|
| 325 | supported_realms[realm].update( |
|---|
| 326 | formatter.get_format_styles(transport, realm) |
|---|
| 327 | ) |
|---|
| 328 | |
|---|
| 329 | |
|---|
| 330 | if req.method == "POST": |
|---|
| 331 | for realm in supported_realms: |
|---|
| 332 | opt = req.args.get('email_format_%s' % realm, False) |
|---|
| 333 | if opt: |
|---|
| 334 | sess['announcer_email_format_%s' % realm] = opt |
|---|
| 335 | |
|---|
| 336 | prefs = {} |
|---|
| 337 | for realm in supported_realms: |
|---|
| 338 | prefs[realm] = sess.get('announcer_email_format_%s' % realm, None) |
|---|
| 339 | |
|---|
| 340 | data = dict( |
|---|
| 341 | realms = supported_realms, |
|---|
| 342 | preferences = prefs, |
|---|
| 343 | ) |
|---|
| 344 | |
|---|
| 345 | return "prefs_announcer_email.html", data |
|---|