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

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

EmailtoTracScript?:

email2trac.py.in:

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