source: trunk/email2trac.py.in @ 234

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

email2trac.py.in:

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