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

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

EmailtoTracScript?:

email2trac.py.in:

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