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

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

EmailtoTracScript?:

email2trac.py.in:

  • Changed the ticket_update function to 0.9 and higher Ticket fetching has changed

debian/changelog:

  • Updated version info

This line, and those below, will be ignored--

M email2trac.py.in
M debian/changelog

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