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

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

EmailtoTracScript?:

email2trac.py.in:

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