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

Last change on this file since 48 was 48, checked in by bas, 17 years ago

EmailtoTracScript?:

email2trac.py.in:

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