source: trunk/email2trac.py.in @ 209

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

email2trac.py.in:

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