Intro

Below is a patch to add HTML Emails to Trac and below that is a sample template to generate a nice looking email. The completed file can be located Here and should be placed in trac/ticket/notification.py. This patch has been tested with Trac 1.0.1dev-r11400. Because we will be mucking with Trac’s source, it is highly recommended that you perform a back up of the files we are editing.


Configure HTML Email

  • In the root of the Trac source, edit trunk/trac/notification.py.
    • Change msg = MIMEText(body, 'plain') to msg = MIMEText(body, 'html')

Edit Ticket Notifications Module

  • Use this patch to apply the below from the root of the Trac source
    Index: trac/ticket/notification.py
    ===================================================================
    --- trac/ticket/notification.py (revision 11657)
    +++ trac/ticket/notification.py (working copy)
    @@ -29,6 +29,12 @@
     from trac.util.datefmt import to_utimestamp
     from trac.util.text import obfuscate_email_address, text_width, wrap
     from trac.util.translation import deactivate, reactivate
    +from trac.web.api import Request
    +from trac.mimeview.api import Context
    +from trac.wiki.formatter import format_to_html
    +from StringIO import StringIO
    +import trac.perm as perm
    +import re
     
     
     class TicketNotificationSystem(Component):
    @@ -45,18 +51,18 @@
     
         always_notify_updater = BoolOption('notification', 'always_notify_updater',
                                            'true',
    -        """Always send notifications to the person who causes the ticket
    +        """Always send notifications to the person who causes the ticket 
             property change and to any previous updater of that ticket.""")
    -
    -    ticket_subject_template = Option('notification', 'ticket_subject_template',
    +        
    +    ticket_subject_template = Option('notification', 'ticket_subject_template', 
                                          '$prefix #$ticket.id: $summary',
             """A Genshi text template snippet used to get the notification subject.
     
             By default, the subject template is `$prefix #$ticket.id: $summary`.
             `$prefix` being the value of the `smtp_subject_prefix` option.
             ''(since 0.11)''""")
    -
    -    batch_subject_template = Option('notification', 'batch_subject_template',
    +        
    +    batch_subject_template = Option('notification', 'batch_subject_template', 
                                          '$prefix Batch modify: $tickets_descr',
             """Like ticket_subject_template but for batch modifications.
     
    @@ -85,7 +91,7 @@
             for row in db("SELECT cc, reporter, owner FROM ticket WHERE id=%s",
                           (tktid,)):
                 if row[0]:
    -                ccrecipients += row[0].replace(',', ' ').split()
    +                ccrecipients += row[0].replace(',', ' ').split() 
                 reporter = row[1]
                 owner = row[2]
                 if notify_reporter:
    @@ -121,14 +127,14 @@
                 if notify_owner and (updater == owner):
                     filter_out = False
                 if filter_out:
    -                torecipients = [r for r in torecipients
    +                torecipients = [r for r in torecipients 
                                     if r and r != updater]
             elif updater:
                 torecipients.append(updater)
     
         return (torecipients, ccrecipients, reporter, owner)
    +        
     
    -
     class TicketNotifyEmail(NotifyEmail):
         """Notification of ticket changes."""
     
    @@ -162,7 +168,8 @@
             self.ticket = ticket
             self.modtime = modtime
             self.newticket = newticket
    -
    +        
    +        changes_list = []
             changes_body = ''
             self.reporter = ''
             self.owner = ''
    @@ -171,52 +178,52 @@
             link = self.env.abs_href.ticket(ticket.id)
             summary = self.ticket['summary']
             author = None
    -
    +        
    +        try:
    +            self.req
    +        except:
    +            self.req = None
    +            
    +        if self.req is None:
    +            self.env.log.warning("Req is None - a dummy Req is made so that wiki to html works")
    +            tracurl = self.env.base_url.replace('https://','')
    +            environ = {'REQUEST_METHOD':'GET','SCRIPT_NAME':'','SERVER_PORT':'443','SERVER_NAME':tracurl, 'wsgi.url_scheme': 'https', 'wsgi.input': StringIO('')}
    +            self.req = Request(environ, None)
    +            self.req.perm = perm.PermissionCache(self.env)
    +        
    +        context = Context.from_request(self.req, absurls=True)
    +        
             if not self.newticket and modtime:  # Ticket change
                 from trac.ticket.web_ui import TicketModule
                 for change in TicketModule(self.env).grouped_changelog_entries(
                                                     ticket, when=modtime):
                     if not change['permanent']: # attachment with same time...
                         continue
    +                  
                     author = change['author']
                     change_data.update({
                         'author': self.obfuscate_email(author),
    -                    'comment': wrap(change['comment'], self.COLS, ' ', ' ',
    -                                    '\n', self.ambiwidth)
    +                    'comment': format_to_html(self.env, context, change['comment'].strip(), escape_newlines=True)
                         })
    +                
                     link += '#comment:%s' % str(change.get('cnum', ''))
                     for field, values in change['fields'].iteritems():
                         old = values['old']
                         new = values['new']
                         newv = ''
                         if field == 'description':
    -                        new_descr = wrap(new, self.COLS, ' ', ' ', '\n',
    -                                         self.ambiwidth)
    -                        old_descr = wrap(old, self.COLS, '> ', '> ', '\n',
    -                                         self.ambiwidth)
    -                        old_descr = old_descr.replace(2 * '\n', '\n' + '>' + \
    -                                                      '\n')
    -                        cdescr = '\n'
    -                        cdescr += 'Old description:' + 2 * '\n' + old_descr + \
    -                                  2 * '\n'
    -                        cdescr += 'New description:' + 2 * '\n' + new_descr + \
    -                                  '\n'
    -                        changes_descr = cdescr
    +                        changes_descr = new.strip()
                         elif field == 'summary':
                             summary = "%s (was: %s)" % (new, old)
                         elif field == 'cc':
                             (addcc, delcc) = self.diff_cc(old, new)
                             chgcc = ''
                             if delcc:
    -                            chgcc += wrap(" * cc: %s (removed)" %
    -                                          ', '.join(delcc),
    -                                          self.COLS, ' ', ' ', '\n',
    -                                          self.ambiwidth) + '\n'
    +                            chgcc += "* cc: %s (removed)\n" % ', '.join(delcc)
    +                            changes_list.append( {'name':'CC','oldvalue':self.parse_cc(old) if old else '', 'newvalue': '%s (removed)' % ', '.join(delcc)} )
                             if addcc:
    -                            chgcc += wrap(" * cc: %s (added)" %
    -                                          ', '.join(addcc),
    -                                          self.COLS, ' ', ' ', '\n',
    -                                          self.ambiwidth) + '\n'
    +                            chgcc += "* cc: %s (added)\n" % ', '.join(addcc)
    +                            changes_list.append( {'name':'CC','oldvalue':self.parse_cc(old) if old else '', 'newvalue':'%s (added)' % ', '.join(addcc)} )
                             if chgcc:
                                 changes_body += chgcc
                             self.prev_cc += self.parse_cc(old) if old else []
    @@ -225,53 +232,43 @@
                                 old = self.obfuscate_email(old)
                                 new = self.obfuscate_email(new)
                             newv = new
    -                        length = 7 + len(field)
    -                        spacer_old, spacer_new = ' ', ' '
    -                        if len(old + new) + length > self.COLS:
    -                            length = 5
    -                            if len(old) + length > self.COLS:
    -                                spacer_old = '\n'
    -                            if len(new) + length > self.COLS:
    -                                spacer_new = '\n'
    -                        chg = '* %s: %s%s%s=>%s%s' % (field, spacer_old, old,
    -                                                      spacer_old, spacer_new,
    +                        chg = '* %s: %s%s%s=>%s%s\n' % (field, 7*' ', old,
    +                                                      7*' ', 7*' ',
                                                           new)
    -                        chg = chg.replace('\n', '\n' + length * ' ')
    -                        chg = wrap(chg, self.COLS, '', length * ' ', '\n',
    -                                   self.ambiwidth)
    -                        changes_body += ' %s%s' % (chg, '\n')
    +                        changes_body += chg
    +                        changes_list.append( {'name':field.title(),'oldvalue': old, 'newvalue': new} )
                         if newv:
                             change_data[field] = {'oldvalue': old, 'newvalue': new}
    -
    +        
             if newticket:
                 author = ticket['reporter']
     
             ticket_values = ticket.values.copy()
             ticket_values['id'] = ticket.id
    -        ticket_values['description'] = wrap(
    -            ticket_values.get('description', ''), self.COLS,
    -            initial_indent=' ', subsequent_indent=' ', linesep='\n',
    -            ambiwidth=self.ambiwidth)
    +        ticket_values['description'] = format_to_html(self.env, context, 
    +            ticket_values.get('description', ''), escape_newlines=True)
             ticket_values['new'] = self.newticket
             ticket_values['link'] = link
    -
    +        
             subject = self.format_subj(summary)
             if not self.newticket:
    -            subject = 'Re: ' + subject
    +            subject = 'RE: ' + subject
    +            
             self.data.update({
                 'ticket_props': self.format_props(),
                 'ticket_body_hdr': self.format_hdr(),
                 'subject': subject,
                 'ticket': ticket_values,
    -            'changes_body': changes_body,
    -            'changes_descr': changes_descr,
    -            'change': change_data
    +            'changes_body': format_to_html(self.env, context,  changes_body.strip(), escape_newlines=True),
    +            'changes_descr': format_to_html(self.env, context, changes_descr.strip(), escape_newlines=True),
    +            'change': change_data,
    +            'changes_list': changes_list
                 })
             NotifyEmail.notify(self, ticket.id, subject, author)
     
         def format_props(self):
             tkt = self.ticket
    -        fields = [f for f in tkt.fields
    +        fields = [f for f in tkt.fields 
                       if f['name'] not in ('summary', 'cc', 'time', 'changetime')]
             width = [0, 0, 0, 0]
             i = 0
    @@ -294,7 +291,7 @@
             width_r = width[2] + width[3] + 5
             half_cols = (self.COLS - 1) / 2
             if width_l + width_r + 1 > self.COLS:
    -            if ((width_l > half_cols and width_r > half_cols) or
    +            if ((width_l > half_cols and width_r > half_cols) or 
                         (width[0] > half_cols / 2 or width[2] > half_cols / 2)):
                     width_l = half_cols
                     width_r = half_cols
    @@ -302,7 +299,7 @@
                     width_l = min((self.COLS - 1) * 2 / 3, width_l)
                     width_r = self.COLS - width_l - 1
                 else:
    -                width_r = min((self.COLS - 1) * 2 / 3, width_r)
    +                width_r = min((self.COLS - 1) * 2 / 3, width_r)         
                     width_l = self.COLS - width_r - 1
             sep = width_l * '-' + '+' + width_r * '-'
             txt = sep + '\n'
    @@ -368,23 +365,23 @@
         def format_subj(self, summary):
             template = self.config.get('notification','ticket_subject_template')
             template = NewTextTemplate(template.encode('utf8'))
    -
    +                                                
             prefix = self.config.get('notification', 'smtp_subject_prefix')
    -        if prefix == '__default__':
    +        if prefix == '__default__': 
                 prefix = '[%s]' % self.env.project_name
    -
    +        
             data = {
                 'prefix': prefix,
                 'summary': summary,
                 'ticket': self.ticket,
                 'env': self.env,
             }
    -
    +        
             return template.generate(**data).render('text', encoding=None).strip()
     
         def get_recipients(self, tktid):
             (torecipients, ccrecipients, reporter, owner) = \
    -            get_ticket_notification_recipients(self.env, self.config,
    +            get_ticket_notification_recipients(self.env, self.config, 
                     tktid, self.prev_cc)
             self.reporter = reporter
             self.owner = owner
    @@ -442,6 +439,18 @@
                 reactivate(t)
     
         def _notify(self, tickets, new_values, comment, action, author):
    +        try:
    +            self.req
    +        except:
    +            self.req = None
    +        if self.req is None:
    +            self.env.log.warning("Req is None - a dummy Req is made so that wiki to html works")
    +            tracurl = self.env.base_url.replace('https://','')
    +            environ = {'REQUEST_METHOD':'GET','SCRIPT_NAME':'','SERVER_PORT':'443','SERVER_NAME':tracurl, 'wsgi.url_scheme': 'http', 'wsgi.input': StringIO('')}
    +            self.req = Request(environ, None)
    +            self.req.perm = perm.PermissionCache(self.env)
    +        context = Context.from_request(self.req, absurls=True)
    +        
             self.tickets = tickets
             changes_body = ''
             self.reporter = ''
    @@ -452,10 +461,10 @@
             subject = self.format_subj(tickets_descr)
             link = self.env.abs_href.query(id=','.join([str(t) for t in tickets]))
             self.data.update({
    -            'tickets_descr': tickets_descr,
    -            'changes_descr': changes_descr,
    -            'comment': comment,
    -            'action': action,
    +            'tickets_descr': format_to_html(self.env, context,  tickets_descr.strip(), escape_newlines=True),
    +            'changes_descr': format_to_html(self.env, context,  changes_descr.strip(), escape_newlines=True),
    +            'comment': format_to_html(self.env, context,  comment.strip(), escape_newlines=True),
    +            'action': format_to_html(self.env, context,  action.strip(), escape_newlines=True),
                 'author': author,
                 'subject': subject,
                 'ticket_query_link': link,
    @@ -465,17 +474,17 @@
         def format_subj(self, tickets_descr):
             template = self.config.get('notification','batch_subject_template')
             template = NewTextTemplate(template.encode('utf8'))
    -
    +                                                
             prefix = self.config.get('notification', 'smtp_subject_prefix')
    -        if prefix == '__default__':
    +        if prefix == '__default__': 
                 prefix = '[%s]' % self.env.project_name
    -
    +        
             data = {
                 'prefix': prefix,
                 'tickets_descr': tickets_descr,
                 'env': self.env,
             }
    -
    +        
             return template.generate(**data).render('text', encoding=None).strip()
     
         def get_recipients(self, tktids):
    @@ -483,7 +492,7 @@
             allccrecipients = []
             for t in tktids:
                 (torecipients, ccrecipients, reporter, owner) = \
    -                get_ticket_notification_recipients(self.env, self.config,
    +                get_ticket_notification_recipients(self.env, self.config, 
                         t, [])
                 alltorecipients.extend(torecipients)
                 allccrecipients.extend(ccrecipients)

Template File

  • ticket_notify_email.txt – This can be edited to fit your needs:
    <style type='text/css'>
        body{
            font-family: Helvetic, Verdana, Arial, sans-serif;
            background-color: #FFF;
            font-size: 13px;
        }
        .greyBg{
            background-color: #f8f8f8;
        }
        .greyBorder{
            border: 1px solid #a0a0a0;
        }
        .subHeader{
            font-size: 18px;
            font-weight: bold;
            text-align: center;
        }
        .header{
            text-align: center;
            font-weight: bolder;
            font-size: 25px;
        }
        div{
            padding: 5px;
            margin: 5px;
        }
        a:link {color: #000000; text-decoration: underline; }
        a:active {color: #000000; text-decoration: underline; }
        a:visited {color: #000000; text-decoration: underline; }
        a:hover {color: #000DDB; text-decoration: underline; }
    </style>
    
    <div class='greyBg greyBorder' style='border-radius: 15px;text-align: center'>
        <span class='header'>
            <a href="${project.url or abs_href()}">Trac Ticket Notification</a>
        </span>
        <hr />
        <span class='subHeader'>
            <a href="$ticket.link">$ticket_body_hdr</a>
        </span>
    </div>
    
    {% choose ticket.new %}
        {% when True %}
            <table style='width: 100%;'>
                <tr>
                    <td class='greyBg' style="font-style: italic;font-weight: bold;">
                        New ticket by <strong>$ticket.reporter</strong>:
                    </td>
                </tr>
                <tr>
                    <td>
                        $ticket.description 
                    </td>
                </tr>
            </table>
        {% end %}
        {% otherwise %}
          {% if changes_body %}
            <table style='width: 100%;'>
                <tr>
                    <td class='greyBg' style="font-style: italic;font-weight: bold;">
                        Changes by <strong>$change.author</strong>:
                    </td>
                </tr>
                <tr>
                    <td>
                        <table>
                            {% for single_change in changes_list %}\
                                <tr>
                                    <td>&bull;</td>
                                    <td style='font-weight: bold;'>$single_change.name:</td>
                                    <td>$single_change.oldvalue</td>
                                    <td style='font-weight: bold;'>&rArr;</td>
                                    <td>$single_change.newvalue</td>
                                </tr>
                            {% end %}
                        </table>
                    </td>
                </tr>
            </table>
          {% end %}
          {% if change.comment %}
            <table style='width: 100%;'>
                <tr>
                    <td class='greyBg' style="font-style: italic;font-weight: bold;">
                        Comment${not changes_body and ' by <strong>%s</strong>' % change.author or ''}:
                    </td>
                </tr>
                <tr>
                    <td>
                        $change.comment
                    </td>
                </tr>
            </table>
          {% end %}
          {% if changes_descr %}
            <table style='width: 100%;'>
                <tr>
                    {% if not changes_body and not change.comment and change.author %}
                        <td class='greyBg' style="font-style: italic;font-weight: bold;">
                            Description changed by <strong>$change.author</strong>:
                        </td>
                    {% end %}
                    {% if changes_body or change.comment or not change.author %}
                        <td class='greyBg' style="font-style: italic;font-weight: bold;">
                            Description:
                        </td>
                    {% end %}
                </tr>
                <tr>
                    <td>
                        $changes_descr
                    </td>
                </tr>
            </table>
          {% end %}
        {% end %}
        <hr/>
    {% end %}
    
    <table>
        <tr>
            <td class='greyBg' style="font-style: italic;font-weight: bold;">
                Ticket Reference:
            </td>
        </tr>
        <tr>
            <td>
                <table cellspacing=2 cellpadding=2>
      
                    {% with
                       pv = [(a[0].strip(), a[1].strip()) for a in [b.split(':') for b in
                             [c.strip() for c in 
                              ticket_props.replace('|', '\n').splitlines()[1:-1]] if ':' in b]];
                       sel = ['Reporter', 'Owner', 'Type', 'Status', 'Priority', 'Component', 'Severity', 'Resolution', 'Keywords', 'Last Author', 'Relationships','Summary'] %}\
                    ${'<tr>'.join('<td style="font-weight: bold;">%s:</td><td>%s</td>' % (format(p[0], ' <12'), p[1]) for p in pv if p[0] in sel)}
                    {% end %}\
                    
                </table>    
            </td>
        </tr>
    </table>

Downloads

Credits


0