source: trunk/email2trac.py.in @ 207

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

email2trac.py.in:

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