source: trunk/email2trac.py.in @ 231

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

email2trac.py.in:

ChangeLog?

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