Index: mailer.py
===================================================================
--- mailer.py	(revision 18770)
+++ mailer.py	(working copy)
@@ -10,9 +10,9 @@
 # USAGE: mailer.py commit      REPOS REVISION [CONFIG-FILE]
 #        mailer.py propchange  REPOS REVISION AUTHOR REVPROPNAME [CONFIG-FILE]
 #        mailer.py propchange2 REPOS REVISION AUTHOR REVPROPNAME ACTION \
-#                              [CONFIG-FILE]
-#        mailer.py lock        REPOS AUTHOR [CONFIG-FILE]
-#        mailer.py unlock      REPOS AUTHOR [CONFIG-FILE]
+#                              [CONFIG-FILE] < OLD-VALUE-FILE
+#        mailer.py lock        REPOS AUTHOR [CONFIG-FILE] < PATHS-LIST-FILE
+#        mailer.py unlock      REPOS AUTHOR [CONFIG-FILE] < PATHS-LIST-FILE
 #
 #   Using CONFIG-FILE, deliver an email describing the changes between
 #   REV and REV-1 for the repository REPOS.
@@ -127,21 +127,65 @@
     self.subject = ""
 
   def make_subject(self, group, params):
-    prefix = self.cfg.get(self.prefix_param, group, params)
-    if prefix:
-      subject = prefix + ' ' + self.subject
+    subject_prefix = self.cfg.get(self.prefix_param, group, params) \
+        or ''
+    if subject_prefix:
+      subject_prefix += ' '
+
+    repos_rev = self.repos.rev or ''
+    repos_log = self.repos.get_rev_prop(svn.core.SVN_PROP_REVISION_LOG) or ''
+
+    # FIXME: Remove hard-coded dependency on the messenger type (Commit, 
+    # PropChange or Lock).  This should get rid of direct assignments to
+    # the Messenger.output.subject member variable.  
+    #
+    # One way to implement this is to set %(subject_messenger)s from 
+    # the Messenger's descendants.
+    #
+    # Better, split the subject_format configuration item into 
+    # subject_commit_format, subject_propchange_format and subject_lock_format
+    # whose regular values would be 
+    #   'r%(repos_rev)s - %(repos_commondir)s%(repos_dirs)s' 
+    # etc.
+    
+    # Obtain the hard-coded subject line set by Messenger's descendants via
+    # the output member variable.
+    subject_messenger = self.subject or ''
+    
+    subject_std = subject_prefix + subject_messenger
+    
+    subject_format = self.cfg.get('subject_format', group, params)
+
+    # FIXME: find a better way to unescape raw strings.
+    subject_format_unescaped = subject_format.replace(r'\n', '\n')
+
+    local_params = { 
+        'subject_prefix': subject_prefix,
+        'subject_messenger': subject_messenger,
+        'repos_rev': repos_rev, 
+        'repos_log': repos_log,
+        'subject_std': subject_std,
+    }
+    if not subject_format:
+      formatted_subject = subject_std
     else:
-      subject = self.subject
-
+      try:
+        formatted_subject = subject_format_unescaped % local_params
+      except KeyError:
+        formatted_subject = subject_std
+    
+    formatted_subject = formatted_subject.replace('\n', ' ')
+    formatted_subject = formatted_subject.strip()
+ 
     try:
       truncate_subject = int(
           self.cfg.get('truncate_subject', group, params))
     except ValueError:
       truncate_subject = 0
 
-    if truncate_subject and len(subject) > truncate_subject:
-      subject = subject[:(truncate_subject - 3)] + "..."
-    return subject
+    if truncate_subject and len(formatted_subject) > truncate_subject:
+      formatted_subject = formatted_subject[:(truncate_subject - 3)] + '...'
+    return formatted_subject
 
   def start(self, group, params):
     """Override this method.
@@ -163,15 +207,17 @@
     Append the literal text string OUTPUT to the output representation."""
     raise NotImplementedError
 
-  def run(self, cmd):
+  def run(self, cmd, w):
     """Override this method, if the default implementation is not sufficient.
     Execute CMD, writing the stdout produced to the output representation."""
+    if w is None:
+      w = self.write
     # By default we choose to incorporate child stderr into the output
     pipe_ob = Popen4(cmd)
 
     buf = pipe_ob.fromchild.read(self._CHUNKSIZE)
     while buf:
