Tuesday, February 28, 2012

trac-0.12.3 and HTML notification

Trac still does not do HTML mails. As I've written before, it can be hacked to send good looking HTML notifications, though. I happily used trac-0.12.2 until I discovered some issues with subversion-1.7.
So I upgraded to trac-0.12.3, the upgrade went seamlessly. All it took as a repository resync for changesets with removed files to show up again. Yay!
Of course trac still doesn't send HTML notifications, so I had to apply my previous patches from trac-0.12.2. Except for trac/ticket/notification.py this was rather easy. See my diff here or grab it from pastebin.



--- Trac-0.12.3/trac/ticket/web_ui.py    Mon Feb  6 21:50:02 2012
+++ Trac-0.12.3-fizze/trac/ticket/web_ui.py    Mon Feb 27 14:24:15 2012
@@ -1194,7 +1194,7 @@

         # Notify
         try:
-            tn = TicketNotifyEmail(self.env)
+            tn = TicketNotifyEmail(self.env, req) #rlrj60:4/10/09
             tn.notify(ticket, newticket=True)
         except Exception, e:
             self.log.error("Failure sending notification on creation of "
@@ -1238,7 +1238,7 @@
                                      cnum=internal_cnum):
             fragment = cnum and '#comment:' + cnum or ''
             try:
