source: trunk/email2trac.py.in @ 257

Last change on this file since 257 was 257, checked in by bas, 13 years ago

email2trac.py.in:

  • A working workflow implementation, must be fine tuned
  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 38.9 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# For vi/emacs or other use tabstop=4 (vi: set ts=4)
21#
22"""
23email2trac.py -- Email tickets to Trac.
24
25A simple MTA filter to create Trac tickets from inbound emails.
26
27Copyright 2005, Daniel Lundin <daniel@edgewall.com>
28Copyright 2005, Edgewall Software
29
30Changed By: Bas van der Vlies <basv@sara.nl>
31Date      : 13 September 2005
32Descr.    : Added config file and command line options, spam level
33            detection, reply address and mailto option. Unicode support
34
35Changed By: Walter de Jong <walter@sara.nl>
36Descr.    : multipart-message code and trac attachments
37
38
39The scripts reads emails from stdin and inserts directly into a Trac database.
40MIME headers are mapped as follows:
41
42        * From:      => Reporter
43                     => CC (Optional via reply_all option)
44        * Subject:   => Summary
45        * Body       => Description
46        * Component  => Can be set to SPAM via spam_level option
47
48How to use
49----------
50 * Create an config file:
51        [DEFAULT]                      # REQUIRED
52        project      : /data/trac/test # REQUIRED
53        debug        : 1               # OPTIONAL, if set print some DEBUG info
54        spam_level   : 4               # OPTIONAL, if set check for SPAM mail
55        reply_all    : 1               # OPTIONAL, if set then fill in ticket CC field
56        umask        : 022             # OPTIONAL, if set then use this umask for creation of the attachments
57        mailto_link  : 1               # OPTIONAL, if set then [mailto:<>] in description
58        mailto_cc    : basv@sara.nl    # OPTIONAL, use this address as CC in mailto line
59        ticket_update: 1               # OPTIONAL, if set then check if this is an update for a ticket
60        trac_version : 0.9             # OPTIONAL, default is 0.10
61
62        [jouvin]                       # OPTIONAL project declaration, if set both fields necessary
63        project      : /data/trac/jouvin # use -p|--project jouvin. 
64       
65 * default config file is : /etc/email2trac.conf
66
67 * Commandline opions:
68                -h,--help
69                -f,--file  <configuration file>
70                -n,--dry-run
71                -p, --project <project name>
72                -t, --ticket_prefix <name>
73
74SVN Info:
75        $Id: email2trac.py.in 257 2009-02-11 10:08:48Z bas $
76"""
77import os
78import sys
79import string
80import getopt
81import stat
82import time
83import email
84import email.Iterators
85import email.Header
86import re
87import urllib
88import unicodedata
89from stat import *
90import mimetypes
91import traceback
92
93
94# Will fail where unavailable, e.g. Windows
95#
96try:
97    import syslog
98    SYSLOG_AVAILABLE = True
99except ImportError:
100    SYSLOG_AVAILABLE = False
101
102from datetime import tzinfo, timedelta, datetime
103from trac import config as trac_config
104
105# Some global variables
106#
107trac_default_version = '0.10'
108m = None
109
110# A UTC class needed for trac version 0.11, added by
111# tbaschak at ktc dot mb dot ca
112#
113class UTC(tzinfo):
114        """UTC"""
115        ZERO = timedelta(0)
116        HOUR = timedelta(hours=1)
117       
118        def utcoffset(self, dt):
119                return self.ZERO
120               
121        def tzname(self, dt):
122                return "UTC"
123               
124        def dst(self, dt):
125                return self.ZERO
126
127
128class TicketEmailParser(object):
129        env = None
130        comment = '> '
131   
132        def __init__(self, env, parameters, version):
133                self.env = env
134
135                # Database connection
136                #
137                self.db = None
138
139                # Save parameters
140                #
141                self.parameters = parameters
142
143                # Some useful mail constants
144                #
145                self.author = None
146                self.email_addr = None
147                self.email_from = None
148                self.id = None
149
150                self.VERSION = version
151                self.DRY_RUN = parameters['dry_run']
152
153                self.get_config = self.env.config.get
154
155                if parameters.has_key('umask'):
156                        os.umask(int(parameters['umask'], 8))
157
158                if parameters.has_key('quote_attachment_filenames'):
159                        self.QUOTE_ATTACHMENT_FILENAMES = int(parameters['quote_attachment_filenames'])
160                else:
161                        self.QUOTE_ATTACHMENT_FILENAMES = 1
162
163                if parameters.has_key('debug'):
164                        self.DEBUG = int(parameters['debug'])
165                else:
166                        self.DEBUG = 0
167
168                if parameters.has_key('mailto_link'):
169                        self.MAILTO = int(parameters['mailto_link'])
170                        if parameters.has_key('mailto_cc'):
171                                self.MAILTO_CC = parameters['mailto_cc']
172                        else:
173                                self.MAILTO_CC = ''
174                else:
175                        self.MAILTO = 0
176
177                if parameters.has_key('spam_level'):
178                        self.SPAM_LEVEL = int(parameters['spam_level'])
179                else:
180                        self.SPAM_LEVEL = 0
181
182                if parameters.has_key('spam_header'):
183                        self.SPAM_HEADER = parameters['spam_header']
184                else:
185                        self.SPAM_HEADER = 'X-Spam-Score'
186
187                if parameters.has_key('email_quote'):
188                        self.EMAIL_QUOTE = str(parameters['email_quote'])
189                else:   
190                        self.EMAIL_QUOTE = '> '
191
192                if parameters.has_key('email_header'):
193                        self.EMAIL_HEADER = int(parameters['email_header'])
194                else:
195                        self.EMAIL_HEADER = 0
196
197                if parameters.has_key('alternate_notify_template'):
198                        self.notify_template = str(parameters['alternate_notify_template'])
199                else:
200                        self.notify_template = None
201
202                if parameters.has_key('alternate_notify_template_update'):
203                        self.notify_template_update = str(parameters['alternate_notify_template_update'])
204                else:
205                        self.notify_template_update = None
206
207                if parameters.has_key('reply_all'):
208                        self.REPLY_ALL = int(parameters['reply_all'])
209                else:
210                        self.REPLY_ALL = 0
211
212                if parameters.has_key('ticket_update'):
213                        self.TICKET_UPDATE = int(parameters['ticket_update'])
214                else:
215                        self.TICKET_UPDATE = 0
216
217                if parameters.has_key('drop_spam'):
218                        self.DROP_SPAM = int(parameters['drop_spam'])
219                else:
220                        self.DROP_SPAM = 0
221
222                if parameters.has_key('verbatim_format'):
223                        self.VERBATIM_FORMAT = int(parameters['verbatim_format'])
224                else:
225                        self.VERBATIM_FORMAT = 1
226
227                if parameters.has_key('reflow'):
228                        self.REFLOW = int(parameters['reflow'])
229                else:
230                        self.REFLOW = 1
231
232                if parameters.has_key('strip_signature'):
233                        self.STRIP_SIGNATURE = int(parameters['strip_signature'])
234                else:
235                        self.STRIP_SIGNATURE = 0
236
237                if parameters.has_key('strip_quotes'):
238                        self.STRIP_QUOTES = int(parameters['strip_quotes'])
239                else:
240                        self.STRIP_QUOTES = 0
241
242                if parameters.has_key('use_textwrap'):
243                        self.USE_TEXTWRAP = int(parameters['use_textwrap'])
244                else:
245                        self.USE_TEXTWRAP = 0
246
247                if parameters.has_key('binhex'):
248                        self.BINHEX = parameters['binhex']
249                else:
250                        self.BINHEX = 'warn'
251
252                if parameters.has_key('applesingle'):
253                        self.APPLESINGLE = parameters['applesingle']
254                else:
255                        self.APPLESINGLE = 'warn'
256
257                if parameters.has_key('appledouble'):
258                        self.APPLEDOUBLE = parameters['appledouble']
259                else:
260                        self.APPLEDOUBLE = 'warn'
261
262                if parameters.has_key('python_egg_cache'):
263                        self.python_egg_cache = str(parameters['python_egg_cache'])
264                        os.environ['PYTHON_EGG_CACHE'] = self.python_egg_cache
265
266                self.WORKFLOW = None
267                if parameters.has_key('workflow'):
268                        self.WORKFLOW = parameters['workflow']
269
270                # Use OS independend functions
271                #
272                self.TMPDIR = os.path.normcase('/tmp')
273                if parameters.has_key('tmpdir'):
274                        self.TMPDIR = os.path.normcase(str(parameters['tmpdir']))
275
276                if parameters.has_key('ignore_trac_user_settings'):
277                        self.IGNORE_TRAC_USER_SETTINGS = int(parameters['ignore_trac_user_settings'])
278                else:
279                        self.IGNORE_TRAC_USER_SETTINGS = 0
280
281        def spam(self, message):
282                """
283                # X-Spam-Score: *** (3.255) BAYES_50,DNS_FROM_AHBL_RHSBL,HTML_
284                # Note if Spam_level then '*' are included
285                """
286                spam = False
287                if message.has_key(self.SPAM_HEADER):
288                        spam_l = string.split(message[self.SPAM_HEADER])
289
290                        try:
291                                number = spam_l[0].count('*')
292                        except IndexError, detail:
293                                number = 0
294                               
295                        if number >= self.SPAM_LEVEL:
296                                spam = True
297                               
298                # treat virus mails as spam
299                #
300                elif message.has_key('X-Virus-found'):                 
301                        spam = True
302
303                # How to handle SPAM messages
304                #
305                if self.DROP_SPAM and spam:
306                        if self.DEBUG > 2 :
307                                print 'This message is a SPAM. Automatic ticket insertion refused (SPAM level > %d' % self.SPAM_LEVEL
308
309                        return 'drop'   
310
311                elif spam:
312
313                        return 'Spam'   
314
315                else:
316
317                        return False
318
319        def email_header_acl(self, keyword, header_field, default):
320                """
321                This function wil check if the email address is allowed or denied
322                to send mail to the ticket list
323            """
324                try:
325                        mail_addresses = self.parameters[keyword]
326
327                        # Check if we have an empty string
328                        #
329                        if not mail_addresses:
330                                return default
331
332                except KeyError, detail:
333                        if self.DEBUG > 2 :
334                                print 'TD: %s not defined, all messages are allowed.' %(keyword)
335
336                        return default
337
338                mail_addresses = string.split(mail_addresses, ',')
339
340                for entry in mail_addresses:
341                        entry = entry.strip()
342                        TO_RE = re.compile(entry, re.VERBOSE|re.IGNORECASE)
343                        result =  TO_RE.search(header_field)
344                        if result:
345                                return True
346
347                return False
348
349        def email_to_unicode(self, message_str):
350                """
351                Email has 7 bit ASCII code, convert it to unicode with the charset
352        that is encoded in 7-bit ASCII code and encode it as utf-8 so Trac
353                understands it.
354                """
355                results =  email.Header.decode_header(message_str)
356                str = None
357                for text,format in results:
358                        if format:
359                                try:
360                                        temp = unicode(text, format)
361                                except UnicodeError, detail:
362                                        # This always works
363                                        #
364                                        temp = unicode(text, 'iso-8859-15')
365                                except LookupError, detail:
366                                        #text = 'ERROR: Could not find charset: %s, please install' %format
367                                        #temp = unicode(text, 'iso-8859-15')
368                                        temp = message_str
369                                       
370                        else:
371                                temp = string.strip(text)
372                                temp = unicode(text, 'iso-8859-15')
373
374                        if str:
375                                str = '%s %s' %(str, temp)
376                        else:
377                                str = '%s' %temp
378
379                #str = str.encode('utf-8')
380                return str
381
382        def debug_body(self, message_body, tempfile=False):
383                if tempfile:
384                        import tempfile
385                        body_file = tempfile.mktemp('.email2trac')
386                else:
387                        body_file = os.path.join(self.TMPDIR, 'body.txt')
388
389                print 'TD: writing body (%s)' % body_file
390                fx = open(body_file, 'wb')
391                if not message_body:
392                        message_body = '(None)'
393                fx.write(message_body)
394                fx.close()
395                try:
396                        os.chmod(body_file,S_IRWXU|S_IRWXG|S_IRWXO)
397                except OSError:
398                        pass
399
400        def debug_attachments(self, message_parts):
401                n = 0
402                for part in message_parts:
403                        # Skip inline text parts
404                        if not isinstance(part, tuple):
405                                continue
406                               
407                        (original, filename, part) = part
408
409                        n = n + 1
410                        print 'TD: part%d: Content-Type: %s' % (n, part.get_content_type())
411                        print 'TD: part%d: filename: %s' % (n, part.get_filename())
412
413                        part_file = os.path.join(self.TMPDIR, filename)
414                        #part_file = '/var/tmp/part%d' % n
415                        print 'TD: writing part%d (%s)' % (n,part_file)
416                        fx = open(part_file, 'wb')
417                        text = part.get_payload(decode=1)
418                        if not text:
419                                text = '(None)'
420                        fx.write(text)
421                        fx.close()
422                        try:
423                                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
424                        except OSError:
425                                pass
426
427        def email_header_txt(self, m):
428                """
429                Display To and CC addresses in description field
430                """
431                str = ''
432                #if m['To'] and len(m['To']) > 0 and m['To'] != 'hic@sara.nl':
433                if m['To'] and len(m['To']) > 0:
434                        str = "'''To:''' %s\r\n" %(m['To'])
435                if m['Cc'] and len(m['Cc']) > 0:
436                        str = "%s'''Cc:''' %s\r\n" % (str, m['Cc'])
437
438                return  self.email_to_unicode(str)
439
440
441        def get_sender_info(self, message):
442                """
443                Get the default author name and email address from the message
444                """
445
446                self.email_to = self.email_to_unicode(message['to'])
447                self.to_name, self.to_email_addr = email.Utils.parseaddr (self.email_to)
448
449                self.email_from = self.email_to_unicode(message['from'])
450                self.author, self.email_addr  = email.Utils.parseaddr(self.email_from)
451
452                # Trac can not handle author's name that contains spaces
453                #
454                self.author = self.email_addr
455
456                if self.IGNORE_TRAC_USER_SETTINGS:
457                        return
458
459                # Is this a registered user, use email address as search key:
460                # result:
461                #   u : login name
462                #   n : Name that the user has set in the settings tab
463                #   e : email address that the user has set in the settings tab
464                #
465                users = [ (u,n,e) for (u, n, e) in self.env.get_known_users(self.db)
466                        if e and (e.lower() == self.email_addr.lower()) ]
467
468                if len(users) == 1:
469                        self.email_from = users[0][0]
470                        self.author = users[0][0]
471
472        def set_reply_fields(self, ticket, message):
473                """
474                Set all the right fields for a new ticket
475                """
476                ticket['reporter'] = self.email_from
477
478                # Put all CC-addresses in ticket CC field
479                #
480                if self.REPLY_ALL:
481                        #tos = message.get_all('to', [])
482                        ccs = message.get_all('cc', [])
483
484                        addrs = email.Utils.getaddresses(ccs)
485                        if not addrs:
486                                return
487
488                        # Remove reporter email address if notification is
489                        # on
490                        #
491                        if self.notification:
492                                try:
493                                        addrs.remove((self.author, self.email_addr))
494                                except ValueError, detail:
495                                        pass
496
497                        for name,mail in addrs:
498                                try:
499                                        mail_list = '%s, %s' %(mail_list, mail)
500                                except UnboundLocalError, detail:
501                                        mail_list = mail
502
503                        if mail_list:
504                                ticket['cc'] = self.email_to_unicode(mail_list)
505
506        def save_email_for_debug(self, message, tempfile=False):
507                if tempfile:
508                        import tempfile
509                        msg_file = tempfile.mktemp('.email2trac')
510                else:
511                        #msg_file = '/var/tmp/msg.txt'
512                        msg_file = os.path.join(self.TMPDIR, 'msg.txt')
513
514                print 'TD: saving email to %s' % msg_file
515                fx = open(msg_file, 'wb')
516                fx.write('%s' % message)
517                fx.close()
518                try:
519                        os.chmod(msg_file,S_IRWXU|S_IRWXG|S_IRWXO)
520                except OSError:
521                        pass
522
523        def str_to_dict(self, str):
524                """
525                Transfrom a str of the form [<key>=<value>]+ to dict[<key>] = <value>
526                """
527                # Skip the last ':' character
528                #
529                fields = string.split(str[:-1], ',')
530
531                result = dict()
532                for field in fields:
533                        try:
534                                index, value = string.split(field,'=')
535
536                                # We can not change the description of a ticket via the subject
537                                # line. The description is the body of the email
538                                #
539                                if index.lower() in ['description']:
540                                        continue
541
542                                if value:
543                                        result[index.lower()] = value
544
545                        except ValueError:
546                                pass
547
548                return result
549
550        def update_ticket_fields(self, ticket, user_dict, use_default=None):
551                """
552                This will update the ticket fields. It will check if the
553                given fields are known and if the right values are specified
554                It will only update the ticket field value:
555                        - If the field is known
556                        - If the value supplied is valid for the ticket field.
557                          If not then there are two options:
558                           1) Skip the value (use_default=None)
559                           2) Set default value for field (use_default=1)
560                """
561
562                # Build a system dictionary from the ticket fields
563                # with field as index and option as value
564                #
565                sys_dict = dict()
566                for field in ticket.fields:
567                        try:
568                                sys_dict[field['name']] = field['options']
569
570                        except KeyError:
571                                sys_dict[field['name']] = None
572                                pass
573
574                # Check user supplied fields an compare them with the
575                # system one's
576                #
577                for field,value in user_dict.items():
578                        if self.DEBUG >= 10:
579                                print  'user_field\t %s = %s' %(field,value)
580
581                        if sys_dict.has_key(field):
582
583                                # Check if value is an allowed system option, if TypeError then
584                                # every value is allowed
585                                #
586                                try:
587                                        if value in sys_dict[field]:
588                                                ticket[field] = value
589                                        else:
590                                                # Must we set a default if value is not allowed
591                                                #
592                                                if use_default:
593                                                        value = self.get_config('ticket', 'default_%s' %(field) )
594                                                        ticket[field] = value
595
596                                except TypeError:
597                                        ticket[field] = value
598
599                                if self.DEBUG >= 10:
600                                        print  'ticket_field\t %s = %s' %(field,  ticket[field])
601                                       
602        def ticket_update(self, m, spam):
603                """
604                If the current email is a reply to an existing ticket, this function
605                will append the contents of this email to that ticket, instead of
606                creating a new one.
607                """
608                if self.DEBUG:
609                        print "TD: ticket_update"
610
611                if not m['Subject']:
612                        return False
613                else:
614                        subject  = self.email_to_unicode(m['Subject'])
615
616                # [hic] #1529: Re: LRZ
617                # [hic] #1529?owner=bas,priority=medium: Re: LRZ
618                #
619                TICKET_RE = re.compile(r"""
620                                        (?P<ticketnr>[#][0-9]+:)
621                                        |(?P<ticketnr_fields>[#][\d]+\?.*?:)
622                                        """, re.VERBOSE)
623
624                result =  TICKET_RE.search(subject)
625                if not result:
626                        return False
627
628                # Must we update ticket fields
629                #
630                update_fields = dict()
631                try:
632                        nr, keywords = string.split(result.group('ticketnr_fields'), '?')
633                        update_fields = self.str_to_dict(keywords)
634
635                        # Strip '#'
636                        #
637                        self.id = int(nr[1:])
638
639                except AttributeError:
640                        # Strip '#' and ':'
641                        #
642                        nr = result.group('ticketnr')
643                        self.id = int(nr[1:-1])
644
645
646                # When is the change committed
647                #
648                #
649                if self.VERSION == 0.11:
650                        utc = UTC()
651                        when = datetime.now(utc)
652                else:
653                        when = int(time.time())
654
655                try:
656                        tkt = Ticket(self.env, self.id, self.db)
657                except util.TracError, detail:
658                        # Not a valid ticket
659                        self.id = None
660                        return False
661
662                # reopen the ticket if it is was closed
663                # We must use the ticket workflow framework
664                #
665                if tkt['status'] in ['closed']:
666
667                        #print controller.actions['reopen']
668                        #
669                        # As reference 
670                        # req = Mock(href=Href('/'), abs_href=Href('http://www.example.com/'), authname='anonymous', perm=MockPerm(), args={})
671                        #
672                        #a = controller.render_ticket_action_control(req, tkt, 'reopen')
673                        #print 'controller : ', a
674                        #
675                        #b = controller.get_all_status()
676                        #print 'get all status: ', b
677                        #
678                        #b = controller.get_ticket_changes(req, tkt, 'reopen')
679                        #print 'get_ticket_changes :', b
680
681                        if self.WORKFLOW:
682                                from trac.ticket.default_workflow import ConfigurableTicketWorkflow
683                                from trac.test import Mock, MockPerm
684
685                                req = Mock(authname='anonymous', perm=MockPerm(), args={})
686
687                                controller = ConfigurableTicketWorkflow(self.env)
688                                fields = controller.get_ticket_changes(req, tkt, self.WORKFLOW)
689
690                                if self.DEBUG:
691                                        print 'TD: Workflow ticket update fields: ', fields
692
693                                for key in fields.keys():
694                                        tkt[key] = fields[key]
695
696                        else:
697                                tkt['status'] = 'reopened'
698                                tkt['resolution'] = ''
699
700                # Must we update some ticket fields properties
701                #
702                if update_fields:
703                        self.update_ticket_fields(tkt, update_fields)
704
705                message_parts = self.get_message_parts(m)
706                message_parts = self.unique_attachment_names(message_parts)
707
708                if self.EMAIL_HEADER:
709                        message_parts.insert(0, self.email_header_txt(m))
710
711                body_text = self.body_text(message_parts)
712
713                if body_text.strip() or update_fields:
714                        if self.DRY_RUN:
715                                print 'DRY_RUN: tkt.save_changes(self.author, comment) ', self.author
716                        else:
717                                tkt.save_changes(self.author, body_text, when)
718
719                if self.VERSION  == 0.9:
720                        str = self.attachments(message_parts, True)
721                else:
722                        str = self.attachments(message_parts)
723
724                if self.notification and not spam:
725                        self.notify(tkt, False, when)
726
727                return True
728
729        def set_ticket_fields(self, ticket):
730                """
731                set the ticket fields to value specified
732                        - /etc/email2trac.conf with <prefix>_<field>
733                        - trac default values, trac.ini
734                """
735                user_dict = dict()
736
737                for field in ticket.fields:
738
739                        name = field['name']
740
741                        # skip some fields like resolution
742                        #
743                        if name in [ 'resolution' ]:
744                                continue
745
746                        # default trac value
747                        #
748                        if not field.get('custom'):
749                                value = self.get_config('ticket', 'default_%s' %(name) )
750                        else:
751                                value = field.get('value')
752                                options = field.get('options')
753                                if value and options and value not in options:
754                                        value = options[int(value)]
755
756                        if self.DEBUG > 10:
757                                print 'trac.ini name %s = %s' %(name, value)
758
759                        prefix = self.parameters['ticket_prefix']
760                        try:
761                                value = self.parameters['%s_%s' %(prefix, name)]
762                                if self.DEBUG > 10:
763                                        print 'email2trac.conf %s = %s ' %(name, value)
764
765                        except KeyError, detail:
766                                pass
767               
768                        if self.DEBUG:
769                                print 'user_dict[%s] = %s' %(name, value)
770
771                        user_dict[name] = value
772
773                self.update_ticket_fields(ticket, user_dict, use_default=1)
774
775                # Set status ticket
776                #`
777                ticket['status'] = 'new'
778
779
780
781        def new_ticket(self, msg, spam):
782                """
783                Create a new ticket
784                """
785                if self.DEBUG:
786                        print "TD: new_ticket"
787
788                tkt = Ticket(self.env)
789                self.set_ticket_fields(tkt)
790
791                # Old style setting for component, will be removed
792                #
793                if spam:
794                        tkt['component'] = 'Spam'
795
796                elif self.parameters.has_key('component'):
797                        tkt['component'] = self.parameters['component']
798
799                if not msg['Subject']:
800                        tkt['summary'] = u'(No subject)'
801                else:
802                        tkt['summary'] = self.email_to_unicode(msg['Subject'])
803
804                self.set_reply_fields(tkt, msg)
805
806                # produce e-mail like header
807                #
808                head = ''
809                if self.EMAIL_HEADER > 0:
810                        head = self.email_header_txt(msg)
811                       
812                message_parts = self.get_message_parts(msg)
813                message_parts = self.unique_attachment_names(message_parts)
814               
815                if self.EMAIL_HEADER > 0:
816                        message_parts.insert(0, self.email_header_txt(msg))
817                       
818                body_text = self.body_text(message_parts)
819
820                tkt['description'] = body_text
821
822                #when = int(time.time())
823                #
824                utc = UTC()
825                when = datetime.now(utc)
826
827                if not self.DRY_RUN:
828                        self.id = tkt.insert()
829               
830                changed = False
831                comment = ''
832
833                # Rewrite the description if we have mailto enabled
834                #
835                if self.MAILTO:
836                        changed = True
837                        comment = u'\nadded mailto line\n'
838                        mailto = self.html_mailto_link( m['Subject'], body_text)
839
840                        tkt['description'] = u'%s\r\n%s%s\r\n' \
841                                %(head, mailto, body_text)
842
843                str =  self.attachments(message_parts)
844                if str:
845                        changed = True
846                        comment = '%s\n%s\n' %(comment, str)
847
848                if changed:
849                        if self.DRY_RUN:
850                                print 'DRY_RUN: tkt.save_changes(self.author, comment) ', self.author
851                        else:
852                                tkt.save_changes(self.author, comment)
853                                #print tkt.get_changelog(self.db, when)
854
855                if self.notification and not spam:
856                        self.notify(tkt, True)
857
858        def parse(self, fp):
859                global m
860
861                m = email.message_from_file(fp)
862               
863                if not m:
864                        if self.DEBUG:
865                                print "TD: This is not a valid email message format"
866                        return
867                       
868                # Work around lack of header folding in Python; see http://bugs.python.org/issue4696
869                m.replace_header('Subject', m['Subject'].replace('\r', '').replace('\n', ''))
870
871                if self.DEBUG > 1:        # save the entire e-mail message text
872                        message_parts = self.get_message_parts(m)
873                        message_parts = self.unique_attachment_names(message_parts)
874                        self.save_email_for_debug(m, True)
875                        body_text = self.body_text(message_parts)
876                        self.debug_body(body_text, True)
877                        self.debug_attachments(message_parts)
878
879                self.db = self.env.get_db_cnx()
880                self.get_sender_info(m)
881
882                if not self.email_header_acl('white_list', self.email_addr, True):
883                        if self.DEBUG > 1 :
884                                print 'Message rejected : %s not in white list' %(self.email_addr)
885                        return False
886
887                if self.email_header_acl('black_list', self.email_addr, False):
888                        if self.DEBUG > 1 :
889                                print 'Message rejected : %s in black list' %(self.email_addr)
890                        return False
891
892                if not self.email_header_acl('recipient_list', self.to_email_addr, True):
893                        if self.DEBUG > 1 :
894                                print 'Message rejected : %s not in recipient list' %(self.to_email_addr)
895                        return False
896
897                # If drop the message
898                #
899                if self.spam(m) == 'drop':
900                        return False
901
902                elif self.spam(m) == 'spam':
903                        spam_msg = True
904
905                else:
906                        spam_msg = False
907
908                if self.get_config('notification', 'smtp_enabled') in ['true']:
909                        self.notification = 1
910                else:
911                        self.notification = 0
912
913                # Must we update existing tickets
914                #
915                if self.TICKET_UPDATE > 0:
916                        if self.ticket_update(m, spam_msg):
917                                return True
918
919                self.new_ticket(m, spam_msg)
920
921        def strip_signature(self, text):
922                """
923                Strip signature from message, inspired by Mailman software
924                """
925                body = []
926                for line in text.splitlines():
927                        if line == '-- ':
928                                break
929                        body.append(line)
930
931                return ('\n'.join(body))
932
933        def reflow(self, text, delsp = 0):
934                """
935                Reflow the message based on the format="flowed" specification (RFC 3676)
936                """
937                flowedlines = []
938                quotelevel = 0
939                prevflowed = 0
940
941                for line in text.splitlines():
942                        from re import match
943                       
944                        # Figure out the quote level and the content of the current line
945                        m = match('(>*)( ?)(.*)', line)
946                        linequotelevel = len(m.group(1))
947                        line = m.group(3)
948
949                        # Determine whether this line is flowed
950                        if line and line != '-- ' and line[-1] == ' ':
951                                flowed = 1
952                        else:
953                                flowed = 0
954
955                        if flowed and delsp and line and line[-1] == ' ':
956                                line = line[:-1]
957
958                        # If the previous line is flowed, append this line to it
959                        if prevflowed and line != '-- ' and linequotelevel == quotelevel:
960                                flowedlines[-1] += line
961                        # Otherwise, start a new line
962                        else:
963                                flowedlines.append('>' * linequotelevel + line)
964
965                        prevflowed = flowed
966                       
967
968                return '\n'.join(flowedlines)
969
970        def strip_quotes(self, text):
971                """
972                Strip quotes from message by Nicolas Mendoza
973                """
974                body = []
975                for line in text.splitlines():
976                        if line.startswith(self.EMAIL_QUOTE):
977                                continue
978                        body.append(line)
979
980                return ('\n'.join(body))
981
982        def wrap_text(self, text, replace_whitespace = False):
983                """
984                Will break a lines longer then given length into several small
985                lines of size given length
986                """
987                import textwrap
988
989                LINESEPARATOR = '\n'
990                reformat = ''
991
992                for s in text.split(LINESEPARATOR):
993                        tmp = textwrap.fill(s,self.USE_TEXTWRAP)
994                        if tmp:
995                                reformat = '%s\n%s' %(reformat,tmp)
996                        else:
997                                reformat = '%s\n' %reformat
998
999                return reformat
1000
1001                # Python2.4 and higher
1002                #
1003                #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
1004                #
1005
1006
1007        def get_message_parts(self, msg):
1008                """
1009                parses the email message and returns a list of body parts and attachments
1010                body parts are returned as strings, attachments are returned as tuples of (filename, Message object)
1011                """
1012                message_parts = []
1013               
1014                # This is used to figure out when we are inside an AppleDouble container
1015                # AppleDouble containers consists of two parts: Mac-specific file data, and platform-independent data
1016                # We strip away Mac-specific stuff
1017                appledouble_parts = []
1018
1019                for part in msg.walk():
1020                        if self.DEBUG:
1021                                print 'TD: Message part: Content-Type: %s' % part.get_content_type()
1022                               
1023                        # Check whether we just finished processing an AppleDouble container
1024                        if part not in appledouble_parts:
1025                                appledouble_parts = []
1026
1027                        # Special handling for BinHex attachments. Options are drop (leave out with no warning), warn (and leave out), and keep
1028                        if part.get_content_type() == 'application/mac-binhex40':
1029                                if self.BINHEX == 'warn':
1030                                        message_parts.append("'''A BinHex attachment named '%s' was ignored (use MIME encoding instead).'''" % part.get_filename())
1031                                        continue
1032                                elif self.BINHEX == 'drop':
1033                                        continue
1034
1035                        # Special handling for AppleSingle attachments. Options are drop (leave out with no warning), warn (and leave out), and keep
1036                        if part.get_content_type() == 'application/applefile' and not part in appledouble_parts:
1037                                if self.APPLESINGLE == 'warn':
1038                                        message_parts.append("'''An AppleSingle attachment named '%s' was ignored (use MIME encoding instead).'''" % part.get_filename())
1039                                        continue
1040                                elif self.APPLESINGLE == 'drop':
1041                                        continue
1042
1043                        # Special handling for the Mac-specific part of AppleDouble attachments. Options are strip (leave out with no warning), warn (and leave out), and keep
1044                        if part.get_content_type() == 'application/applefile':
1045                                if self.APPLEDOUBLE == 'warn':
1046                                        message_parts.append("'''The resource fork of an attachment named '%s' was removed.'''" % part.get_filename())
1047                                        continue
1048                                elif self.APPLEDOUBLE == 'strip':
1049                                        continue
1050
1051                        # If we entering an AppleDouble container, set up appledouble_parts so that we know what to do with its subparts
1052                        if part.get_content_type() == 'multipart/appledouble':
1053                                appledouble_parts = part.get_payload()
1054                                continue
1055
1056                        # Any other multipart/* is just a container for multipart messages
1057                        if part.get_content_maintype() == 'multipart':
1058                                continue
1059
1060                        # Check if this is an inline part. It's inline if there is co Cont-Disp header, or if there is one and it says "inline"
1061                        inline = self.inline_part(part)
1062
1063                        # Inline text parts are where the body is
1064                        if part.get_content_type() == 'text/plain' and inline:
1065                                if self.DEBUG:
1066                                        print 'TD:               Inline body part'
1067
1068                                # Try to decode, if fails then do not decode
1069                                #
1070                                body_text = part.get_payload(decode=1)
1071                                if not body_text:                       
1072                                        body_text = part.get_payload(decode=0)
1073
1074                                format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
1075                                delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
1076
1077                                if self.REFLOW and not self.VERBATIM_FORMAT and format == 'flowed':
1078                                        body_text = self.reflow(body_text, delsp == 'yes')
1079       
1080                                if self.STRIP_SIGNATURE:
1081                                        body_text = self.strip_signature(body_text)
1082
1083                                if self.STRIP_QUOTES:
1084                                        body_text = self.strip_quotes(body_text)
1085
1086                                if self.USE_TEXTWRAP:
1087                                        body_text = self.wrap_text(body_text)
1088
1089                                # Get contents charset (iso-8859-15 if not defined in mail headers)
1090                                #
1091                                charset = part.get_content_charset()
1092                                if not charset:
1093                                        charset = 'iso-8859-15'
1094
1095                                try:
1096                                        ubody_text = unicode(body_text, charset)
1097
1098                                except UnicodeError, detail:
1099                                        ubody_text = unicode(body_text, 'iso-8859-15')
1100
1101                                except LookupError, detail:
1102                                        ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
1103
1104                                if self.VERBATIM_FORMAT:
1105                                        message_parts.append('{{{\r\n%s\r\n}}}' %ubody_text)
1106                                else:
1107                                        message_parts.append('%s' %ubody_text)
1108                        else:
1109                                if self.DEBUG:
1110                                        print 'TD:               Filename: %s' % part.get_filename()
1111
1112                                message_parts.append((part.get_filename(), part))
1113
1114                return message_parts
1115               
1116        def unique_attachment_names(self, message_parts):
1117                renamed_parts = []
1118                attachment_names = set()
1119                for part in message_parts:
1120                       
1121                        # If not an attachment, leave it alone
1122                        if not isinstance(part, tuple):
1123                                renamed_parts.append(part)
1124                                continue
1125                               
1126                        (filename, part) = part
1127                        # Decode the filename
1128                        if filename:
1129                                filename = self.email_to_unicode(filename)                     
1130                        # If no name, use a default one
1131                        else:
1132                                filename = 'untitled-part'
1133
1134                                # Guess the extension from the content type, use non strict mode
1135                                # some additional non-standard but commonly used MIME types
1136                                # are also recognized
1137                                #
1138                                ext = mimetypes.guess_extension(part.get_content_type(), False)
1139                                if not ext:
1140                                        ext = '.bin'
1141
1142                                filename = '%s%s' % (filename, ext)
1143
1144                        # Discard relative paths in attachment names
1145                        filename = filename.replace('\\', '/').replace(':', '/')
1146                        filename = os.path.basename(filename)
1147
1148                        # We try to normalize the filename to utf-8 NFC if we can.
1149                        # Files uploaded from OS X might be in NFD.
1150                        # Check python version and then try it
1151                        #
1152                        if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
1153                                try:
1154                                        filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
1155                                except TypeError:
1156                                        pass
1157                                       
1158                        if self.QUOTE_ATTACHMENT_FILENAMES:
1159                                filename = urllib.quote(filename)
1160
1161                        # Make the filename unique for this ticket
1162                        num = 0
1163                        unique_filename = filename
1164                        filename, ext = os.path.splitext(filename)
1165
1166                        while unique_filename in attachment_names or self.attachment_exists(unique_filename):
1167                                num += 1
1168                                unique_filename = "%s-%s%s" % (filename, num, ext)
1169                               
1170                        if self.DEBUG:
1171                                print 'TD: Attachment with filename %s will be saved as %s' % (filename, unique_filename)
1172
1173                        attachment_names.add(unique_filename)
1174
1175                        renamed_parts.append((filename, unique_filename, part))
1176               
1177                return renamed_parts
1178                       
1179        def inline_part(self, part):
1180                return part.get_param('inline', None, 'Content-Disposition') == '' or not part.has_key('Content-Disposition')
1181               
1182                       
1183        def attachment_exists(self, filename):
1184
1185                if self.DEBUG:
1186                        print "TD: attachment_exists: Ticket number : %s, Filename : %s" %(self.id, filename)
1187
1188                # We have no valid ticket id
1189                #
1190                if not self.id:
1191                        return False
1192
1193                try:
1194                        att = attachment.Attachment(self.env, 'ticket', self.id, filename)
1195                        return True
1196                except attachment.ResourceNotFound:
1197                        return False
1198                       
1199        def body_text(self, message_parts):
1200                body_text = []
1201               
1202                for part in message_parts:
1203                        # Plain text part, append it
1204                        if not isinstance(part, tuple):
1205                                body_text.extend(part.strip().splitlines())
1206                                body_text.append("")
1207                                continue
1208                               
1209                        (original, filename, part) = part
1210                        inline = self.inline_part(part)
1211                       
1212                        if part.get_content_maintype() == 'image' and inline:
1213                                body_text.append('[[Image(%s)]]' % filename)
1214                                body_text.append("")
1215                        else:
1216                                body_text.append('[attachment:"%s"]' % filename)
1217                                body_text.append("")
1218                               
1219                body_text = '\r\n'.join(body_text)
1220                return body_text
1221
1222        def notify(self, tkt, new=True, modtime=0):
1223                """
1224                A wrapper for the TRAC notify function. So we can use templates
1225                """
1226                if self.DRY_RUN:
1227                                print 'DRY_RUN: self.notify(tkt, True) ', self.author
1228                                return
1229                try:
1230                        # create false {abs_}href properties, to trick Notify()
1231                        #
1232                        if not self.VERSION == 0.11:
1233                                self.env.abs_href = Href(self.get_config('project', 'url'))
1234                                self.env.href = Href(self.get_config('project', 'url'))
1235
1236                        tn = TicketNotifyEmail(self.env)
1237
1238                        if self.notify_template:
1239
1240                                if self.VERSION == 0.11:
1241
1242                                        from trac.web.chrome import Chrome
1243
1244                                        if self.notify_template_update and not new:
1245                                                tn.template_name = self.notify_template_update
1246                                        else:
1247                                                tn.template_name = self.notify_template
1248
1249                                        tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
1250                                               
1251                                else:
1252
1253                                        tn.template_name = self.notify_template;
1254
1255                        tn.notify(tkt, new, modtime)
1256
1257                except Exception, e:
1258                        print 'TD: Failure sending notification on creation of ticket #%s: %s' %(self.id, e)
1259
1260        def html_mailto_link(self, subject, body):
1261                """
1262                This function returns a HTML mailto tag with the ticket id and author email address
1263                """
1264                if not self.author:
1265                        author = self.email_addr
1266                else:   
1267                        author = self.author
1268
1269                # use urllib to escape the chars
1270                #
1271                str = 'mailto:%s?Subject=%s&Cc=%s' %(
1272                       urllib.quote(self.email_addr),
1273                           urllib.quote('Re: #%s: %s' %(self.id, subject)),
1274                           urllib.quote(self.MAILTO_CC)
1275                           )
1276
1277                str = '\r\n{{{\r\n#!html\r\n<a\r\n href="%s">Reply to: %s\r\n</a>\r\n}}}\r\n' %(str, author)
1278                return str
1279
1280        def attachments(self, message_parts, update=False):
1281                '''
1282                save any attachments as files in the ticket's directory
1283                '''
1284                if self.DRY_RUN:
1285                        print "DRY_RUN: no attachments saved"
1286                        return ''
1287
1288                count = 0
1289
1290                # Get Maxium attachment size
1291                #
1292                max_size = int(self.get_config('attachment', 'max_size'))
1293                status   = ''
1294               
1295                for part in message_parts:
1296                        # Skip body parts
1297                        if not isinstance(part, tuple):
1298                                continue
1299                               
1300                        (original, filename, part) = part
1301                        #
1302                        # Must be tuneables HvB
1303                        #
1304                        path, fd =  util.create_unique_file(os.path.join(self.TMPDIR, filename))
1305                        text = part.get_payload(decode=1)
1306                        if not text:
1307                                text = '(None)'
1308                        fd.write(text)
1309                        fd.close()
1310
1311                        # get the file_size
1312                        #
1313                        stats = os.lstat(path)
1314                        file_size = stats[stat.ST_SIZE]
1315
1316                        # Check if the attachment size is allowed
1317                        #
1318                        if (max_size != -1) and (file_size > max_size):
1319                                status = '%s\nFile %s is larger then allowed attachment size (%d > %d)\n\n' \
1320                                        %(status, original, file_size, max_size)
1321
1322                                os.unlink(path)
1323                                continue
1324                        else:
1325                                count = count + 1
1326                                       
1327                        # Insert the attachment
1328                        #
1329                        fd = open(path, 'rb')
1330                        att = attachment.Attachment(self.env, 'ticket', self.id)
1331
1332                        # This will break the ticket_update system, the body_text is vaporized
1333                        # ;-(
1334                        #
1335                        if not update:
1336                                att.author = self.author
1337                                att.description = self.email_to_unicode('Added by email2trac')
1338
1339                        att.insert(filename, fd, file_size)
1340                        #except  util.TracError, detail:
1341                        #       print detail
1342
1343                        # Remove the created temporary filename
1344                        #
1345                        fd.close()
1346                        os.unlink(path)
1347
1348                # Return how many attachments
1349                #
1350                status = 'This message has %d attachment(s)\n%s' %(count, status)
1351                return status
1352
1353
1354def mkdir_p(dir, mode):
1355        '''do a mkdir -p'''
1356
1357        arr = string.split(dir, '/')
1358        path = ''
1359        for part in arr:
1360                path = '%s/%s' % (path, part)
1361                try:
1362                        stats = os.stat(path)
1363                except OSError:
1364                        os.mkdir(path, mode)
1365
1366def ReadConfig(file, name):
1367        """
1368        Parse the config file
1369        """
1370        if not os.path.isfile(file):
1371                print 'File %s does not exist' %file
1372                sys.exit(1)
1373
1374        config = trac_config.Configuration(file)
1375
1376        # Use given project name else use defaults
1377        #
1378        if name:
1379                sections = config.sections()
1380                if not name in sections:
1381                        print "Not a valid project name: %s" %name
1382                        print "Valid names: %s" %sections
1383                        sys.exit(1)
1384
1385                project =  dict()
1386                for option, value in  config.options(name):
1387                        project[option] = value
1388
1389        else:
1390                # use some trac internales to get the defaults
1391                #
1392                project = config.parser.defaults()
1393
1394        return project
1395
1396
1397if __name__ == '__main__':
1398        # Default config file
1399        #
1400        configfile = '@email2trac_conf@'
1401        project = ''
1402        component = ''
1403        ticket_prefix = 'default'
1404        dry_run = None
1405
1406        ENABLE_SYSLOG = 0
1407
1408
1409        SHORT_OPT = 'chf:np:t:'
1410        LONG_OPT  =  ['component=', 'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=']
1411
1412        try:
1413                opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
1414        except getopt.error,detail:
1415                print __doc__
1416                print detail
1417                sys.exit(1)
1418       
1419        project_name = None
1420        for opt,value in opts:
1421                if opt in [ '-h', '--help']:
1422                        print __doc__
1423                        sys.exit(0)
1424                elif opt in ['-c', '--component']:
1425                        component = value
1426                elif opt in ['-f', '--file']:
1427                        configfile = value
1428                elif opt in ['-n', '--dry-run']:
1429                        dry_run = True
1430                elif opt in ['-p', '--project']:
1431                        project_name = value
1432                elif opt in ['-t', '--ticket_prefix']:
1433                        ticket_prefix = value
1434       
1435        settings = ReadConfig(configfile, project_name)
1436        if not settings.has_key('project'):
1437                print __doc__
1438                print 'No Trac project is defined in the email2trac config file.'
1439                sys.exit(1)
1440       
1441        if component:
1442                settings['component'] = component
1443
1444        # The default prefix for ticket values in email2trac.conf
1445        #
1446        settings['ticket_prefix'] = ticket_prefix
1447        settings['dry_run'] = dry_run
1448       
1449        if settings.has_key('trac_version'):
1450                version = settings['trac_version']
1451        else:
1452                version = trac_default_version
1453
1454
1455        #debug HvB
1456        #print settings
1457
1458        try:
1459                if version == '0.9':
1460                        from trac import attachment
1461                        from trac.env import Environment
1462                        from trac.ticket import Ticket
1463                        from trac.web.href import Href
1464                        from trac import util
1465                        from trac.Notify import TicketNotifyEmail
1466                elif version == '0.10':
1467                        from trac import attachment
1468                        from trac.env import Environment
1469                        from trac.ticket import Ticket
1470                        from trac.web.href import Href
1471                        from trac import util
1472                        #
1473                        # return  util.text.to_unicode(str)
1474                        #
1475                        # see http://projects.edgewall.com/trac/changeset/2799
1476                        from trac.ticket.notification import TicketNotifyEmail
1477                        from trac import config as trac_config
1478                elif version == '0.11':
1479                        from trac import attachment
1480                        from trac.env import Environment
1481                        from trac.ticket import Ticket
1482                        from trac.web.href import Href
1483                        from trac import config as trac_config
1484                        from trac import util
1485                        #
1486                        # return  util.text.to_unicode(str)
1487                        #
1488                        # see http://projects.edgewall.com/trac/changeset/2799
1489                        from trac.ticket.notification import TicketNotifyEmail
1490                else:
1491                        print 'TRAC version %s is not supported' %version
1492                        sys.exit(1)
1493                       
1494                if settings.has_key('enable_syslog'):
1495                        if SYSLOG_AVAILABLE:
1496                                ENABLE_SYSLOG =  float(settings['enable_syslog'])
1497
1498                env = Environment(settings['project'], create=0)
1499                tktparser = TicketEmailParser(env, settings, float(version))
1500                tktparser.parse(sys.stdin)
1501
1502        # Catch all errors ans log to SYSLOG if we have enabled this
1503        # else stdout
1504        #
1505        except Exception, error:
1506                if ENABLE_SYSLOG:
1507                        syslog.openlog('email2trac', syslog.LOG_NOWAIT)
1508
1509                        etype, evalue, etb = sys.exc_info()
1510                        for e in traceback.format_exception(etype, evalue, etb):
1511                                syslog.syslog(e)
1512
1513                        syslog.closelog()
1514                else:
1515                        traceback.print_exc()
1516
1517                if m:
1518                        tktparser.save_email_for_debug(m, True)
1519
1520                sys.exit(1)
1521# EOB
Note: See TracBrowser for help on using the repository browser.