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

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

EmailtoTracScript?:

email2trac.py.in:

  • Added options for mailto_cc and ticket_update
  • Ticket updates are now working

INSTALL:

  • Adjusted the docs for new options
  • 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 74 2006-05-22 13:23:24Z 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
86import sqlite
87from email import Header
88from stat import *
89import mimetypes
90
91trac_default_version = 0.9
92
93class TicketEmailParser(object):
94        env = None
95        comment = '> '
96   
97        def __init__(self, env, parameters, version):
98                self.env = env
99
100                # Database connection
101                #
102                self.db = None
103
104                # Some useful mail constants
105                #
106                self.author = None
107                self.email_addr = None
108                self.email_field = None
109
110                self.VERSION = version
111                if self.VERSION == 0.8:
112                        self.get_config = self.env.get_config
113                else:
114                        self.get_config = self.env.config.get
115
116                if parameters.has_key('umask'):
117                        os.umask(int(parameters['umask'], 8))
118
119                if parameters.has_key('debug'):
120                        self.DEBUG = int(parameters['debug'])
121                else:
122                        self.DEBUG = 0
123
124                if parameters.has_key('mailto_link'):
125                        self.MAILTO = int(parameters['mailto_link'])
126                        if parameters.has_key('mailto_cc'):
127                                self.MAILTO_CC = parameters['mailto_cc']
128                        else:
129                                self.MAILTO_CC = ''
130                else:
131                        self.MAILTO = 0
132
133                if parameters.has_key('spam_level'):
134                        self.SPAM_LEVEL = int(parameters['spam_level'])
135                else:
136                        self.SPAM_LEVEL = 0
137
138                if parameters.has_key('email_comment'):
139                        self.comment = str(parameters['email_comment'])
140
141                if parameters.has_key('email_header'):
142                        self.EMAIL_HEADER = int(parameters['email_header'])
143                else:
144                        self.EMAIL_HEADER = 0
145
146                if parameters.has_key('alternate_notify_template'):
147                        self.notify_template = str(parameters['alternate_notify_template'])
148                else:
149                        self.notify_template = None
150
151                if parameters.has_key('reply_all'):
152                        self.REPLY_ALL = int(parameters['reply_all'])
153                else:
154                        self.REPLY_ALL = 0
155
156                if parameters.has_key('ticket_update'):
157                        self.TICKET_UPDATE = int(parameters['ticket_update'])
158                else:
159                        self.TICKET_UPDATE = 0
160
161
162        # X-Spam-Score: *** (3.255) BAYES_50,DNS_FROM_AHBL_RHSBL,HTML_
163        # Note if Spam_level then '*' are included
164        def spam(self, message):
165                if message.has_key('X-Spam-Score'):
166                        spam_l = string.split(message['X-Spam-Score'])
167                        number = spam_l[0].count('*')
168
169                        if number >= self.SPAM_LEVEL:
170                                return 'Spam'
171
172                elif message.has_key('X-Virus-found'):                  # treat virus mails as spam
173                        return 'Spam'
174
175                return self.get_config('ticket', 'default_component')
176
177        def to_unicode(self, str):
178                """
179                Email has 7 bit ASCII code, convert it to unicode with the charset
180                that is encoded in 7-bit ASCII code and encode it as utf-8 so TRAC
181                understands it.
182                """
183                results =  Header.decode_header(str)
184                str = None
185                for text,format in results:
186                        if format:
187                                try:
188                                        temp = unicode(text, format)
189                                except UnicodeError:
190                                        # This always works
191                                        #
192                                        temp = unicode(text, 'iso-8859-15')
193                                temp =  temp.encode('utf-8')
194                        else:
195                                temp = string.strip(text)
196
197                        if str:
198                                str = '%s %s' %(str, temp)
199                        else:
200                                str = temp
201
202                return str
203
204        def debug_attachments(self, message):
205                n = 0
206                for part in message.walk():
207                        if part.get_content_maintype() == 'multipart':      # multipart/* is just a container
208                                print 'TD: multipart container'
209                                continue
210
211                        n = n + 1
212                        print 'TD: part%d: Content-Type: %s' % (n, part.get_content_type())
213                        print 'TD: part%d: filename: %s' % (n, part.get_filename())
214
215                        if part.is_multipart():
216                                print 'TD: this part is multipart'
217                                payload = part.get_payload(decode=1)
218                                print 'TD: payload:', payload
219                        else:
220                                print 'TD: this part is not multipart'
221
222                        part_file = '/var/tmp/part%d' % n
223                        print 'TD: writing part%d (%s)' % (n,part_file)
224                        fx = open(part_file, 'wb')
225                        text = part.get_payload(decode=1)
226                        if not text:
227                                text = '(None)'
228                        fx.write(text)
229                        fx.close()
230                        try:
231                                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
232                        except OSError:
233                                pass
234
235        def email_header_txt(self, m):
236                """
237                Display To and CC addresses in description field
238                """
239                str = ''
240                if m['To'] and len(m['To']) > 0 and m['To'] != 'hic@sara.nl':
241                        str = "'''To:''' %s [[BR]]" %(m['To'])
242                if m['Cc'] and len(m['Cc']) > 0:
243                        str = "%s'''Cc:''' %s [[BR]]" % (str, m['Cc'])
244
245                return str
246
247        def set_owner(self, ticket):
248                """
249                Select default owner for ticket component
250                """
251                cursor = self.db.cursor()
252                sql = "SELECT owner FROM component WHERE name='%s'" % ticket['component']
253                cursor.execute(sql)
254                try:
255                        ticket['owner'] = cursor.fetchone()[0]
256                except TypeError, detail:
257                        ticket['owner'] = "UNKNOWN"
258
259        def get_author_emailaddrs(self, message):
260                """
261                Get the default author name and email address from the message
262                """
263                self.author, self.email_addr  = email.Utils.parseaddr(message['from'])
264
265                # Look for email address in registered trac users
266                #
267                if self.VERSION == 0.8:
268                        users = []
269                else:
270                        users = [ u for (u, n, e) in self.env.get_known_users(self.db)
271                                if e == self.email_addr ]
272
273                if len(users) == 1:
274                        self.email_field = users[0]
275                else:
276                        self.email_field =  self.to_unicode(message['from'])
277
278        def set_reply_fields(self, ticket, message):
279                """
280                Set all the right fields for a new ticket
281                """
282                ticket['reporter'] = self.email_field
283
284                # Put all CC-addresses in ticket CC field
285                #
286                if self.REPLY_ALL:
287                        #tos = message.get_all('to', [])
288                        ccs = message.get_all('cc', [])
289
290                        addrs = email.Utils.getaddresses(ccs)
291
292                        # Remove reporter email address if notification is
293                        # on
294                        #
295                        if self.notification:
296                                try:
297                                        addrs.remove((self.author, self.email_addr))
298                                except ValueError, detail:
299                                        pass
300
301                        for name,mail in addrs:
302                                        try:
303                                                ticket['cc'] = '%s,%s' %(ticket['cc'], mail)
304                                        except:
305                                                ticket['cc'] = mail
306
307        def save_email_for_debug(self, message):
308
309                msg_file = '/var/tmp/msg.txt'
310                print 'TD: saving email to %s' % msg_file
311                fx = open(msg_file, 'wb')
312                fx.write('%s' % message)
313                fx.close()
314                try:
315                        os.chmod(msg_file,S_IRWXU|S_IRWXG|S_IRWXO)
316                except OSError:
317                        pass
318
319        def ticket_update(self, m):
320
321                if not m['Subject']:
322                        return False
323                else:
324                        subject  = self.to_unicode(m['Subject'])
325                print subject
326
327                TICKET_RE = re.compile(r"""
328                                        (?P<ticketnr>[#][0-9]+:)
329                                        """, re.VERBOSE)
330
331                result =  TICKET_RE.search(subject)
332                if not result:
333                        return False
334
335                # Strip '#' and ':' from ticket_id
336                ticket_id = result.group('ticketnr')
337                ticket_id = int(ticket_id[1:-1])
338
339                tkt = Ticket(self.db, ticket_id)
340
341                body_text = self.get_body_text(m)
342                body_text = '{{{\n%s\n}}}' %body_text
343                tkt.save_changes(self.db, self.author, body_text)
344
345                self.attachments(m, tkt)
346                if self.notification:
347                        self.notify(tkt, False)
348
349                return True
350
351
352        def parse(self, fp):
353                msg = email.message_from_file(fp)
354                if not msg:
355                        return
356
357                if self.DEBUG > 1:        # save the entire e-mail message text
358                        self.save_email_for_debug(msg)
359                        self.debug_attachments(msg)
360
361                self.db = self.env.get_db_cnx()
362                self.get_author_emailaddrs(msg)
363
364                if self.get_config('notification', 'smtp_enabled') in ['true']:
365                        self.notification = 1
366                else:
367                        self.notification = 0
368
369                # Must we update existing tickets
370                #
371                if self.TICKET_UPDATE > 0:
372                        if self.ticket_update(msg):
373                                return True
374               
375                tkt = Ticket(self.env)
376                tkt['status'] = 'new'
377
378                # Some defaults
379                #
380                tkt['milestone'] = self.get_config('ticket', 'default_milestone')
381                tkt['priority'] = self.get_config('ticket', 'default_priority')
382                tkt['severity'] = self.get_config('ticket', 'default_severity')
383                tkt['version'] = self.get_config('ticket', 'default_version')
384
385                if not msg['Subject']:
386                        tkt['summary'] = '(geen subject)'
387                else:
388                        tkt['summary'] = self.to_unicode(msg['Subject'])
389
390
391                if settings.has_key('component'):
392                        tkt['component'] = settings['component']
393                else:
394                        tkt['component'] = self.spam(msg)
395
396                # Must make this an option or so, discard SPAM messages or save then
397                # and delete later
398                #
399                #if self.SPAM_LEVEL and self.spam(msg):
400                #       print 'This message is a SPAM. Automatic ticket insertion refused (SPAM level > %d' % self.SPAM_LEVEL
401                #       sys.exit(1)
402
403                # Set default owner for component
404                #
405                self.set_owner(tkt)
406                self.set_reply_fields(tkt, msg)
407
408                # produce e-mail like header
409                #
410                head = ''
411                if self.EMAIL_HEADER > 0:
412                        head = self.email_header_txt(msg)
413
414
415                body_text = self.get_body_text(msg)
416                tkt['description'] = '\n{{{\n%s\n}}}\n' %(body_text)
417
418                # Insert ticket in database
419                #
420                if self.VERSION == 0.8:
421                        tkt['id'] = tkt.insert(self.db)
422                else:
423                        tkt['id'] = tkt.insert()
424
425                # Else we do no have a ticket id for the subject line
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\n{{{\n%s\n}}}\n' %(head, mailto, body_text)
431                        tkt.save_changes(self.db, self.author, "")
432
433                self.attachments(msg, tkt)
434                if self.notification:
435                        self.notify(tkt)
436
437        def get_body_text(self, msg):
438                """
439                put the message text in the ticket description or in the changes field
440                message text can be plain text or html or something else
441                """
442                has_description = 0
443                ubody_text = '\n{{{\nNo plain text message\n}}}\n'
444                for part in msg.walk():
445
446                        # 'multipart/*' is a container for multipart messages
447                        #
448                        if part.get_content_maintype() == 'multipart':
449                                continue
450
451                        if part.get_content_type() == 'text/plain':
452                                # Try to decode, if fails then do not decode
453                                #
454                                body_text = part.get_payload(decode=1)         
455                                if not body_text:                       
456                                        body_text = part.get_payload(decode=0) 
457
458                                # Get contents charset (iso-8859-15 if not defined in mail headers)
459                                # UTF-8 encode body_text
460                                #
461                                charset = msg.get_content_charset('iso-8859-15')
462                                ubody_text = unicode(body_text, charset).encode('utf-8')
463
464                        elif part.get_content_type() == 'text/html':
465                                ubody_text = '\n\n(see attachment for HTML mail message)\n'
466
467                        else:
468                                ubody_text = '\n\n(see attachment for message)\n'
469
470                        has_description = 1
471                        break           # we have the description, so break
472
473                if not has_description:
474                        ubody_text = '\n\n(see attachment for message)\n'
475
476                return ubody_text
477
478        def notify(self, tkt , new=True):
479                try:
480                        # create false {abs_}href properties, to trick Notify()
481                        #
482                        self.env.abs_href = Href(self.get_config('project', 'url'))
483                        self.env.href = Href(self.get_config('project', 'url'))
484
485                        tn = TicketNotifyEmail(self.env)
486                        if self.notify_template:
487                                tn.template_name = self.notify_template;
488
489                        tn.notify(tkt, new)
490
491                except Exception, e:
492                        print 'TD: Failure sending notification on creation of ticket #%s: %s' \
493                                % (tkt['id'], e)
494
495        def mail_line(self, str):
496                return '%s %s' % (self.comment, str)
497
498
499        def html_mailto_link(self, subject, id, body):
500                if not self.author:
501                        author = self.mail_addr
502                else:   
503                        author = self.to_unicode(self.author)
504
505                # Must find a fix
506                #
507                #arr = string.split(body, '\n')
508                #arr = map(self.mail_line, arr)
509                #body = string.join(arr, '\n')
510                #body = '%s wrote:\n%s' %(author, body)
511
512                # Temporary fix
513                str = 'mailto:%s?Subject=%s&Cc=%s' %(
514                       urllib.quote(self.email_addr),
515                           urllib.quote('Re: #%s: %s' %(id, subject)),
516                           urllib.quote(self.MAILTO_CC)
517                           )
518
519                str = '\n{{{\n#!html\n<a href="%s">Reply to: %s</a>\n}}}\n' %(str, author)
520
521                return str
522
523        def attachments(self, message, ticket):
524                '''save any attachments as file in the ticket's directory'''
525
526                count = 0
527                first = 0
528                for part in message.walk():
529                        if part.get_content_maintype() == 'multipart':          # multipart/* is just a container
530                                continue
531
532                        if not first:                                                                           # first content is the message
533                                first = 1
534                                if part.get_content_type() == 'text/plain':             # if first is text, is was already put in the description
535                                        continue
536
537                        filename = part.get_filename()
538                        if not filename:
539                                count = count + 1
540                                filename = 'part%04d' % count
541
542                                ext = mimetypes.guess_extension(part.get_content_type())
543                                if not ext:
544                                        ext = '.bin'
545
546                                filename = '%s%s' % (filename, ext)
547                        else:
548                                filename = self.to_unicode(filename)
549
550                        # From the trac code
551                        #
552                        filename = filename.replace('\\', '/').replace(':', '/')
553                        filename = os.path.basename(filename)
554
555                        # We try to normalize the filename to utf-8 NFC if we can.
556                        # Files uploaded from OS X might be in NFD.
557                        #
558                        if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
559                                filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
560
561                        url_filename = urllib.quote(filename)
562                        if self.VERSION == 0.8:
563                                dir = os.path.join(self.env.get_attachments_dir(), 'ticket',
564                                                        urllib.quote(str(ticket['id'])))
565                                if not os.path.exists(dir):
566                                        mkdir_p(dir, 0755)
567                        else:
568                                dir = '/tmp'
569
570                        path, fd =  util.create_unique_file(os.path.join(dir, url_filename))
571                        text = part.get_payload(decode=1)
572                        if not text:
573                                text = '(None)'
574                        fd.write(text)
575                        fd.close()
576
577                        # get the filesize
578                        #
579                        stats = os.lstat(path)
580                        filesize = stats[stat.ST_SIZE]
581
582                        # Insert the attachment it differs for the different TRAC versions
583                        #
584                        if self.VERSION == 0.8:
585                                cursor = self.db.cursor()
586                                try:
587                                        cursor.execute('INSERT INTO attachment VALUES("%s","%s","%s",%d,%d,"%s","%s","%s")'
588                                                %('ticket', urllib.quote(str(ticket['id'])), filename + '?format=raw', filesize,
589                                                int(time.time()),'', self.author, 'e-mail') )
590
591                                # Attachment is already known
592                                #
593                                except sqlite.IntegrityError:   
594                                        self.db.close()
595                                        return
596
597                                self.db.commit()
598
599                        else:
600                                fd = open(path)
601                                att = attachment.Attachment(self.env, 'ticket', ticket['id'])
602                                att.insert(url_filename, fd, filesize)
603                                fd.close()
604
605
606def mkdir_p(dir, mode):
607        '''do a mkdir -p'''
608
609        arr = string.split(dir, '/')
610        path = ''
611        for part in arr:
612                path = '%s/%s' % (path, part)
613                try:
614                        stats = os.stat(path)
615                except OSError:
616                        os.mkdir(path, mode)
617
618
619def ReadConfig(file, name):
620        """
621        Parse the config file
622        """
623
624        if not os.path.isfile(file):
625                print 'File %s does not exists' %file
626                sys.exit(1)
627
628        config = ConfigParser.ConfigParser()
629        try:
630                config.read(file)
631        except ConfigParser.MissingSectionHeaderError,detail:
632                print detail
633                sys.exit(1)
634
635
636        # Use given project name else use defaults
637        #
638        if name:
639                if not config.has_section(name):
640                        print "Not an valid project name: %s" %name
641                        print "Valid names: %s" %config.sections()
642                        sys.exit(1)
643
644                project =  dict()
645                for option in  config.options(name):
646                        project[option] = config.get(name, option)
647
648        else:
649                project = config.defaults()
650
651        return project
652
653if __name__ == '__main__':
654        # Default config file
655        #
656        configfile = '@email2trac_conf@'
657        project = ''
658        component = ''
659       
660        try:
661                opts, args = getopt.getopt(sys.argv[1:], 'chf:p:', ['component=','help', 'file=', 'project='])
662        except getopt.error,detail:
663                print __doc__
664                print detail
665                sys.exit(1)
666
667        project_name = None
668        for opt,value in opts:
669                if opt in [ '-h', '--help']:
670                        print __doc__
671                        sys.exit(0)
672                elif opt in ['-c', '--component']:
673                        component = value
674                elif opt in ['-f', '--file']:
675                        configfile = value
676                elif opt in ['-p', '--project']:
677                        project_name = value
678
679        settings = ReadConfig(configfile, project_name)
680        if not settings.has_key('project'):
681                print __doc__
682                print 'No project defined in config file, eg:\n\t project: /data/trac/bas'
683                sys.exit(1)
684
685        if component:
686                settings['component'] = component
687
688        if settings.has_key('trac_version'):
689                version = float(settings['trac_version'])
690        else:
691                version = trac_default_version
692
693        #debug HvB
694        #print settings
695
696        if version == 0.8:
697                from trac.Environment import Environment
698                from trac.Ticket import Ticket
699                from trac.Notify import TicketNotifyEmail
700                from trac.Href import Href
701                from trac import util
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.