source: trunk/email2trac.py.in @ 249

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

email2trac.py.in:

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