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

Last change on this file since 86 was 86, checked in by bas, 16 years ago

EmailtoTracScript?:

email2trac.py.in:

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