| 1 |
# -*- coding: utf-8 -*- |
|---|
| 2 |
# |
|---|
| 3 |
# Copyright (c) 2008, Stephen Hansen |
|---|
| 4 |
# Copyright (c) 2009, Robert Corsaro |
|---|
| 5 |
# Copyright (c) 2010, Steffen Hoffmann |
|---|
| 6 |
# |
|---|
| 7 |
# All rights reserved. |
|---|
| 8 |
# |
|---|
| 9 |
# Redistribution and use in source and binary forms, with or without |
|---|
| 10 |
# modification, are permitted provided that the following conditions are met: |
|---|
| 11 |
# |
|---|
| 12 |
# * Redistributions of source code must retain the above copyright |
|---|
| 13 |
# notice, this list of conditions and the following disclaimer. |
|---|
| 14 |
# * Redistributions in binary form must reproduce the above copyright |
|---|
| 15 |
# notice, this list of conditions and the following disclaimer in the |
|---|
| 16 |
# documentation and/or other materials provided with the distribution. |
|---|
| 17 |
# * Neither the name of the <ORGANIZATION> nor the names of its |
|---|
| 18 |
# contributors may be used to endorse or promote products derived from |
|---|
| 19 |
# this software without specific prior written permission. |
|---|
| 20 |
# |
|---|
| 21 |
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
|---|
| 22 |
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
|---|
| 23 |
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
|---|
| 24 |
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR |
|---|
| 25 |
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, |
|---|
| 26 |
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, |
|---|
| 27 |
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR |
|---|
| 28 |
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF |
|---|
| 29 |
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING |
|---|
| 30 |
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
|---|
| 31 |
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
|---|
| 32 |
# ---------------------------------------------------------------------------- |
|---|
| 33 |
|
|---|
| 34 |
# TODO: pick format based on subscription. For now users will use the same |
|---|
| 35 |
# format for all announcements, but in the future we can make this more |
|---|
| 36 |
# flexible, since it's in the subscription table. |
|---|
| 37 |
import Queue |
|---|
| 38 |
import random |
|---|
| 39 |
import re |
|---|
| 40 |
import smtplib |
|---|
| 41 |
import sys |
|---|
| 42 |
import threading |
|---|
| 43 |
import time |
|---|
| 44 |
|
|---|
| 45 |
from subprocess import Popen, PIPE |
|---|
| 46 |
|
|---|
| 47 |
from email.MIMEMultipart import MIMEMultipart |
|---|
| 48 |
from email.MIMEText import MIMEText |
|---|
| 49 |
from email.Utils import formatdate, formataddr |
|---|
| 50 |
from email.Charset import Charset, QP, BASE64 |
|---|
| 51 |
try: |
|---|
| 52 |
from email.header import Header |
|---|
| 53 |
except: |
|---|
| 54 |
from email.Header import Header |
|---|
| 55 |
|
|---|
| 56 |
from trac.core import * |
|---|
| 57 |
from trac.util.compat import set, sorted |
|---|
| 58 |
from trac.config import Option, BoolOption, IntOption, \ |
|---|
| 59 |
OrderedExtensionsOption, ChoiceOption |
|---|
| 60 |
from trac.config import ExtensionOption |
|---|
| 61 |
from trac.util import get_pkginfo, md5 |
|---|
| 62 |
from trac.util.datefmt import to_timestamp |
|---|
| 63 |
from trac.util.text import to_unicode, CRLF |
|---|
| 64 |
|
|---|
| 65 |
from announcer.api import AnnouncementSystem |
|---|
| 66 |
from announcer.api import IAnnouncementAddressResolver |
|---|
| 67 |
from announcer.api import IAnnouncementDistributor |
|---|
| 68 |
from announcer.api import IAnnouncementFormatter |
|---|
| 69 |
from announcer.api import IAnnouncementPreferenceProvider |
|---|
| 70 |
from announcer.api import IAnnouncementProducer |
|---|
| 71 |
from announcer.api import _ |
|---|
| 72 |
|
|---|
| 73 |
from announcer.util.mail import set_header |
|---|
| 74 |
from announcer.util.mail_crypto import CryptoTxt |
|---|
| 75 |
|
|---|
| 76 |
|
|---|
| 77 |
class IEmailSender(Interface): |
|---|
| 78 |
"""Extension point interface for components that allow sending e-mail.""" |
|---|
| 79 |
|
|---|
| 80 |
def send(self, from_addr, recipients, message): |
|---|
| 81 |
"""Send message to recipients.""" |
|---|
| 82 |
|
|---|
| 83 |
|
|---|
| 84 |
class IAnnouncementEmailDecorator(Interface): |
|---|
| 85 |
def decorate_message(event, message, decorators): |
|---|
| 86 |
"""Manipulate the message before it is sent on it's way. The callee |
|---|
| 87 |
should call the next decorator by popping decorators and calling the |
|---|
| 88 |
popped decorator. If decorators is empty, don't worry about it. |
|---|
| 89 |
""" |
|---|
| 90 |
|
|---|
| 91 |
|
|---|
| 92 |
class EmailDistributor(Component): |
|---|
| 93 |
|
|---|
| 94 |
implements(IAnnouncementDistributor) |
|---|
| 95 |
|
|---|
| 96 |
formatters = ExtensionPoint(IAnnouncementFormatter) |
|---|
| 97 |
# Make ordered |
|---|
| 98 |
decorators = ExtensionPoint(IAnnouncementEmailDecorator) |
|---|
| 99 |
|
|---|
| 100 |
resolvers = OrderedExtensionsOption('announcer', 'email_address_resolvers', |
|---|
| 101 |
IAnnouncementAddressResolver, 'SpecifiedEmailResolver, '\ |
|---|
| 102 |
'SessionEmailResolver, DefaultDomainEmailResolver', |
|---|
| 103 |
"""Comma seperated list of email resolver components in the order |
|---|
| 104 |
they will be called. If an email address is resolved, the remaining |
|---|
| 105 |
resolvers will not be called. |
|---|
| 106 |
""") |
|---|
| 107 |
|
|---|
| 108 |
email_sender = ExtensionOption('announcer', 'email_sender', |
|---|
| 109 |
IEmailSender, 'SmtpEmailSender', |
|---|
| 110 |
"""Name of the component implementing `IEmailSender`. |
|---|
| 111 |
|
|---|
| 112 |
This component is used by the announcer system to send emails. |
|---|
| 113 |
Currently, `SmtpEmailSender` and `SendmailEmailSender` are provided. |
|---|
| 114 |
""") |
|---|
| 115 |
|
|---|
| 116 |
enabled = BoolOption('announcer', 'email_enabled', 'true', |
|---|
| 117 |
"""Enable email notification.""") |
|---|
| 118 |
|
|---|
| 119 |
email_from = Option('announcer', 'email_from', 'trac@localhost', |
|---|
| 120 |
"""Sender address to use in notification emails.""") |
|---|
| 121 |
|
|---|
| 122 |
from_name = Option('announcer', 'email_from_name', '', |
|---|
| 123 |
"""Sender name to use in notification emails.""") |
|---|
| 124 |
|
|---|
| 125 |
replyto = Option('announcer', 'email_replyto', 'trac@localhost', |
|---|
| 126 |
"""Reply-To address to use in notification emails.""") |
|---|
| 127 |
|
|---|
| 128 |
mime_encoding = ChoiceOption('announcer', 'mime_encoding', |
|---|
| 129 |
['base64', 'qp', 'none'], |
|---|
| 130 |
"""Specifies the MIME encoding scheme for emails. |
|---|
| 131 |
|
|---|
| 132 |
Valid options are 'base64' for Base64 encoding, 'qp' for |
|---|
| 133 |
Quoted-Printable, and 'none' for no encoding. Note that the no encoding |
|---|
| 134 |
means that non-ASCII characters in text are going to cause problems |
|---|
| 135 |
with notifications. |
|---|
| 136 |
""") |
|---|
| 137 |
|
|---|
| 138 |
use_public_cc = BoolOption('announcer', 'use_public_cc', 'false', |
|---|
| 139 |
"""Recipients can see email addresses of other CC'ed recipients. |
|---|
| 140 |
|
|---|
| 141 |
If this option is disabled (the default), recipients are put on BCC |
|---|
| 142 |
""") |
|---|
| 143 |
|
|---|
| 144 |
# used in email decorators, but not here |
|---|
| 145 |
subject_prefix = Option('announcer', 'email_subject_prefix', |
|---|
| 146 |
'__default__', |
|---|
| 147 |
"""Text to prepend to subject line of notification emails. |
|---|
| 148 |
|
|---|
| 149 |
If the setting is not defined, then the [$project_name] prefix. |
|---|
| 150 |
If no prefix is desired, then specifying an empty option |
|---|
| 151 |
will disable it. |
|---|
| 152 |
""") |
|---|
| 153 |
|
|---|
| 154 |
to = Option('announcer', 'email_to', 'undisclosed-recipients: ;', |
|---|
| 155 |
'Default To: field') |
|---|
| 156 |
|
|---|
| 157 |
use_threaded_delivery = BoolOption('announcer', 'use_threaded_delivery', |
|---|
| 158 |
'false', |
|---|
| 159 |
"""Do message delivery in a separate thread. |
|---|
| 160 |
|
|---|
| 161 |
Enabling this will improve responsiveness for requests that end up |
|---|
| 162 |
with an announcement being sent over email. It requires building |
|---|
| 163 |
Python with threading support enabled-- which is usually the case. |
|---|
| 164 |
To test, start Python and type 'import threading' to see |
|---|
| 165 |
if it raises an error. |
|---|
| 166 |
""") |
|---|
| 167 |
|
|---|
| 168 |
default_email_format = Option('announcer', 'default_email_format', |
|---|
| 169 |
'text/plain', |
|---|
| 170 |
"""The default mime type of the email notifications. |
|---|
| 171 |
|
|---|
| 172 |
This can be overridden on a per user basis through the announcer |
|---|
| 173 |
preferences panel. |
|---|
| 174 |
""") |
|---|
| 175 |
|
|---|
| 176 |
rcpt_allow_regexp = Option('announcer', 'rcpt_allow_regexp', '', |
|---|
| 177 |
"""A whitelist pattern to match any address to before adding to |
|---|
| 178 |
recipients list. |
|---|
| 179 |
""") |
|---|
| 180 |
|
|---|
| 181 |
rcpt_local_regexp = Option('announcer', 'rcpt_local_regexp', '', |
|---|
| 182 |
"""A whitelist pattern to match any address, that should be |
|---|
| 183 |
considered local. |
|---|
| 184 |
|
|---|
| 185 |
This will be evaluated only if msg encryption is set too. |
|---|
| 186 |
Recipients with matching email addresses will continue to |
|---|
| 187 |
receive unencrypted email messages. |
|---|
| 188 |
""") |
|---|
| 189 |
|
|---|
| 190 |
crypto = Option('announcer', 'email_crypto', '', |
|---|
| 191 |
"""Enable cryptographically operation on email msg body. |
|---|
| 192 |
|
|---|
| 193 |
Empty string, the default for unset, disables all crypto operations. |
|---|
| 194 |
Valid values are: |
|---|
| 195 |
sign sign msg body with given privkey |
|---|
| 196 |
encrypt encrypt msg body with pubkeys of all recipients |
|---|
| 197 |
sign,encrypt sign, than encrypt msg body |
|---|
| 198 |
""") |
|---|
| 199 |
|
|---|
| 200 |
# get GnuPG configuration options |
|---|
| 201 |
gpg_binary = Option('announcer', 'gpg_binary', 'gpg', |
|---|
| 202 |
"""GnuPG binary name, allows for full path too. |
|---|
| 203 |
|
|---|
| 204 |
Value 'gpg' is same default as in python-gnupg itself. |
|---|
| 205 |
For usual installations location of the gpg binary is auto-detected. |
|---|
| 206 |
""") |
|---|
| 207 |
|
|---|
| 208 |
gpg_home = Option('announcer', 'gpg_home', '', |
|---|
| 209 |
"""Directory containing keyring files. |
|---|
| 210 |
|
|---|
| 211 |
In case of wrong configuration missing keyring files without content |
|---|
| 212 |
will be created in the configured location, provided necessary |
|---|
| 213 |
write permssion is granted for the corresponding parent directory. |
|---|
| 214 |
""") |
|---|
| 215 |
|
|---|
| 216 |
private_key = Option('announcer', 'gpg_signing_key', None, |
|---|
| 217 |
"""Keyid of private key (last 8 chars or more) used for signing. |
|---|
| 218 |
|
|---|
| 219 |
If unset, a private key will be selected from keyring automagicly. |
|---|
| 220 |
The password must be available i.e. provided by running gpg-agent |
|---|
| 221 |
or empty (bad security). On failing to unlock the private key, |
|---|
| 222 |
msg body will get emptied. |
|---|
| 223 |
""") |
|---|
| 224 |
|
|---|
| 225 |
|
|---|
| 226 |
def __init__(self): |
|---|
| 227 |
self.delivery_queue = None |
|---|
| 228 |
self._init_pref_encoding() |
|---|
| 229 |
|
|---|
| 230 |
def get_delivery_queue(self): |
|---|
| 231 |
if not self.delivery_queue: |
|---|
| 232 |
self.delivery_queue = Queue.Queue() |
|---|
| 233 |
thread = DeliveryThread(self.delivery_queue, self.send) |
|---|
| 234 |
thread.start() |
|---|
| 235 |
return self.delivery_queue |
|---|
| 236 |
|
|---|
| 237 |
# IAnnouncementDistributor |
|---|
| 238 |
def transports(self): |
|---|
| 239 |
yield "email" |
|---|
| 240 |
|
|---|
| 241 |
def formats(self, transport, realm): |
|---|
| 242 |
"Find valid formats for transport and realm" |
|---|
| 243 |
formats = {} |
|---|
| 244 |
for f in self.formatters: |
|---|
| 245 |
for style in f.styles(transport, realm): |
|---|
| 246 |
formats[style] = f |
|---|
| 247 |
self.log.debug( |
|---|
| 248 |
"EmailDistributor has found the following formats capable " |
|---|
| 249 |
"of handling '%s' of '%s': %s"%(transport, realm, |
|---|
| 250 |
', '.join(formats.keys()))) |
|---|
| 251 |
if not formats: |
|---|
| 252 |
self.log.error("EmailDistributor is unable to continue " \ |
|---|
| 253 |
"without supporting formatters.") |
|---|
| 254 |
return formats |
|---|
| 255 |
|
|---|
| 256 |
def distribute(self, transport, recipients, event): |
|---|
| 257 |
found = False |
|---|
| 258 |
for supported_transport in self.transports(): |
|---|
| 259 |
if supported_transport == transport: |
|---|
| 260 |
found = True |
|---|
| 261 |
if not self.enabled or not found: |
|---|
| 262 |
self.log.debug("EmailDistributer email_enabled set to false") |
|---|
| 263 |
return |
|---|
| 264 |
fmtdict = self.formats(transport, event.realm) |
|---|
| 265 |
if not fmtdict: |
|---|
| 266 |
self.log.error( |
|---|
| 267 |
"EmailDistributer No formats found for %s %s"%( |
|---|
| 268 |
transport, event.realm)) |
|---|
| 269 |
return |
|---|
| 270 |
msgdict = {} |
|---|
| 271 |
msgdict_encrypt = {} |
|---|
| 272 |
msg_pubkey_ids = [] |
|---|
| 273 |
# compile pattern before use for better performance |
|---|
| 274 |
RCPT_ALLOW_RE = re.compile(self.rcpt_allow_regexp) |
|---|
| 275 |
RCPT_LOCAL_RE = re.compile(self.rcpt_local_regexp) |
|---|
| 276 |
|
|---|
| 277 |
if self.crypto != '': |
|---|
| 278 |
self.log.debug("EmailDistributor attempts crypto operation.") |
|---|
| 279 |
self.enigma = CryptoTxt(self.gpg_binary, self.gpg_home) |
|---|
| 280 |
|
|---|
| 281 |
for name, authed, addr in recipients: |
|---|
| 282 |
fmt = name and \ |
|---|
| 283 |
self._get_preferred_format(event.realm, name, authed) or \ |
|---|
| 284 |
self._get_default_format() |
|---|
| 285 |
if fmt not in fmtdict: |
|---|
| 286 |
self.log.debug(("EmailDistributer format %s not available " + |
|---|
| 287 |
"for %s %s, looking for an alternative")%( |
|---|
| 288 |
fmt, transport, event.realm)) |
|---|
| 289 |
# If the fmt is not available for this realm, then try to find |
|---|
| 290 |
# an alternative |
|---|
| 291 |
oldfmt = fmt |
|---|
| 292 |
fmt = None |
|---|
| 293 |
for f in fmtdict.values(): |
|---|
| 294 |
fmt = f.alternative_style_for( |
|---|
| 295 |
transport, event.realm, oldfmt) |
|---|
| 296 |
if fmt: break |
|---|
| 297 |
if not fmt: |
|---|
| 298 |
self.log.error( |
|---|
| 299 |
"EmailDistributer was unable to find a formatter " + |
|---|
| 300 |
"for format %s"%k |
|---|
| 301 |
) |
|---|
| 302 |
continue |
|---|
| 303 |
rslvr = None |
|---|
| 304 |
if name and not addr: |
|---|
| 305 |
# figure out what the addr should be if it's not defined |
|---|
| 306 |
for rslvr in self.resolvers: |
|---|
| 307 |
addr = rslvr.get_address_for_name(name, authed) |
|---|
| 308 |
if addr: break |
|---|
| 309 |
if addr: |
|---|
| 310 |
self.log.debug("EmailDistributor found the " \ |
|---|
| 311 |
"address '%s' for '%s (%s)' via: %s"%( |
|---|
| 312 |
addr, name, authed and \ |
|---|
| 313 |
'authenticated' or 'not authenticated', |
|---|
| 314 |
rslvr.__class__.__name__)) |
|---|
| 315 |
|
|---|
| 316 |
# ok, we found an addr, add the message |
|---|
| 317 |
# but wait, check for allowed rcpt first, if set |
|---|
| 318 |
if RCPT_ALLOW_RE.search(addr) is not None: |
|---|
| 319 |
# check for local recipients now |
|---|
| 320 |
local_match = RCPT_LOCAL_RE.search(addr) |
|---|
| 321 |
if self.crypto in ['encrypt', 'sign,encrypt'] and \ |
|---|
| 322 |
local_match is None: |
|---|
| 323 |
# search available public keys for matching UID |
|---|
| 324 |
pubkey_ids = self.enigma.get_pubkey_ids(addr) |
|---|
| 325 |
if len(pubkey_ids) > 0: |
|---|
| 326 |
msgdict_encrypt.setdefault(fmt, set()).add((name, |
|---|
| 327 |
authed, addr)) |
|---|
| 328 |
msg_pubkey_ids[len(msg_pubkey_ids):] = pubkey_ids |
|---|
| 329 |
self.log.debug("EmailDistributor got pubkeys " \ |
|---|
| 330 |
"for %s: %s" % (addr, pubkey_ids)) |
|---|
| 331 |
else: |
|---|
| 332 |
self.log.debug("EmailDistributor dropped %s " \ |
|---|
| 333 |
"after missing pubkey with corresponding " \ |
|---|
| 334 |
"address %s in any UID" % (name, addr)) |
|---|
| 335 |
else: |
|---|
| 336 |
msgdict.setdefault(fmt, set()).add((name, authed, |
|---|
| 337 |
addr)) |
|---|
| 338 |
if local_match is not None: |
|---|
| 339 |
self.log.debug("EmailDistributor expected " \ |
|---|
| 340 |
"local delivery for %s to: %s" % (name, addr)) |
|---|
| 341 |
else: |
|---|
| 342 |
self.log.debug("EmailDistributor dropped %s for " \ |
|---|
| 343 |
"not matching allowed recipient pattern %s" % \ |
|---|
| 344 |
(addr, self.rcpt_allow_regexp)) |
|---|
| 345 |
else: |
|---|
| 346 |
self.log.debug("EmailDistributor was unable to find an " \ |
|---|
| 347 |
"address for: %s (%s)"%(name, authed and \ |
|---|
| 348 |
'authenticated' or 'not authenticated')) |
|---|
| 349 |
for k, v in msgdict.items(): |
|---|
| 350 |
if not v or not fmtdict.get(k): |
|---|
| 351 |
continue |
|---|
| 352 |
self.log.debug( |
|---|
| 353 |
"EmailDistributor is sending event as '%s' to: %s"%( |
|---|
| 354 |
fmt, ', '.join(x[2] for x in v))) |
|---|
| 355 |
self._do_send(transport, event, k, v, fmtdict[k]) |
|---|
| 356 |
for k, v in msgdict_encrypt.items(): |
|---|
| 357 |
if not v or not fmtdict.get(k): |
|---|
| 358 |
continue |
|---|
| 359 |
self.log.debug( |
|---|
| 360 |
"EmailDistributor is sending encrypted info on event " \ |
|---|
| 361 |
"as '%s' to: %s"%(fmt, ', '.join(x[2] for x in v))) |
|---|
| 362 |
self._do_send(transport, event, k, v, fmtdict[k], msg_pubkey_ids) |
|---|
| 363 |
|
|---|
| 364 |
def _get_default_format(self): |
|---|
| 365 |
return self.default_email_format |
|---|
| 366 |
|
|---|
| 367 |
def _get_preferred_format(self, realm, sid, authenticated): |
|---|
| 368 |
if authenticated is None: |
|---|
| 369 |
authenticated = 0 |
|---|
| 370 |
db = self.env.get_db_cnx() |
|---|
| 371 |
cursor = db.cursor() |
|---|
| 372 |
cursor.execute(""" |
|---|
| 373 |
SELECT value |
|---|
| 374 |
FROM session_attribute |
|---|
| 375 |
WHERE sid=%s |
|---|
| 376 |
AND authenticated=%s |
|---|
| 377 |
AND name=%s |
|---|
| 378 |
""", (sid, int(authenticated), 'announcer_email_format_%s' % realm)) |
|---|
| 379 |
result = cursor.fetchone() |
|---|
| 380 |
if result: |
|---|
| 381 |
chosen = result[0] |
|---|
| 382 |
self.log.debug("EmailDistributor determined the preferred format" \ |
|---|
| 383 |
" for '%s (%s)' is: %s"%(sid, authenticated and \ |
|---|
| 384 |
'authenticated' or 'not authenticated', chosen)) |
|---|
| 385 |
return chosen |
|---|
| 386 |
else: |
|---|
| 387 |
return self._get_default_format() |
|---|
| 388 |
|
|---|
| 389 |
def _init_pref_encoding(self): |
|---|
| 390 |
self._charset = Charset() |
|---|
| 391 |
self._charset.input_charset = 'utf-8' |
|---|
| 392 |
pref = self.mime_encoding.lower() |
|---|
| 393 |
if pref == 'base64': |
|---|
| 394 |
self._charset.header_encoding = BASE64 |
|---|
| 395 |
self._charset.body_encoding = BASE64 |
|---|
| 396 |
self._charset.output_charset = 'utf-8' |
|---|
| 397 |
self._charset.input_codec = 'utf-8' |
|---|
| 398 |
self._charset.output_codec = 'utf-8' |
|---|
| 399 |
elif pref in ['qp', 'quoted-printable']: |
|---|
| 400 |
self._charset.header_encoding = QP |
|---|
| 401 |
self._charset.body_encoding = QP |
|---|
| 402 |
self._charset.output_charset = 'utf-8' |
|---|
| 403 |
self._charset.input_codec = 'utf-8' |
|---|
| 404 |
self._charset.output_codec = 'utf-8' |
|---|
| 405 |
elif pref == 'none': |
|---|
| 406 |
self._charset.header_encoding = None |
|---|
| 407 |
self._charset.body_encoding = None |
|---|
| 408 |
self._charset.input_codec = None |
|---|
| 409 |
self._charset.output_charset = 'ascii' |
|---|
| 410 |
else: |
|---|
| 411 |
raise TracError(_('Invalid email encoding setting: %s'%pref)) |
|---|
| 412 |
|
|---|
| 413 |
def _message_id(self, realm): |
|---|
| 414 |
"""Generate a predictable, but sufficiently unique message ID.""" |
|---|
| 415 |
modtime = time.time() |
|---|
| 416 |
rand = random.randint(0,32000) |
|---|
| 417 |
s = '%s.%d.%d.%s' % (self.env.project_url, |
|---|
| 418 |
modtime, rand, |
|---|
| 419 |
realm.encode('ascii', 'ignore')) |
|---|
| 420 |
dig = md5(s).hexdigest() |
|---|
| 421 |
host = self.email_from[self.email_from.find('@') + 1:] |
|---|
| 422 |
msgid = '<%03d.%s@%s>' % (len(s), dig, host) |
|---|
| 423 |
return msgid |
|---|
| 424 |
|
|---|
| 425 |
def _filter_recipients(self, rcpt): |
|---|
| 426 |
return rcpt |
|---|
| 427 |
|
|---|
| 428 |
def _do_send(self, transport, event, format, recipients, formatter, |
|---|
| 429 |
pubkey_ids=[]): |
|---|
| 430 |
|
|---|
| 431 |
output = formatter.format(transport, event.realm, format, event) |
|---|
| 432 |
|
|---|
| 433 |
# DEVEL: force message body plaintext style for crypto operations |
|---|
| 434 |
if self.crypto != '' and pubkey_ids != []: |
|---|
| 435 |
if self.crypto == 'sign': |
|---|
| 436 |
output = self.enigma.sign(output, self.private_key) |
|---|
| 437 |
elif self.crypto == 'encrypt': |
|---|
| 438 |
output = self.enigma.encrypt(output, pubkey_ids) |
|---|
| 439 |
elif self.crypto == 'sign,encrypt': |
|---|
| 440 |
output = self.enigma.sign_encrypt(output, pubkey_ids, |
|---|
| 441 |
self.private_key) |
|---|
| 442 |
|
|---|
| 443 |
self.log.debug(output) |
|---|
| 444 |
self.log.debug(_("EmailDistributor crypto operaton successful.")) |
|---|
| 445 |
alternate_output = None |
|---|
| 446 |
else: |
|---|
| 447 |
alternate_style = formatter.alternative_style_for( |
|---|
| 448 |
transport, |
|---|
| 449 |
event.realm, |
|---|
| 450 |
format |
|---|
| 451 |
) |
|---|
| 452 |
if alternate_style: |
|---|
| 453 |
alternate_output = formatter.format( |
|---|
| 454 |
transport, |
|---|
| 455 |
event.realm, |
|---|
| 456 |
alternate_style, |
|---|
| 457 |
event |
|---|
| 458 |
) |
|---|
| 459 |
else: |
|---|
| 460 |
alternate_output = None |
|---|
| 461 |
|
|---|
| 462 |
# sanity check |
|---|
| 463 |
if not self._charset.body_encoding: |
|---|
| 464 |
try: |
|---|
| 465 |
dummy = output.encode('ascii') |
|---|
| 466 |
except UnicodeDecodeError: |
|---|
| 467 |
raise TracError(_("Ticket contains non-ASCII chars. " \ |
|---|
| 468 |
"Please change encoding setting")) |
|---|
| 469 |
|
|---|
| 470 |
rootMessage = MIMEMultipart("related") |
|---|
| 471 |
# TODO: is this good? (from jabber branch) |
|---|
| 472 |
#rootMessage.set_charset(self._charset) |
|---|
| 473 |
|
|---|
| 474 |
headers = dict() |
|---|
| 475 |
headers['Message-ID'] = self._message_id(event.realm) |
|---|
| 476 |
headers['Date'] = formatdate() |
|---|
| 477 |
from_header = formataddr(( |
|---|
| 478 |
self.from_name or self.env.project_name, |
|---|
| 479 |
self.email_from |
|---|
| 480 |
)) |
|---|
| 481 |
headers['From'] = from_header |
|---|
| 482 |
headers['To'] = '"%s"'%(self.to) |
|---|
| 483 |
if self.use_public_cc: |
|---|
| 484 |
headers['Cc'] = ', '.join([x[2] for x in recipients if x]) |
|---|
| 485 |
headers['Reply-To'] = self.replyto |
|---|
| 486 |
for k, v in headers.iteritems(): |
|---|
| 487 |
set_header(rootMessage, k, v) |
|---|
| 488 |
|
|---|
| 489 |
rootMessage.preamble = 'This is a multi-part message in MIME format.' |
|---|
| 490 |
if alternate_output: |
|---|
| 491 |
parentMessage = MIMEMultipart('alternative') |
|---|
| 492 |
rootMessage.attach(parentMessage) |
|---|
| 493 |
|
|---|
| 494 |
alt_msg_format = 'html' in alternate_style and 'html' or 'plain' |
|---|
| 495 |
msgText = MIMEText(alternate_output, alt_msg_format) |
|---|
| 496 |
parentMessage.attach(msgText) |
|---|
| 497 |
else: |
|---|
| 498 |
parentMessage = rootMessage |
|---|
| 499 |
|
|---|
| 500 |
msg_format = 'html' in format and 'html' or 'plain' |
|---|
| 501 |
msgText = MIMEText(output, msg_format) |
|---|
| 502 |
del msgText['Content-Transfer-Encoding'] |
|---|
| 503 |
msgText.set_charset(self._charset) |
|---|
| 504 |
parentMessage.attach(msgText) |
|---|
| 505 |
decorators = self._get_decorators() |
|---|
| 506 |
if len(decorators) > 0: |
|---|
| 507 |
decorator = decorators.pop() |
|---|
| 508 |
decorator.decorate_message(event, rootMessage, decorators) |
|---|
| 509 |
|
|---|
| 510 |
recip_adds = [x[2] for x in recipients if x] |
|---|
| 511 |
# Append any to, cc or bccs added to the recipient list |
|---|
| 512 |
for field in ('To', 'Cc', 'Bcc'): |
|---|
| 513 |
if rootMessage[field] and \ |
|---|
| 514 |
len(str(rootMessage[field]).split(',')) > 0: |
|---|
| 515 |
for addy in str(rootMessage[field]).split(','): |
|---|
| 516 |
self._add_recipient(recip_adds, addy) |
|---|
| 517 |
# replace with localized bcc hint |
|---|
| 518 |
if headers['To'] == 'undisclosed-recipients: ;': |
|---|
| 519 |
set_header(rootMessage, 'To', _('undisclosed-recipients: ;')) |
|---|
| 520 |
|
|---|
| 521 |
self.log.debug("Content of recip_adds: %s" %(recip_adds)) |
|---|
| 522 |
package = (from_header, recip_adds, rootMessage.as_string()) |
|---|
| 523 |
start = time.time() |
|---|
| 524 |
if self.use_threaded_delivery: |
|---|
| 525 |
self.get_delivery_queue().put(package) |
|---|
| 526 |
else: |
|---|
| 527 |
self.send(*package) |
|---|
| 528 |
stop = time.time() |
|---|
| 529 |
self.log.debug("EmailDistributor took %s seconds to send."\ |
|---|
| 530 |
%(round(stop-start,2))) |
|---|
| 531 |
|
|---|
| 532 |
def send(self, from_addr, recipients, message): |
|---|
| 533 |
"""Send message to recipients via e-mail.""" |
|---|
| 534 |
# Ensure the message complies with RFC2822: use CRLF line endings |
|---|
| 535 |
message = CRLF.join(re.split("\r?\n", message)) |
|---|
| 536 |
self.email_sender.send(from_addr, recipients, message) |
|---|
| 537 |
|
|---|
| 538 |
def _get_decorators(self): |
|---|
| 539 |
return self.decorators[:] |
|---|
| 540 |
|
|---|
| 541 |
def _add_recipient(self, recipients, addy): |
|---|
| 542 |
if addy.strip() != '"undisclosed-recipients: ;"': |
|---|
| 543 |
recipients.append(addy) |
|---|
| 544 |
|
|---|
| 545 |
|
|---|
| 546 |
|
|---|
| 547 |
class SmtpEmailSender(Component): |
|---|
| 548 |
"""E-mail sender connecting to an SMTP server.""" |
|---|
| 549 |
|
|---|
| 550 |
implements(IEmailSender) |
|---|
| 551 |
|
|---|
| 552 |
server = Option('smtp', 'server', 'localhost', |
|---|
| 553 |
"""SMTP server hostname to use for email notifications.""") |
|---|
| 554 |
|
|---|
| 555 |
timeout = IntOption('smtp', 'timeout', 10, |
|---|
| 556 |
"""SMTP server connection timeout. (requires python-2.6)""") |
|---|
| 557 |
|
|---|
| 558 |
port = IntOption('smtp', 'port', 25, |
|---|
| 559 |
"""SMTP server port to use for email notification.""") |
|---|
| 560 |
|
|---|
| 561 |
user = Option('smtp', 'user', '', |
|---|
| 562 |
"""Username for SMTP server.""") |
|---|
| 563 |
|
|---|
| 564 |
password = Option('smtp', 'password', '', |
|---|
| 565 |
"""Password for SMTP server.""") |
|---|
| 566 |
|
|---|
| 567 |
use_tls = BoolOption('smtp', 'use_tls', 'false', |
|---|
| 568 |
"""Use SSL/TLS to send notifications over SMTP.""") |
|---|
| 569 |
|
|---|
| 570 |
use_ssl = BoolOption('smtp', 'use_ssl', 'false', |
|---|
| 571 |
"""Use ssl for smtp connection.""") |
|---|
| 572 |
|
|---|
| 573 |
debuglevel = IntOption('smtp', 'debuglevel', 0, |
|---|
| 574 |
"""Set to 1 for useful smtp debugging on stdout.""") |
|---|
| 575 |
|
|---|
| 576 |
|
|---|
| 577 |
def send(self, from_addr, recipients, message): |
|---|
| 578 |
# use defaults to make sure connect() is called in the constructor |
|---|
| 579 |
smtpclass = smtplib.SMTP |
|---|
| 580 |
if self.use_ssl: |
|---|
| 581 |
smtpclass = smtplib.SMTP_SSL |
|---|
| 582 |
|
|---|
| 583 |
args = { |
|---|
| 584 |
'host': self.server, |
|---|
| 585 |
'port': self.port |
|---|
| 586 |
} |
|---|
| 587 |
# timeout isn't supported until python 2.6 |
|---|
| 588 |
vparts = sys.version_info[0:2] |
|---|
| 589 |
if vparts[0] >= 2 and vparts[1] >= 6: |
|---|
| 590 |
args['timeout'] = self.timeout |
|---|
| 591 |
|
|---|
| 592 |
smtp = smtpclass(**args) |
|---|
| 593 |
smtp.set_debuglevel(self.debuglevel) |
|---|
| 594 |
if self.use_tls: |
|---|
| 595 |
smtp.ehlo() |
|---|
| 596 |
if not smtp.esmtp_features.has_key('starttls'): |
|---|
| 597 |
raise TracError(_("TLS enabled but server does not support " \ |
|---|
| 598 |
"TLS")) |
|---|
| 599 |
smtp.starttls() |
|---|
| 600 |
smtp.ehlo() |
|---|
| 601 |
if self.user: |
|---|
| 602 |
smtp.login( |
|---|
| 603 |
self.user.encode('utf-8'), |
|---|
| 604 |
self.password.encode('utf-8') |
|---|
| 605 |
) |
|---|
| 606 |
smtp.sendmail(from_addr, recipients, message) |
|---|
| 607 |
if self.use_tls or self.use_ssl: |
|---|
| 608 |
# avoid false failure detection when the server closes |
|---|
| 609 |
# the SMTP connection with TLS/SSL enabled |
|---|
| 610 |
import socket |
|---|
| 611 |
try: |
|---|
| 612 |
smtp.quit() |
|---|
| 613 |
except socket.sslerror: |
|---|
| 614 |
pass |
|---|
| 615 |
else: |
|---|
| 616 |
smtp.quit() |
|---|
| 617 |
|
|---|
| 618 |
|
|---|
| 619 |
class SendmailEmailSender(Component): |
|---|
| 620 |
"""E-mail sender using a locally-installed sendmail program.""" |
|---|
| 621 |
|
|---|
| 622 |
implements(IEmailSender) |
|---|
| 623 |
|
|---|
| 624 |
sendmail_path = Option('sendmail', 'sendmail_path', 'sendmail', |
|---|
| 625 |
"""Path to the sendmail executable. |
|---|
| 626 |
|
|---|
| 627 |
The sendmail program must accept the `-i` and `-f` options. |
|---|
| 628 |
""") |
|---|
| 629 |
|
|---|
| 630 |
def send(self, from_addr, recipients, message): |
|---|
| 631 |
self.log.info("Sending notification through sendmail at %s to %s" |
|---|
| 632 |
% (self.sendmail_path, recipients)) |
|---|
| 633 |
cmdline = [self.sendmail_path, "-i", "-f", from_addr] |
|---|
| 634 |
cmdline.extend(recipients) |
|---|
| 635 |
self.log.debug("Sendmail command line: %s" % ' '.join(cmdline)) |
|---|
| 636 |
try: |
|---|
| 637 |
child = Popen(cmdline, bufsize=-1, stdin=PIPE, stdout=PIPE, |
|---|
| 638 |
stderr=PIPE) |
|---|
| 639 |
(out, err) = child.communicate(message) |
|---|
| 640 |
if child.returncode or err: |
|---|
| 641 |
raise Exception("Sendmail failed with (%s, %s), command: '%s'" |
|---|
| 642 |
% (child.returncode, err.strip(), cmdline)) |
|---|
| 643 |
except OSError, e: |
|---|
| 644 |
self.log.error("Failed to run sendmail[%s] with error %s"%\ |
|---|
| 645 |
(self.sendmail_path, e)) |
|---|
| 646 |
|
|---|
| 647 |
|
|---|
| 648 |
class DeliveryThread(threading.Thread): |
|---|
| 649 |
def __init__(self, queue, sender): |
|---|
| 650 |
threading.Thread.__init__(self) |
|---|
| 651 |
self._sender = sender |
|---|
| 652 |
self._queue = queue |
|---|
| 653 |
self.setDaemon(True) |
|---|
| 654 |
|
|---|
| 655 |
def run(self): |
|---|
| 656 |
while 1: |
|---|
| 657 |
sendfrom, recipients, message = self._queue.get() |
|---|
| 658 |
self._sender(sendfrom, recipients, message) |
|---|