source: trunk/email2trac.py.in @ 221

Last change on this file since 221 was 221, checked in by bas, 16 years ago

email2trac.py.in:

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