source: trunk/email2trac.py.in @ 214

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

email2trac.py.in:

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