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

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

EmailtoTracScript?:

email2trac.py.in:

  • Fixed some bugs in ticket merging and re-arrange some code.
  • Attachments are not listed when a new ticket is created with email. So added a line how many attachments there are.
  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 19.4 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 77 2006-05-26 12:57:10Z 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
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 not m['Subject']:
321                        return False
322                else:
323                        subject  = self.to_unicode(m['Subject'])
324
325                TICKET_RE = re.compile(r"""
326                                        (?P<ticketnr>[#][0-9]+:)
327                                        """, re.VERBOSE)
328
329                result =  TICKET_RE.search(subject)
330                if not result:
331                        return False
332
333                # Strip '#' and ':' from ticket_id
334                #
335                ticket_id = result.group('ticketnr')
336                ticket_id = int(ticket_id[1:-1])
337
338                tkt = Ticket(self.db, ticket_id)
339
340                body_text = self.get_body_text(m)
341                body_text = '{{{\n%s\n}}}' %body_text
342
343                # Get current time
344                #
345                when = int(time.time())
346
347                if self.VERSION  == 0.8:
348                        tkt.save_changes(self.db, self.author, body_text, when)
349                else:
350                        tkt.save_changes(self.author, body_text, when)
351
352                self.attachments(m, tkt)
353
354                if self.notification:
355                        self.notify(tkt, False, when)
356
357                return True
358
359
360        def new(self, msg):
361                """
362                Create a new ticket
363                """
364                tkt = Ticket(self.env)
365                tkt['status'] = 'new'
366
367                # Some defaults
368                #
369                tkt['milestone'] = self.get_config('ticket', 'default_milestone')
370                tkt['priority'] = self.get_config('ticket', 'default_priority')
371                tkt['severity'] = self.get_config('ticket', 'default_severity')
372                tkt['version'] = self.get_config('ticket', 'default_version')
373
374                if not msg['Subject']:
375                        tkt['summary'] = '(geen subject)'
376                else:
377                        tkt['summary'] = self.to_unicode(msg['Subject'])
378
379
380                if settings.has_key('component'):
381                        tkt['component'] = settings['component']
382                else:
383                        tkt['component'] = self.spam(msg)
384
385                # Must make this an option or so, discard SPAM messages or save then
386                # and delete later
387                #
388                #if self.SPAM_LEVEL and self.spam(msg):
389                #       print 'This message is a SPAM. Automatic ticket insertion refused (SPAM level > %d' % self.SPAM_LEVEL
390                #       sys.exit(1)
391
392                # Set default owner for component
393                #
394                self.set_owner(tkt)
395                self.set_reply_fields(tkt, msg)
396
397                # produce e-mail like header
398                #
399                head = ''
400                if self.EMAIL_HEADER > 0:
401                        head = self.email_header_txt(msg)
402
403
404                body_text = self.get_body_text(msg)
405                tkt['description'] = ''
406
407                # Insert ticket in database with empty description
408                #
409                when = int(time.time())
410                if self.VERSION == 0.8:
411                        tkt['id'] = tkt.insert(self.db)
412                else:
413                        tkt['id'] = tkt.insert()
414
415                n =  self.attachments(msg, tkt)
416                if n:
417                        attach_str = '\nThis message has %d attachment(s)\n' %(n)
418                else:
419                        attach_str = ''
420
421                # Always update the description else we get two emails one for the new ticket
422                # and for the attachments. It is an ugly hack but with trac you can not add
423                # attachments without an ticket id
424                #
425                mailto = ''
426                if self.MAILTO:
427                        mailto = self.html_mailto_link(self.to_unicode(msg['subject']), tkt['id'], body_text)
428                        tkt['description'] = '%s%s%s\n{{{\n%s\n}}}\n' %(head, attach_str, mailto, body_text)
429                        comment = 'Added mailto: link + description'
430                else:
431                        tkt['description'] = '%s%s\n{{{\n%s\n}}}\n' %(head, attach_str, body_text)
432                        comment = 'Added description'
433
434                # Save the real description and other changes
435                #
436                if self.VERSION  == 0.8:
437                        tkt.save_changes(self.db, self.author, comment, when)
438                else:
439                        tkt.save_changes(self.author, comment, when)
440
441                if self.notification:
442                        self.notify(tkt, True, when)
443
444        def parse(self, fp):
445                m = email.message_from_file(fp)
446                if not m:
447                        return
448
449                if self.DEBUG > 1:        # save the entire e-mail message text
450                        self.save_email_for_debug(m)
451                        self.debug_attachments(m)
452
453                self.db = self.env.get_db_cnx()
454                self.get_author_emailaddrs(m)
455
456                if self.get_config('notification', 'smtp_enabled') in ['true']:
457                        self.notification = 1
458                else:
459                        self.notification = 0
460
461                # Must we update existing tickets
462                #
463                if self.TICKET_UPDATE > 0:
464                        if self.ticket_update(m):
465                                return True
466
467                self.new(m)
468
469        def get_body_text(self, msg):
470                """
471                put the message text in the ticket description or in the changes field
472                message text can be plain text or html or something else
473                """
474                has_description = 0
475                ubody_text = '\n{{{\nNo plain text message\n}}}\n'
476                for part in msg.walk():
477
478                        # 'multipart/*' is a container for multipart messages
479                        #
480                        if part.get_content_maintype() == 'multipart':
481                                continue
482
483                        if part.get_content_type() == 'text/plain':
484                                # Try to decode, if fails then do not decode
485                                #
486                                body_text = part.get_payload(decode=1)         
487                                if not body_text:                       
488                                        body_text = part.get_payload(decode=0) 
489
490                                # Get contents charset (iso-8859-15 if not defined in mail headers)
491                                # UTF-8 encode body_text
492                                #
493                                charset = msg.get_content_charset('iso-8859-15')
494                                ubody_text = unicode(body_text, charset).encode('utf-8')
495
496                        elif part.get_content_type() == 'text/html':
497                                ubody_text = '\n\n(see attachment for HTML mail message)\n'
498
499                        else:
500                                ubody_text = '\n\n(see attachment for message)\n'
501
502                        has_description = 1
503                        break           # we have the description, so break
504
505                if not has_description:
506                        ubody_text = '\n\n(see attachment for message)\n'
507
508                return ubody_text
509
510        def notify(self, tkt , new=True, modtime=0):
511                try:
512                        # create false {abs_}href properties, to trick Notify()
513                        #
514                        self.env.abs_href = Href(self.get_config('project', 'url'))
515                        self.env.href = Href(self.get_config('project', 'url'))
516
517                        tn = TicketNotifyEmail(self.env)
518                        if self.notify_template:
519                                tn.template_name = self.notify_template;
520
521                        tn.notify(tkt, new, modtime)
522
523                except Exception, e:
524                        print 'TD: Failure sending notification on creation of ticket #%s: %s' \
525                                % (tkt['id'], e)
526
527        def mail_line(self, str):
528                return '%s %s' % (self.comment, str)
529
530
531        def html_mailto_link(self, subject, id, body):
532                if not self.author:
533                        author = self.mail_addr
534                else:   
535                        author = self.to_unicode(self.author)
536
537                # Must find a fix
538                #
539                #arr = string.split(body, '\n')
540                #arr = map(self.mail_line, arr)
541                #body = string.join(arr, '\n')
542                #body = '%s wrote:\n%s' %(author, body)
543
544                # Temporary fix
545                str = 'mailto:%s?Subject=%s&Cc=%s' %(
546                       urllib.quote(self.email_addr),
547                           urllib.quote('Re: #%s: %s' %(id, subject)),
548                           urllib.quote(self.MAILTO_CC)
549                           )
550
551                str = '\n{{{\n#!html\n<a href="%s">Reply to: %s</a>\n}}}\n' %(str, author)
552
553                return str
554
555        def attachments(self, message, ticket):
556                '''save any attachments as file in the ticket's directory'''
557
558                count = 0
559                first = 0
560                number = 0
561                for part in message.walk():
562                        if part.get_content_maintype() == 'multipart':          # multipart/* is just a container
563                                continue
564
565                        if not first:                                                                           # first content is the message
566                                first = 1
567                                if part.get_content_type() == 'text/plain':             # if first is text, is was already put in the description
568                                        continue
569
570                        filename = part.get_filename()
571                        count = count + 1
572                        if not filename:
573                                number = number + 1
574                                filename = 'part%04d' % number
575
576                                ext = mimetypes.guess_extension(part.get_content_type())
577                                if not ext:
578                                        ext = '.bin'
579
580                                filename = '%s%s' % (filename, ext)
581                        else:
582                                filename = self.to_unicode(filename)
583
584                        # From the trac code
585                        #
586                        filename = filename.replace('\\', '/').replace(':', '/')
587                        filename = os.path.basename(filename)
588
589                        # We try to normalize the filename to utf-8 NFC if we can.
590                        # Files uploaded from OS X might be in NFD.
591                        #
592                        if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
593                                filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
594
595                        url_filename = urllib.quote(filename)
596                        if self.VERSION == 0.8:
597                                dir = os.path.join(self.env.get_attachments_dir(), 'ticket',
598                                                        urllib.quote(str(ticket['id'])))
599                                if not os.path.exists(dir):
600                                        mkdir_p(dir, 0755)
601                        else:
602                                dir = '/tmp'
603
604                        path, fd =  util.create_unique_file(os.path.join(dir, url_filename))
605                        text = part.get_payload(decode=1)
606                        if not text:
607                                text = '(None)'
608                        fd.write(text)
609                        fd.close()
610
611                        # get the filesize
612                        #
613                        stats = os.lstat(path)
614                        filesize = stats[stat.ST_SIZE]
615
616                        # Insert the attachment it differs for the different TRAC versions
617                        #
618                        if self.VERSION == 0.8:
619                                cursor = self.db.cursor()
620                                try:
621                                        cursor.execute('INSERT INTO attachment VALUES("%s","%s","%s",%d,%d,"%s","%s","%s")'
622                                                %('ticket', urllib.quote(str(ticket['id'])), filename + '?format=raw', filesize,
623                                                int(time.time()),'', self.author, 'e-mail') )
624
625                                # Attachment is already known
626                                #
627                                except sqlite.IntegrityError:   
628                                        self.db.close()
629                                        return
630
631                                self.db.commit()
632
633                        else:
634                                fd = open(path)
635                                att = attachment.Attachment(self.env, 'ticket', ticket['id'])
636                                att.insert(url_filename, fd, filesize)
637                                fd.close()
638
639                # Return how many attachments
640                #
641                return count
642
643
644def mkdir_p(dir, mode):
645        '''do a mkdir -p'''
646
647        arr = string.split(dir, '/')
648        path = ''
649        for part in arr:
650                path = '%s/%s' % (path, part)
651                try:
652                        stats = os.stat(path)
653                except OSError:
654                        os.mkdir(path, mode)
655
656
657def ReadConfig(file, name):
658        """
659        Parse the config file
660        """
661
662        if not os.path.isfile(file):
663                print 'File %s does not exists' %file
664                sys.exit(1)
665
666        config = ConfigParser.ConfigParser()
667        try:
668                config.read(file)
669        except ConfigParser.MissingSectionHeaderError,detail:
670                print detail
671                sys.exit(1)
672
673
674        # Use given project name else use defaults
675        #
676        if name:
677                if not config.has_section(name):
678                        print "Not an valid project name: %s" %name
679                        print "Valid names: %s" %config.sections()
680                        sys.exit(1)
681
682                project =  dict()
683                for option in  config.options(name):
684                        project[option] = config.get(name, option)
685
686        else:
687                project = config.defaults()
688
689        return project
690
691if __name__ == '__main__':
692        # Default config file
693        #
694        configfile = '@email2trac_conf@'
695        project = ''
696        component = ''
697       
698        try:
699                opts, args = getopt.getopt(sys.argv[1:], 'chf:p:', ['component=','help', 'file=', 'project='])
700        except getopt.error,detail:
701                print __doc__
702                print detail
703                sys.exit(1)
704
705        project_name = None
706        for opt,value in opts:
707                if opt in [ '-h', '--help']:
708                        print __doc__
709                        sys.exit(0)
710                elif opt in ['-c', '--component']:
711                        component = value
712                elif opt in ['-f', '--file']:
713                        configfile = value
714                elif opt in ['-p', '--project']:
715                        project_name = value
716
717        settings = ReadConfig(configfile, project_name)
718        if not settings.has_key('project'):
719                print __doc__
720                print 'No project defined in config file, eg:\n\t project: /data/trac/bas'
721                sys.exit(1)
722
723        if component:
724                settings['component'] = component
725
726        if settings.has_key('trac_version'):
727                version = float(settings['trac_version'])
728        else:
729                version = trac_default_version
730
731        #debug HvB
732        #print settings
733
734        if version == 0.8:
735                from trac.Environment import Environment
736                from trac.Ticket import Ticket
737                from trac.Notify import TicketNotifyEmail
738                from trac.Href import Href
739                from trac import util
740                import sqlite
741        elif version == 0.9:
742                from trac import attachment
743                from trac.env import Environment
744                from trac.ticket import Ticket
745                from trac.web.href import Href
746                from trac import util
747                from trac.Notify import TicketNotifyEmail
748        elif version == 0.10:
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                # see http://projects.edgewall.com/trac/changeset/2799
755                from trac.ticket.notification import TicketNotifyEmail
756
757        env = Environment(settings['project'], create=0)
758        tktparser = TicketEmailParser(env, settings, version)
759        tktparser.parse(sys.stdin)
760
761# EOB
Note: See TracBrowser for help on using the repository browser.