<html><head><meta name="color-scheme" content="light dark"></head><body><pre style="word-wrap: break-word; white-space: pre-wrap;">diff --git ConfParser.py ConfParser.py
index df14743..636a4c3 100755
--- ConfParser.py
+++ ConfParser.py
@@ -41,7 +41,7 @@ This module is similar, except:
 I welcome questions and comments at &lt;software @ discworld.dyndns.org&gt;.
 '''
 
-__version__ = '3.1'
+__version__ = '3.1-filter3-20180804'
 __author__ = 'Charles Cazabon &lt;software @ discworld.dyndns.org&gt;'
 
 #
@@ -49,11 +49,14 @@ __author__ = 'Charles Cazabon &lt;software @ discworld.dyndns.org&gt;'
 #
 
 import string
-import UserDict
 import sys
 import shlex
-import cStringIO
+import io
 from types import *
+try:
+    from UserDict import UserDict
+except ImportError:
+    from collections import UserDict
 
 
 #
@@ -124,15 +127,15 @@ def log (msg):
 #
 
 #######################################
-class SmartDict (UserDict.UserDict):
+class SmartDict (UserDict):
     '''Dictionary class which handles lists and singletons intelligently.
     '''
     #######################################
     def __init__ (self, initialdata = {}):
         '''Constructor.
         '''
-        UserDict.UserDict.__init__ (self, {})
-        for (key, value) in initialdata.items ():
+        UserDict.__init__ (self, {})
+        for (key, value) in list(initialdata.items ()):
             self.__setitem (key, value)
 
     #######################################
@@ -144,14 +147,14 @@ class SmartDict (UserDict.UserDict):
             if len (value) == 1:
                 return value[0]
             return value
-        except KeyError, txt:
-            raise KeyError, txt
+        except KeyError as txt:
+            raise KeyError(txt)
 
     #######################################
     def __setitem__ (self, key, value):
         '''
         '''
-        if type (value) in (ListType, TupleType):
+        if type (value) in (list, tuple):
             self.data[key] = list (value)
         else:
             self.data[key] = [value]
@@ -176,17 +179,17 @@ class ConfParser:
         self.__defaults = SmartDict ()
 
         try:
-            for key in defaults.keys ():
+            for key in list(defaults.keys ()):
                 self.__defaults[key] = defaults[key]
 
         except AttributeError:
-            raise ParsingError, 'defaults not a dictionary (%s)' % defaults
+            raise ParsingError('defaults not a dictionary (%s)' % defaults)
 
     #######################################
     def read (self, filelist):
         '''Read configuration file(s) from list of 1 or more filenames.
         '''
-        if type (filelist) not in (ListType, TupleType):
+        if type (filelist) not in (list, tuple):
             filelist = [filelist]
 
         try:
@@ -196,8 +199,8 @@ class ConfParser:
                 self.__rawdata = self.__rawdata + f.readlines ()
                 f.close ()
     
-        except IOError, txt:
-            raise ParsingError, 'error reading configuration file (%s)' % txt
+        except IOError as txt:
+            raise ParsingError('error reading configuration file (%s)' % txt)
 
         self.__parse ()
         return self
@@ -206,8 +209,8 @@ class ConfParser:
     def __parse (self):
         '''Parse the read-in configuration file.
         '''
-        config = string.join (self.__rawdata, '\n')
-        f = cStringIO.StringIO (config)
+        config = u'\n'.join (self.__rawdata)
+        f = io.StringIO (config)
         lex = shlex.shlex (f)
         lex.wordchars = lex.wordchars + '|/.,$^\\():;@-+?&lt;&gt;!%&amp;*`~'
         section_name = ''
@@ -221,38 +224,36 @@ class ConfParser:
 
             if not (section_name):
                 if token != '[':
-                    raise ParsingError, 'expected section start, got %s' % token
+                    raise ParsingError('expected section start, got %s' % token)
                 section_name = ''
                 while 1:
                     token = lex.get_token ()
                     if token == ']':
                         break
                     if token == '':
-                        raise ParsingError, 'expected section end, hit EOF'
+                        raise ParsingError('expected section end, hit EOF')
                     if section_name:
                         section_name = section_name + ' '
                     section_name = section_name + token
                 if not section_name:
-                    raise ParsingError, 'expected section name, got nothing'
+                    raise ParsingError('expected section name, got nothing')
 
                 section = SmartDict ()
                 # Collapse case on section names
-                section_name = string.lower (section_name)
+                section_name = section_name.lower ()
                 if section_name in self.__sectionlist:
-                    raise DuplicateSectionError, \
-                        'duplicate section (%s)' % section_name
+                    raise DuplicateSectionError('duplicate section (%s)' % section_name)
                 section['__name__'] = section_name
                 continue
 
             if token == '=':
-                raise ParsingError, 'expected option name, got ='
+                raise ParsingError('expected option name, got =')
 
             if token == '[':
                 # Start new section
                 lex.push_token (token)
                 if section_name in self.__sectionlist:
-                    raise DuplicateSectionError, \
-                        'duplicate section (%s)' % section_name
+                    raise DuplicateSectionError('duplicate section (%s)' % section_name)
                 if section['__name__'] == 'default':
                     self.__defaults.update (section)
                 self.__sectionlist.append (section_name)
@@ -264,18 +265,18 @@ class ConfParser:
                 option_name = token
                 token = lex.get_token ()
                 if token != '=':
-                    raise ParsingError, 'Expected =, got %s' % token
+                    raise ParsingError('Expected =, got %s' % token)
 
                 token = lex.get_token ()
                 if token in ('[', '='):
-                    raise ParsingError, 'expected option value, got %s' % token
+                    raise ParsingError('expected option value, got %s' % token)
                 option_value = token
 
                 if option_value[0] in ('"', "'") and option_value[0] == option_value[-1]:
                     option_value = option_value[1:-1]
                                   
-                if section.has_key (option_name):
-                    if type (section[option_name]) == ListType:
+                if option_name in section:
+                    if type (section[option_name]) == list:
                         section[option_name].append (option_value)
                     else:
                         section[option_name] = [section[option_name], option_value]
@@ -287,15 +288,14 @@ class ConfParser:
         # Done parsing        
         if section_name:
             if section_name in self.__sectionlist:
-                raise DuplicateSectionError, \
-                    'duplicate section (%s)' % section_name
+                raise DuplicateSectionError('duplicate section (%s)' % section_name)
             if section['__name__'] == 'default':
                 self.__defaults.update (section)
             self.__sectionlist.append (section_name)
             self.__sections.append (section.copy ())
         
         if not self.__sectionlist:
-            raise MissingSectionHeaderError, 'no section headers in file'
+            raise MissingSectionHeaderError('no section headers in file')
 
     #######################################
     def defaults (self):
@@ -308,7 +308,7 @@ class ConfParser:
         '''Indicates whether the named section is present in the configuration. 
         The default section is not acknowledged.
         '''
-        section = string.lower (section)
+        section = section.lower ()
         if section not in self.sections ():
             return 0
         return 1
@@ -333,12 +333,12 @@ class ConfParser:
         '''Return list of options in section.
         '''
         try:
-            s = self.__sectionlist.index (string.lower (section))
+            s = self.__sectionlist.index (section.lower ())
 
         except ValueError:
-            raise NoSectionError, 'missing section:  "%s"' % section
+            raise NoSectionError('missing section:  "%s"' % section)
 
-        return self.__sections[s].keys ()
+        return list(self.__sections[s].keys ())
 
     #######################################
     def get (self, section, option, raw=0, _vars={}):
@@ -349,19 +349,19 @@ class ConfParser:
         '''
 
         try:
