source: trunk/trac-hacks/announcerplugin/announcerplugin/distributors/email_distributor.py @ 745

Revision 745, 14.3 KB checked in by rcorsaro, 5 years ago (diff)

merged latest announcer trac-hacks commits

Line 
1from trac.core import Component, implements, ExtensionPoint
2from trac.util.compat import set, sorted
3from trac.config import Option, BoolOption, IntOption, OrderedExtensionsOption
4from trac.util import get_pkginfo
5from trac.util.translation import _
6
7from announcerplugin.api import IAnnouncementDistributor
8from announcerplugin.api import IAnnouncementFormatter
9from announcerplugin.api import IAnnouncementPreferenceProvider
10from announcerplugin.api import IAnnouncementAddressResolver
11from announcerplugin.api import AnnouncementSystem
12import announcerplugin, trac
13
14from email.MIMEMultipart import MIMEMultipart
15from email.MIMEText import MIMEText
16from email.Utils import formatdate
17try:
18    from email.header import Header
19except:
20    from email.Header import Header
21import time, Queue, threading, smtplib
22
23class 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           
36class 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   
Note: See TracBrowser for help on using the repository browser.