-      self.write(buf)
+      w(buf)
       buf = pipe_ob.fromchild.read(self._CHUNKSIZE)
 
     # wait on the child so we don't end up with a billion zombies
@@ -404,37 +450,46 @@
       self.groups[group, tuple(param_list)] = params
 
     self.output.subject = 'r%d - %s' % (repos.rev, propname)
+    self.body_format = cfg.get('body_format', group, params)
 
   def generate(self):
     actions = { 'A': 'added', 'M': 'modified', 'D': 'deleted' }
     for (group, param_tuple), params in self.groups.items():
       self.output.start(group, params)
-      self.output.write('Author: %s\n'
-                        'Revision: %s\n'
-                        'Property Name: %s\n'
-                        'Action: %s\n'
-                        '\n'
-                        % (self.author, self.repos.rev, self.propname,
+      body_header = '\nAuthor: %s\n' \
+                        'Revision: %s\n' \
+                        'Property Name: %s\n' \
+                        'Action: %s\n' \
+                        '\n' \
+                        % (self.author, self.repos.rev, self.propname, \
                            actions.get(self.action, 'Unknown (\'%s\')' \
-                                       % self.action)))
+                                       % self.action))
+      string_output = cStringIO.StringIO()
+      w = string_output.write
       if self.action == 'A' or not actions.has_key(self.action):
-        self.output.write('Property value:\n')
-        propvalue = self.repos.get_rev_prop(self.propname)
-        self.output.write(propvalue)
+        w('\nProperty value:\n')
+        propvalue = self.repos.get_rev_prop(self.propname) or ''
+        w(propvalue)
       elif self.action == 'M':
-        self.output.write('Property diff:\n')
+        w('\nProperty diff:\n')
         tempfile1 = NamedTemporaryFile()
         tempfile1.write(sys.stdin.read())
         tempfile1.flush()
         tempfile2 = NamedTemporaryFile()
-        tempfile2.write(self.repos.get_rev_prop(self.propname))
+        tempfile2.write(self.repos.get_rev_prop(self.propname) or '')
         tempfile2.flush()
         self.output.run(self.cfg.get_diff_cmd(group, {
           'label_from' : 'old property value',
           'label_to' : 'new property value',
           'from' : tempfile1.name,
           'to' : tempfile2.name,
-          }))
+          }), w)
+      body_summary = string_output.getvalue()
+      self.output.write(
+        format_body(
+            self.body_format,
+            self.author, '', self.repos.rev, '', 
+            body_header, body_summary, '', ''))
       self.output.finish()
 
 
@@ -514,25 +569,66 @@
     # the comment for the first path in the dirlist and cache it.
     self.lock = svn.fs.svn_fs_get_lock(self.repos.fs_ptr,
                                        self.dirlist[0], self.pool)
+    self.body_format = cfg.get('body_format', group, params)
 
   def generate(self):
     for (group, param_tuple), (params, paths) in self.groups.items():
       self.output.start(group, params)
 
-      self.output.write('Author: %s\n'
-                        '%s paths:\n' %
-                        (self.author, self.do_lock and 'Locked' or 'Unlocked'))
+      body_header = '\nAuthor: %s\n%s.\n' % \
+        (self.author, self.do_lock and 'Locked' or 'Unlocked')
 
+      body_summary = '\nPaths:\n'
       self.dirlist.sort()
       for dir in self.dirlist:
-        self.output.write('   %s\n\n' % dir)
+        body_summary += '   %s\n' % dir
 
+      body_log = '\nComment:\n'
       if self.do_lock:
-        self.output.write('Comment:\n%s\n' % (self.lock.comment or ''))
+        try:
+            body_log += '%s\n' % (self.lock.comment or '')
+        except AttributeError:
+            # Backward compatibility where lock doesn't have comment.
+            pass
 
+      self.output.write(
+        format_body(
+            self.body_format,
+            self.author, '', '', '', 
+            body_header, body_summary, body_log, ''))
       self.output.finish()
 
 
