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

Last change on this file since 54 was 54, checked in by walter, 17 years ago

EmailtoTracScript?:

When you include the body tag, some mailers (like Thunderbird) will leave out the signature.
Therefore this version leaves out the body tag.

  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 17.0 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:<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 54 2006-02-16 15:02:24Z walter $
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                str = 'mailto:%s?subject=%s' %(urllib.quote(mail_addr), urllib.quote('Re: %s' % subject))
460                str = '\n{{{\n#!html\n<a href="%s">Reply to: %s</a>\n}}}\n' %(str, author)
461
462                return str
463
464        def attachments(self, message, ticket, user):
465                '''save any attachments as file in the ticket's directory'''
466
467                count = 0
468                first = 0
469                for part in message.walk():
470                        if part.get_content_maintype() == 'multipart':          # multipart/* is just a container
471                                continue
472
473                        if not first:                                                                           # first content is the message
474                                first = 1
475                                if part.get_content_type() == 'text/plain':             # if first is text, is was already put in the description
476                                        continue
477
478                        filename = part.get_filename()
479                        if not filename:
480                                count = count + 1
481                                filename = 'part%04d' % count
482
483                                ext = mimetypes.guess_extension(part.get_type())
484                                if not ext:
485                                        ext = '.bin'
486
487                                filename = '%s%s' % (filename, ext)
488                        else:
489                                filename = self.to_unicode(filename)
490
491                        # From the trac code
492                        #
493                        filename = filename.replace('\\', '/').replace(':', '/')
494                        filename = os.path.basename(filename)
495
496                        # We try to normalize the filename to utf-8 NFC if we can.
497                        # Files uploaded from OS X might be in NFD.
498                        #
499                        if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
500                                filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
501
502                        url_filename = urllib.quote(filename)
503
504                        if self.VERSION > 0.8:
505                                #tmpfile = '/tmp/email2trac-ticket%sattachment' % str(ticket['id'])
506                                dir = '/tmp'
507                        else:
508                                dir = os.path.join(self.env.get_attachments_dir(), 'ticket',
509                                                        urllib.quote(str(ticket['id'])))
510                                if not os.path.exists(dir):
511                                        mkdir_p(dir, 0755)
512
513                                #tmpfile = os.path.join(dir, url_filename)
514
515                        path, fd =  util.create_unique_file(os.path.join(dir, url_filename))
516                        #f = open(tmpfile, 'wb')
517                        text = part.get_payload(decode=1)
518                        if not text:
519                                text = '(None)'
520                        fd.write(text)
521                        fd.close()
522
523                        # get the filesize
524                        #
525                        #stats = os.lstat(tmpfile)
526                        stats = os.lstat(path)
527                        filesize = stats[stat.ST_SIZE]
528
529                        # Insert the attachment it differs for the different TRAC versions
530                        #
531                        if self.VERSION > 0.8:
532                                fd = open(path)
533                                att = attachment.Attachment(self.env,' ticket', ticket['id'])
534                                att.insert(url_filename, fd, filesize)
535                                fd.close()
536                        else:
537                                cursor = self.db.cursor()
538                                cursor.execute('INSERT INTO attachment VALUES("%s","%s","%s",%d,%d,"%s","%s","%s")'
539                                        %('ticket', urllib.quote(str(ticket['id'])), filename + '?format=raw', filesize,
540                                          int(time.time()),'', user, 'e-mail') )
541                                self.db.commit()
542
543
544def mkdir_p(dir, mode):
545        '''do a mkdir -p'''
546
547        arr = string.split(dir, '/')
548        path = ''
549        for part in arr:
550                path = '%s/%s' % (path, part)
551                try:
552                        stats = os.stat(path)
553                except OSError:
554                        os.mkdir(path, mode)
555
556
557def ReadConfig(file, name):
558        """
559        Parse the config file
560        """
561
562        if not os.path.isfile(file):
563                print 'File %s does not exists' %file
564                sys.exit(1)
565
566        config = ConfigParser.ConfigParser()
567        try:
568                config.read(file)
569        except ConfigParser.MissingSectionHeaderError,detail:
570                print detail
571                sys.exit(1)
572
573
574        # Use given project name else use defaults
575        #
576        if name:
577                if not config.has_section(name):
578                        print "Not an valid project name: %s" %name
579                        print "Valid names: %s" %config.sections()
580                        sys.exit(1)
581
582                project =  dict()
583                for option in  config.options(name):
584                        project[option] = config.get(name, option)
585
586        else:
587                project = config.defaults()
588
589        return project
590
591if __name__ == '__main__':
592        # Default config file
593        #
594        configfile = '@email2trac_conf@'
595        project = ''
596        component = ''
597       
598        try:
599                opts, args = getopt.getopt(sys.argv[1:], 'chf:p:', ['component=','help', 'file=', 'project='])
600        except getopt.error,detail:
601                print __doc__
602                print detail
603                sys.exit(1)
604
605        project_name = None
606        for opt,value in opts:
607                if opt in [ '-h', '--help']:
608                        print __doc__
609                        sys.exit(0)
610                elif opt in ['-c', '--component']:
611                        component = value
612                elif opt in ['-f', '--file']:
613                        configfile = value
614                elif opt in ['-p', '--project']:
615                        project_name = value
616
617        settings = ReadConfig(configfile, project_name)
618        if not settings.has_key('project'):
619                print __doc__
620                print 'No project defined in config file, eg:\n\t project: /data/trac/bas'
621                sys.exit(1)
622
623        if component:
624                settings['component'] = component
625
626        if settings.has_key('trac_version'):
627                version = float(settings['trac_version'])
628        else:
629                version = trac_default_version
630
631        #debug HvB
632        #print settings
633
634        if version > 0.8:
635                from trac import attachment
636                from trac.env import Environment
637                from trac.ticket import Ticket
638                from trac.Notify import TicketNotifyEmail
639                from trac.web.href import Href
640                from trac import util
641        else:
642                from trac.Environment import Environment
643                from trac.Ticket import Ticket
644                from trac.Notify import TicketNotifyEmail
645                from trac.Href import Href
646                from trac import util
647
648        env = Environment(settings['project'], create=0)
649        tktparser = TicketEmailParser(env, settings, version)
650        tktparser.parse(sys.stdin)
651
652# EOB
Note: See TracBrowser for help on using the repository browser.