-                tn = TicketNotifyEmail(self.env)
+                tn = TicketNotifyEmail(self.env, req) #rlrj60:4/10/09
                 tn.notify(ticket, newticket=False, modtime=now)
             except Exception, e:
                 self.log.error("Failure sending notification on change to "


--- Trac-0.12.3/trac/notification.py    Mon Feb  6 21:50:22 2012
+++ Trac-0.12.3-fizze/trac/notification.py    Mon Feb 27 14:22:30 2012
@@ -277,6 +277,7 @@
         self.longaddr_re = re.compile(r'^\s*(.*)\s+<\s*(%s)\s*>\s*$' % addrfmt)
         self._init_pref_encoding()
         domains = self.env.config.get('notification', 'ignore_domains', '')
+        self.from_email = self.env.config.get('notification', 'smtp_from')
         self._ignore_domains = [x.strip() for x in domains.lower().split(',')]
         # Get the email addresses of all known users
         self.email_map = {}
@@ -454,7 +455,7 @@
         if pcc:
             headers['Cc'] = ', '.join(pcc)
         headers['Date'] = formatdate()
-        msg = MIMEText(body, 'plain')
+        msg = MIMEText(body, 'html')
         # Message class computes the wrong type from MIMEText constructor,
         # which does not take a Charset object as initializer. Reset the
         # encoding type to force a new, valid evaluation


--- Trac-0.12.3/trac/ticket/notification.py    Mon Feb  6 21:50:04 2012
+++ Trac-0.12.3-fizze/trac/ticket/notification.py    Tue Feb 28 10:06:34 2012
@@ -18,13 +18,14 @@

 from genshi.template.text import NewTextTemplate

+from trac.wiki.formatter import *
 from trac.core import *
 from trac.config import *
 from trac.notification import NotifyEmail
 from trac.ticket.api import TicketSystem
 from trac.util import md5
 from trac.util.datefmt import to_utimestamp
-from trac.util.text import obfuscate_email_address, text_width, wrap
+from trac.util.text import obfuscate_email_address, text_width, wrap, CRLF
 from trac.util.translation import deactivate, reactivate

 class TicketNotificationSystem(Component):
@@ -73,9 +74,10 @@
     from_email = 'trac+ticket@localhost'
     COLS = 75

-    def __init__(self, env):
+    def __init__(self, env, req):
         NotifyEmail.__init__(self, env)
         self.prev_cc = []
+        self.req = req
         ambiguous_char_width = env.config.get('notification',
                                               'ambiguous_char_width',
                                               'single')
@@ -102,6 +104,7 @@
         self.owner = ''
         changes_descr = ''
         change_data = {}
+        BRCRLF = '<br />' + CRLF
         link = self.env.abs_href.ticket(ticket.id)
         summary = self.ticket['summary']
         
@@ -112,9 +115,9 @@
                 if not change['permanent']: # attachment with same time...
                     continue
                 change_data.update({
-                    'author': self.obfuscate_email(change['author']),
-                    'comment': wrap(change['comment'], self.COLS, ' ', ' ',
-                                    '\n', self.ambiwidth)
+                    'author': change['author'],
+                    'comment': wiki_to_html(change['comment'], env=self.env, req=self.req, absurls=True)
+
                     })
                 link += '#comment:%s' % str(change.get('cnum', ''))
                 for field, values in change['fields'].iteritems():
@@ -122,17 +125,14 @@
                     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'
+                        new_descr = wrap(new, self.COLS, ' ', ' ', BRCRLF)
+                        old_descr = wrap(old, self.COLS, '&gt;', '&gt;', BRCRLF)
+                        old_descr = old_descr.replace(2*CRLF, CRLF + '&gt;' + BRCRLF)
+
+                        cdescr = CRLF
+                        cdescr += 'Old description:' + 2*CRLF + old_descr + CRLF + BRCRLF
+                        cdescr += 'New description:' + 2*CRLF + new_descr + CRLF + BRCRLF
+
                         changes_descr = cdescr
                     elif field == 'summary':
                         summary = "%s (was: %s)" % (new, old)
@@ -140,15 +140,14 @@
                         (addcc, delcc) = self.diff_cc(old, new)
                         chgcc = ''
                         if delcc:
-                            chgcc += wrap(" * cc: %s (removed)" %
-                                          ', '.join(delcc),
-                                          self.COLS, ' ', ' ', '\n',
-                                          self.ambiwidth) + '\n'
+                            chgcc += '<li>' + wrap("cc: %s (removed)" % ', '.join(delcc),
+                                           self.COLS, ' ', ' ', BRCRLF) + '</li>'
+                            chgcc += CRLF
                         if addcc:
-                            chgcc += wrap(" * cc: %s (added)" %
-                                          ', '.join(addcc),
-                                          self.COLS, ' ', ' ', '\n',
-                                          self.ambiwidth) + '\n'
+                            chgcc += '<li>' + wrap("cc: %s (added)" % ', '.join(addcc),
+                                           self.COLS, ' ', ' ', BRCRLF) + '</li>'
+                            chgcc += CRLF
+
                         if chgcc:
                             changes_body += chgcc
                         self.prev_cc += old and self.parse_cc(old) or []
@@ -162,25 +161,23 @@
                         if len(old + new) + length > self.COLS:
                             length = 5
                             if len(old) + length > self.COLS:
-                                spacer_old = '\n'
+                                spacer_old = CRLF
                             if len(new) + length > self.COLS:
-                                spacer_new = '\n'
-                        chg = '* %s: %s%s%s=>%s%s' % (field, spacer_old, old,
-                                                      spacer_old, spacer_new,
-                                                      new)
-                        chg = chg.replace('\n', '\n' + length * ' ')
-                        chg = wrap(chg, self.COLS, '', length * ' ', '\n',
-                                   self.ambiwidth)
-                        changes_body += ' %s%s' % (chg, '\n')
+                                spacer_new = CRLF
+                        chg = wrap('%s &rarr; %s' % (old, new), self.COLS , '',
+                                    ' ', CRLF)
+                        changes_body += '<li>' + '%s:  %s%s' % (field, chg, CRLF) + '</li>'
+
                     if newv:
                         change_data[field] = {'oldvalue': old, 'newvalue': new}
+        if changes_body:
+            changes_body = '<ul>' + changes_body + '</ul>'
         
         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)
+        # convert wiki syntax to html
+        ticket_values['description'] = wiki_to_html(ticket_values.get('description', ''), env=self.env, req=self.req, absurls=True)
+
         ticket_values['new'] = self.newticket
         ticket_values['link'] = link
         
@@ -200,6 +197,7 @@

     def format_props(self):
         tkt = self.ticket
+        BRCRLF = '<br />' + CRLF
         fields = [f for f in tkt.fields
                   if f['name'] not in ('summary', 'cc', 'time', 'changetime')]
         width = [0, 0, 0, 0]
@@ -233,8 +231,11 @@
             else:
                 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'
+        format = ('<tr valign="top"><td align="right"><b>%s:</b></td><td align="left">%s</td>','<td align="right"><b>%s:</b></td><td align="left">%s</td></tr>'+CRLF)
+        #sep = width_l * '-' + '+' + width_r * '-'
+        sep = CRLF
+        txt = sep + CRLF
+        txt = '<table border="0" cellpadding="2" cellspacing="0">' + CRLF
         cell_tmp = [u'', u'']
         big = []
         i = 0
