source: trunk/email2trac.py.in @ 222

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

email2trac.py.in, email2trac.conf:

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