-            s = self.__sectionlist.index (string.lower (section))
+            s = self.__sectionlist.index (section.lower ())
             options = self.__sections[s]
         except ValueError:
-            raise NoSectionError, 'missing section (%s)' % section
+            raise NoSectionError('missing section (%s)' % section)
 
         expand = self.__defaults.copy ()
         expand.update (_vars)
         
-        if not options.has_key (option):
-            if expand.has_key (option):
+        if option not in options:
+            if option in expand:
                 return expand[option]
-            raise NoOptionError, 'section [%s] missing option (%s)' \
-                % (section, option)
+            raise NoOptionError('section [%s] missing option (%s)' \
+                % (section, option))
 
         rawval = options[option]
         
@@ -370,7 +370,7 @@ class ConfParser:
 
         try:
             value = []
-            if type (rawval) != ListType:
+            if type (rawval) != list:
                 rawval = [rawval]
             for part in rawval:
                 try:
@@ -381,12 +381,12 @@ class ConfParser:
             if len (value) == 1:
                 return value[0]
             return value                
-        except KeyError, txt:
-            raise NoOptionError, 'section [%s] missing option (%s)' \
-                % (section, option)
-        except TypeError, txt:
-            raise InterpolationError, 'invalid conversion or specification' \
-                ' for option %s (%s (%s))' % (option, rawval, txt)
+        except KeyError as txt:
+            raise NoOptionError('section [%s] missing option (%s)' \
+                % (section, option))
+        except TypeError as txt:
+            raise InterpolationError('invalid conversion or specification' \
+                ' for option %s (%s (%s))' % (option, rawval, txt))
 
     #######################################
     def getint (self, section, option):