+def format_body(
+    body_format,
+    repos_author, repos_date, repos_rev, repos_log,
+    body_header, body_summary, body_log, body_diff):
+        
+  body_std = body_header + body_summary + body_log + body_diff
+    
+  local_params = { 
+    'repos_author': repos_author,
+    'repos_date': repos_date,
+    'repos_rev': repos_rev,
+    'repos_log': repos_log,
+    'body_header': body_header, 
+    'body_summary': body_summary, 
+    'body_log': body_log,
+    'body_diff': body_diff,
+    'body_std': body_std,
+  }
+  if not body_format:
+    formatted_body = body_std
+  else:
+    # FIXME: find a better way to unescape raw strings.
+    body_format_unescaped = body_format.replace(r'\n', '\n')
+    try:
+      formatted_body = body_format_unescaped % local_params
+    except KeyError:
+      formatted_body = body_std
+  return formatted_body
+
+
 class DiffSelections:
   def __init__(self, cfg, group, params):
     self.add = False
@@ -591,6 +687,8 @@
   show_nonmatching_paths = cfg.get('show_nonmatching_paths', group, params) \
       or 'yes'
 
+  body_format = cfg.get('body_format', group, params)
+
   # figure out the lists of changes outside the selected path-space
   other_added_data = other_removed_data = other_modified_data = [ ]
   if len(paths) != len(changelist) and show_nonmatching_paths != 'no':
@@ -619,6 +717,7 @@
     diffs=DiffGenerator(changelist, paths, True, cfg, repos, date, group,
                         params, diffsels, diffurls, pool),
     other_diffs=other_diffs,
+    body_format=body_format,
     )
   renderer.render(data)
 
@@ -880,39 +979,50 @@
   def render(self, data):
     "Render the commit defined by 'data'."
 
-    w = self.output.write
+    body_header = '\nAuthor: %s\nDate: %s\nNew Revision: %s\n' \
+                  % (data.author, data.date, data.rev)
 
-    w('Author: %s\nDate: %s\nNew Revision: %s\n\n'
-      % (data.author, data.date, data.rev))
-
     # print summary sections
-    self._render_list('Added', data.added_data)
-    self._render_list('Removed', data.removed_data)
-    self._render_list('Modified', data.modified_data)
+    string_output = cStringIO.StringIO()
+    w = string_output.write
+    w('\n')
+    self._render_list(w, 'Added', data.added_data)
+    self._render_list(w, 'Removed', data.removed_data)
+    self._render_list(w, 'Modified', data.modified_data)
 
     if data.other_added_data or data.other_removed_data \
            or data.other_modified_data:
       if data.show_nonmatching_paths:
         w('\nChanges in other areas also in this revision:\n')
-        self._render_list('Added', data.other_added_data)
-        self._render_list('Removed', data.other_removed_data)
-        self._render_list('Modified', data.other_modified_data)
+        self._render_list(w, 'Added', data.other_added_data)
+        self._render_list(w, 'Removed', data.other_removed_data)
+        self._render_list(w, 'Modified', data.other_modified_data)
       else:
         w('and changes in other areas\n')
+    body_summary = string_output.getvalue()
 
-    w('\nLog:\n%s\n' % data.log)
+    body_log = '\nLog:\n%s\n' % data.log
 
-    self._render_diffs(data.diffs, '')
+    string_output = cStringIO.StringIO()
+    w = string_output.write
+    w('\n')
+    self._render_diffs(w, data.diffs, '')
     if data.other_diffs:
