source: tags/1.4.5/email2trac.py.in

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

email2trac.py.in:

  • added print_unicode function. So we can delete some code lines,
  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 54.4 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
30Authors:
31  Bas van der Vlies <basv@sara.nl>
32  Walter de Jong <walter@sara.nl>
33
34The scripts reads emails from stdin and inserts directly into a Trac database.
35
36How to use
37----------
38 * See https://subtrac.sara.nl/oss/email2trac/
39
40 * Create an config file:
41    [DEFAULT]                        # REQUIRED
42    project      : /data/trac/test   # REQUIRED
43    debug        : 1                 # OPTIONAL, if set print some DEBUG info
44
45    [jouvin]                         # OPTIONAL project declaration, if set both fields necessary
46    project      : /data/trac/jouvin # use -p|--project jouvin. 
47       
48 * default config file is : /etc/email2trac.conf
49
50 * Commandline opions:
51                -h,--help
52                -f,--file  <configuration file>
53                -n,--dry-run
54                -p, --project <project name>
55                -t, --ticket_prefix <name>
56
57SVN Info:
58        $Id: email2trac.py.in 379 2010-06-22 11:22:33Z bas $
59"""
60import os
61import sys
62import string
63import getopt
64import stat
65import time
66import email
67import email.Iterators
68import email.Header
69import re
70import urllib
71import unicodedata
72from stat import *
73import mimetypes
74import traceback
75
76from trac import __version__ as trac_version
77
78# Will fail where unavailable, e.g. Windows
79#
80try:
81    import syslog
82    SYSLOG_AVAILABLE = True
83except ImportError:
84    SYSLOG_AVAILABLE = False
85
86from datetime import tzinfo, timedelta, datetime
87from trac import config as trac_config
88
89# Some global variables
90#
91trac_default_version = '0.11'
92m = None
93
94# A UTC class needed for trac version 0.11, added by
95# tbaschak at ktc dot mb dot ca
96#
97class UTC(tzinfo):
98        """UTC"""
99        ZERO = timedelta(0)
100        HOUR = timedelta(hours=1)
101       
102        def utcoffset(self, dt):
103                return self.ZERO
104               
105        def tzname(self, dt):
106                return "UTC"
107               
108        def dst(self, dt):
109                return self.ZERO
110
111
112class TicketEmailParser(object):
113        env = None
114        comment = '> '
115
116        def __init__(self, env, parameters, version):
117                self.env = env
118
119                # Database connection
120                #
121                self.db = None
122
123                # Save parameters
124                #
125                self.parameters = parameters
126
127                # Some useful mail constants
128                #
129                self.email_name = None
130                self.email_addr = None
131                self.email_from = None
132                self.author     = None
133                self.id         = None
134               
135                self.STRIP_CONTENT_TYPES = list()
136
137                self.VERSION = version
138                self.DRY_RUN = parameters['dry_run']
139                self.VERBOSE = parameters['verbose']
140
141                self.get_config = self.env.config.get
142
143                if parameters.has_key('umask'):
144                        os.umask(int(parameters['umask'], 8))
145
146                if parameters.has_key('debug'):
147                        self.DEBUG = int(parameters['debug'])
148                else:
149                        self.DEBUG = 0
150
151                if parameters.has_key('mailto_link'):
152                        self.MAILTO = int(parameters['mailto_link'])
153                        if parameters.has_key('mailto_cc'):
154                                self.MAILTO_CC = parameters['mailto_cc']
155                        else:
156                                self.MAILTO_CC = ''
157                else:
158                        self.MAILTO = 0
159
160                if parameters.has_key('spam_level'):
161                        self.SPAM_LEVEL = int(parameters['spam_level'])
162                else:
163                        self.SPAM_LEVEL = 0
164
165                if parameters.has_key('spam_header'):
166                        self.SPAM_HEADER = parameters['spam_header']
167                else:
168                        self.SPAM_HEADER = 'X-Spam-Score'
169
170                if parameters.has_key('email_quote'):
171                        self.EMAIL_QUOTE = str(parameters['email_quote'])
172                else:   
173                        self.EMAIL_QUOTE = '> '
174
175                if parameters.has_key('email_header'):
176                        self.EMAIL_HEADER = int(parameters['email_header'])
177                else:
178                        self.EMAIL_HEADER = 0
179
180                if parameters.has_key('alternate_notify_template'):
181                        self.notify_template = str(parameters['alternate_notify_template'])
182                else:
183                        self.notify_template = None
184
185                if parameters.has_key('alternate_notify_template_update'):
186                        self.notify_template_update = str(parameters['alternate_notify_template_update'])
187                else:
188                        self.notify_template_update = None
189
190                if parameters.has_key('reply_all'):
191                        self.REPLY_ALL = int(parameters['reply_all'])
192                else:
193                        self.REPLY_ALL = 0
194
195                if parameters.has_key('ticket_update'):
196                        self.TICKET_UPDATE = int(parameters['ticket_update'])
197                else:
198                        self.TICKET_UPDATE = 0
199
200                if parameters.has_key('ticket_update_by_subject'):
201                        self.TICKET_UPDATE_BY_SUBJECT = int(parameters['ticket_update_by_subject'])
202                else:
203                        self.TICKET_UPDATE_BY_SUBJECT = 0
204
205                if parameters.has_key('ticket_update_by_subject_lookback'):
206                        self.TICKET_UPDATE_BY_SUBJECT_LOOKBACK = int(parameters['ticket_update_by_subject_lookback'])
207                else:
208                        self.TICKET_UPDATE_BY_SUBJECT_LOOKBACK = 30
209
210                if parameters.has_key('drop_spam'):
211                        self.DROP_SPAM = int(parameters['drop_spam'])
212                else:
213                        self.DROP_SPAM = 0
214
215                if parameters.has_key('verbatim_format'):
216                        self.VERBATIM_FORMAT = int(parameters['verbatim_format'])
217                else:
218                        self.VERBATIM_FORMAT = 1
219
220                if parameters.has_key('reflow'):
221                        self.REFLOW = int(parameters['reflow'])
222                else:
223                        self.REFLOW = 1
224
225                if parameters.has_key('drop_alternative_html_version'):
226                        self.DROP_ALTERNATIVE_HTML_VERSION = int(parameters['drop_alternative_html_version'])
227                else:
228                        self.DROP_ALTERNATIVE_HTML_VERSION = 0
229
230                if parameters.has_key('strip_signature'):
231                        self.STRIP_SIGNATURE = int(parameters['strip_signature'])
232                else:
233                        self.STRIP_SIGNATURE = 0
234
235                if parameters.has_key('strip_quotes'):
236                        self.STRIP_QUOTES = int(parameters['strip_quotes'])
237                else:
238                        self.STRIP_QUOTES = 0
239
240                self.properties = dict()
241                if parameters.has_key('inline_properties'):
242                        self.INLINE_PROPERTIES = int(parameters['inline_properties'])
243                else:
244                        self.INLINE_PROPERTIES = 0
245
246                if parameters.has_key('use_textwrap'):
247                        self.USE_TEXTWRAP = int(parameters['use_textwrap'])
248                else:
249                        self.USE_TEXTWRAP = 0
250
251                if parameters.has_key('binhex'):
252                        self.STRIP_CONTENT_TYPES.append('application/mac-binhex40')
253
254                if parameters.has_key('applesingle'):
255                        self.STRIP_CONTENT_TYPES.append('application/applefile')
256
257                if parameters.has_key('appledouble'):
258                        self.STRIP_CONTENT_TYPES.append('application/applefile')
259
260                if parameters.has_key('strip_content_types'):
261                        items = parameters['strip_content_types'].split(',')
262                        for item in items:
263                                self.STRIP_CONTENT_TYPES.append(item.strip())
264
265                self.WORKFLOW = None
266                if parameters.has_key('workflow'):
267                        self.WORKFLOW = parameters['workflow']
268
269                # Use OS independend functions
270                #
271                self.TMPDIR = os.path.normcase('/tmp')
272                if parameters.has_key('tmpdir'):
273                        self.TMPDIR = os.path.normcase(str(parameters['tmpdir']))
274
275                if parameters.has_key('ignore_trac_user_settings'):
276                        self.IGNORE_TRAC_USER_SETTINGS = int(parameters['ignore_trac_user_settings'])
277                else:
278                        self.IGNORE_TRAC_USER_SETTINGS = 0
279
280                if parameters.has_key('email_triggers_workflow'):
281                        self.EMAIL_TRIGGERS_WORKFLOW = int(parameters['email_triggers_workflow'])
282                else:
283                        self.EMAIL_TRIGGERS_WORKFLOW = 1
284
285                if parameters.has_key('subject_field_separator'):
286                        self.SUBJECT_FIELD_SEPARATOR = parameters['subject_field_separator'].strip()
287                else:
288                        self.SUBJECT_FIELD_SEPARATOR = '&'
289
290                self.trac_smtp_from = self.get_config('notification', 'smtp_from')
291
292                self.system = None
293
294########## Email Header Functions ###########################################################
295
296        def spam(self, message):
297                """
298                # X-Spam-Score: *** (3.255) BAYES_50,DNS_FROM_AHBL_RHSBL,HTML_
299                # Note if Spam_level then '*' are included
300                """
301                spam = False
302                if message.has_key(self.SPAM_HEADER):
303                        spam_l = string.split(message[self.SPAM_HEADER])
304
305                        try:
306                                number = spam_l[0].count('*')
307                        except IndexError, detail:
308                                number = 0
309                               
310                        if number >= self.SPAM_LEVEL:
311                                spam = True
312                               
313                # treat virus mails as spam
314                #
315                elif message.has_key('X-Virus-found'):                 
316                        spam = True
317
318                # How to handle SPAM messages
319                #
320                if self.DROP_SPAM and spam:
321                        if self.DEBUG > 2 :
322                                print 'This message is a SPAM. Automatic ticket insertion refused (SPAM level > %d' % self.SPAM_LEVEL
323
324                        return 'drop'   
325
326                elif spam:
327
328                        return 'Spam'   
329
330                else:
331
332                        return False
333
334        def email_header_acl(self, keyword, header_field, default):
335                """
336                This function wil check if the email address is allowed or denied
337                to send mail to the ticket list
338            """
339                try:
340                        mail_addresses = self.parameters[keyword]
341
342                        # Check if we have an empty string
343                        #
344                        if not mail_addresses:
345                                return default
346
347                except KeyError, detail:
348                        if self.DEBUG > 2 :
349                                print 'TD: %s not defined, all messages are allowed.' %(keyword)
350
351                        return default
352
353                mail_addresses = string.split(mail_addresses, ',')
354
355                for entry in mail_addresses:
356                        entry = entry.strip()
357                        TO_RE = re.compile(entry, re.VERBOSE|re.IGNORECASE)
358                        result =  TO_RE.search(header_field)
359                        if result:
360                                return True
361
362                return False
363
364        def email_header_txt(self, m):
365                """
366                Display To and CC addresses in description field
367                """
368                s = ''
369
370                if m['To'] and len(m['To']) > 0:
371                        s = "'''To:''' %s\r\n" %(m['To'])
372                if m['Cc'] and len(m['Cc']) > 0:
373                        s = "%s'''Cc:''' %s\r\n" % (s, m['Cc'])
374
375                return  self.email_to_unicode(s)
376
377
378        def get_sender_info(self, message):
379                """
380                Get the default author name and email address from the message
381                """
382
383                self.email_to = self.email_to_unicode(message['to'])
384                self.to_name, self.to_email_addr = email.Utils.parseaddr (self.email_to)
385
386                self.email_from = self.email_to_unicode(message['from'])
387                self.email_name, self.email_addr  = email.Utils.parseaddr(self.email_from)
388
389                ## Trac can not handle author's name that contains spaces
390                #  and forbid the ticket email address as author field
391
392                if self.email_addr == self.trac_smtp_from:
393                        self.author = "email2trac"
394                else:
395                        self.author = self.email_addr
396
397                if self.IGNORE_TRAC_USER_SETTINGS:
398                        return
399
400                # Is this a registered user, use email address as search key:
401                # result:
402                #   u : login name
403                #   n : Name that the user has set in the settings tab
404                #   e : email address that the user has set in the settings tab
405                #
406                users = [ (u,n,e) for (u, n, e) in self.env.get_known_users(self.db)
407                        if e and (e.lower() == self.email_addr.lower()) ]
408
409                if len(users) == 1:
410                        self.email_from = users[0][0]
411                        self.author = users[0][0]
412
413        def set_reply_fields(self, ticket, message):
414                """
415                Set all the right fields for a new ticket
416                """
417                if self.DEBUG:
418                        print 'TD: set_reply_fields'
419
420                ## Only use name or email adress
421                #ticket['reporter'] = self.email_from
422                ticket['reporter'] = self.author
423
424
425                # Put all CC-addresses in ticket CC field
426                #
427                if self.REPLY_ALL:
428
429                        email_cc = ''
430
431                        cc_addrs = email.Utils.getaddresses( message.get_all('cc', []) )
432
433                        if not cc_addrs:
434                                return
435
436                        ## Build a list of forbidden CC addresses
437                        #
438                        #to_addrs = email.Utils.getaddresses( message.get_all('to', []) )
439                        #to_list = list()
440                        #for n,e in to_addrs:
441                        #       to_list.append(e)
442                               
443                        # Remove reporter email address if notification is
444                        # on
445                        #
446                        if self.notification:
447                                try:
448                                        cc_addrs.remove((self.author, self.email_addr))
449                                except ValueError, detail:
450                                        pass
451
452                        for name,addr in cc_addrs:
453               
454                                ## Prevent mail loop
455                                #
456                                #if addr in to_list:
457
458                                if addr == self.trac_smtp_from:
459                                        if self.DEBUG:
460                                                print "Skipping %s mail address for CC-field" %(addr)
461                                        continue
462
463                                if email_cc:
464                                        email_cc = '%s, %s' %(email_cc, addr)
465                                else:
466                                        email_cc = addr
467
468                        if email_cc:
469                                if self.DEBUG:
470                                        print 'TD: set_reply_fields: %s' %email_cc
471
472                                ticket['cc'] = self.email_to_unicode(email_cc)
473
474
475########## DEBUG functions  ###########################################################
476
477        def debug_body(self, message_body, tempfile=False):
478                if tempfile:
479                        import tempfile
480                        body_file = tempfile.mktemp('.email2trac')
481                else:
482                        body_file = os.path.join(self.TMPDIR, 'body.txt')
483
484                if self.DRY_RUN:
485                        print 'DRY-RUN: not saving body to %s' %(body_file)
486                        return
487
488                print 'TD: writing body to %s' %(body_file)
489                fx = open(body_file, 'wb')
490                if not message_body:
491                                message_body = '(None)'
492
493                message_body = message_body.encode('utf-8')
494                #message_body = unicode(message_body, 'iso-8859-15')
495
496                fx.write(message_body)
497                fx.close()
498                try:
499                        os.chmod(body_file,S_IRWXU|S_IRWXG|S_IRWXO)
500                except OSError:
501                        pass
502
503        def debug_attachments(self, message_parts):
504                """
505                """
506                if self.VERBOSE:
507                        print "VB: debug_attachments"
508               
509                n = 0
510                for item in message_parts:
511                        # Skip inline text parts
512                        if not isinstance(item, tuple):
513                                continue
514                               
515                        (original, filename, part) = item
516
517                        n = n + 1
518                        print 'TD: part%d: Content-Type: %s' % (n, part.get_content_type())
519               
520                        s = 'TD: part%d: filename: %s' %(n, filename)
521                        self.print_unicode(s)
522       
523                        ## Forbidden chars
524                        #
525                        filename = filename.replace('\\', '_')
526                        filename = filename.replace('/', '_')
527       
528
529                        part_file = os.path.join(self.TMPDIR, filename)
530                        s = 'TD: writing part%d (%s)' % (n,part_file)
531                        self.print_unicode(s)
532
533                        if self.DRY_RUN:
534                                print 'DRY_RUN: NOT saving attachments'
535                                continue
536
537                        part_file = util.text.unicode_quote(part_file)
538
539                        fx = open(part_file, 'wb')
540                        text = part.get_payload(decode=1)
541
542                        if not text:
543                                text = '(None)'
544
545                        fx.write(text)
546                        fx.close()
547
548                        try:
549                                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
550                        except OSError:
551                                pass
552
553        def save_email_for_debug(self, message, tempfile=False):
554
555                if tempfile:
556                        import tempfile
557                        msg_file = tempfile.mktemp('.email2trac')
558                else:
559                        #msg_file = '/var/tmp/msg.txt'
560                        msg_file = os.path.join(self.TMPDIR, 'msg.txt')
561
562                if self.DRY_RUN:
563                        print 'DRY_RUN: NOT saving email message to %s' %(msg_file)
564                else:
565                        print 'TD: saving email to %s' %(msg_file)
566
567                        fx = open(msg_file, 'wb')
568                        fx.write('%s' % message)
569                        fx.close()
570                       
571                        try:
572                                os.chmod(msg_file,S_IRWXU|S_IRWXG|S_IRWXO)
573                        except OSError:
574                                pass
575
576                message_parts = self.get_message_parts(message)
577                message_parts = self.unique_attachment_names(message_parts)
578                body_text = self.body_text(message_parts)
579                self.debug_body(body_text, True)
580                self.debug_attachments(message_parts)
581
582########## Conversion functions  ###########################################################
583
584        def email_to_unicode(self, message_str):
585                """
586                Email has 7 bit ASCII code, convert it to unicode with the charset
587                that is encoded in 7-bit ASCII code and encode it as utf-8 so Trac
588                understands it.
589                """
590                if self.VERBOSE:
591                        print "VB: email_to_unicode"
592
593                results =  email.Header.decode_header(message_str)
594
595                s = None
596                for text,format in results:
597                        if format:
598                                try:
599                                        temp = unicode(text, format)
600                                except UnicodeError, detail:
601                                        # This always works
602                                        #
603                                        temp = unicode(text, 'iso-8859-15')
604                                except LookupError, detail:
605                                        #text = 'ERROR: Could not find charset: %s, please install' %format
606                                        #temp = unicode(text, 'iso-8859-15')
607                                        temp = message_str
608                                       
609                        else:
610                                temp = string.strip(text)
611                                temp = unicode(text, 'iso-8859-15')
612
613                        if s:
614                                s = '%s %s' %(s, temp)
615                        else:
616                                s = '%s' %temp
617
618                #s = s.encode('utf-8')
619                return s
620
621        def str_to_dict(self, s):
622                """
623                Transfrom a string of the form [<key>=<value>]+ to dict[<key>] = <value>
624                """
625                if self.VERBOSE:
626                        print "VB: str_to_dict"
627
628                fields = string.split(s, self.SUBJECT_FIELD_SEPARATOR)
629
630                result = dict()
631                for field in fields:
632                        try:
633                                index, value = string.split(field, '=')
634
635                                # We can not change the description of a ticket via the subject
636                                # line. The description is the body of the email
637                                #
638                                if index.lower() in ['description']:
639                                        continue
640
641                                if value:
642                                        result[index.lower()] = value
643
644                        except ValueError:
645                                pass
646                return result
647
648        def print_unicode(self,s):
649                """
650                This function prints unicode strings uif possible else it will quote it
651                """
652                try:
653                        print s
654                except UnicodeEncodeError, detail:
655                        print util.text.unicode_quote(s)
656
657########## TRAC ticket functions  ###########################################################
658
659        def update_ticket_fields(self, ticket, user_dict, use_default=None):
660                """
661                This will update the ticket fields. It will check if the
662                given fields are known and if the right values are specified
663                It will only update the ticket field value:
664                        - If the field is known
665                        - If the value supplied is valid for the ticket field.
666                          If not then there are two options:
667                           1) Skip the value (use_default=None)
668                           2) Set default value for field (use_default=1)
669                """
670                if self.VERBOSE:
671                        print "VB: update_ticket_fields"
672
673                # Build a system dictionary from the ticket fields
674                # with field as index and option as value
675                #
676                sys_dict = dict()
677                for field in ticket.fields:
678                        try:
679                                sys_dict[field['name']] = field['options']
680
681                        except KeyError:
682                                sys_dict[field['name']] = None
683                                pass
684
685                ## Check user supplied fields an compare them with the
686                # system one's
687                #
688                for field,value in user_dict.items():
689                        if self.DEBUG >= 10:
690                                s = 'TD: user_field\t %s = %s' %(field,value)
691                                self.print_unicode(s)
692
693                        ## To prevent mail loop
694                        #
695                        if field == 'cc':
696
697                                cc_list = user_dict['cc'].split(',')
698
699                                if self.trac_smtp_from in cc_list:
700                                        if self.DEBUG > 10:
701                                                print 'TD: MAIL LOOP: %s is not allowed as CC address' %(self.trac_smtp_from)
702                                        cc_list.remove(self.trac_smtp_from)
703
704                                value = ','.join(cc_list)
705                               
706
707                        if sys_dict.has_key(field):
708
709                                # Check if value is an allowed system option, if TypeError then
710                                # every value is allowed
711                                #
712                                try:
713                                        if value in sys_dict[field]:
714                                                ticket[field] = value
715                                        else:
716                                                # Must we set a default if value is not allowed
717                                                #
718                                                if use_default:
719                                                        value = self.get_config('ticket', 'default_%s' %(field) )
720
721                                except TypeError:
722                                        pass
723
724                                ## Only set if we have a value
725                                #
726                                if value:
727                                        ticket[field] = value
728
729                                if self.DEBUG >= 10:
730                                        s = 'ticket_field\t %s = %s' %(field,  ticket[field])
731                                        self.print_unicode(s)
732
733        def ticket_update(self, m, id, spam):
734                """
735                If the current email is a reply to an existing ticket, this function
736                will append the contents of this email to that ticket, instead of
737                creating a new one.
738                """
739                if self.VERBOSE:
740                        print "VB: ticket_update: %s" %id
741
742                # Must we update ticket fields
743                #
744                update_fields = dict()
745                try:
746                        id, keywords = string.split(id, '?')
747
748                        # Skip the last ':' character
749                        #
750                        keywords = keywords[:-1]
751                        update_fields = self.str_to_dict(keywords)
752
753                        # Strip '#'
754                        #
755                        self.id = int(id[1:])
756
757                except ValueError:
758                        # Strip '#'
759                        #
760                        self.id = int(id[1:])
761
762                if self.VERBOSE:
763                        print "VB: ticket_update: %s" %id
764
765
766                # When is the change committed
767                #
768                if self.VERSION == 0.11:
769                        utc = UTC()
770                        when = datetime.now(utc)
771                else:
772                        when = int(time.time())
773
774                try:
775                        tkt = Ticket(self.env, self.id, self.db)
776                except util.TracError, detail:
777                        # Not a valid ticket
778                        self.id = None
779                        return False
780
781                # How many changes has this ticket
782                cnum = len(tkt.get_changelog())
783
784
785                # reopen the ticket if it is was closed
786                # We must use the ticket workflow framework
787                #
788                if tkt['status'] in ['closed'] and self.EMAIL_TRIGGERS_WORKFLOW:
789
790                        #print controller.actions['reopen']
791                        #
792                        # As reference 
793                        # req = Mock(href=Href('/'), abs_href=Href('http://www.example.com/'), authname='anonymous', perm=MockPerm(), args={})
794                        #
795                        #a = controller.render_ticket_action_control(req, tkt, 'reopen')
796                        #print 'controller : ', a
797                        #
798                        #b = controller.get_all_status()
799                        #print 'get all status: ', b
800                        #
801                        #b = controller.get_ticket_changes(req, tkt, 'reopen')
802                        #print 'get_ticket_changes :', b
803
804                        if self.WORKFLOW and (self.VERSION in [0.11]) :
805                                from trac.ticket.default_workflow import ConfigurableTicketWorkflow
806                                from trac.test import Mock, MockPerm
807
808                                req = Mock(authname='anonymous', perm=MockPerm(), args={})
809
810                                controller = ConfigurableTicketWorkflow(self.env)
811                                fields = controller.get_ticket_changes(req, tkt, self.WORKFLOW)
812
813                                if self.DEBUG:
814                                        print 'TD: Workflow ticket update fields: ', fields
815
816                                for key in fields.keys():
817                                        tkt[key] = fields[key]
818
819                        else:
820                                tkt['status'] = 'reopened'
821                                tkt['resolution'] = ''
822
823                # Must we update some ticket fields properties via subjectline
824                #
825                if update_fields:
826                        self.update_ticket_fields(tkt, update_fields)
827
828                message_parts = self.get_message_parts(m)
829                message_parts = self.unique_attachment_names(message_parts)
830
831                # Must we update some ticket fields properties via body_text
832                #
833                if self.properties:
834                                self.update_ticket_fields(tkt, self.properties)
835
836                if self.EMAIL_HEADER:
837                        message_parts.insert(0, self.email_header_txt(m))
838
839                body_text = self.body_text(message_parts)
840
841                if self.VERSION  == 0.9:
842                        error_with_attachments = self.attach_attachments(message_parts, True)
843                else:
844                        error_with_attachments = self.attach_attachments(message_parts)
845
846                if body_text.strip() or update_fields or self.properties:
847                        if self.DRY_RUN:
848                                print 'DRY_RUN: tkt.save_changes(self.author, body_text, ticket_change_number) ', self.author, cnum
849                        else:
850                                if error_with_attachments:
851                                        body_text = '%s\\%s' %(error_with_attachments, body_text)
852                               
853                                tkt.save_changes(self.author, body_text, when, None, str(cnum))
854                       
855
856                #if self.VERSION  == 0.9:
857                #       error_with_attachments = self.attach_attachments(message_parts, True)
858                #else:
859                #       error_with_attachments = self.attach_attachments(message_parts)
860
861                if self.notification and not spam:
862                        self.notify(tkt, False, when)
863
864                return True
865
866        def set_ticket_fields(self, ticket):
867                """
868                set the ticket fields to value specified
869                        - /etc/email2trac.conf with <prefix>_<field>
870                        - trac default values, trac.ini
871                """
872                user_dict = dict()
873
874                for field in ticket.fields:
875
876                        name = field['name']
877
878                        ## default trac value
879                        #
880                        if not field.get('custom'):
881                                value = self.get_config('ticket', 'default_%s' %(name) )
882                        else:
883                                ##  Else we get the default value for reporter
884                                #
885                                value = field.get('value')
886                                options = field.get('options')
887
888                                if value and options and (value not in options):
889                                         value = options[int(value)]
890       
891                        if self.DEBUG > 10:
892                                s = 'TD: trac.ini name %s = %s' %(name, value)
893                                self.print_unicode(s)
894
895                        ## email2trac.conf settings
896                        #
897                        prefix = self.parameters['ticket_prefix']
898                        try:
899                                value = self.parameters['%s_%s' %(prefix, name)]
900                                if self.DEBUG > 10:
901                                        s = 'TD: email2trac.conf %s = %s ' %(name, value)
902                                        self.print_unicode(s)
903
904                        except KeyError, detail:
905                                pass
906               
907                        if self.DEBUG:
908                                s = 'TD: user_dict[%s] = %s' %(name, value)
909                                self.print_unicode(s)
910
911                        if value:
912                                user_dict[name] = value
913
914                self.update_ticket_fields(ticket, user_dict, use_default=1)
915
916                if 'status' not in user_dict.keys():
917                        ticket['status'] = 'new'
918
919
920        def ticket_update_by_subject(self, subject):
921                """
922                This list of Re: prefixes is probably incomplete. Taken from
923                wikipedia. Here is how the subject is matched
924                  - Re: <subject>
925                  - Re: (<Mail list label>:)+ <subject>
926
927                So we must have the last column
928                """
929                if self.VERBOSE:
930                        print "VB: ticket_update_by_subject()"
931
932                matched_id = None
933                if self.TICKET_UPDATE and self.TICKET_UPDATE_BY_SUBJECT:
934                               
935                        SUBJECT_RE = re.compile(r'^(RE|AW|VS|SV):(.*:)*\s*(.*)', re.IGNORECASE)
936                        result = SUBJECT_RE.search(subject)
937
938                        if result:
939                                # This is a reply
940                                orig_subject = result.group(3)
941
942                                if self.DEBUG:
943                                        print 'TD: subject search string: %s' %(orig_subject)
944
945                                cursor = self.db.cursor()
946                                summaries = [orig_subject, '%%: %s' % orig_subject]
947
948                                ##
949                                # Convert days to seconds
950                                lookback = int(time.mktime(time.gmtime())) - \
951                                                self.TICKET_UPDATE_BY_SUBJECT_LOOKBACK * 24 * 3600
952
953
954                                for summary in summaries:
955                                        if self.DEBUG:
956                                                print 'TD: Looking for summary matching: "%s"' % summary
957                                        sql = """SELECT id FROM ticket
958                                                        WHERE changetime >= %s AND summary LIKE %s
959                                                        ORDER BY changetime DESC"""
960                                        cursor.execute(sql, [lookback, summary.strip()])
961
962                                        for row in cursor:
963                                                (matched_id,) = row
964                                                if self.DEBUG:
965                                                        print 'TD: Found matching ticket id: %d' % matched_id
966                                                break
967
968                                        if matched_id:
969                                                matched_id = '#%d' % matched_id
970                                                return matched_id
971
972                return matched_id
973
974
975        def new_ticket(self, msg, subject, spam, set_fields = None):
976                """
977                Create a new ticket
978                """
979                if self.VERBOSE:
980                        print "VB: function new_ticket()"
981
982                tkt = Ticket(self.env)
983
984                self.set_reply_fields(tkt, msg)
985
986                self.set_ticket_fields(tkt)
987
988                # Old style setting for component, will be removed
989                #
990                if spam:
991                        tkt['component'] = 'Spam'
992
993                elif self.parameters.has_key('component'):
994                        tkt['component'] = self.parameters['component']
995
996                if not msg['Subject']:
997                        tkt['summary'] = u'(No subject)'
998                else:
999                        tkt['summary'] = subject
1000
1001
1002                if set_fields:
1003                        rest, keywords = string.split(set_fields, '?')
1004
1005                        if keywords:
1006                                update_fields = self.str_to_dict(keywords)
1007                                self.update_ticket_fields(tkt, update_fields)
1008
1009                # produce e-mail like header
1010                #
1011                head = ''
1012                if self.EMAIL_HEADER > 0:
1013                        head = self.email_header_txt(msg)
1014
1015                message_parts = self.get_message_parts(msg)
1016
1017                # Must we update some ticket fields properties via body_text
1018                #
1019                if self.properties:
1020                                self.update_ticket_fields(tkt, self.properties)
1021
1022                if self.DEBUG:
1023                        print 'TD: self.get_message_parts ',
1024                        print message_parts
1025
1026                message_parts = self.unique_attachment_names(message_parts)
1027                if self.DEBUG:
1028                        print 'TD: self.unique_attachment_names',
1029                        print message_parts
1030               
1031                if self.EMAIL_HEADER > 0:
1032                        message_parts.insert(0, self.email_header_txt(msg))
1033                       
1034                body_text = self.body_text(message_parts)
1035
1036                tkt['description'] = body_text
1037
1038                #when = int(time.time())
1039                #
1040                utc = UTC()
1041                when = datetime.now(utc)
1042
1043                if not self.DRY_RUN:
1044                        self.id = tkt.insert()
1045       
1046                changed = False
1047                comment = ''
1048
1049                # some routines in trac are dependend on ticket id     
1050                # like alternate notify template
1051                #
1052                if self.notify_template:
1053                        tkt['id'] = self.id
1054                        changed = True
1055
1056                ## Rewrite the description if we have mailto enabled
1057                #
1058                if self.MAILTO:
1059                        changed = True
1060                        comment = u'\nadded mailto line\n'
1061                        mailto = self.html_mailto_link( m['Subject'])
1062
1063                        tkt['description'] = u'%s\r\n%s%s\r\n' \
1064                                %(head, mailto, body_text)
1065       
1066                ## Save the attachments to the ticket   
1067                #
1068                error_with_attachments =  self.attach_attachments(message_parts)
1069
1070                if error_with_attachments:
1071                        changed = True
1072                        comment = '%s\n%s\n' %(comment, error_with_attachments)
1073
1074                if changed:
1075                        if self.DRY_RUN:
1076                                print 'DRY_RUN: tkt.save_changes(%s, comment) real reporter = %s' %( tkt['reporter'], self.author)
1077                        else:
1078                                tkt.save_changes(tkt['reporter'], comment)
1079                                #print tkt.get_changelog(self.db, when)
1080
1081                if self.notification and not spam:
1082                        self.notify(tkt, True)
1083
1084
1085        def attach_attachments(self, message_parts, update=False):
1086                '''
1087                save any attachments as files in the ticket's directory
1088                '''
1089                if self.VERBOSE:
1090                        print "VB: attach_attachments()"
1091
1092                if self.DRY_RUN:
1093                        print "DRY_RUN: no attachments attached to tickets"
1094                        return ''
1095
1096                count = 0
1097
1098                # Get Maxium attachment size
1099                #
1100                max_size = int(self.get_config('attachment', 'max_size'))
1101                status   = None
1102               
1103                for item in message_parts:
1104                        # Skip body parts
1105                        if not isinstance(item, tuple):
1106                                continue
1107                               
1108                        (original, filename, part) = item
1109                        #
1110                        # We have to determine the size so we use this temporary solution. we must escape it
1111                        # else we get UnicodeErrors.
1112                        #
1113                        path, fd =  util.create_unique_file(os.path.join(self.TMPDIR, util.text.unicode_quote(filename)))
1114                        text = part.get_payload(decode=1)
1115                        if not text:
1116                                text = '(None)'
1117                        fd.write(text)
1118                        fd.close()
1119
1120                        # get the file_size
1121                        #
1122                        stats = os.lstat(path)
1123                        file_size = stats[stat.ST_SIZE]
1124
1125                        # Check if the attachment size is allowed
1126                        #
1127                        if (max_size != -1) and (file_size > max_size):
1128                                status = '%s\nFile %s is larger then allowed attachment size (%d > %d)\n\n' \
1129                                        %(status, original, file_size, max_size)
1130
1131                                os.unlink(path)
1132                                continue
1133                        else:
1134                                count = count + 1
1135                                       
1136                        # Insert the attachment
1137                        #
1138                        fd = open(path, 'rb')
1139                        if self.system == 'discussion':
1140                                att = attachment.Attachment(self.env, 'discussion', 'topic/%s'
1141                                  % (self.id,))
1142                        else:
1143                                att = attachment.Attachment(self.env, 'ticket', self.id)
1144 
1145                        # This will break the ticket_update system, the body_text is vaporized
1146                        # ;-(
1147                        #
1148                        if not update:
1149                                att.author = self.author
1150                                att.description = self.email_to_unicode('Added by email2trac')
1151
1152                        try:
1153                                att.insert(filename, fd, file_size)
1154                        except OSError, detail:
1155                                status = '%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail)
1156
1157                        # Remove the created temporary filename
1158                        #
1159                        fd.close()
1160                        os.unlink(path)
1161
1162                ## return error
1163                #
1164                return status
1165
1166########## Fullblog functions  #################################################
1167
1168        def blog(self, id):
1169                """
1170                The blog create/update function
1171                """
1172                # import the modules
1173                #
1174                from tracfullblog.core import FullBlogCore
1175                from tracfullblog.model import BlogPost, BlogComment
1176                from trac.test import Mock, MockPerm
1177
1178                # instantiate blog core
1179                blog = FullBlogCore(self.env)
1180                req = Mock(authname='anonymous', perm=MockPerm(), args={})
1181
1182                if id:
1183
1184                        # update blog
1185                        #
1186                        comment = BlogComment(self.env, id)
1187                        comment.author = self.author
1188
1189                        message_parts = self.get_message_parts(m)
1190                        comment.comment = self.body_text(message_parts)
1191
1192                        blog.create_comment(req, comment)
1193
1194                else:
1195                        # create blog
1196                        #
1197                        import time
1198                        post = BlogPost(self.env, 'blog_'+time.strftime("%Y%m%d%H%M%S", time.gmtime()))
1199
1200                        #post = BlogPost(self.env, blog._get_default_postname(self.env))
1201                       
1202                        post.author = self.author
1203                        post.title = self.email_to_unicode(m['Subject'])
1204
1205                        message_parts = self.get_message_parts(m)
1206                        post.body = self.body_text(message_parts)
1207                       
1208                        blog.create_post(req, post, self.author, u'Created by email2trac', False)
1209
1210
1211########## Discussion functions  ##############################################
1212
1213        def discussion_topic(self, content, subject):
1214
1215                # Import modules.
1216                from tracdiscussion.api import DiscussionApi
1217                from trac.util.datefmt import to_timestamp, utc
1218
1219                if self.DEBUG:
1220                        print 'TD: Creating a new topic in forum:', self.id
1221
1222                # Get dissussion API component.
1223                api = self.env[DiscussionApi]
1224                context = self._create_context(content, subject)
1225
1226                # Get forum for new topic.
1227                forum = api.get_forum(context, self.id)
1228
1229                if not forum and self.DEBUG:
1230                        print 'ERROR: Replied forum doesn\'t exist'
1231
1232                # Prepare topic.
1233                topic = {'forum' : forum['id'],
1234                                 'subject' : context.subject,
1235                                 'time': to_timestamp(datetime.now(utc)),
1236                                 'author' : self.author,
1237                                 'subscribers' : [self.email_addr],
1238                                 'body' : self.body_text(context.content_parts)}
1239
1240                # Add topic to DB and commit it.
1241                self._add_topic(api, context, topic)
1242                self.db.commit()
1243
1244        def discussion_topic_reply(self, content, subject):
1245
1246                # Import modules.
1247                from tracdiscussion.api import DiscussionApi
1248                from trac.util.datefmt import to_timestamp, utc
1249
1250                if self.DEBUG:
1251                        print 'TD: Replying to discussion topic', self.id
1252
1253                # Get dissussion API component.
1254                api = self.env[DiscussionApi]
1255                context = self._create_context(content, subject)
1256
1257                # Get replied topic.
1258                topic = api.get_topic(context, self.id)
1259
1260                if not topic and self.DEBUG:
1261                        print 'ERROR: Replied topic doesn\'t exist'
1262
1263                # Prepare message.
1264                message = {'forum' : topic['forum'],
1265                                   'topic' : topic['id'],
1266                                   'replyto' : -1,
1267                                   'time' : to_timestamp(datetime.now(utc)),
1268                                   'author' : self.author,
1269                                   'body' : self.body_text(context.content_parts)}
1270
1271                # Add message to DB and commit it.
1272                self._add_message(api, context, message)
1273                self.db.commit()
1274
1275        def discussion_message_reply(self, content, subject):
1276
1277                # Import modules.
1278                from tracdiscussion.api import DiscussionApi
1279                from trac.util.datefmt import to_timestamp, utc
1280
1281                if self.DEBUG:
1282                        print 'TD: Replying to discussion message', self.id
1283
1284                # Get dissussion API component.
1285                api = self.env[DiscussionApi]
1286                context = self._create_context(content, subject)
1287
1288                # Get replied message.
1289                message = api.get_message(context, self.id)
1290
1291                if not message and self.DEBUG:
1292                        print 'ERROR: Replied message doesn\'t exist'
1293
1294                # Prepare message.
1295                message = {'forum' : message['forum'],
1296                                   'topic' : message['topic'],
1297                                   'replyto' : message['id'],
1298                                   'time' : to_timestamp(datetime.now(utc)),
1299                                   'author' : self.author,
1300                                   'body' : self.body_text(context.content_parts)}
1301
1302                # Add message to DB and commit it.
1303                self._add_message(api, context, message)
1304                self.db.commit()
1305
1306        def _create_context(self, content, subject):
1307
1308                # Import modules.
1309                from trac.mimeview import Context
1310                from trac.web.api import Request
1311                from trac.perm import PermissionCache
1312
1313                # TODO: Read server base URL from config.
1314                # Create request object to mockup context creation.
1315                #
1316                environ = {'SERVER_PORT' : 80,
1317                                   'SERVER_NAME' : 'test',
1318                                   'REQUEST_METHOD' : 'POST',
1319                                   'wsgi.url_scheme' : 'http',
1320                                   'wsgi.input' : sys.stdin}
1321                chrome =  {'links': {},
1322                                   'scripts': [],
1323                                   'ctxtnav': [],
1324                                   'warnings': [],
1325                                   'notices': []}
1326
1327                if self.env.base_url_for_redirect:
1328                        environ['trac.base_url'] = self.env.base_url
1329
1330                req = Request(environ, None)
1331                req.chrome = chrome
1332                req.tz = 'missing'
1333                req.authname = self.author
1334                req.perm = PermissionCache(self.env, self.author)
1335
1336                # Create and return context.
1337                context = Context.from_request(req)
1338                context.realm = 'discussion-email2trac'
1339                context.cursor = self.db.cursor()
1340                context.content = content
1341                context.subject = subject
1342
1343                # Read content parts from content.
1344                context.content_parts = self.get_message_parts(content)
1345                context.content_parts = self.unique_attachment_names(
1346                  context.content_parts)
1347
1348                return context
1349
1350        def _add_topic(self, api, context, topic):
1351                context.req.perm.assert_permission('DISCUSSION_APPEND')
1352
1353                # Filter topic.
1354                for discussion_filter in api.discussion_filters:
1355                        accept, topic_or_error = discussion_filter.filter_topic(
1356                          context, topic)
1357                        if accept:
1358                                topic = topic_or_error
1359                        else:
1360                                raise TracError(topic_or_error)
1361
1362                # Add a new topic.
1363                api.add_topic(context, topic)
1364
1365                # Get inserted topic with new ID.
1366                topic = api.get_topic_by_time(context, topic['time'])
1367
1368                # Attach attachments.
1369                self.id = topic['id']
1370                self.attach_attachments(context.content_parts, self.VERSION == 0.9)
1371
1372                # Notify change listeners.
1373                for listener in api.topic_change_listeners:
1374                        listener.topic_created(context, topic)
1375
1376        def _add_message(self, api, context, message):
1377                context.req.perm.assert_permission('DISCUSSION_APPEND')
1378
1379                # Filter message.
1380                for discussion_filter in api.discussion_filters:
1381                        accept, message_or_error = discussion_filter.filter_message(
1382                          context, message)
1383                        if accept:
1384                                message = message_or_error
1385                        else:
1386                                raise TracError(message_or_error)
1387
1388                # Add message.
1389                api.add_message(context, message)
1390
1391                # Get inserted message with new ID.
1392                message = api.get_message_by_time(context, message['time'])
1393
1394                # Attach attachments.
1395                self.id = message['topic']
1396                self.attach_attachments(context.content_parts, self.VERSION == 0.9)
1397
1398                # Notify change listeners.
1399                for listener in api.message_change_listeners:
1400                        listener.message_created(context, message)
1401
1402########## MAIN function  ######################################################
1403
1404        def parse(self, fp):
1405                """
1406                """
1407                if self.VERBOSE:
1408                        print "VB: main function parse()"
1409                global m
1410
1411                m = email.message_from_file(fp)
1412               
1413                if not m:
1414                        if self.DEBUG:
1415                                print "TD: This is not a valid email message format"
1416                        return
1417                       
1418                # Work around lack of header folding in Python; see http://bugs.python.org/issue4696
1419                try:
1420                        m.replace_header('Subject', m['Subject'].replace('\r', '').replace('\n', ''))
1421                except AttributeError, detail:
1422                        pass
1423
1424                if self.DEBUG > 1:        # save the entire e-mail message text
1425                        self.save_email_for_debug(m, True)
1426
1427                self.db = self.env.get_db_cnx()
1428                self.get_sender_info(m)
1429
1430                if not self.email_header_acl('white_list', self.email_addr, True):
1431                        if self.DEBUG > 1 :
1432                                print 'Message rejected : %s not in white list' %(self.email_addr)
1433                        return False
1434
1435                if self.email_header_acl('black_list', self.email_addr, False):
1436                        if self.DEBUG > 1 :
1437                                print 'Message rejected : %s in black list' %(self.email_addr)
1438                        return False
1439
1440                if not self.email_header_acl('recipient_list', self.to_email_addr, True):
1441                        if self.DEBUG > 1 :
1442                                print 'Message rejected : %s not in recipient list' %(self.to_email_addr)
1443                        return False
1444
1445                # If drop the message
1446                #
1447                if self.spam(m) == 'drop':
1448                        return False
1449
1450                elif self.spam(m) == 'spam':
1451                        spam_msg = True
1452                else:
1453                        spam_msg = False
1454
1455                if self.get_config('notification', 'smtp_enabled') in ['true']:
1456                        self.notification = 1
1457                else:
1458                        self.notification = 0
1459
1460                if not m['Subject']:
1461                        subject  = 'No Subject'
1462                else:
1463                        subject  = self.email_to_unicode(m['Subject'])
1464
1465                if self.DEBUG:
1466                         print "TD:", subject
1467
1468                #
1469                # [hic] #1529: Re: LRZ
1470                # [hic] #1529?owner=bas,priority=medium: Re: LRZ
1471                #
1472                ticket_regex = r'''
1473                        (?P<new_fields>[#][?].*)
1474                        |(?P<reply>(?P<id>[#][\d]+)(?P<fields>\?.*?:)*)
1475                        '''
1476                # Check if  FullBlogPlugin is installed
1477                #
1478                blog_enabled = None
1479                blog_regex = ''
1480                if self.get_config('components', 'tracfullblog.*') in ['enabled']:
1481                        blog_enabled = True
1482                        blog_regex = '''|(?P<blog>blog:(?P<blog_id>\w*))'''
1483
1484
1485                # Check if DiscussionPlugin is installed
1486                #
1487                discussion_enabled = None
1488                discussion_regex = ''
1489                if self.get_config('components', 'tracdiscussion.api.*') in ['enabled']:
1490                        discussion_enabled = True
1491                        discussion_regex = r'''
1492                        |(?P<forum>Forum[ ][#](?P<forum_id>\d+)[ ]-[ ]?)
1493                        |(?P<topic>Topic[ ][#](?P<topic_id>\d+)[ ]-[ ]?)
1494                        |(?P<message>Message[ ][#](?P<message_id>\d+)[ ]-[ ]?)
1495                        '''
1496
1497
1498                regex_str = ticket_regex + blog_regex + discussion_regex
1499                SYSTEM_RE = re.compile(regex_str, re.VERBOSE)
1500
1501                # Find out if this is a ticket, a blog or a discussion
1502                #
1503                result =  SYSTEM_RE.search(subject)
1504                if result:
1505                        # update ticket + fields
1506                        #
1507                        if result.group('reply') and self.TICKET_UPDATE:
1508                                self.system = 'ticket'
1509                                self.ticket_update(m, result.group('reply'), spam_msg)
1510
1511                        # New ticket + fields
1512                        #
1513                        elif result.group('new_fields'):
1514                                self.system = 'ticket'
1515                                self.new_ticket(m, subject[:result.start('new_fields')], spam_msg, result.group('new_fields'))
1516
1517                        if blog_enabled:
1518                                if result.group('blog'):
1519                                        self.system = 'blog'
1520                                        self.blog(result.group('blog_id'))
1521
1522                        if discussion_enabled:
1523                                # New topic.
1524                                #
1525                                if result.group('forum'):
1526                                        self.system = 'discussion'
1527                                        self.id = int(result.group('forum_id'))
1528                                        self.discussion_topic(m, subject[result.end('forum'):])
1529
1530                                # Reply to topic.
1531                                #
1532                                elif result.group('topic'):
1533                                        self.system = 'discussion'
1534                                        self.id = int(result.group('topic_id'))
1535                                        self.discussion_topic_reply(m, subject[result.end('topic'):])
1536
1537                                # Reply to topic message.
1538                                #
1539                                elif result.group('message'):
1540                                        self.system = 'discussion'
1541                                        self.id = int(result.group('message_id'))
1542                                        self.discussion_message_reply(m, subject[result.end('message'):])
1543
1544                else:
1545                        self.system = 'ticket'
1546                        result = self.ticket_update_by_subject(subject)
1547                        if result:
1548                                self.ticket_update(m, result, spam_msg)
1549                        else:
1550                                # No update by subject, so just create a new ticket
1551                                self.new_ticket(m, subject, spam_msg)
1552
1553
1554########## BODY TEXT functions  ###########################################################
1555
1556        def strip_signature(self, text):
1557                """
1558                Strip signature from message, inspired by Mailman software
1559                """
1560                body = []
1561                for line in text.splitlines():
1562                        if line == '-- ':
1563                                break
1564                        body.append(line)
1565
1566                return ('\n'.join(body))
1567
1568        def reflow(self, text, delsp = 0):
1569                """
1570                Reflow the message based on the format="flowed" specification (RFC 3676)
1571                """
1572                flowedlines = []
1573                quotelevel = 0
1574                prevflowed = 0
1575
1576                for line in text.splitlines():
1577                        from re import match
1578                       
1579                        # Figure out the quote level and the content of the current line
1580                        m = match('(>*)( ?)(.*)', line)
1581                        linequotelevel = len(m.group(1))
1582                        line = m.group(3)
1583
1584                        # Determine whether this line is flowed
1585                        if line and line != '-- ' and line[-1] == ' ':
1586                                flowed = 1
1587                        else:
1588                                flowed = 0
1589
1590                        if flowed and delsp and line and line[-1] == ' ':
1591                                line = line[:-1]
1592
1593                        # If the previous line is flowed, append this line to it
1594                        if prevflowed and line != '-- ' and linequotelevel == quotelevel:
1595                                flowedlines[-1] += line
1596                        # Otherwise, start a new line
1597                        else:
1598                                flowedlines.append('>' * linequotelevel + line)
1599
1600                        prevflowed = flowed
1601                       
1602
1603                return '\n'.join(flowedlines)
1604
1605        def strip_quotes(self, text):
1606                """
1607                Strip quotes from message by Nicolas Mendoza
1608                """
1609                body = []
1610                for line in text.splitlines():
1611                        if line.startswith(self.EMAIL_QUOTE):
1612                                continue
1613                        body.append(line)
1614
1615                return ('\n'.join(body))
1616
1617        def inline_properties(self, text):
1618                """
1619                Parse text if we use inline keywords to set ticket fields
1620                """
1621                if self.DEBUG:
1622                        print 'TD: inline_properties function'
1623
1624                properties = dict()
1625                body = list()
1626
1627                INLINE_EXP = re.compile('\s*[@]\s*([a-zA-Z]+)\s*:(.*)$')
1628
1629                for line in text.splitlines():
1630                        match = INLINE_EXP.match(line)
1631                        if match:
1632                                keyword, value = match.groups()
1633                                self.properties[keyword] = value.strip()
1634                                if self.DEBUG:
1635                                        print "TD: inline properties: %s : %s" %(keyword,value)
1636                        else:
1637                                body.append(line)
1638                               
1639                return '\n'.join(body)
1640
1641
1642        def wrap_text(self, text, replace_whitespace = False):
1643                """
1644                Will break a lines longer then given length into several small
1645                lines of size given length
1646                """
1647                import textwrap
1648
1649                LINESEPARATOR = '\n'
1650                reformat = ''
1651
1652                for s in text.split(LINESEPARATOR):
1653                        tmp = textwrap.fill(s,self.USE_TEXTWRAP)
1654                        if tmp:
1655                                reformat = '%s\n%s' %(reformat,tmp)
1656                        else:
1657                                reformat = '%s\n' %reformat
1658
1659                return reformat
1660
1661                # Python2.4 and higher
1662                #
1663                #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
1664                #
1665
1666########## EMAIL attachements functions ###########################################################
1667
1668        def inline_part(self, part):
1669                """
1670                """
1671                if self.VERBOSE:
1672                        print "VB: inline_part()"
1673
1674                return part.get_param('inline', None, 'Content-Disposition') == '' or not part.has_key('Content-Disposition')
1675
1676        def get_message_parts(self, msg):
1677                """
1678                parses the email message and returns a list of body parts and attachments
1679                body parts are returned as strings, attachments are returned as tuples of (filename, Message object)
1680                """
1681                if self.VERBOSE:
1682                        print "VB: get_message_parts()"
1683
1684                message_parts = list()
1685       
1686                ALTERNATIVE_MULTIPART = False
1687
1688                for part in msg.walk():
1689                        if self.DEBUG:
1690                                print 'TD: Message part: Main-Type: %s' % part.get_content_maintype()
1691                                print 'TD: Message part: Content-Type: %s' % part.get_content_type()
1692
1693                        ## Check content type
1694                        #
1695                        if part.get_content_type() in self.STRIP_CONTENT_TYPES:
1696
1697                                if self.DEBUG:
1698                                        print "TD: A %s attachment named '%s' was skipped" %(part.get_content_type(), part.get_filename())
1699
1700                                continue
1701
1702                        ## Catch some mulitpart execptions
1703                        #
1704                        if part.get_content_type() == 'multipart/alternative':
1705                                ALTERNATIVE_MULTIPART = True
1706                                continue
1707
1708                        ## Skip multipart containers
1709                        #
1710                        if part.get_content_maintype() == 'multipart':
1711                                if self.DEBUG:
1712                                        print "TD: Skipping multipart container"
1713                                continue
1714                       
1715                        ## Check if this is an inline part. It's inline if there is co Cont-Disp header, or if there is one and it says "inline"
1716                        #
1717                        inline = self.inline_part(part)
1718
1719                        ## Drop HTML message
1720                        #
1721                        if ALTERNATIVE_MULTIPART and self.DROP_ALTERNATIVE_HTML_VERSION:
1722                                if part.get_content_type() == 'text/html':
1723                                        if self.DEBUG:
1724                                                print "TD: Skipping alternative HTML message"
1725
1726                                        ALTERNATIVE_MULTIPART = False
1727                                        continue
1728
1729                        ## Inline text parts are where the body is
1730                        #
1731                        if part.get_content_type() == 'text/plain' and inline:
1732                                if self.DEBUG:
1733                                        print 'TD:               Inline body part'
1734
1735                                # Try to decode, if fails then do not decode
1736                                #
1737                                body_text = part.get_payload(decode=1)
1738                                if not body_text:                       
1739                                        body_text = part.get_payload(decode=0)
1740
1741                                format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
1742                                delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
1743
1744                                if self.REFLOW and not self.VERBATIM_FORMAT and format == 'flowed':
1745                                        body_text = self.reflow(body_text, delsp == 'yes')
1746       
1747                                if self.STRIP_SIGNATURE:
1748                                        body_text = self.strip_signature(body_text)
1749
1750                                if self.STRIP_QUOTES:
1751                                        body_text = self.strip_quotes(body_text)
1752
1753                                if self.INLINE_PROPERTIES:
1754                                        body_text = self.inline_properties(body_text)
1755
1756                                if self.USE_TEXTWRAP:
1757                                        body_text = self.wrap_text(body_text)
1758
1759                                ## Get contents charset (iso-8859-15 if not defined in mail headers)
1760                                #
1761                                charset = part.get_content_charset()
1762                                if not charset:
1763                                        charset = 'iso-8859-15'
1764
1765                                try:
1766                                        ubody_text = unicode(body_text, charset)
1767
1768                                except UnicodeError, detail:
1769                                        ubody_text = unicode(body_text, 'iso-8859-15')
1770
1771                                except LookupError, detail:
1772                                        ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
1773
1774                                if self.VERBATIM_FORMAT:
1775                                        message_parts.append('{{{\r\n%s\r\n}}}' %ubody_text)
1776                                else:
1777                                        message_parts.append('%s' %ubody_text)
1778                        else:
1779                                if self.DEBUG:
1780                                        s = 'TD:               Filename: %s' % part.get_filename()
1781                                        self.print_unicode(s)
1782
1783                                ##
1784                                #
1785                                filename = part.get_filename()
1786                                message_parts.append((filename, part))
1787
1788                return message_parts
1789               
1790        def unique_attachment_names(self, message_parts):
1791                """
1792                """
1793                renamed_parts = []
1794                attachment_names = set()
1795
1796                for item in message_parts:
1797                       
1798                        ## If not an attachment, leave it alone
1799                        #
1800                        if not isinstance(item, tuple):
1801                                renamed_parts.append(item)
1802                                continue
1803                               
1804                        (filename, part) = item
1805
1806                        ## If no filename, use a default one
1807                        #
1808                        if not filename:
1809                                filename = 'untitled-part'
1810
1811                                # Guess the extension from the content type, use non strict mode
1812                                # some additional non-standard but commonly used MIME types
1813                                # are also recognized
1814                                #
1815                                ext = mimetypes.guess_extension(part.get_content_type(), False)
1816                                if not ext:
1817                                        ext = '.bin'
1818
1819                                filename = '%s%s' % (filename, ext)
1820
1821                        ## Discard relative paths for windows/unix in attachment names
1822                        #
1823                        #filename = filename.replace('\\', '/').replace(':', '/')
1824                        filename = filename.replace('\\', '_')
1825                        filename = filename.replace('/', '_')
1826
1827                        #
1828                        # We try to normalize the filename to utf-8 NFC if we can.
1829                        # Files uploaded from OS X might be in NFD.
1830                        # Check python version and then try it
1831                        #
1832                        #if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
1833                        #       try:
1834                        #               filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
1835                        #       except TypeError:
1836                        #               pass
1837
1838                        # Make the filename unique for this ticket
1839                        num = 0
1840                        unique_filename = filename
1841                        dummy_filename, ext = os.path.splitext(filename)
1842
1843                        while (unique_filename in attachment_names) or self.attachment_exists(unique_filename):
1844                                num += 1
1845                                unique_filename = "%s-%s%s" % (dummy_filename, num, ext)
1846                               
1847                        if self.DEBUG:
1848                                s = 'TD: Attachment with filename %s will be saved as %s' % (filename, unique_filename)
1849                                self.print_unicode(s)
1850
1851                        attachment_names.add(unique_filename)
1852
1853                        renamed_parts.append((filename, unique_filename, part))
1854       
1855                return renamed_parts
1856                       
1857                       
1858        def attachment_exists(self, filename):
1859
1860                if self.DEBUG:
1861                        s = 'TD: attachment already exists: Id : %s, Filename : %s' %(self.id, filename)
1862                        self.print_unicode(s)
1863
1864                # We have no valid ticket id
1865                #
1866                if not self.id:
1867                        return False
1868
1869                try:
1870                        if self.system == 'discussion':
1871                                att = attachment.Attachment(self.env, 'discussion', 'ticket/%s'
1872                                  % (self.id,), filename)
1873                        else:
1874                                att = attachment.Attachment(self.env, 'ticket', self.id,
1875                                  filename)
1876                        return True
1877                except attachment.ResourceNotFound:
1878                        return False
1879
1880########## TRAC Ticket Text ###########################################################
1881                       
1882        def body_text(self, message_parts):
1883                body_text = []
1884               
1885                for part in message_parts:
1886                        # Plain text part, append it
1887                        if not isinstance(part, tuple):
1888                                body_text.extend(part.strip().splitlines())
1889                                body_text.append("")
1890                                continue
1891                               
1892                        (original, filename, part) = part
1893                        inline = self.inline_part(part)
1894                       
1895                        if part.get_content_maintype() == 'image' and inline:
1896                                if self.system != 'discussion':
1897                                        body_text.append('[[Image(%s)]]' % filename)
1898                                body_text.append("")
1899                        else:
1900                                if self.system != 'discussion':
1901                                        body_text.append('[attachment:"%s"]' % filename)
1902                                body_text.append("")
1903                               
1904                body_text = '\r\n'.join(body_text)
1905                return body_text
1906
1907        def html_mailto_link(self, subject):
1908                """
1909                This function returns a HTML mailto tag with the ticket id and author email address
1910                """
1911                if not self.author:
1912                        author = self.email_addr
1913                else:   
1914                        author = self.author
1915
1916                # use urllib to escape the chars
1917                #
1918                s = 'mailto:%s?Subject=%s&Cc=%s' %(
1919                       urllib.quote(self.email_addr),
1920                           urllib.quote('Re: #%s: %s' %(self.id, subject)),
1921                           urllib.quote(self.MAILTO_CC)
1922                           )
1923
1924                s = '\r\n{{{\r\n#!html\r\n<a\r\n href="%s">Reply to: %s\r\n</a>\r\n}}}\r\n' %(s, author)
1925                return s
1926
1927########## TRAC notify section ###########################################################
1928
1929        def notify(self, tkt, new=True, modtime=0):
1930                """
1931                A wrapper for the TRAC notify function. So we can use templates
1932                """
1933                if self.DRY_RUN:
1934                                print 'DRY_RUN: self.notify(tkt, True) reporter = %s' %tkt['reporter']
1935                                return
1936                try:
1937                        # create false {abs_}href properties, to trick Notify()
1938                        #
1939                        if not (self.VERSION in [0.11, 0.12]):
1940                                self.env.abs_href = Href(self.get_config('project', 'url'))
1941                                self.env.href = Href(self.get_config('project', 'url'))
1942
1943                        tn = TicketNotifyEmail(self.env)
1944
1945                        if self.notify_template:
1946
1947                                if self.VERSION == 0.11:
1948
1949                                        from trac.web.chrome import Chrome
1950
1951                                        if self.notify_template_update and not new:
1952                                                tn.template_name = self.notify_template_update
1953                                        else:
1954                                                tn.template_name = self.notify_template
1955
1956                                        tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
1957                                               
1958                                else:
1959
1960                                        tn.template_name = self.notify_template;
1961
1962                        tn.notify(tkt, new, modtime)
1963
1964                except Exception, e:
1965                        print 'TD: Failure sending notification on creation of ticket #%s: %s' %(self.id, e)
1966
1967
1968
1969########## Parse Config File  ###########################################################
1970
1971def ReadConfig(file, name):
1972        """
1973        Parse the config file
1974        """
1975        if not os.path.isfile(file):
1976                print 'File %s does not exist' %file
1977                sys.exit(1)
1978
1979        config = trac_config.Configuration(file)
1980
1981        # Use given project name else use defaults
1982        #
1983        if name:
1984                sections = config.sections()
1985                if not name in sections:
1986                        print "Not a valid project name: %s" %name
1987                        print "Valid names: %s" %sections
1988                        sys.exit(1)
1989
1990                project =  dict()
1991                for option, value in  config.options(name):
1992                        project[option] = value
1993
1994        else:
1995                # use some trac internals to get the defaults
1996                #
1997                project = config.parser.defaults()
1998
1999        return project
2000
2001
2002if __name__ == '__main__':
2003        # Default config file
2004        #
2005        configfile = '@email2trac_conf@'
2006        project = ''
2007        component = ''
2008        ticket_prefix = 'default'
2009        dry_run = None
2010        verbose = None
2011
2012        ENABLE_SYSLOG = 0
2013
2014        SHORT_OPT = 'chf:np:t:v'
2015        LONG_OPT  =  ['component=', 'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=', 'verbose']
2016
2017        try:
2018                opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
2019        except getopt.error,detail:
2020                print __doc__
2021                print detail
2022                sys.exit(1)
2023       
2024        project_name = None
2025        for opt,value in opts:
2026                if opt in [ '-h', '--help']:
2027                        print __doc__
2028                        sys.exit(0)
2029                elif opt in ['-c', '--component']:
2030                        component = value
2031                elif opt in ['-f', '--file']:
2032                        configfile = value
2033                elif opt in ['-n', '--dry-run']:
2034                        dry_run = True
2035                elif opt in ['-p', '--project']:
2036                        project_name = value
2037                elif opt in ['-t', '--ticket_prefix']:
2038                        ticket_prefix = value
2039                elif opt in ['-v', '--version']:
2040                        verbose = True
2041       
2042        settings = ReadConfig(configfile, project_name)
2043        if not settings.has_key('project'):
2044                print __doc__
2045                print 'No Trac project is defined in the email2trac config file.'
2046                sys.exit(1)
2047       
2048        if component:
2049                settings['component'] = component
2050
2051        # The default prefix for ticket values in email2trac.conf
2052        #
2053        settings['ticket_prefix'] = ticket_prefix
2054        settings['dry_run'] = dry_run
2055        settings['verbose'] = verbose
2056
2057
2058        # Determine major trac version used to be in email2trac.conf
2059        # Quick hack for 0.12
2060        #
2061        version = '0.%s' %(trac_version.split('.')[1])
2062        if version.startswith('0.12'):
2063                version = '0.12'
2064
2065        if verbose:
2066                print "Found trac version: %s" %(version)
2067       
2068        #debug HvB
2069        #print settings
2070
2071        try:
2072                if version == '0.9':
2073                        from trac import attachment
2074                        from trac.env import Environment
2075                        from trac.ticket import Ticket
2076                        from trac.web.href import Href
2077                        from trac import util
2078                        from trac.Notify import TicketNotifyEmail
2079                elif version == '0.10':
2080                        from trac import attachment
2081                        from trac.env import Environment
2082                        from trac.ticket import Ticket
2083                        from trac.web.href import Href
2084                        from trac import util
2085                        #
2086                        # return  util.text.to_unicode(str)
2087                        #
2088                        # see http://projects.edgewall.com/trac/changeset/2799
2089                        from trac.ticket.notification import TicketNotifyEmail
2090                        from trac import config as trac_config
2091                        from trac.core import TracError
2092
2093                elif version == '0.11':
2094                        from trac import attachment
2095                        from trac.env import Environment
2096                        from trac.ticket import Ticket
2097                        from trac.web.href import Href
2098                        from trac import config as trac_config
2099                        from trac import util
2100                        from trac.core import TracError
2101
2102                        #
2103                        # return  util.text.to_unicode(str)
2104                        #
2105                        # see http://projects.edgewall.com/trac/changeset/2799
2106                        from trac.ticket.notification import TicketNotifyEmail
2107
2108                elif version == '0.12':
2109                        from trac import attachment
2110                        from trac.env import Environment
2111                        from trac.ticket import Ticket
2112                        from trac.web.href import Href
2113                        from trac import config as trac_config
2114                        from trac import util
2115                        from trac.core import TracError
2116
2117                        #
2118                        # return  util.text.to_unicode(str)
2119                        #
2120                        # see http://projects.edgewall.com/trac/changeset/2799
2121                        from trac.ticket.notification import TicketNotifyEmail
2122
2123
2124                else:
2125                        print 'TRAC version %s is not supported' %version
2126                        sys.exit(1)
2127                       
2128                if settings.has_key('enable_syslog'):
2129                        if SYSLOG_AVAILABLE:
2130                                ENABLE_SYSLOG =  float(settings['enable_syslog'])
2131
2132
2133                # Must be set before environment is created
2134                #
2135                if settings.has_key('python_egg_cache'):
2136                        python_egg_cache = str(settings['python_egg_cache'])
2137                        os.environ['PYTHON_EGG_CACHE'] = python_egg_cache
2138
2139       
2140                if int(settings['debug']) > 0:
2141                        print 'Loading environment', settings['project']
2142
2143                env = Environment(settings['project'], create=0)
2144
2145                tktparser = TicketEmailParser(env, settings, float(version))
2146                tktparser.parse(sys.stdin)
2147
2148        # Catch all errors ans log to SYSLOG if we have enabled this
2149        # else stdout
2150        #
2151        except Exception, error:
2152                if ENABLE_SYSLOG:
2153                        syslog.openlog('email2trac', syslog.LOG_NOWAIT)
2154
2155                        etype, evalue, etb = sys.exc_info()
2156                        for e in traceback.format_exception(etype, evalue, etb):
2157                                syslog.syslog(e)
2158
2159                        syslog.closelog()
2160                else:
2161                        traceback.print_exc()
2162
2163                if m:
2164                        tktparser.save_email_for_debug(m, True)
2165
2166
2167                sys.exit(1)
2168# EOB
Note: See TracBrowser for help on using the repository browser.