source: trunk/email2trac.py.in @ 234

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

email2trac.py.in:

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