@@ -397,8 +397,8 @@ class ConfParser:
         try:
             return int (val)
         except ValueError:
-            raise InterpolationError, 'option %s not an integer (%s)' \
-                % (option, val)
+            raise InterpolationError('option %s not an integer (%s)' \
+                % (option, val))
 
     #######################################
     def getfloat (self, section, option):
@@ -409,8 +409,8 @@ class ConfParser:
         try:
             return float (val)
         except ValueError:
-            raise InterpolationError, 'option %s not a float (%s)' \
-                % (option, val)
+            raise InterpolationError('option %s not a float (%s)' \
+                % (option, val))
 
     #######################################
     def getboolean (self, section, option):
@@ -434,11 +434,11 @@ class ConfParser:
             options.sort ()
             for option in options:
                 values = self.get (section, option)
-                if type (values) == ListType:
+                if type (values) == list:
                     sys.stderr.write ('    %s:\n' % option)
                     for value in values:
                         sys.stderr.write ('         %s\n' % value)
                 else:
                     sys.stderr.write ('    %s:  %s\n' % (option, values))
             sys.stderr.write ('\n')
-            
\ No newline at end of file
+
diff --git pymsgauth-filter pymsgauth-filter
new file mode 100755
index 0000000..9b90b07
--- /dev/null
+++ pymsgauth-filter
@@ -0,0 +1,9 @@
+#!/usr/bin/python
+
+from pymsgauth import *
+
+import io
+import sys
+
+msg = tokenize_message_if_needed (io.StringIO (u'' + sys.stdin.read ()))
+sys.stdout.write (msg)
diff --git pymsgauth.py pymsgauth.py
index 941bc09..d8b4162 100755
--- pymsgauth.py
+++ pymsgauth.py
@@ -19,7 +19,7 @@ Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
 
 '''
 
-__version__ = '2.1.0'
+__version__ = '2.1.0-filter3-20180804'
 __author__ = 'Charles Cazabon &lt;software @ discworld.dyndns.org&gt;'
 
 
@@ -30,12 +30,16 @@ __author__ = 'Charles Cazabon &lt;software @ discworld.dyndns.org&gt;'
 import sys
 import os
 import string
-import rfc822
-import cStringIO
+import email
+import io
 import time
 import types
 import ConfParser
 
+if sys.version_info[0] &lt; 3:
+    import codecs
+    sys.stdin = codecs.getreader('utf-8')(sys.stdin)
+
 #
 # Configuration constants
 #
@@ -49,7 +53,7 @@ loglevels = {
     'ERROR' : 5,
     'FATAL' : 6,
 }
-(TRACE, DEBUG, INFO, WARN, ERROR, FATAL) = range (1, 7)
+(TRACE, DEBUG, INFO, WARN, ERROR, FATAL) = list(range(1, 7))
 
 # Build-in default values
 defaults = {
@@ -116,7 +120,7 @@ FILENAME, LINENO, FUNCNAME = 0, 1, 2        #SOURCELINE = 3 ; not used
 logfd = None
 
 #############################
-class pymsgauthError (StandardError):
+class pymsgauthError (Exception):
     pass
 
 #############################
@@ -127,6 +131,52 @@ class DeliveryError (pymsgauthError):
 class ConfigurationError (pymsgauthError):
     pass
 
+#############################
+class RFC822Message:
+    def __init__(self, buf, seekable=1):
+        self.message = email.message_from_file(buf)
+        self.headers = self.init_headers()
+        self.fp = self.init_fp(buf)
+
+    def init_headers(self):
+        headers = []
+        for field, value in self.message.items():
+            headers.extend(field + ': ' + value + '\n')
+        return headers
+
+    def init_fp(self, buf):
+        if buf != sys.stdin:
+            buf.seek(0)
+            while 1:
+                line = buf.readline()
+                if line == '\n' or line == '':
+                    break
+        return buf
+
+    def getaddr(self, field):
+        value = self.message.get(field)
+        if value == None:
+            name = None
+            addr = None
+        else:
+            name, addr = email.utils.parseaddr(value)
+        return name, addr
+
+    def getheader(self, field, default):
+        return self.message.get(field, '')
+
+    def getaddrlist(self, field):
+        addrlist = []
+        values = self.message.get_all(field)
+        if values:
+            for value in values:
+                name_addr = email.utils.parseaddr(value)
+                addrlist.append(name_addr)
+        return addrlist
+
+    def rewindbody(self):
+        self.init_fp(self.fp)
+
 #######################################
 def log (level=INFO, msg=''):
     global logfd
@@ -147,9 +197,9 @@ def log (level=INFO, msg=''):
         if not logfd:
             try:
                 logfd = open (os.path.expanduser (config['log_file']), 'a')
-            except IOError, txt:
-                raise ConfigurationError, 'failed to open log file %s (%s)' \
-                    % (config['log_file'], txt)
+            except IOError as txt:
+                raise ConfigurationError('failed to open log file %s (%s)' \
+                    % (config['log_file'], txt))
         t = time.localtime (time.time ())
         logfd.write ('%s %s' % (time.strftime ('%d %b %Y %H:%M:%S', t), s))
         logfd.flush ()
@@ -182,17 +232,17 @@ def read_config ():
                 try:
                     value = loglevels[value]
                 except KeyError:
-                    raise ConfigurationError, \
-                        '"%s" not a valid logging level' % value
+                    raise ConfigurationError('"%s" not a valid logging level' % value)
             config[option] = value
             if option == 'secret':
                 log (TRACE, 'option secret == %s...' % value[:20])
             else:
                 log (TRACE, 'option %s == %s...' % (option, config[option]))
-    except (ConfigurationError, ConfParser.ConfParserException), txt:
-        log (FATAL, 'Fatal:  exception reading %s (%s)' % (config_file, txt))
-        raise
-    if type (config['token_recipient']) != types.ListType:
+    except (ConfigurationError, ConfParser.ConfParserException) as txt:
+        if not os.environ.get ('PYMSGAUTH_TOLERATE_UNCONFIGURED'):
+            log (FATAL, 'Fatal:  exception reading %s (%s)' % (config_file, txt))
+            raise
+    if type (config['token_recipient']) != list:
         config['token_recipient'] = [config['token_recipient']]
     log (TRACE)
 
@@ -214,27 +264,26 @@ def extract_original_message (msg):
         del lines[0]
 
     # Strip blank line(s)
-    while lines and string.strip (lines[0]) == '':
+    while lines and lines[0].strip () == '':
         del lines[0]
 
-    buf = cStringIO.StringIO (string.join (lines, ''))
+    buf = io.StringIO (''.join (lines))
     buf.seek (0)
-    orig_msg = rfc822.Message (buf)
+    orig_msg = RFC822Message (buf)
     return orig_msg
 
 #############################
 def gen_token (msg):
-    import sha
+    import hashlib
     lines = []
-    token = sha.new('%s,%s,%s,%s'
-        % (os.getpid(), time.time(), string.join (msg.headers),
-            config['secret'])).hexdigest()
+    contents = '%s,%s,%s,%s' % (os.getpid(), time.time(), ''.join (msg.headers), config['secret'])
+    token = hashlib.sha1(contents.encode('utf-8')).hexdigest()
     # Record token
     p = os.path.join (config['pymsgauth_dir'], '.%s' % token)
     try:
         open (p, 'wb')
         log (TRACE, 'Recorded token %s.' % p)
-    except IOError, txt:
+    except IOError as txt:
         log (FATAL, 'Fatal:  exception creating %s (%s)' % (p, txt))
         raise
     return token
@@ -252,7 +301,7 @@ def check_token (msg, token):
         log (INFO, 'Matched token %s, removing.' % token)
         os.unlink (p)
         log (TRACE, 'Removed token %s.' % token)
-    except OSError, txt:
+    except OSError as txt:
         log (FATAL, 'Fatal:  error handling token %s (%s)' % (token, txt))
         log_exception ()
         # Exit 0 so qmail delivers the qsecretary notice to user
@@ -261,19 +310,19 @@ def check_token (msg, token):
 
 #############################
 def send_mail (msgbuf, mailcmd):
-    import popen2
-    popen2._cleanup()
+    import subprocess
     log (TRACE, 'Mail command is "%s".' % mailcmd)
-    cmd = popen2.Popen3 (mailcmd, 1, bufsize=-1)
-    cmdout, cmdin, cmderr = cmd.fromchild, cmd.tochild, cmd.childerr
+    cmd = subprocess.Popen (mailcmd, shell=True, bufsize=-1, universal_newlines=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True)
+    cmdout, cmdin, cmderr = cmd.stdout, cmd.stdin, cmd.stderr
+
     cmdin.write (msgbuf)
     cmdin.flush ()
     cmdin.close ()
     log (TRACE)
 
-    err = string.strip (cmderr.read ())
+    err = cmderr.read ().strip ()
     cmderr.close ()
-    out = string.strip (cmdout.read ())
+    out = cmdout.read ().strip ()
     cmdout.close ()
 
     r = cmd.wait ()
@@ -292,18 +341,18 @@ def send_mail (msgbuf, mailcmd):
                     errtext = ', err: "%s"' % err
                 else:
                     errtext = ''
-                raise DeliveryError, 'mail command %s exited %s, %s%s' \
-                    % (mailcmd, exitcode, exitsignal, errtext)
+                raise DeliveryError('mail command %s exited %s, %s%s' \
+                    % (mailcmd, exitcode, exitsignal, errtext))
             else:
                 exitcode = 127
-                raise DeliveryError, 'mail command %s did not exit (rc == %s)' \
-                    % (mailcmd, r)
+                raise DeliveryError('mail command %s did not exit (rc == %s)' \
+                    % (mailcmd, r))
 
         if err:
-            raise DeliveryError, 'mail command %s error: "%s"' % (mailcmd, err)
+            raise DeliveryError('mail command %s error: "%s"' % (mailcmd, err))
             exitcode = 1
 
-    except DeliveryError, txt:
+    except DeliveryError as txt:
         log (FATAL, 'Fatal:  failed sending mail (%s)' % txt)
         log_exception ()
         sys.exit (exitcode or 1)
@@ -336,12 +385,12 @@ def clean_old_tokens ():
                 if s[stat.ST_CTIME] &lt; oldest:
                     log (INFO, 'Removing old token %s.' % filename)
                     os.unlink (p)
-            except OSError, txt:
+            except OSError as txt:
                 log (ERROR, 'Error:  error handling token %s (%s)'
                     % (filename, txt))
                 raise
 
-    except StandardError, txt:
+    except Exception as txt:
         log (FATAL, 'Fatal:  caught exception (%s)' % txt)
         log_exception ()
         sys.exit (1)
@@ -361,10 +410,25 @@ def sendmail_wrapper (args):
             mailcmd += config['extra_mail_args']
         mailcmd += args
         log (TRACE, 'mailcmd == %s' % mailcmd)
-        buf = cStringIO.StringIO (sys.stdin.read())
-        msg = rfc822.Message (buf, seekable=1)
+        buf = io.StringIO (u'' + sys.stdin.read())
+        new_buf = tokenize_message_if_needed (buf, args)
 
+        send_mail (new_buf, mailcmd)
+        if (new_buf != buf.getvalue ()):
+            log (TRACE, 'Sent tokenized mail.')
+        else:
+            log (TRACE, 'Passed mail through unchanged.')
+
+    except Exception as txt:
+        log (FATAL, 'Fatal:  caught exception (%s)' % txt)
+        log_exception ()
+        sys.exit (1)
+
+#############################
+def should_tokenize_message (msg, *args):
+    try:
         sign_message = 0
+
         for arg in args:
             if arg in config['token_recipient']:
                 sign_message = 1
@@ -373,22 +437,34 @@ def sendmail_wrapper (args):
             recips = []
             for field in ('to', 'cc', 'bcc', 'resent-to', 'resent-cc', 'resent-bcc'):
                 recips.extend (msg.getaddrlist (field))
-            recips = map (lambda (name, addr):  addr, recips)
+            recips = [name_addr[1] for name_addr in recips]
             for recip in recips:
                 if recip in config['token_recipient']:
                     sign_message = 1
                     break
-        if sign_message:
+
+        return sign_message
+
+    except Exception as txt:
+        log (FATAL, 'Fatal:  caught exception (%s)' % txt)
+        log_exception ()
+        sys.exit (1)
+
+#############################
+def tokenize_message_if_needed (buf, *args):
+    try:
+        read_config ()
+        log (TRACE)
+        msg = RFC822Message (buf, seekable=1)
+
+        if should_tokenize_message (msg, args):
             token = gen_token (msg)
             log (INFO, 'Generated token %s.' % token)
-            new_buf = '%s: %s\n' % (config['auth_field'], token) + buf.getvalue ()
-            send_mail (new_buf, mailcmd)
-            log (TRACE, 'Sent tokenized mail.')
+            return '%s: %s\n' % (config['auth_field'], token) + buf.getvalue ()
         else:
-            send_mail (buf.getvalue (), mailcmd)
-            log (TRACE, 'Passed mail through unchanged.')
+            return buf.getvalue ()
 
-    except StandardError, txt:
+    except Exception as txt:
         log (FATAL, 'Fatal:  caught exception (%s)' % txt)
         log_exception ()
         sys.exit (1)
@@ -398,8 +474,8 @@ def process_qsecretary_message ():
     try:
         read_config ()
         log (TRACE)
-        buf = cStringIO.StringIO (sys.stdin.read())
-        msg = rfc822.Message (buf, seekable=1)
+        buf = io.StringIO (u'' + sys.stdin.read())
+        msg = RFC822Message (buf, seekable=1)
         from_name, from_addr = msg.getaddr ('from')
         if from_name != 'The qsecretary program':
             # Not a confirmation message, just quit
@@ -409,9 +485,9 @@ def process_qsecretary_message ():
 
         # Verify the message came from a domain we recognize
         confirm = 0
-        domain = string.split (from_addr, '@')[-1]
+        domain = from_addr.split ('@')[-1]
         cdomains = config['confirm_domain']
-        if type (cdomains) != types.ListType:  cdomains = [cdomains]
+        if type (cdomains) != list:  cdomains = [cdomains]
         for cd in cdomains:
             if cd == domain:  confirm = 1
         if not confirm:
@@ -422,7 +498,8 @@ def process_qsecretary_message ():
 
         # check message here
         orig_msg = extract_original_message (msg)
-        orig_token = string.strip (orig_msg.getheader (config['auth_field'], ''))
+        orig_header = orig_msg.getheader (config['auth_field'], '')
+        orig_token = orig_header.strip ()
         if orig_token:
             log (TRACE, 'Received qsecretary notice with token %s.'
                 % orig_token)
@@ -435,7 +512,7 @@ def process_qsecretary_message ():
                 try:
                     source_addr = config['confirmation_address']
                 except:
-                    raise ConfigurationError, 'no confirmation_address configured'
+                    raise ConfigurationError('no confirmation_address configured')
                 # Confirm this confirmation notice
                 #confirm_cmd = config['mail_prog'] \
                 #    + ' ' + '-f "%s" "%s"' % (source_addr, from_addr)
@@ -446,14 +523,14 @@ def process_qsecretary_message ():
                 log (INFO, 'Authenticated qsecretary notice, from "%s", token "%s"'
                     % (from_addr, orig_token))
                 sys.exit (99)
-            except ConfigurationError, txt:
+            except ConfigurationError as txt:
                 log (ERROR, 'Error:  failed sending confirmation notice (%s)'
                     % txt)
         else:
             log (ERROR, 'Error:  did not find matching token file (%s)'
                 % orig_token)
 
-    except StandardError, txt:
+    except Exception as txt:
         log (FATAL, 'Fatal:  caught exception (%s)' % txt)
         log_exception ()
 
</pre></body></html>