source: trunk/email2trac.py.in @ 236

Last change on this file since 236 was 236, checked in by bromine, 13 years ago

email2trac.py.in:

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