source: tags/0.60/email2trac.py.in

Last change on this file was 242, checked in by bas, 15 years ago

email2trac.py.in:

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