source: emailtotracscript/trunk/email2trac.py.in @ 79

Last change on this file since 79 was 79, checked in by bas, 18 years ago

EmailtoTracScript?:

email2trac.py.in:

  • Language corrections by Nathaniel Irons
  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 19.7 KB
Line 
1#!@PYTHON@
2# Copyright (C) 2002
3#
4# This file is part of the email2trac utils
5#
6# This program is free software; you can redistribute it and/or modify it
7# under the terms of the GNU General Public License as published by the
8# Free Software Foundation; either version 2, or (at your option) any
9# later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA
19#
20"""
21email2trac.py -- Email tickets to Trac.
22
23A simple MTA filter to create Trac tickets from inbound emails.
24
25Copyright 2005, Daniel Lundin <daniel@edgewall.com>
26Copyright 2005, Edgewall Software
27
28Changed By: Bas van der Vlies <basv@sara.nl>
29Date      : 13 September 2005
30Descr.    : Added config file and command line options, spam level
31            detection, reply address and mailto option. Unicode support
32
33Changed By: Walter de Jong <walter@sara.nl>
34Descr.    : multipart-message code and trac attachments
35
36
37The scripts reads emails from stdin and inserts directly into a Trac database.
38MIME headers are mapped as follows:
39
40        * From:      => Reporter
41                     => CC (Optional via reply_address option)
42        * Subject:   => Summary
43        * Body       => Description
44        * Component  => Can be set to SPAM via spam_level option
45
46How to use
47----------
48 * Create an config file:
49        [DEFAULT]                      # REQUIRED
50        project      : /data/trac/test # REQUIRED
51        debug        : 1               # OPTIONAL, if set print some DEBUG info
52        spam_level   : 4               # OPTIONAL, if set check for SPAM mail
53        reply_address: 1               # OPTIONAL, if set then fill in ticket CC field
54    umask        : 022             # OPTIONAL, if set then use this umask for creation of the attachments
55        mailto_link  : 1               # OPTIONAL, if set then [mailto:<>] in description
56        mailto_cc    : basv@sara.nl    # OPTIONAL, use this address as CC in mailto line
57        ticket_update: 1               # OPTIONAL, if set then check if this is an update for a ticket
58        trac_version : 0.8             # OPTIONAL, default is 0.9
59
60        [jouvin]                         # OPTIONAL project declaration, if set both fields necessary
61        project      : /data/trac/jouvin # use -p|--project jouvin. 
62       
63 * default config file is : /etc/email2trac.conf
64
65 * Commandline opions:
66                -h | --help
67                -c <value> | --component=<value>
68                -f <config file> | --file=<config file>
69                -p <project name> | --project=<project name>
70
71SVN Info:
72        $Id: email2trac.py.in 79 2006-06-02 06:53:36Z bas $
73"""
74
75import os
76import sys
77import string
78import getopt
79import stat
80import time
81import email
82import re
83import urllib
84import unicodedata
85import ConfigParser
86from email import Header
87from stat import *
88import mimetypes
89
90trac_default_version = 0.9
91
92class TicketEmailParser(object):
93        env = None
94        comment = '> '
95   
96        def __init__(self, env, parameters, version):
97                self.env = env
98
99                # Database connection
100                #
101                self.db = None
102
103                # Some useful mail constants
104                #
105                self.author = None
106                self.email_addr = None
107                self.email_field = None
108
109                self.VERSION = version
110                if self.VERSION == 0.8:
111                        self.get_config = self.env.get_config
112                else:
113                        self.get_config = self.env.config.get
114
115                if parameters.has_key('umask'):
116                        os.umask(int(parameters['umask'], 8))
117
118                if parameters.has_key('debug'):
119                        self.DEBUG = int(parameters['debug'])
120                else:
121                        self.DEBUG = 0
122
123                if parameters.has_key('mailto_link'):
124                        self.MAILTO = int(parameters['mailto_link'])
125                        if parameters.has_key('mailto_cc'):
126                                self.MAILTO_CC = parameters['mailto_cc']
127                        else:
128                                self.MAILTO_CC = ''
129                else:
130                        self.MAILTO = 0
131
132                if parameters.has_key('spam_level'):
133                        self.SPAM_LEVEL = int(parameters['spam_level'])
134                else:
135                        self.SPAM_LEVEL = 0
136
137                if parameters.has_key('email_comment'):
138                        self.comment = str(parameters['email_comment'])
139
140                if parameters.has_key('email_header'):
141                        self.EMAIL_HEADER = int(parameters['email_header'])
142                else:
143                        self.EMAIL_HEADER = 0
144
145                if parameters.has_key('alternate_notify_template'):
146                        self.notify_template = str(parameters['alternate_notify_template'])
147                else:
148                        self.notify_template = None
149
150                if parameters.has_key('reply_all'):
151                        self.REPLY_ALL = int(parameters['reply_all'])
152                else:
153                        self.REPLY_ALL = 0
154
155                if parameters.has_key('ticket_update'):
156                        self.TICKET_UPDATE = int(parameters['ticket_update'])
157                else:
158                        self.TICKET_UPDATE = 0
159
160
161        # X-Spam-Score: *** (3.255) BAYES_50,DNS_FROM_AHBL_RHSBL,HTML_
162        # Note if Spam_level then '*' are included
163        def spam(self, message):
164                if message.has_key('X-Spam-Score'):
165                        spam_l = string.split(message['X-Spam-Score'])
166                        number = spam_l[0].count('*')
167
168                        if number >= self.SPAM_LEVEL:
169                                return 'Spam'
170
171                elif message.has_key('X-Virus-found'):                  # treat virus mails as spam
172                        return 'Spam'
173
174                return self.get_config('ticket', 'default_component')
175
176        def to_unicode(self, str):
177                """
178                Email has 7 bit ASCII code, convert it to unicode with the charset
179        that is encoded in 7-bit ASCII code and encode it as utf-8 so Trac
180                understands it.
181                """
182                results =  Header.decode_header(str)
183                str = None
184                for text,format in results:
185                        if format:
186                                try:
187                                        temp = unicode(text, format)
188                                except UnicodeError:
189                                        # This always works
190                                        #
191                                        temp = unicode(text, 'iso-8859-15')
192                                temp =  temp.encode('utf-8')
193                        else:
194                                temp = string.strip(text)
195
196                        if str:
197                                str = '%s %s' %(str, temp)
198                        else:
199                                str = temp
200
201                return str
202
203        def debug_attachments(self, message):
204                n = 0
205                for part in message.walk():
206                        if part.get_content_maintype() == 'multipart':      # multipart/* is just a container
207                                print 'TD: multipart container'
208                                continue
209
210                        n = n + 1
211                        print 'TD: part%d: Content-Type: %s' % (n, part.get_content_type())
212                        print 'TD: part%d: filename: %s' % (n, part.get_filename())
213
214                        if part.is_multipart():
215                                print 'TD: this part is multipart'
216                                payload = part.get_payload(decode=1)
217                                print 'TD: payload:', payload
218                        else:
219                                print 'TD: this part is not multipart'
220
221                        part_file = '/var/tmp/part%d' % n
222                        print 'TD: writing part%d (%s)' % (n,part_file)
223                        fx = open(part_file, 'wb')
224                        text = part.get_payload(decode=1)
225                        if not text:
226                                text = '(None)'
227                        fx.write(text)
228                        fx.close()
229                        try:
230                                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
231                        except OSError:
232                                pass
233
234        def email_header_txt(self, m):
235                """
236                Display To and CC addresses in description field
237                """
238                str = ''
239                if m['To'] and len(m['To']) > 0 and m['To'] != 'hic@sara.nl':
240                        str = "'''To:''' %s [[BR]]" %(m['To'])
241                if m['Cc'] and len(m['Cc']) > 0:
242                        str = "%s'''Cc:''' %s [[BR]]" % (str, m['Cc'])
243
244                return str
245
246        def set_owner(self, ticket):
247                """
248                Select default owner for ticket component
249                """
250                cursor = self.db.cursor()
251                sql = "SELECT owner FROM component WHERE name='%s'" % ticket['component']
252                cursor.execute(sql)
253                try:
254                        ticket['owner'] = cursor.fetchone()[0]
255                except TypeError, detail:
256                        ticket['owner'] = "UNKNOWN"
257
258        def get_author_emailaddrs(self, message):
259                """
260                Get the default author name and email address from the message
261                """
262                self.author, self.email_addr  = email.Utils.parseaddr(message['from'])
263
264                # Look for email address in registered trac users
265                #
266                if self.VERSION == 0.8:
267                        users = []
268                else:
269                        users = [ u for (u, n, e) in self.env.get_known_users(self.db)
270                                if e == self.email_addr ]
271
272                if len(users) == 1:
273                        self.email_field = users[0]
274                else:
275                        self.email_field =  self.to_unicode(message['from'])
276
277        def set_reply_fields(self, ticket, message):
278                """
279                Set all the right fields for a new ticket
280                """
281                ticket['reporter'] = self.email_field
282
283                # Put all CC-addresses in ticket CC field
284                #
285                if self.REPLY_ALL:
286                        #tos = message.get_all('to', [])
287                        ccs = message.get_all('cc', [])
288
289                        addrs = email.Utils.getaddresses(ccs)
290
291                        # Remove reporter email address if notification is
292                        # on
293                        #
294                        if self.notification:
295                                try:
296                                        addrs.remove((self.author, self.email_addr))
297                                except ValueError, detail:
298                                        pass
299
300                        for name,mail in addrs:
301                                        try:
302                                                ticket['cc'] = '%s,%s' %(ticket['cc'], mail)
303                                        except:
304                                                ticket['cc'] = mail
305
306        def save_email_for_debug(self, message):
307                msg_file = '/var/tmp/msg.txt'
308                print 'TD: saving email to %s' % msg_file
309                fx = open(msg_file, 'wb')
310                fx.write('%s' % message)
311                fx.close()
312                try:
313                        os.chmod(msg_file,S_IRWXU|S_IRWXG|S_IRWXO)
314                except OSError:
315                        pass
316
317        def ticket_update(self, m):
318                """
319                If the current email is a reply to an existing ticket, this function
320                will append the contents of this email to that ticket, instead of
321                creating a new one.
322                """
323                if not m['Subject']:
324                        return False
325                else:
326                        subject  = self.to_unicode(m['Subject'])
327
328                TICKET_RE = re.compile(r"""
329                                        (?P<ticketnr>[#][0-9]+:)
330                                        """, re.VERBOSE)
331
332                result =  TICKET_RE.search(subject)
333                if not result:
334                        return False
335
336                body_text = self.get_body_text(m)
337                body_text = '{{{\n%s\n}}}' %body_text
338
339                # Strip '#' and ':' from ticket_id
340                #
341                ticket_id = result.group('ticketnr')
342                ticket_id = int(ticket_id[1:-1])
343
344                # Get current time
345                #
346                when = int(time.time())
347
348                if self.VERSION  == 0.8:
349                        tkt = Ticket(self.db, ticket_id)
350                        tkt.save_changes(self.db, self.author, body_text, when)
351                else:
352                        tkt = Ticket(self.env, ticket_id, self.db)
353                        tkt.save_changes(self.author, body_text, when)
354
355                self.attachments(m, tkt)
356
357                if self.notification:
358                        self.notify(tkt, False, when)
359
360                return True
361
362        def new(self, msg):
363                """
364                Create a new ticket
365                """
366                tkt = Ticket(self.env)
367                tkt['status'] = 'new'
368
369                # Some defaults
370                #
371                tkt['milestone'] = self.get_config('ticket', 'default_milestone')
372                tkt['priority'] = self.get_config('ticket', 'default_priority')
373                tkt['severity'] = self.get_config('ticket', 'default_severity')
374                tkt['version'] = self.get_config('ticket', 'default_version')
375
376                if not msg['Subject']:
377                        tkt['summary'] = '(geen subject)'
378                else:
379                        tkt['summary'] = self.to_unicode(msg['Subject'])
380
381
382                if settings.has_key('component'):
383                        tkt['component'] = settings['component']
384                else:
385                        tkt['component'] = self.spam(msg)
386
387                # Must make this an option or so, discard SPAM messages or save then
388                # and delete later
389                #
390                #if self.SPAM_LEVEL and self.spam(msg):
391                #       print 'This message is a SPAM. Automatic ticket insertion refused (SPAM level > %d' % self.SPAM_LEVEL
392                #       sys.exit(1)
393
394                # Set default owner for component
395                #
396                self.set_owner(tkt)
397                self.set_reply_fields(tkt, msg)
398
399                # produce e-mail like header
400                #
401                head = ''
402                if self.EMAIL_HEADER > 0:
403                        head = self.email_header_txt(msg)
404
405
406                body_text = self.get_body_text(msg)
407                tkt['description'] = ''
408
409                # Insert ticket in database with empty description
410                #
411                when = int(time.time())
412                if self.VERSION == 0.8:
413                        tkt['id'] = tkt.insert(self.db)
414                else:
415                        tkt['id'] = tkt.insert()
416
417                n =  self.attachments(msg, tkt)
418                if n:
419                        attach_str = '\nThis message has %d attachment(s)\n' %(n)
420                else:
421                        attach_str = ''
422
423                # Always update the description else we get two emails one for the new ticket
424                # and for the attachments. It is an ugly hack but with trac you can not add
425                # attachments without an ticket id
426                #
427                mailto = ''
428                if self.MAILTO:
429                        mailto = self.html_mailto_link(self.to_unicode(msg['subject']), tkt['id'], body_text)
430                        tkt['description'] = '%s%s%s\n{{{\n%s\n}}}\n' %(head, attach_str, mailto, body_text)
431                        comment = 'Added mailto: link + description'
432                else:
433                        tkt['description'] = '%s%s\n{{{\n%s\n}}}\n' %(head, attach_str, body_text)
434                        comment = 'Added description'
435
436                # Save the real description and other changes
437                #
438                if self.VERSION  == 0.8:
439                        tkt.save_changes(self.db, self.author, comment, when)
440                else:
441                        tkt.save_changes(self.author, comment, when)
442
443                if self.notification:
444                        self.notify(tkt, True, when)
445
446        def parse(self, fp):
447                m = email.message_from_file(fp)
448                if not m:
449                        return
450
451                if self.DEBUG > 1:        # save the entire e-mail message text
452                        self.save_email_for_debug(m)
453                        self.debug_attachments(m)
454
455                self.db = self.env.get_db_cnx()
456                self.get_author_emailaddrs(m)
457
458                if self.get_config('notification', 'smtp_enabled') in ['true']:
459                        self.notification = 1
460                else:
461                        self.notification = 0
462
463                # Must we update existing tickets
464                #
465                if self.TICKET_UPDATE > 0:
466                        if self.ticket_update(m):
467                                return True
468
469                self.new(m)
470
471        def get_body_text(self, msg):
472                """
473                put the message text in the ticket description or in the changes field.
474                message text can be plain text or html or something else
475                """
476                has_description = 0
477                ubody_text = '\n{{{\nNo plain text message\n}}}\n'
478                for part in msg.walk():
479
480                        # 'multipart/*' is a container for multipart messages
481                        #
482                        if part.get_content_maintype() == 'multipart':
483                                continue
484
485                        if part.get_content_type() == 'text/plain':
486                                # Try to decode, if fails then do not decode
487                                #
488                                body_text = part.get_payload(decode=1)         
489                                if not body_text:                       
490                                        body_text = part.get_payload(decode=0) 
491
492                                # Get contents charset (iso-8859-15 if not defined in mail headers)
493                                # UTF-8 encode body_text
494                                #
495                                charset = msg.get_content_charset('iso-8859-15')
496                                ubody_text = unicode(body_text, charset).encode('utf-8')
497
498                        elif part.get_content_type() == 'text/html':
499                                ubody_text = '\n\n(see attachment for HTML mail message)\n'
500
501                        else:
502                                ubody_text = '\n\n(see attachment for message)\n'
503
504                        has_description = 1
505                        break           # we have the description, so break
506
507                if not has_description:
508                        ubody_text = '\n\n(see attachment for message)\n'
509
510                return ubody_text
511
512        def notify(self, tkt , new=True, modtime=0):
513                """
514                A wrapper for the TRAC notify function. So we can use templates
515                """
516                try:
517                        # create false {abs_}href properties, to trick Notify()
518                        #
519                        self.env.abs_href = Href(self.get_config('project', 'url'))
520                        self.env.href = Href(self.get_config('project', 'url'))
521
522                        tn = TicketNotifyEmail(self.env)
523                        if self.notify_template:
524                                tn.template_name = self.notify_template;
525
526                        tn.notify(tkt, new, modtime)
527
528                except Exception, e:
529                        print 'TD: Failure sending notification on creation of ticket #%s: %s' %(tkt['id'], e)
530
531        def mail_line(self, str):
532                return '%s %s' % (self.comment, str)
533
534
535        def html_mailto_link(self, subject, id, body):
536                if not self.author:
537                        author = self.mail_addr
538                else:   
539                        author = self.to_unicode(self.author)
540
541                # Must find a fix
542                #
543                #arr = string.split(body, '\n')
544                #arr = map(self.mail_line, arr)
545                #body = string.join(arr, '\n')
546                #body = '%s wrote:\n%s' %(author, body)
547
548                # Temporary fix
549                str = 'mailto:%s?Subject=%s&Cc=%s' %(
550                       urllib.quote(self.email_addr),
551                           urllib.quote('Re: #%s: %s' %(id, subject)),
552                           urllib.quote(self.MAILTO_CC)
553                           )
554
555                str = '\n{{{\n#!html\n<a href="%s">Reply to: %s</a>\n}}}\n' %(str, author)
556
557                return str
558
559        def attachments(self, message, ticket):
560                '''
561                save any attachments as files in the ticket's directory
562                '''
563                count = 0
564                first = 0
565                number = 0
566                for part in message.walk():
567                        if part.get_content_maintype() == 'multipart':          # multipart/* is just a container
568                                continue
569
570                        if not first:                                                                           # first content is the message
571                                first = 1
572                                if part.get_content_type() == 'text/plain':             # if first is text, is was already put in the description
573                                        continue
574
575                        filename = part.get_filename()
576                        count = count + 1
577                        if not filename:
578                                number = number + 1
579                                filename = 'part%04d' % number
580
581                                ext = mimetypes.guess_extension(part.get_content_type())
582                                if not ext:
583                                        ext = '.bin'
584
585                                filename = '%s%s' % (filename, ext)
586                        else:
587                                filename = self.to_unicode(filename)
588
589                        # From the trac code
590                        #
591                        filename = filename.replace('\\', '/').replace(':', '/')
592                        filename = os.path.basename(filename)
593
594                        # We try to normalize the filename to utf-8 NFC if we can.
595                        # Files uploaded from OS X might be in NFD.
596                        #
597                        if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
598                                filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
599
600                        url_filename = urllib.quote(filename)
601                        if self.VERSION == 0.8:
602                                dir = os.path.join(self.env.get_attachments_dir(), 'ticket',
603                                                        urllib.quote(str(ticket['id'])))
604                                if not os.path.exists(dir):
605                                        mkdir_p(dir, 0755)
606                        else:
607                                dir = '/tmp'
608
609                        path, fd =  util.create_unique_file(os.path.join(dir, url_filename))
610                        text = part.get_payload(decode=1)
611                        if not text:
612                                text = '(None)'
613                        fd.write(text)
614                        fd.close()
615
616                        # get the filesize
617                        #
618                        stats = os.lstat(path)
619                        filesize = stats[stat.ST_SIZE]
620
621                        # Insert the attachment it differs for the different TRAC versions
622                        #
623                        if self.VERSION == 0.8:
624                                cursor = self.db.cursor()
625                                try:
626                                        cursor.execute('INSERT INTO attachment VALUES("%s","%s","%s",%d,%d,"%s","%s","%s")'
627                                                %('ticket', urllib.quote(str(ticket['id'])), filename + '?format=raw', filesize,
628                                                int(time.time()),'', self.author, 'e-mail') )
629
630                                # Attachment is already known
631                                #
632                                except sqlite.IntegrityError:   
633                                        self.db.close()
634                                        return
635
636                                self.db.commit()
637
638                        else:
639                                fd = open(path)
640                                att = attachment.Attachment(self.env, 'ticket', ticket['id'])
641                                att.insert(url_filename, fd, filesize)
642                                fd.close()
643
644                # Return how many attachments
645                #
646                return count
647
648
649def mkdir_p(dir, mode):
650        '''do a mkdir -p'''
651
652        arr = string.split(dir, '/')
653        path = ''
654        for part in arr:
655                path = '%s/%s' % (path, part)
656                try:
657                        stats = os.stat(path)
658                except OSError:
659                        os.mkdir(path, mode)
660
661
662def ReadConfig(file, name):
663        """
664        Parse the config file
665        """
666
667        if not os.path.isfile(file):
668                print 'File %s does not exist' %file
669                sys.exit(1)
670
671        config = ConfigParser.ConfigParser()
672        try:
673                config.read(file)
674        except ConfigParser.MissingSectionHeaderError,detail:
675                print detail
676                sys.exit(1)
677
678
679        # Use given project name else use defaults
680        #
681        if name:
682                if not config.has_section(name):
683                        print "Not a valid project name: %s" %name
684                        print "Valid names: %s" %config.sections()
685                        sys.exit(1)
686
687                project =  dict()
688                for option in  config.options(name):
689                        project[option] = config.get(name, option)
690
691        else:
692                project = config.defaults()
693
694        return project
695
696if __name__ == '__main__':
697        # Default config file
698        #
699        configfile = '@email2trac_conf@'
700        project = ''
701        component = ''
702       
703        try:
704                opts, args = getopt.getopt(sys.argv[1:], 'chf:p:', ['component=','help', 'file=', 'project='])
705        except getopt.error,detail:
706                print __doc__
707                print detail
708                sys.exit(1)
709
710        project_name = None
711        for opt,value in opts:
712                if opt in [ '-h', '--help']:
713                        print __doc__
714                        sys.exit(0)
715                elif opt in ['-c', '--component']:
716                        component = value
717                elif opt in ['-f', '--file']:
718                        configfile = value
719                elif opt in ['-p', '--project']:
720                        project_name = value
721
722        settings = ReadConfig(configfile, project_name)
723        if not settings.has_key('project'):
724                print __doc__
725                print 'No Trac project is defined in the email2trac config file.'
726                sys.exit(1)
727
728        if component:
729                settings['component'] = component
730
731        if settings.has_key('trac_version'):
732                version = float(settings['trac_version'])
733        else:
734                version = trac_default_version
735
736        #debug HvB
737        #print settings
738
739        if version == 0.8:
740                from trac.Environment import Environment
741                from trac.Ticket import Ticket
742                from trac.Notify import TicketNotifyEmail
743                from trac.Href import Href
744                from trac import util
745                import sqlite
746        elif version == 0.9:
747                from trac import attachment
748                from trac.env import Environment
749                from trac.ticket import Ticket
750                from trac.web.href import Href
751                from trac import util
752                from trac.Notify import TicketNotifyEmail
753        elif version == 0.10:
754                from trac import attachment
755                from trac.env import Environment
756                from trac.ticket import Ticket
757                from trac.web.href import Href
758                from trac import util
759                # see http://projects.edgewall.com/trac/changeset/2799
760                from trac.ticket.notification import TicketNotifyEmail
761
762        env = Environment(settings['project'], create=0)
763        tktparser = TicketEmailParser(env, settings, version)
764        tktparser.parse(sys.stdin)
765
766# EOB
Note: See TracBrowser for help on using the repository browser.