-      self._render_diffs(data.other_diffs,
+      self._render_diffs(w, data.other_diffs,
                          '\nDiffs of changes in other areas also'
                          ' in this revision:\n')
+    body_diff = string_output.getvalue()
 
-  def _render_list(self, header, data_list):
+    self.output.write(
+      format_body(
+        data.body_format,
+        data.author, data.date, data.rev, data.log,
+        body_header, body_summary, body_log, body_diff))
+
+  def _render_list(self, w, header, data_list):
     if not data_list:
       return
 
-    w = self.output.write
     w(header + ':\n')
     for d in data_list:
       if d.is_dir:
@@ -937,10 +1047,9 @@
         w('      - copied%s from r%d, %s%s\n'
           % (text, d.base_rev, d.base_path, is_dir))
 
-  def _render_diffs(self, diffs, section_header):
+  def _render_diffs(self, w, diffs, section_header):
     """Render diffs. Write the SECTION_HEADER iff there are actually
     any diffs to render."""
-    w = self.output.write
     section_header_printed = False
 
     for diff in diffs:
@@ -961,7 +1070,7 @@
         w('\nModified: %s\n' % diff.path)
 
       if diff.diff_url:
-        w('Url: %s\n' % diff.diff_url)
+        w('\nUrl: %s\n' % diff.diff_url)
 
       if not diff.diff:
         continue
@@ -978,7 +1087,9 @@
       for line in diff.content:
         w(line.raw)
 
+      w('\n')
 
+
 class Repository:
   "Hold roots and other information about the repository."
 
@@ -1074,7 +1185,10 @@
     
     # parameterize it
     if params is not None:
-      value = value % params
+      try:
+        value = value % params
+      except KeyError:
+        pass
 
     # apply any mapper
     mapper = getattr(self.maps, option, None)
@@ -1228,9 +1342,10 @@
     sys.stderr.write(
 """USAGE: %s commit      REPOS REVISION [CONFIG-FILE]
        %s propchange  REPOS REVISION AUTHOR REVPROPNAME [CONFIG-FILE]
-       %s propchange2 REPOS REVISION AUTHOR REVPROPNAME ACTION [CONFIG-FILE]
-       %s lock        REPOS AUTHOR [CONFIG-FILE]
-       %s unlock      REPOS AUTHOR [CONFIG-FILE]
+       %s propchange2 REPOS REVISION AUTHOR REVPROPNAME ACTION \
+                            [CONFIG-FILE] < OLD-VALUE-FILE
+       %s lock        REPOS AUTHOR [CONFIG-FILE] < PATHS-LIST-FILE
+       %s unlock      REPOS AUTHOR [CONFIG-FILE] < PATHS-LIST-FILE
 
 If no CONFIG-FILE is provided, the script will first search for a mailer.conf
 file in REPOS/conf/.  Failing that, it will search the directory in which
@@ -1258,8 +1373,13 @@
     usage()
 
   cmd = sys.argv[1]
-  repos_dir = svn.core.svn_path_canonicalize(sys.argv[2])
   try:
+    repos_dir = svn.core.svn_path_canonicalize(sys.argv[2])
+  except AttributeError:
+    # backward compatibility for svn.core without svn_path_canonicalize
+    repos_dir = sys.argv[2]
+
+  try:
     expected_args = cmd_list[cmd]
   except KeyError:
     usage()
Index: mailer.conf.example
===================================================================
--- mailer.conf.example	(revision 18770)
+++ mailer.conf.example	(working copy)
@@ -143,6 +143,32 @@
 # whitespace in the command, then ### something ###.
 diff = /usr/bin/diff -u -L %(label_from)s -L %(label_to)s %(from)s %(to)s
 
+# The format of the subject line.  May include the following:
+#   %(subject_prefix)s      = one of xxx_subject_prefix + ' '
+#   %(subject_messenger)s   = change-type-specific, such as this pseudo 
+#                   format line for commits:
+#                   r%(repos_rev)s - %(repos_commondir)s%(repos_dirs)s
+#   %(subject_std)s         = %(subject_prefix)s%(subject_messenger)s
+#   %(repos_rev)s
+#   %(repos_log)s
+# subject_format = %(subject_prefix)s%(repos_log)s
+
+# The format of the body content.
+# May include the following parameters:
+#   %(body_header)s = Author: %(repos_author)s\n
+#                     Date: %(repos_date)s\n
+#                     New Revision: %(repos_rev)s\n\n
+#   %(body_summary)s
+#   %(body_log)s    = \nLog:\n%(repos_log)s\n
+#   %(body_diff)s
+#   %(body_std)s    = %(body_header)s%(body_summary)s%(body_log)s%(body_diff)s
+#   %(repos_author)s
+#   %(repos_date)s
+#   %(repos_rev)s
+#   %(repos_log)s
+# body_format = http://example.com/cgi-bin/viewcvs.cgi?rev=%(repos_rev)s&view=rev
+#                 %(body_header)s%(body_log)s%(body_summary)s%(body_diff)s
+
 # The default prefix for the Subject: header for commits.
 commit_subject_prefix =
 


