source: trunk/email2trac.py.in @ 232

Last change on this file since 232 was 232, checked in by bas, 14 years ago

email2trac.py.in:

  • rfc 3676 bug fix, closes #99
  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 33.1 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 232 2008-11-04 12:31:21Z 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                        value = self.get_config('ticket', 'default_%s' %(name) )
686                        if self.DEBUG > 10:
687                                print 'trac.ini name %s = %s' %(name, value)
688
689                        prefix = self.parameters['ticket_prefix']
690                        try:
691                                value = self.parameters['%s_%s' %(prefix, name)]
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
711        def new_ticket(self, msg, spam):
712                """
713                Create a new ticket
714                """
715                tkt = Ticket(self.env)
716
717                self.set_ticket_fields(tkt)
718
719                # Some defaults
720                #
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')
727
728                # Old style setting for component, will be removed
729                #
730                if spam:
731                        tkt['component'] = 'Spam'
732
733                elif self.parameters.has_key('component'):
734                        tkt['component'] = self.parameters['component']
735
736                if not msg['Subject']:
737                        tkt['summary'] = u'(No subject)'
738                else:
739                        tkt['summary'] = self.email_to_unicode(msg['Subject'])
740
741
742                self.set_reply_fields(tkt, msg)
743
744                # produce e-mail like header
745                #
746                head = ''
747                if self.EMAIL_HEADER > 0:
748                        head = self.email_header_txt(msg)
749                       
750                body_text = self.get_body_text(msg)
751
752                tkt['description'] = '%s\r\n%s' \
753                        %(head, body_text)
754
755                #when = int(time.time())
756                #
757                utc = UTC()
758                when = datetime.now(utc)
759
760                if self.DRY_RUN:
761                        ticket_id = 'DRY_RUN'
762                else:
763                        ticket_id = tkt.insert()
764                       
765                tkt['id'] = ticket_id
766
767                changed = False
768                comment = ''
769
770                # Rewrite the description if we have mailto enabled
771                #
772                if self.MAILTO:
773                        changed = True
774                        comment = u'\nadded mailto line\n'
775                        #mailto = self.html_mailto_link(tkt['summary'], ticket_id, body_text)
776                        mailto = self.html_mailto_link( m['Subject'], ticket_id, body_text)
777                        tkt['description'] = u'%s\r\n%s%s\r\n' \
778                                %(head, mailto, body_text)
779
780                str =  self.attachments(msg, tkt)
781                if str:
782                        changed = True
783                        comment = '%s\n%s\n' %(comment, str)
784
785                if changed:
786                        if self.DRY_RUN:
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)
791
792                if self.notification:
793                        if self.DRY_RUN:
794                                print 'DRY_RUN: self.notify(tkt, True)'
795                        else:
796                                if not spam:
797                                        self.notify(tkt, True)
798                                #self.notify(tkt, False)
799
800        def parse(self, fp):
801                global m
802
803                m = email.message_from_file(fp)
804                if not m:
805                        if self.DEBUG:
806                                print "This is not a valid email message format"
807                        return
808
809                if self.DEBUG > 1:        # save the entire e-mail message text
810                        self.save_email_for_debug(m, True)
811                        self.debug_attachments(m)
812
813                self.db = self.env.get_db_cnx()
814                self.get_sender_info(m)
815
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
820
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
826                if not self.email_header_acl('recipient_list', self.to_email_addr, True):
827                        if self.DEBUG > 1 :
828                                print 'Message rejected : %s not in recipient list' %(self.to_email_addr)
829                        return False
830
831                # If drop the message
832                #
833                if self.spam(m) == 'drop':
834                        return False
835
836                elif self.spam(m) == 'spam':
837                        spam_msg = True
838
839                else:
840                        spam_msg = False
841
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:
850                        if self.ticket_update(m, spam_msg):
851                                return True
852
853                self.new_ticket(m, spam_msg)
854
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
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
904        def strip_quotes(self, text):
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)
913
914                return ('\n'.join(body))
915
916        def wrap_text(self, text, replace_whitespace = False):
917                """
918                Will break a lines longer then given length into several small
919                lines of size given length
920                """
921                import textwrap
922
923                LINESEPARATOR = '\n'
924                reformat = ''
925
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
932
933                return reformat
934
935                # Python2.4 and higher
936                #
937                #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
938                #
939
940
941        def get_body_text(self, msg):
942                """
943                put the message text in the ticket description or in the changes field.
944                message text can be plain text or html or something else
945                """
946                has_description = 0
947                encoding = True
948                ubody_text = u'No plain text message'
949                for part in msg.walk():
950
951                        # 'multipart/*' is a container for multipart messages
952                        #
953                        if part.get_content_maintype() == 'multipart':
954                                continue
955
956                        if part.get_content_type() == 'text/plain':
957                                # Try to decode, if fails then do not decode
958                                #
959                                body_text = part.get_payload(decode=1)
960                                if not body_text:                       
961                                        body_text = part.get_payload(decode=0)
962
963                                format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
964                                delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
965
966                                if self.REFLOW and not self.VERBATIM_FORMAT and format == 'flowed':
967                                        body_text = self.reflow(body_text, delsp == 'yes')
968       
969                                if self.STRIP_SIGNATURE:
970                                        body_text = self.strip_signature(body_text)
971
972                                if self.STRIP_QUOTES:
973                                        body_text = self.strip_quotes(body_text)
974
975                                if self.USE_TEXTWRAP:
976                                        body_text = self.wrap_text(body_text)
977
978                                # Get contents charset (iso-8859-15 if not defined in mail headers)
979                                #
980                                charset = part.get_content_charset()
981                                if not charset:
982                                        charset = 'iso-8859-15'
983
984                                try:
985                                        ubody_text = unicode(body_text, charset)
986
987                                except UnicodeError, detail:
988                                        ubody_text = unicode(body_text, 'iso-8859-15')
989
990                                except LookupError, detail:
991                                        ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
992
993                        elif part.get_content_type() == 'text/html':
994                                ubody_text = '(see attachment for HTML mail message)'
995
996                        else:
997                                ubody_text = '(see attachment for message)'
998
999                        has_description = 1
1000                        break           # we have the description, so break
1001
1002                if not has_description:
1003                        ubody_text = '(see attachment for message)'
1004
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()))
1009
1010                #  If we can unicode it try to encode it for trac
1011                #  else we a lot of garbage
1012                #
1013                #if encoding:
1014                #       ubody_text = ubody_text.encode('utf-8')
1015
1016                if self.VERBATIM_FORMAT:
1017                        ubody_text = '{{{\r\n%s\r\n}}}' %ubody_text
1018                else:
1019                        ubody_text = '%s' %ubody_text
1020
1021                return ubody_text
1022
1023        def notify(self, tkt , new=True, modtime=0):
1024                """
1025                A wrapper for the TRAC notify function. So we can use templates
1026                """
1027                try:
1028                        # create false {abs_}href properties, to trick Notify()
1029                        #
1030                        if not self.VERSION == 0.11:
1031                                self.env.abs_href = Href(self.get_config('project', 'url'))
1032                                self.env.href = Href(self.get_config('project', 'url'))
1033
1034                        tn = TicketNotifyEmail(self.env)
1035
1036                        if self.notify_template:
1037
1038                                if self.VERSION == 0.11:
1039
1040                                        from trac.web.chrome import Chrome
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
1047                                        tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
1048                                               
1049                                else:
1050
1051                                        tn.template_name = self.notify_template;
1052
1053                        tn.notify(tkt, new, modtime)
1054
1055                except Exception, e:
1056                        print 'TD: Failure sending notification on creation of ticket #%s: %s' %(tkt['id'], e)
1057
1058        def html_mailto_link(self, subject, id, body):
1059                if not self.author:
1060                        author = self.email_addr
1061                else:   
1062                        author = self.author
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
1072                #
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
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)
1080                return str
1081
1082        def attachments(self, message, ticket, update=False):
1083                '''
1084                save any attachments as files in the ticket's directory
1085                '''
1086                count = 0
1087                first = 0
1088                number = 0
1089
1090                # Get Maxium attachment size
1091                #
1092                max_size = int(self.get_config('attachment', 'max_size'))
1093                status   = ''
1094
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:
1106                                number = number + 1
1107                                filename = 'part%04d' % number
1108
1109                                ext = mimetypes.guess_extension(part.get_content_type())
1110                                if not ext:
1111                                        ext = '.bin'
1112
1113                                filename = '%s%s' % (filename, ext)
1114                        else:
1115                                filename = self.email_to_unicode(filename)
1116
1117                        # From the trac code
1118                        #
1119                        filename = filename.replace('\\', '/').replace(':', '/')
1120                        filename = os.path.basename(filename)
1121
1122                        # We try to normalize the filename to utf-8 NFC if we can.
1123                        # Files uploaded from OS X might be in NFD.
1124                        # Check python version and then try it
1125                        #
1126                        if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
1127                                try:
1128                                        filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
1129                                except TypeError:
1130                                        pass
1131
1132                        url_filename = urllib.quote(filename)
1133                        #
1134                        # Must be tuneables HvB
1135                        #
1136                        path, fd =  util.create_unique_file(os.path.join(self.TMPDIR, url_filename))
1137                        text = part.get_payload(decode=1)
1138                        if not text:
1139                                text = '(None)'
1140                        fd.write(text)
1141                        fd.close()
1142
1143                        # get the file_size
1144                        #
1145                        stats = os.lstat(path)
1146                        file_size = stats[stat.ST_SIZE]
1147
1148                        # Check if the attachment size is allowed
1149                        #
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)
1153
1154                                os.unlink(path)
1155                                continue
1156                        else:
1157                                count = count + 1
1158                                       
1159                        # Insert the attachment
1160                        #
1161                        fd = open(path)
1162                        att = attachment.Attachment(self.env, 'ticket', ticket['id'])
1163
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')
1170
1171                        att.insert(url_filename, fd, file_size)
1172                        #except  util.TracError, detail:
1173                        #       print detail
1174
1175                        # Remove the created temporary filename
1176                        #
1177                        fd.close()
1178                        os.unlink(path)
1179
1180                # Return how many attachments
1181                #
1182                status = 'This message has %d attachment(s)\n%s' %(count, status)
1183                return status
1184
1185
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):
1203                print 'File %s does not exist' %file
1204                sys.exit(1)
1205
1206        config = trac_config.Configuration(file)
1207
1208        # Use given project name else use defaults
1209        #
1210        if name:
1211                sections = config.sections()
1212                if not name in sections:
1213                        print "Not a valid project name: %s" %name
1214                        print "Valid names: %s" %sections
1215                        sys.exit(1)
1216
1217                project =  dict()
1218                for option, value in  config.options(name):
1219                        project[option] = value
1220
1221        else:
1222                # use some trac internales to get the defaults
1223                #
1224                project = config.parser.defaults()
1225
1226        return project
1227
1228
1229if __name__ == '__main__':
1230        # Default config file
1231        #
1232        configfile = '@email2trac_conf@'
1233        project = ''
1234        component = ''
1235        ticket_prefix = 'default'
1236        dry_run = None
1237
1238        ENABLE_SYSLOG = 0
1239
1240
1241        SHORT_OPT = 'chf:np:t:'
1242        LONG_OPT  =  ['component=', 'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=']
1243
1244        try:
1245                opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
1246        except getopt.error,detail:
1247                print __doc__
1248                print detail
1249                sys.exit(1)
1250       
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
1260                elif opt in ['-n', '--dry-run']:
1261                        dry_run = True
1262                elif opt in ['-p', '--project']:
1263                        project_name = value
1264                elif opt in ['-t', '--ticket_prefix']:
1265                        ticket_prefix = value
1266       
1267        settings = ReadConfig(configfile, project_name)
1268        if not settings.has_key('project'):
1269                print __doc__
1270                print 'No Trac project is defined in the email2trac config file.'
1271                sys.exit(1)
1272       
1273        if component:
1274                settings['component'] = component
1275
1276        # The default prefix for ticket values in email2trac.conf
1277        #
1278        settings['ticket_prefix'] = ticket_prefix
1279        settings['dry_run'] = dry_run
1280       
1281        if settings.has_key('trac_version'):
1282                version = settings['trac_version']
1283        else:
1284                version = trac_default_version
1285
1286
1287        #debug HvB
1288        #print settings
1289
1290        try:
1291                if version == '0.9':
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
1298                elif version == '0.10':
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                        #
1305                        # return  util.text.to_unicode(str)
1306                        #
1307                        # see http://projects.edgewall.com/trac/changeset/2799
1308                        from trac.ticket.notification import TicketNotifyEmail
1309                        from trac import config as trac_config
1310                elif version == '0.11':
1311                        from trac import attachment
1312                        from trac.env import Environment
1313                        from trac.ticket import Ticket
1314                        from trac.web.href import Href
1315                        from trac import config as trac_config
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
1322                else:
1323                        print 'TRAC version %s is not supported' %version
1324                        sys.exit(1)
1325                       
1326                if settings.has_key('enable_syslog'):
1327                        if SYSLOG_AVAILABLE:
1328                                ENABLE_SYSLOG =  float(settings['enable_syslog'])
1329
1330                env = Environment(settings['project'], create=0)
1331                tktparser = TicketEmailParser(env, settings, float(version))
1332                tktparser.parse(sys.stdin)
1333
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)
1340
1341                        etype, evalue, etb = sys.exc_info()
1342                        for e in traceback.format_exception(etype, evalue, etb):
1343                                syslog.syslog(e)
1344
1345                        syslog.closelog()
1346                else:
1347                        traceback.print_exc()
1348
1349                if m:
1350                        tktparser.save_email_for_debug(m, True)
1351
1352# EOB
Note: See TracBrowser for help on using the repository browser.