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

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

EmailtoTracScript?:

email2trac.py.in:

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