@@ -247,10 +248,11 @@
             if fname in ['owner', 'reporter']:
                 fval = self.obfuscate_email(fval)
             if f['type'] == 'textarea' or '\n' in unicode(fval):
-                big.append((f['label'], '\n'.join(fval.splitlines())))
+                big.append((f['label'], CRLF.join(fval.splitlines())))
             else:
                 # Note: f['label'] is a Babel's LazyObject, make sure its
                 # __str__ method won't be called.
+                txt += format[i % 2] % (f['label'], unicode(fval))
                 str_tmp = u'%s:  %s' % (f['label'], unicode(fval))
                 idx = i % 2
                 cell_tmp[idx] += wrap(str_tmp, width_lr[idx] - 2 + 2 * idx,
@@ -269,12 +271,19 @@
                 cell_r.append('')
             fmt_width = width_l - self.get_text_width(cell_l[i]) \
                         + len(cell_l[i])
-            txt += u'%-*s|%s%s' % (fmt_width, cell_l[i], cell_r[i], '\n')
+            #txt += u'%-*s|%s%s' % (fmt_width, cell_l[i], cell_r[i], '\n')
+
+        if i % 2:
+            txt += '<td colspan="2">&nbsp;</td></tr>' + CRLF
+            txt += CRLF
+
         if big:
             txt += sep
             for name, value in big:
-                txt += '\n'.join(['', name + ':', value, '', ''])
+                txt += CRLF.join(['<tr align="left"><td colspan="2"><b>' + name + ':' + '</b></td></tr>', '<tr align="left"><td colspan="2">' + value + '</td></tr>'])
+                """txt += CRLF.join(['', name + ':', value, '', ''])"""
         txt += sep
+        txt += '</table>' + CRLF
         return txt

     def parse_cc(self, txt):
@@ -283,15 +292,13 @@
     def diff_cc(self, old, new):
         oldcc = NotifyEmail.addrsep_re.split(old)
         newcc = NotifyEmail.addrsep_re.split(new)
-        added = [self.obfuscate_email(x) \
-                                for x in newcc if x and x not in oldcc]
-        removed = [self.obfuscate_email(x) \
-                                for x in oldcc if x and x not in newcc]
+        added = [x for x in newcc if x and x not in oldcc]
+        removed = [x for x in oldcc if x and x not in newcc]
         return (added, removed)

     def format_hdr(self):
         return '#%s: %s' % (self.ticket.id, wrap(self.ticket['summary'],
-                                                 self.COLS, linesep='\n',
+                                                 self.COLS, linesep=CRLF,
                                                  ambiwidth=self.ambiwidth))

     def format_subj(self, summary):
@@ -383,6 +390,7 @@
         hdrs = {}
         hdrs['Message-ID'] = self.get_message_id(dest, self.modtime)
         hdrs['X-Trac-Ticket-ID'] = str(self.ticket.id)
+        hdrs['Content-Type'] = 'text/html; charset=utf-8'
         hdrs['X-Trac-Ticket-URL'] = self.data['ticket']['link']
         if not self.newticket:
             msgid = self.get_message_id(dest)

            
--- Trac-0.12.3/trac/ticket/templates/ticket_notify_email.txt    Mon Feb  6 21:50:02 2012
+++ Trac-0.12.3-fizze/trac/ticket/templates/ticket_notify_email.txt    Thu Dec 29 08:58:44 2011
@@ -1,32 +1,51 @@
-$ticket_body_hdr
-$ticket_props
-{% choose ticket.new %}\
-{%   when True %}\
-$ticket.description
-{%   end %}\
-{%   otherwise %}\
-{%     if changes_body %}\
-${_('Changes (by %(author)s):', author=change.author)}
-
-$changes_body
+<div>  
+  <div style="font-family: Verdana, Arial, Helvetica, sans-serif; background-color:#f8f8f8">
+    <hr>
+    <a style="text-decoration:none;color:#069; font-size: 19px" href="${project.url or abs_href()}"><strong>$project.name</strong></a>
+    <hr>
+    <a style="text-decoration:none;color:#666666; font-size: 17px" href="$ticket.link">$ticket_body_hdr</a>
+    <hr>
+  </div>    
+ {% choose ticket.new %}\
+ {%   when True %}\
+    <div style="color:#069; font-size: 15px"><em>New ticket</em> (by <strong>$ticket.reporter</strong>)</div>
+    <br/>
+     <div style="padding:1.5em;">$ticket.description</div>
+    <br/>
+ {%   end %}\
+ {%   otherwise %}\
+ {%     if changes_body %}\
+      <div style="color:#069; font-size: 15px"><em>Changes</em> (by <strong>$change.author</strong>)</div>
+      $changes_body
 {%     end %}\
 {%     if changes_descr %}\
 {%       if not changes_body and not change.comment and change.author %}\
-${_('Description changed by %(author)s:', author=change.author)}
+        <div style="color:#069; font-size: 15px"><em>Description changed by</em> <strong>$change.author</strong></div>
 {%       end %}\
-$changes_descr
---
+{%      if changes_body or change.comment or not change.author %}\
+        <div style="color:#069; font-size: 15px"><em>Description</em></div>
 {%     end %}\
-{%     if change.comment %}\
+      <div style="padding:1.5em; font-size: 14px">$changes_descr</div>
+      <br/>
+{%    end %}\
+{%    if change.comment %}\
+      <div style="color:#069;"><em>Comment</em>  ${not changes_body and '(by <strong>%s</strong>)' % change.author or ''}</div>
+      <div style="padding:1.5em;">$change.comment</div>
+      <br/>
+{%    end %}\
+{%  end %}\
+  <hr/>
+{% end %}\

-${changes_body and _('Comment:') or _('Comment (by %(author)s):', author=change.author)}
+<style type="text/css">
+  th { font-family: Verdana, Arial, Helvetica, sans-serif; font-size: 12px }
+  td { font-family: Verdana, Arial, Helvetica, sans-serif; font-size: 12px }
+</style>
+  
+    $ticket_props
+    
+    <hr/>
+    <div style="font-family: Verdana, Arial, Helvetica, sans-serif; font-size: 13px;background-color:#f0f0f0;color:#999">$project.descr</div>
+    <cite style="display:block;padding:4px;background-color:#f0f0f0;color:#999;font-size:95%;border-top:1px dotted #ccc;">$project.descr</cite>    
+</div>

-$change.comment
-{%     end %}\
-{%   end %}\
-{% end %}\
-
---
-${_('Ticket URL: <%(link)s>', link=ticket.link)}
-$project.name <${project.url or abs_href()}>
-$project.descr

This proper diff patch was brought to you by TortoiseSVN's TortoiseMerge.

4 comments:

FReNeTiC said...

I'VE commented on the older post, but this one IS FREAKING GREAT!

Really, thank you!
I'll try this on my Trac install, and if I succeed I'll be back to spread the good word :D

Thanks!

FReNeTiC said...

THAT IS REALLY HELPFUL!!!!

My team got really crazy over here.
The IT Manager gave me that smile.
YAAAAAY
Now I can be lazy the rest of the day :P

Thank you!!!
Really, thank you.

I'm really really happy righ now :D

FReNeTiC said...

HEY THERE!

I found, and fixed, two bugs on your modification, and it's working fine for me now.

First problem I found, it wasn't sending emails :P
Log was dumping a problem with /trac/ticket/web_ui.py
But the problem wasn't there.

The problem was in
/trac/ticket/notification.py
new line 250
where you wrote the code
txt += format[i % 2] % (f['label'], unicode(fval))

I have to say, I have no idea what this code does (I'm new to Python =/), but this was bugging.
I'm brazillian and on my language we have characters like ç or õ or even à.
This piece of code couldnt those characters and Trac wasnt sending emails.
Without them, Trac got working like a charm.


The second problem I found, the comments in emails didnt have breake lines.
If in a comment I write
"hey
there"
on emails it would go like "hey there"
But, if I insert 2 break-lines
"hey

there"
on emails I could read
"hey
there"

To fix this, I did another small change on your set of changes.

ON the file
/trac/ticket/notification.py
near line 112
WHERE you wrote
'comment': wiki_to_html(change['comment'], env=self.env, req=self.req, absurls=True)

I added another parameter to the function wiki_to_html
I've set escape_newlines=True
So, at that line my code was like

'comment': wiki_to_html(change['comment'], env=self.env, req=self.req, absurls=True, escape_newlines=True)


And, that way, I fixed two problems.

sorry for my kind of bad english, and thanks for you code, and time :D

fizze said...

You're welcome!
I also found out that this hack isn't compatible with the tracopt.ticket.commit_updater plugin.

I thought about using the new TracNotification plugin but for now this is not a viable option.

Thanks for your fixes, I'll probably incorporate them into an updated post.