source: trunk/email2trac.py.in @ 378

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

email2trac.py.in, fixed some unciode errors when debug is enabled, see #205

  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 55.3 KB
Line 
1#!@PYTHON@
2# Copyright (C) 2002
3#
4# This file is part of the email2trac utils
5#
6# This program is free software; you can redistribute it and/or modify it
7# under the terms of the GNU General Public License as published by the
8# Free Software Foundation; either version 2, or (at your option) any
9# later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA
19#
20# For vi/emacs or other use tabstop=4 (vi: set ts=4)
21#
22"""
23email2trac.py -- Email tickets to Trac.
24
25A simple MTA filter to create Trac tickets from inbound emails.
26
27Copyright 2005, Daniel Lundin <daniel@edgewall.com>
28Copyright 2005, Edgewall Software
29
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 378 2010-06-22 09:21:22Z 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                        try:
521                                print 'TD: part%d: filename: %s' % (n, filename)
522                        except UnicodeEncodeError, detail:
523                                print 'TD: part%d: filename: %s' % (n, util.text.unicode_quote(filename))
524
525                        ## Forbidden chars
526                        #
527                        filename = filename.replace('\\', '_')
528                        filename = filename.replace('/', '_')
529       
530
531                        part_file = os.path.join(self.TMPDIR, filename)
532                        try:
533                                print 'TD: writing part%d (%s)' % (n,part_file)
534                        except UnicodeEncodeError, detail:
535                                print 'TD: writing part%d (%s)' % (n, util.text.unicode_quote(part_file))
536
537                        if self.DRY_RUN:
538                                print 'DRY_RUN: NOT saving attachments'
539                                continue
540
541                        part_file = util.text.unicode_quote(part_file)
542
543                        fx = open(part_file, 'wb')
544                        text = part.get_payload(decode=1)
545
546                        if not text:
547                                text = '(None)'
548
549                        fx.write(text)
550                        fx.close()
551
552                        try:
553                                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
554                        except OSError:
555                                pass
556
557        def save_email_for_debug(self, message, tempfile=False):
558
559                if tempfile:
560                        import tempfile
561                        msg_file = tempfile.mktemp('.email2trac')
562                else:
563                        #msg_file = '/var/tmp/msg.txt'
564                        msg_file = os.path.join(self.TMPDIR, 'msg.txt')
565
566                if self.DRY_RUN:
567                        print 'DRY_RUN: NOT saving email message to %s' %(msg_file)
568                else:
569                        print 'TD: saving email to %s' %(msg_file)
570
571                        fx = open(msg_file, 'wb')
572                        fx.write('%s' % message)
573                        fx.close()
574                       
575                        try:
576                                os.chmod(msg_file,S_IRWXU|S_IRWXG|S_IRWXO)
577                        except OSError:
578                                pass
579
580                message_parts = self.get_message_parts(message)
581                message_parts = self.unique_attachment_names(message_parts)
582                body_text = self.body_text(message_parts)
583                self.debug_body(body_text, True)
584                self.debug_attachments(message_parts)
585
586########## Conversion functions  ###########################################################
587
588        def email_to_unicode(self, message_str):
589                """
590                Email has 7 bit ASCII code, convert it to unicode with the charset
591                that is encoded in 7-bit ASCII code and encode it as utf-8 so Trac
592                understands it.
593                """
594                if self.VERBOSE:
595                        print "VB: email_to_unicode"
596
597
598
599                results =  email.Header.decode_header(message_str)
600
601                s = None
602                for text,format in results:
603                        if format:
604                                try:
605                                        temp = unicode(text, format)
606                                except UnicodeError, detail:
607                                        # This always works
608                                        #
609                                        temp = unicode(text, 'iso-8859-15')
610                                except LookupError, detail:
611                                        #text = 'ERROR: Could not find charset: %s, please install' %format
612                                        #temp = unicode(text, 'iso-8859-15')
613                                        temp = message_str
614                                       
615                        else:
616                                temp = string.strip(text)
617                                temp = unicode(text, 'iso-8859-15')
618
619                        if s:
620                                s = '%s %s' %(s, temp)
621                        else:
622                                s = '%s' %temp
623
624                #s = s.encode('utf-8')
625                return s
626
627        def str_to_dict(self, s):
628                """
629                Transfrom a string of the form [<key>=<value>]+ to dict[<key>] = <value>
630                """
631                if self.VERBOSE:
632                        print "VB: str_to_dict"
633
634                fields = string.split(s, self.SUBJECT_FIELD_SEPARATOR)
635
636                result = dict()
637                for field in fields:
638                        try:
639                                index, value = string.split(field, '=')
640
641                                # We can not change the description of a ticket via the subject
642                                # line. The description is the body of the email
643                                #
644                                if index.lower() in ['description']:
645                                        continue
646
647                                if value:
648                                        result[index.lower()] = value
649
650                        except ValueError:
651                                pass
652                return result
653
654########## TRAC ticket functions  ###########################################################
655
656        def update_ticket_fields(self, ticket, user_dict, use_default=None):
657                """
658                This will update the ticket fields. It will check if the
659                given fields are known and if the right values are specified
660                It will only update the ticket field value:
661                        - If the field is known
662                        - If the value supplied is valid for the ticket field.
663                          If not then there are two options:
664                           1) Skip the value (use_default=None)
665                           2) Set default value for field (use_default=1)
666                """
667                if self.VERBOSE:
668                        print "VB: update_ticket_fields"
669
670                # Build a system dictionary from the ticket fields
671                # with field as index and option as value
672                #
673                sys_dict = dict()
674                for field in ticket.fields:
675                        try:
676                                sys_dict[field['name']] = field['options']
677
678                        except KeyError:
679                                sys_dict[field['name']] = None
680                                pass
681
682                ## Check user supplied fields an compare them with the
683                # system one's
684                #
685                for field,value in user_dict.items():
686                        if self.DEBUG >= 10:
687                                try:
688                                        print  'user_field\t %s = %s' %(field,value)
689                                except UnicodeEncodeError, detail:
690                                        print  'user_field\t %s = %s' %(field, util.text.unicode_quote(value))
691
692                        ## To prevent mail loop
693                        #
694                        if field == 'cc':
695
696                                cc_list = user_dict['cc'].split(',')
697
698                                if self.trac_smtp_from in cc_list:
699                                        if self.DEBUG > 10:
700                                                print 'TD: MAIL LOOP: %s is not allowed as CC address' %(self.trac_smtp_from)
701                                        cc_list.remove(self.trac_smtp_from)
702
703                                value = ','.join(cc_list)
704                               
705
706                        if sys_dict.has_key(field):
707
708                                # Check if value is an allowed system option, if TypeError then
709                                # every value is allowed
710                                #
711                                try:
712                                        if value in sys_dict[field]:
713                                                ticket[field] = value
714                                        else:
715                                                # Must we set a default if value is not allowed
716                                                #
717                                                if use_default:
718                                                        value = self.get_config('ticket', 'default_%s' %(field) )
719
720                                except TypeError:
721                                        pass
722
723                                ## Only set if we have a value
724                                #
725                                if value:
726                                        ticket[field] = value
727
728                                if self.DEBUG >= 10:
729                                        try:
730                                                print  'ticket_field\t %s = %s' %(field,  ticket[field])
731                                        except UnicodeEncodeError, detail:
732                                                print  'ticket_field\t %s = %s' %(field,  util.text.unicode_quote(ticket[field]))
733
734        def ticket_update(self, m, id, spam):
735                """
736                If the current email is a reply to an existing ticket, this function
737                will append the contents of this email to that ticket, instead of
738                creating a new one.
739                """
740                if self.VERBOSE:
741                        print "VB: ticket_update: %s" %id
742
743                # Must we update ticket fields
744                #
745                update_fields = dict()
746                try:
747                        id, keywords = string.split(id, '?')
748
749                        # Skip the last ':' character
750                        #
751                        keywords = keywords[:-1]
752                        update_fields = self.str_to_dict(keywords)
753
754                        # Strip '#'
755                        #
756                        self.id = int(id[1:])
757
758                except ValueError:
759                        # Strip '#'
760                        #
761                        self.id = int(id[1:])
762
763                if self.VERBOSE:
764                        print "VB: ticket_update: %s" %id
765
766
767                # When is the change committed
768                #
769                if self.VERSION == 0.11:
770                        utc = UTC()
771                        when = datetime.now(utc)
772                else:
773                        when = int(time.time())
774
775                try:
776                        tkt = Ticket(self.env, self.id, self.db)
777                except util.TracError, detail:
778                        # Not a valid ticket
779                        self.id = None
780                        return False
781
782                # How many changes has this ticket
783                cnum = len(tkt.get_changelog())
784
785
786                # reopen the ticket if it is was closed
787                # We must use the ticket workflow framework
788                #
789                if tkt['status'] in ['closed'] and self.EMAIL_TRIGGERS_WORKFLOW:
790
791                        #print controller.actions['reopen']
792                        #
793                        # As reference 
794                        # req = Mock(href=Href('/'), abs_href=Href('http://www.example.com/'), authname='anonymous', perm=MockPerm(), args={})
795                        #
796                        #a = controller.render_ticket_action_control(req, tkt, 'reopen')
797                        #print 'controller : ', a
798                        #
799                        #b = controller.get_all_status()
800                        #print 'get all status: ', b
801                        #
802                        #b = controller.get_ticket_changes(req, tkt, 'reopen')
803                        #print 'get_ticket_changes :', b
804
805                        if self.WORKFLOW and (self.VERSION in [0.11]) :
806                                from trac.ticket.default_workflow import ConfigurableTicketWorkflow
807                                from trac.test import Mock, MockPerm
808
809                                req = Mock(authname='anonymous', perm=MockPerm(), args={})
810
811                                controller = ConfigurableTicketWorkflow(self.env)
812                                fields = controller.get_ticket_changes(req, tkt, self.WORKFLOW)
813
814                                if self.DEBUG:
815                                        print 'TD: Workflow ticket update fields: ', fields
816
817                                for key in fields.keys():
818                                        tkt[key] = fields[key]
819
820                        else:
821                                tkt['status'] = 'reopened'
822                                tkt['resolution'] = ''
823
824                # Must we update some ticket fields properties via subjectline
825                #
826                if update_fields:
827                        self.update_ticket_fields(tkt, update_fields)
828
829                message_parts = self.get_message_parts(m)
830                message_parts = self.unique_attachment_names(message_parts)
831
832                # Must we update some ticket fields properties via body_text
833                #
834                if self.properties:
835                                self.update_ticket_fields(tkt, self.properties)
836
837                if self.EMAIL_HEADER:
838                        message_parts.insert(0, self.email_header_txt(m))
839
840                body_text = self.body_text(message_parts)
841
842                if self.VERSION  == 0.9:
843                        error_with_attachments = self.attach_attachments(message_parts, True)
844                else:
845                        error_with_attachments = self.attach_attachments(message_parts)
846
847                if body_text.strip() or update_fields or self.properties:
848                        if self.DRY_RUN:
849                                print 'DRY_RUN: tkt.save_changes(self.author, body_text, ticket_change_number) ', self.author, cnum
850                        else:
851                                if error_with_attachments:
852                                        body_text = '%s\\%s' %(error_with_attachments, body_text)
853                               
854                                tkt.save_changes(self.author, body_text, when, None, str(cnum))
855                       
856
857                #if self.VERSION  == 0.9:
858                #       error_with_attachments = self.attach_attachments(message_parts, True)
859                #else:
860                #       error_with_attachments = self.attach_attachments(message_parts)
861
862                if self.notification and not spam:
863                        self.notify(tkt, False, when)
864
865                return True
866
867        def set_ticket_fields(self, ticket):
868                """
869                set the ticket fields to value specified
870                        - /etc/email2trac.conf with <prefix>_<field>
871                        - trac default values, trac.ini
872                """
873                user_dict = dict()
874
875                for field in ticket.fields:
876
877                        name = field['name']
878
879                        ## default trac value
880                        #
881                        if not field.get('custom'):
882                                value = self.get_config('ticket', 'default_%s' %(name) )
883                        else:
884                                ##  Else we get the default value for reporter
885                                #
886                                value = field.get('value')
887                                options = field.get('options')
888
889                                if value and options and (value not in options):
890                                         value = options[int(value)]
891       
892                        if self.DEBUG > 10:
893                                try:
894                                        print 'TD: trac.ini name %s = %s' %(name, value)
895                                except UnicodeEncodeError, detail:
896                                        print 'TD: trac.ini name %s = %s' %(name, util.text.unicode_quote(value))
897
898                        ## email2trac.conf settings
899                        #
900                        prefix = self.parameters['ticket_prefix']
901                        try:
902                                value = self.parameters['%s_%s' %(prefix, name)]
903                                if self.DEBUG > 10:
904                                        try:
905                                                print 'TD: email2trac.conf %s = %s ' %(name, value)
906                                        except UnicodeEncodeError, detail:
907                                                print 'TD: email2trac.conf %s = %s ' %(name, util.text.unicode_quote(value))
908
909                        except KeyError, detail:
910                                pass
911               
912                        if self.DEBUG:
913                                try:
914                                        print 'user_dict[%s] = %s' %(name, value)
915                                except UnicodeEncodeError, detail:
916                                        print 'user_dict[%s] = %s' %(name, util.text.unicode_quote(value))
917
918                        if value:
919                                user_dict[name] = value
920
921                self.update_ticket_fields(ticket, user_dict, use_default=1)
922
923                if 'status' not in user_dict.keys():
924                        ticket['status'] = 'new'
925
926
927        def ticket_update_by_subject(self, subject):
928                """
929                This list of Re: prefixes is probably incomplete. Taken from
930                wikipedia. Here is how the subject is matched
931                  - Re: <subject>
932                  - Re: (<Mail list label>:)+ <subject>
933
934                So we must have the last column
935                """
936                if self.VERBOSE:
937                        print "VB: ticket_update_by_subject()"
938
939                matched_id = None
940                if self.TICKET_UPDATE and self.TICKET_UPDATE_BY_SUBJECT:
941                               
942                        SUBJECT_RE = re.compile(r'^(RE|AW|VS|SV):(.*:)*\s*(.*)', re.IGNORECASE)
943                        result = SUBJECT_RE.search(subject)
944
945                        if result:
946                                # This is a reply
947                                orig_subject = result.group(3)
948
949                                if self.DEBUG:
950                                        print 'TD: subject search string: %s' %(orig_subject)
951
952                                cursor = self.db.cursor()
953                                summaries = [orig_subject, '%%: %s' % orig_subject]
954
955                                ##
956                                # Convert days to seconds
957                                lookback = int(time.mktime(time.gmtime())) - \
958                                                self.TICKET_UPDATE_BY_SUBJECT_LOOKBACK * 24 * 3600
959
960
961                                for summary in summaries:
962                                        if self.DEBUG:
963                                                print 'TD: Looking for summary matching: "%s"' % summary
964                                        sql = """SELECT id FROM ticket
965                                                        WHERE changetime >= %s AND summary LIKE %s
966                                                        ORDER BY changetime DESC"""
967                                        cursor.execute(sql, [lookback, summary.strip()])
968
969                                        for row in cursor:
970                                                (matched_id,) = row
971                                                if self.DEBUG:
972                                                        print 'TD: Found matching ticket id: %d' % matched_id
973                                                break
974
975                                        if matched_id:
976                                                matched_id = '#%d' % matched_id
977                                                return matched_id
978
979                return matched_id
980
981
982        def new_ticket(self, msg, subject, spam, set_fields = None):
983                """
984                Create a new ticket
985                """
986                if self.VERBOSE:
987                        print "VB: function new_ticket()"
988
989                tkt = Ticket(self.env)
990
991                self.set_reply_fields(tkt, msg)
992
993                self.set_ticket_fields(tkt)
994
995                # Old style setting for component, will be removed
996                #
997                if spam:
998                        tkt['component'] = 'Spam'
999
1000                elif self.parameters.has_key('component'):
1001                        tkt['component'] = self.parameters['component']
1002
1003                if not msg['Subject']:
1004                        tkt['summary'] = u'(No subject)'
1005                else:
1006                        tkt['summary'] = subject
1007
1008
1009                if set_fields:
1010                        rest, keywords = string.split(set_fields, '?')
1011
1012                        if keywords:
1013                                update_fields = self.str_to_dict(keywords)
1014                                self.update_ticket_fields(tkt, update_fields)
1015
1016                # produce e-mail like header
1017                #
1018                head = ''
1019                if self.EMAIL_HEADER > 0:
1020                        head = self.email_header_txt(msg)
1021
1022                message_parts = self.get_message_parts(msg)
1023
1024                # Must we update some ticket fields properties via body_text
1025                #
1026                if self.properties:
1027                                self.update_ticket_fields(tkt, self.properties)
1028
1029                if self.DEBUG:
1030                        print 'TD: self.get_message_parts ',
1031                        print message_parts
1032
1033                message_parts = self.unique_attachment_names(message_parts)
1034                if self.DEBUG:
1035                        print 'TD: self.unique_attachment_names',
1036                        print message_parts
1037               
1038                if self.EMAIL_HEADER > 0:
1039                        message_parts.insert(0, self.email_header_txt(msg))
1040                       
1041                body_text = self.body_text(message_parts)
1042
1043                tkt['description'] = body_text
1044
1045                #when = int(time.time())
1046                #
1047                utc = UTC()
1048                when = datetime.now(utc)
1049
1050                if not self.DRY_RUN:
1051                        self.id = tkt.insert()
1052       
1053                changed = False
1054                comment = ''
1055
1056                # some routines in trac are dependend on ticket id     
1057                # like alternate notify template
1058                #
1059                if self.notify_template:
1060                        tkt['id'] = self.id
1061                        changed = True
1062
1063                ## Rewrite the description if we have mailto enabled
1064                #
1065                if self.MAILTO:
1066                        changed = True
1067                        comment = u'\nadded mailto line\n'
1068                        mailto = self.html_mailto_link( m['Subject'])
1069
1070                        tkt['description'] = u'%s\r\n%s%s\r\n' \
1071                                %(head, mailto, body_text)
1072       
1073                ## Save the attachments to the ticket   
1074                #
1075                error_with_attachments =  self.attach_attachments(message_parts)
1076
1077                if error_with_attachments:
1078                        changed = True
1079                        comment = '%s\n%s\n' %(comment, error_with_attachments)
1080
1081                if changed:
1082                        if self.DRY_RUN:
1083                                print 'DRY_RUN: tkt.save_changes(%s, comment) real reporter = %s' %( tkt['reporter'], self.author)
1084                        else:
1085                                tkt.save_changes(tkt['reporter'], comment)
1086                                #print tkt.get_changelog(self.db, when)
1087
1088                if self.notification and not spam:
1089                        self.notify(tkt, True)
1090
1091
1092        def attach_attachments(self, message_parts, update=False):
1093                '''
1094                save any attachments as files in the ticket's directory
1095                '''
1096                if self.VERBOSE:
1097                        print "VB: attach_attachments()"
1098
1099                if self.DRY_RUN:
1100                        print "DRY_RUN: no attachments attached to tickets"
1101                        return ''
1102
1103                count = 0
1104
1105                # Get Maxium attachment size
1106                #
1107                max_size = int(self.get_config('attachment', 'max_size'))
1108                status   = None
1109               
1110                for item in message_parts:
1111                        # Skip body parts
1112                        if not isinstance(item, tuple):
1113                                continue
1114                               
1115                        (original, filename, part) = item
1116                        #
1117                        # We have to determine the size so we use this temporary solution. we must escape it
1118                        # else we get UnicodeErrors.
1119                        #
1120                        path, fd =  util.create_unique_file(os.path.join(self.TMPDIR, util.text.unicode_quote(filename)))
1121                        text = part.get_payload(decode=1)
1122                        if not text:
1123                                text = '(None)'
1124                        fd.write(text)
1125                        fd.close()
1126
1127                        # get the file_size
1128                        #
1129                        stats = os.lstat(path)
1130                        file_size = stats[stat.ST_SIZE]
1131
1132                        # Check if the attachment size is allowed
1133                        #
1134                        if (max_size != -1) and (file_size > max_size):
1135                                status = '%s\nFile %s is larger then allowed attachment size (%d > %d)\n\n' \
1136                                        %(status, original, file_size, max_size)
1137
1138                                os.unlink(path)
1139                                continue
1140                        else:
1141                                count = count + 1
1142                                       
1143                        # Insert the attachment
1144                        #
1145                        fd = open(path, 'rb')
1146                        if self.system == 'discussion':
1147                                att = attachment.Attachment(self.env, 'discussion', 'topic/%s'
1148                                  % (self.id,))
1149                        else:
1150                                att = attachment.Attachment(self.env, 'ticket', self.id)
1151 
1152                        # This will break the ticket_update system, the body_text is vaporized
1153                        # ;-(
1154                        #
1155                        if not update:
1156                                att.author = self.author
1157                                att.description = self.email_to_unicode('Added by email2trac')
1158
1159                        try:
1160                                att.insert(filename, fd, file_size)
1161                        except OSError, detail:
1162                                status = '%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail)
1163
1164                        # Remove the created temporary filename
1165                        #
1166                        fd.close()
1167                        os.unlink(path)
1168
1169                ## return error
1170                #
1171                return status
1172
1173########## Fullblog functions  #################################################
1174
1175        def blog(self, id):
1176                """
1177                The blog create/update function
1178                """
1179                # import the modules
1180                #
1181                from tracfullblog.core import FullBlogCore
1182                from tracfullblog.model import BlogPost, BlogComment
1183                from trac.test import Mock, MockPerm
1184
1185                # instantiate blog core
1186                blog = FullBlogCore(self.env)
1187                req = Mock(authname='anonymous', perm=MockPerm(), args={})
1188
1189                if id:
1190
1191                        # update blog
1192                        #
1193                        comment = BlogComment(self.env, id)
1194                        comment.author = self.author
1195
1196                        message_parts = self.get_message_parts(m)
1197                        comment.comment = self.body_text(message_parts)
1198
1199                        blog.create_comment(req, comment)
1200
1201                else:
1202                        # create blog
1203                        #
1204                        import time
1205                        post = BlogPost(self.env, 'blog_'+time.strftime("%Y%m%d%H%M%S", time.gmtime()))
1206
1207                        #post = BlogPost(self.env, blog._get_default_postname(self.env))
1208                       
1209                        post.author = self.author
1210                        post.title = self.email_to_unicode(m['Subject'])
1211
1212                        message_parts = self.get_message_parts(m)
1213                        post.body = self.body_text(message_parts)
1214                       
1215                        blog.create_post(req, post, self.author, u'Created by email2trac', False)
1216
1217
1218########## Discussion functions  ##############################################
1219
1220        def discussion_topic(self, content, subject):
1221
1222                # Import modules.
1223                from tracdiscussion.api import DiscussionApi
1224                from trac.util.datefmt import to_timestamp, utc
1225
1226                if self.DEBUG:
1227                        print 'TD: Creating a new topic in forum:', self.id
1228
1229                # Get dissussion API component.
1230                api = self.env[DiscussionApi]
1231                context = self._create_context(content, subject)
1232
1233                # Get forum for new topic.
1234                forum = api.get_forum(context, self.id)
1235
1236                if not forum and self.DEBUG:
1237                        print 'ERROR: Replied forum doesn\'t exist'
1238
1239                # Prepare topic.
1240                topic = {'forum' : forum['id'],
1241                                 'subject' : context.subject,
1242                                 'time': to_timestamp(datetime.now(utc)),
1243                                 'author' : self.author,
1244                                 'subscribers' : [self.email_addr],
1245                                 'body' : self.body_text(context.content_parts)}
1246
1247                # Add topic to DB and commit it.
1248                self._add_topic(api, context, topic)
1249                self.db.commit()
1250
1251        def discussion_topic_reply(self, content, subject):
1252
1253                # Import modules.
1254                from tracdiscussion.api import DiscussionApi
1255                from trac.util.datefmt import to_timestamp, utc
1256
1257                if self.DEBUG:
1258                        print 'TD: Replying to discussion topic', self.id
1259
1260                # Get dissussion API component.
1261                api = self.env[DiscussionApi]
1262                context = self._create_context(content, subject)
1263
1264                # Get replied topic.
1265                topic = api.get_topic(context, self.id)
1266
1267                if not topic and self.DEBUG:
1268                        print 'ERROR: Replied topic doesn\'t exist'
1269
1270                # Prepare message.
1271                message = {'forum' : topic['forum'],
1272                                   'topic' : topic['id'],
1273                                   'replyto' : -1,
1274                                   'time' : to_timestamp(datetime.now(utc)),
1275                                   'author' : self.author,
1276                                   'body' : self.body_text(context.content_parts)}
1277
1278                # Add message to DB and commit it.
1279                self._add_message(api, context, message)
1280                self.db.commit()
1281
1282        def discussion_message_reply(self, content, subject):
1283
1284                # Import modules.
1285                from tracdiscussion.api import DiscussionApi
1286                from trac.util.datefmt import to_timestamp, utc
1287
1288                if self.DEBUG:
1289                        print 'TD: Replying to discussion message', self.id
1290
1291                # Get dissussion API component.
1292                api = self.env[DiscussionApi]
1293                context = self._create_context(content, subject)
1294
1295                # Get replied message.
1296                message = api.get_message(context, self.id)
1297
1298                if not message and self.DEBUG:
1299                        print 'ERROR: Replied message doesn\'t exist'
1300
1301                # Prepare message.
1302                message = {'forum' : message['forum'],
1303                                   'topic' : message['topic'],
1304                                   'replyto' : message['id'],
1305                                   'time' : to_timestamp(datetime.now(utc)),
1306                                   'author' : self.author,
1307                                   'body' : self.body_text(context.content_parts)}
1308
1309                # Add message to DB and commit it.
1310                self._add_message(api, context, message)
1311                self.db.commit()
1312
1313        def _create_context(self, content, subject):
1314
1315                # Import modules.
1316                from trac.mimeview import Context
1317                from trac.web.api import Request
1318                from trac.perm import PermissionCache
1319
1320                # TODO: Read server base URL from config.
1321                # Create request object to mockup context creation.
1322                #
1323                environ = {'SERVER_PORT' : 80,
1324                                   'SERVER_NAME' : 'test',
1325                                   'REQUEST_METHOD' : 'POST',
1326                                   'wsgi.url_scheme' : 'http',
1327                                   'wsgi.input' : sys.stdin}
1328                chrome =  {'links': {},
1329                                   'scripts': [],
1330                                   'ctxtnav': [],
1331                                   'warnings': [],
1332                                   'notices': []}
1333
1334                if self.env.base_url_for_redirect:
1335                        environ['trac.base_url'] = self.env.base_url
1336
1337                req = Request(environ, None)
1338                req.chrome = chrome
1339                req.tz = 'missing'
1340                req.authname = self.author
1341                req.perm = PermissionCache(self.env, self.author)
1342
1343                # Create and return context.
1344                context = Context.from_request(req)
1345                context.realm = 'discussion-email2trac'
1346                context.cursor = self.db.cursor()
1347                context.content = content
1348                context.subject = subject
1349
1350                # Read content parts from content.
1351                context.content_parts = self.get_message_parts(content)
1352                context.content_parts = self.unique_attachment_names(
1353                  context.content_parts)
1354
1355                return context
1356
1357        def _add_topic(self, api, context, topic):
1358                context.req.perm.assert_permission('DISCUSSION_APPEND')
1359
1360                # Filter topic.
1361                for discussion_filter in api.discussion_filters:
1362                        accept, topic_or_error = discussion_filter.filter_topic(
1363                          context, topic)
1364                        if accept:
1365                                topic = topic_or_error
1366                        else:
1367                                raise TracError(topic_or_error)
1368
1369                # Add a new topic.
1370                api.add_topic(context, topic)
1371
1372                # Get inserted topic with new ID.
1373                topic = api.get_topic_by_time(context, topic['time'])
1374
1375                # Attach attachments.
1376                self.id = topic['id']
1377                self.attach_attachments(context.content_parts, self.VERSION == 0.9)
1378
1379                # Notify change listeners.
1380                for listener in api.topic_change_listeners:
1381                        listener.topic_created(context, topic)
1382
1383        def _add_message(self, api, context, message):
1384                context.req.perm.assert_permission('DISCUSSION_APPEND')
1385
1386                # Filter message.
1387                for discussion_filter in api.discussion_filters:
1388                        accept, message_or_error = discussion_filter.filter_message(
1389                          context, message)
1390                        if accept:
1391                                message = message_or_error
1392                        else:
1393                                raise TracError(message_or_error)
1394
1395                # Add message.
1396                api.add_message(context, message)
1397
1398                # Get inserted message with new ID.
1399                message = api.get_message_by_time(context, message['time'])
1400
1401                # Attach attachments.
1402                self.id = message['topic']
1403                self.attach_attachments(context.content_parts, self.VERSION == 0.9)
1404
1405                # Notify change listeners.
1406                for listener in api.message_change_listeners:
1407                        listener.message_created(context, message)
1408
1409########## MAIN function  ######################################################
1410
1411        def parse(self, fp):
1412                """
1413                """
1414                if self.VERBOSE:
1415                        print "VB: main function parse()"
1416                global m
1417
1418                m = email.message_from_file(fp)
1419               
1420                if not m:
1421                        if self.DEBUG:
1422                                print "TD: This is not a valid email message format"
1423                        return
1424                       
1425                # Work around lack of header folding in Python; see http://bugs.python.org/issue4696
1426                try:
1427                        m.replace_header('Subject', m['Subject'].replace('\r', '').replace('\n', ''))
1428                except AttributeError, detail:
1429                        pass
1430
1431                if self.DEBUG > 1:        # save the entire e-mail message text
1432                        self.save_email_for_debug(m, True)
1433
1434                self.db = self.env.get_db_cnx()
1435                self.get_sender_info(m)
1436
1437                if not self.email_header_acl('white_list', self.email_addr, True):
1438                        if self.DEBUG > 1 :
1439                                print 'Message rejected : %s not in white list' %(self.email_addr)
1440                        return False
1441
1442                if self.email_header_acl('black_list', self.email_addr, False):
1443                        if self.DEBUG > 1 :
1444                                print 'Message rejected : %s in black list' %(self.email_addr)
1445                        return False
1446
1447                if not self.email_header_acl('recipient_list', self.to_email_addr, True):
1448                        if self.DEBUG > 1 :
1449                                print 'Message rejected : %s not in recipient list' %(self.to_email_addr)
1450                        return False
1451
1452                # If drop the message
1453                #
1454                if self.spam(m) == 'drop':
1455                        return False
1456
1457                elif self.spam(m) == 'spam':
1458                        spam_msg = True
1459                else:
1460                        spam_msg = False
1461
1462                if self.get_config('notification', 'smtp_enabled') in ['true']:
1463                        self.notification = 1
1464                else:
1465                        self.notification = 0
1466
1467                if not m['Subject']:
1468                        subject  = 'No Subject'
1469                else:
1470                        subject  = self.email_to_unicode(m['Subject'])
1471
1472                if self.DEBUG:
1473                         print "TD:", subject
1474
1475                #
1476                # [hic] #1529: Re: LRZ
1477                # [hic] #1529?owner=bas,priority=medium: Re: LRZ
1478                #
1479                ticket_regex = r'''
1480                        (?P<new_fields>[#][?].*)
1481                        |(?P<reply>(?P<id>[#][\d]+)(?P<fields>\?.*?:)*)
1482                        '''
1483                # Check if  FullBlogPlugin is installed
1484                #
1485                blog_enabled = None
1486                blog_regex = ''
1487                if self.get_config('components', 'tracfullblog.*') in ['enabled']:
1488                        blog_enabled = True
1489                        blog_regex = '''|(?P<blog>blog:(?P<blog_id>\w*))'''
1490
1491
1492                # Check if DiscussionPlugin is installed
1493                #
1494                discussion_enabled = None
1495                discussion_regex = ''
1496                if self.get_config('components', 'tracdiscussion.api.*') in ['enabled']:
1497                        discussion_enabled = True
1498                        discussion_regex = r'''
1499                        |(?P<forum>Forum[ ][#](?P<forum_id>\d+)[ ]-[ ]?)
1500                        |(?P<topic>Topic[ ][#](?P<topic_id>\d+)[ ]-[ ]?)
1501                        |(?P<message>Message[ ][#](?P<message_id>\d+)[ ]-[ ]?)
1502                        '''
1503
1504
1505                regex_str = ticket_regex + blog_regex + discussion_regex
1506                SYSTEM_RE = re.compile(regex_str, re.VERBOSE)
1507
1508                # Find out if this is a ticket, a blog or a discussion
1509                #
1510                result =  SYSTEM_RE.search(subject)
1511                if result:
1512                        # update ticket + fields
1513                        #
1514                        if result.group('reply') and self.TICKET_UPDATE:
1515                                self.system = 'ticket'
1516                                self.ticket_update(m, result.group('reply'), spam_msg)
1517
1518                        # New ticket + fields
1519                        #
1520                        elif result.group('new_fields'):
1521                                self.system = 'ticket'
1522                                self.new_ticket(m, subject[:result.start('new_fields')], spam_msg, result.group('new_fields'))
1523
1524                        if blog_enabled:
1525                                if result.group('blog'):
1526                                        self.system = 'blog'
1527                                        self.blog(result.group('blog_id'))
1528
1529                        if discussion_enabled:
1530                                # New topic.
1531                                #
1532                                if result.group('forum'):
1533                                        self.system = 'discussion'
1534                                        self.id = int(result.group('forum_id'))
1535                                        self.discussion_topic(m, subject[result.end('forum'):])
1536
1537                                # Reply to topic.
1538                                #
1539                                elif result.group('topic'):
1540                                        self.system = 'discussion'
1541                                        self.id = int(result.group('topic_id'))
1542                                        self.discussion_topic_reply(m, subject[result.end('topic'):])
1543
1544                                # Reply to topic message.
1545                                #
1546                                elif result.group('message'):
1547                                        self.system = 'discussion'
1548                                        self.id = int(result.group('message_id'))
1549                                        self.discussion_message_reply(m, subject[result.end('message'):])
1550
1551                else:
1552                        self.system = 'ticket'
1553                        result = self.ticket_update_by_subject(subject)
1554                        if result:
1555                                self.ticket_update(m, result, spam_msg)
1556                        else:
1557                                # No update by subject, so just create a new ticket
1558                                self.new_ticket(m, subject, spam_msg)
1559
1560
1561########## BODY TEXT functions  ###########################################################
1562
1563        def strip_signature(self, text):
1564                """
1565                Strip signature from message, inspired by Mailman software
1566                """
1567                body = []
1568                for line in text.splitlines():
1569                        if line == '-- ':
1570                                break
1571                        body.append(line)
1572
1573                return ('\n'.join(body))
1574
1575        def reflow(self, text, delsp = 0):
1576                """
1577                Reflow the message based on the format="flowed" specification (RFC 3676)
1578                """
1579                flowedlines = []
1580                quotelevel = 0
1581                prevflowed = 0
1582
1583                for line in text.splitlines():
1584                        from re import match
1585                       
1586                        # Figure out the quote level and the content of the current line
1587                        m = match('(>*)( ?)(.*)', line)
1588                        linequotelevel = len(m.group(1))
1589                        line = m.group(3)
1590
1591                        # Determine whether this line is flowed
1592                        if line and line != '-- ' and line[-1] == ' ':
1593                                flowed = 1
1594                        else:
1595                                flowed = 0
1596
1597                        if flowed and delsp and line and line[-1] == ' ':
1598                                line = line[:-1]
1599
1600                        # If the previous line is flowed, append this line to it
1601                        if prevflowed and line != '-- ' and linequotelevel == quotelevel:
1602                                flowedlines[-1] += line
1603                        # Otherwise, start a new line
1604                        else:
1605                                flowedlines.append('>' * linequotelevel + line)
1606
1607                        prevflowed = flowed
1608                       
1609
1610                return '\n'.join(flowedlines)
1611
1612        def strip_quotes(self, text):
1613                """
1614                Strip quotes from message by Nicolas Mendoza
1615                """
1616                body = []
1617                for line in text.splitlines():
1618                        if line.startswith(self.EMAIL_QUOTE):
1619                                continue
1620                        body.append(line)
1621
1622                return ('\n'.join(body))
1623
1624        def inline_properties(self, text):
1625                """
1626                Parse text if we use inline keywords to set ticket fields
1627                """
1628                if self.DEBUG:
1629                        print 'TD: inline_properties function'
1630
1631                properties = dict()
1632                body = list()
1633
1634                INLINE_EXP = re.compile('\s*[@]\s*([a-zA-Z]+)\s*:(.*)$')
1635
1636                for line in text.splitlines():
1637                        match = INLINE_EXP.match(line)
1638                        if match:
1639                                keyword, value = match.groups()
1640                                self.properties[keyword] = value.strip()
1641                                if self.DEBUG:
1642                                        print "TD: inline properties: %s : %s" %(keyword,value)
1643                        else:
1644                                body.append(line)
1645                               
1646                return '\n'.join(body)
1647
1648
1649        def wrap_text(self, text, replace_whitespace = False):
1650                """
1651                Will break a lines longer then given length into several small
1652                lines of size given length
1653                """
1654                import textwrap
1655
1656                LINESEPARATOR = '\n'
1657                reformat = ''
1658
1659                for s in text.split(LINESEPARATOR):
1660                        tmp = textwrap.fill(s,self.USE_TEXTWRAP)
1661                        if tmp:
1662                                reformat = '%s\n%s' %(reformat,tmp)
1663                        else:
1664                                reformat = '%s\n' %reformat
1665
1666                return reformat
1667
1668                # Python2.4 and higher
1669                #
1670                #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
1671                #
1672
1673########## EMAIL attachements functions ###########################################################
1674
1675        def inline_part(self, part):
1676                """
1677                """
1678                if self.VERBOSE:
1679                        print "VB: inline_part()"
1680
1681                return part.get_param('inline', None, 'Content-Disposition') == '' or not part.has_key('Content-Disposition')
1682
1683        def get_message_parts(self, msg):
1684                """
1685                parses the email message and returns a list of body parts and attachments
1686                body parts are returned as strings, attachments are returned as tuples of (filename, Message object)
1687                """
1688                if self.VERBOSE:
1689                        print "VB: get_message_parts()"
1690
1691                message_parts = list()
1692       
1693                ALTERNATIVE_MULTIPART = False
1694
1695                for part in msg.walk():
1696                        if self.DEBUG:
1697                                print 'TD: Message part: Main-Type: %s' % part.get_content_maintype()
1698                                print 'TD: Message part: Content-Type: %s' % part.get_content_type()
1699
1700                        ## Check content type
1701                        #
1702                        if part.get_content_type() in self.STRIP_CONTENT_TYPES:
1703
1704                                if self.DEBUG:
1705                                        print "TD: A %s attachment named '%s' was skipped" %(part.get_content_type(), part.get_filename())
1706
1707                                continue
1708
1709                        ## Catch some mulitpart execptions
1710                        #
1711                        if part.get_content_type() == 'multipart/alternative':
1712                                ALTERNATIVE_MULTIPART = True
1713                                continue
1714
1715                        ## Skip multipart containers
1716                        #
1717                        if part.get_content_maintype() == 'multipart':
1718                                if self.DEBUG:
1719                                        print "TD: Skipping multipart container"
1720                                continue
1721                       
1722                        ## 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"
1723                        #
1724                        inline = self.inline_part(part)
1725
1726                        ## Drop HTML message
1727                        #
1728                        if ALTERNATIVE_MULTIPART and self.DROP_ALTERNATIVE_HTML_VERSION:
1729                                if part.get_content_type() == 'text/html':
1730                                        if self.DEBUG:
1731                                                print "TD: Skipping alternative HTML message"
1732
1733                                        ALTERNATIVE_MULTIPART = False
1734                                        continue
1735
1736                        ## Inline text parts are where the body is
1737                        #
1738                        if part.get_content_type() == 'text/plain' and inline:
1739                                if self.DEBUG:
1740                                        print 'TD:               Inline body part'
1741
1742                                # Try to decode, if fails then do not decode
1743                                #
1744                                body_text = part.get_payload(decode=1)
1745                                if not body_text:                       
1746                                        body_text = part.get_payload(decode=0)
1747
1748                                format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
1749                                delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
1750
1751                                if self.REFLOW and not self.VERBATIM_FORMAT and format == 'flowed':
1752                                        body_text = self.reflow(body_text, delsp == 'yes')
1753       
1754                                if self.STRIP_SIGNATURE:
1755                                        body_text = self.strip_signature(body_text)
1756
1757                                if self.STRIP_QUOTES:
1758                                        body_text = self.strip_quotes(body_text)
1759
1760                                if self.INLINE_PROPERTIES:
1761                                        body_text = self.inline_properties(body_text)
1762
1763                                if self.USE_TEXTWRAP:
1764                                        body_text = self.wrap_text(body_text)
1765
1766                                ## Get contents charset (iso-8859-15 if not defined in mail headers)
1767                                #
1768                                charset = part.get_content_charset()
1769                                if not charset:
1770                                        charset = 'iso-8859-15'
1771
1772                                try:
1773                                        ubody_text = unicode(body_text, charset)
1774
1775                                except UnicodeError, detail:
1776                                        ubody_text = unicode(body_text, 'iso-8859-15')
1777
1778                                except LookupError, detail:
1779                                        ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
1780
1781                                if self.VERBATIM_FORMAT:
1782                                        message_parts.append('{{{\r\n%s\r\n}}}' %ubody_text)
1783                                else:
1784                                        message_parts.append('%s' %ubody_text)
1785                        else:
1786                                if self.DEBUG:
1787                                        try:
1788                                                print 'TD:               Filename: %s' % part.get_filename()
1789                                        except UnicodeEncodeError, detail:
1790                                                print 'TD:               Filename: %s' % util.text.unicode_quote(part.get_filename())
1791
1792                                ##
1793                                #
1794                                filename = part.get_filename()
1795                                message_parts.append((filename, part))
1796
1797                return message_parts
1798               
1799        def unique_attachment_names(self, message_parts):
1800                """
1801                """
1802                renamed_parts = []
1803                attachment_names = set()
1804
1805                for item in message_parts:
1806                       
1807                        ## If not an attachment, leave it alone
1808                        #
1809                        if not isinstance(item, tuple):
1810                                renamed_parts.append(item)
1811                                continue
1812                               
1813                        (filename, part) = item
1814
1815                        ## If no filename, use a default one
1816                        #
1817                        if not filename:
1818                                filename = 'untitled-part'
1819
1820                                # Guess the extension from the content type, use non strict mode
1821                                # some additional non-standard but commonly used MIME types
1822                                # are also recognized
1823                                #
1824                                ext = mimetypes.guess_extension(part.get_content_type(), False)
1825                                if not ext:
1826                                        ext = '.bin'
1827
1828                                filename = '%s%s' % (filename, ext)
1829
1830                        ## Discard relative paths for windows/unix in attachment names
1831                        #
1832                        #filename = filename.replace('\\', '/').replace(':', '/')
1833                        filename = filename.replace('\\', '_')
1834                        filename = filename.replace('/', '_')
1835
1836                        #
1837                        # We try to normalize the filename to utf-8 NFC if we can.
1838                        # Files uploaded from OS X might be in NFD.
1839                        # Check python version and then try it
1840                        #
1841                        #if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
1842                        #       try:
1843                        #               filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
1844                        #       except TypeError:
1845                        #               pass
1846
1847                        # Make the filename unique for this ticket
1848                        num = 0
1849                        unique_filename = filename
1850                        dummy_filename, ext = os.path.splitext(filename)
1851
1852                        while (unique_filename in attachment_names) or self.attachment_exists(unique_filename):
1853                                num += 1
1854                                unique_filename = "%s-%s%s" % (dummy_filename, num, ext)
1855                               
1856                        if self.DEBUG:
1857                                try:
1858                                        print 'TD: Attachment with filename %s will be saved as %s' % (filename, unique_filename)
1859                                except UnicodeEncodeError, detail:
1860                                        print 'Filename can not be printed due to non-ascii characters'
1861
1862                        attachment_names.add(unique_filename)
1863
1864                        renamed_parts.append((filename, unique_filename, part))
1865       
1866                return renamed_parts
1867                       
1868                       
1869        def attachment_exists(self, filename):
1870
1871                if self.DEBUG:
1872                        s = 'TD: attachment already exists: Id : '
1873                        try:
1874                                print "%s%s, Filename : %s" % (s, self.id, filename)
1875                        except UnicodeEncodeError, detail:
1876                                print "%s%s, Filename : Can not be printed due to non-ascii characters" %(s, self.id)
1877
1878                # We have no valid ticket id
1879                #
1880                if not self.id:
1881                        return False
1882
1883                try:
1884                        if self.system == 'discussion':
1885                                att = attachment.Attachment(self.env, 'discussion', 'ticket/%s'
1886                                  % (self.id,), filename)
1887                        else:
1888                                att = attachment.Attachment(self.env, 'ticket', self.id,
1889                                  filename)
1890                        return True
1891                except attachment.ResourceNotFound:
1892                        return False
1893
1894########## TRAC Ticket Text ###########################################################
1895                       
1896        def body_text(self, message_parts):
1897                body_text = []
1898               
1899                for part in message_parts:
1900                        # Plain text part, append it
1901                        if not isinstance(part, tuple):
1902                                body_text.extend(part.strip().splitlines())
1903                                body_text.append("")
1904                                continue
1905                               
1906                        (original, filename, part) = part
1907                        inline = self.inline_part(part)
1908                       
1909                        if part.get_content_maintype() == 'image' and inline:
1910                                if self.system != 'discussion':
1911                                        body_text.append('[[Image(%s)]]' % filename)
1912                                body_text.append("")
1913                        else:
1914                                if self.system != 'discussion':
1915                                        body_text.append('[attachment:"%s"]' % filename)
1916                                body_text.append("")
1917                               
1918                body_text = '\r\n'.join(body_text)
1919                return body_text
1920
1921        def html_mailto_link(self, subject):
1922                """
1923                This function returns a HTML mailto tag with the ticket id and author email address
1924                """
1925                if not self.author:
1926                        author = self.email_addr
1927                else:   
1928                        author = self.author
1929
1930                # use urllib to escape the chars
1931                #
1932                s = 'mailto:%s?Subject=%s&Cc=%s' %(
1933                       urllib.quote(self.email_addr),
1934                           urllib.quote('Re: #%s: %s' %(self.id, subject)),
1935                           urllib.quote(self.MAILTO_CC)
1936                           )
1937
1938                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)
1939                return s
1940
1941########## TRAC notify section ###########################################################
1942
1943        def notify(self, tkt, new=True, modtime=0):
1944                """
1945                A wrapper for the TRAC notify function. So we can use templates
1946                """
1947                if self.DRY_RUN:
1948                                print 'DRY_RUN: self.notify(tkt, True) reporter = %s' %tkt['reporter']
1949                                return
1950                try:
1951                        # create false {abs_}href properties, to trick Notify()
1952                        #
1953                        if not (self.VERSION in [0.11, 0.12]):
1954                                self.env.abs_href = Href(self.get_config('project', 'url'))
1955                                self.env.href = Href(self.get_config('project', 'url'))
1956
1957                        tn = TicketNotifyEmail(self.env)
1958
1959                        if self.notify_template:
1960
1961                                if self.VERSION == 0.11:
1962
1963                                        from trac.web.chrome import Chrome
1964
1965                                        if self.notify_template_update and not new:
1966                                                tn.template_name = self.notify_template_update
1967                                        else:
1968                                                tn.template_name = self.notify_template
1969
1970                                        tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
1971                                               
1972                                else:
1973
1974                                        tn.template_name = self.notify_template;
1975
1976                        tn.notify(tkt, new, modtime)
1977
1978                except Exception, e:
1979                        print 'TD: Failure sending notification on creation of ticket #%s: %s' %(self.id, e)
1980
1981
1982
1983########## Parse Config File  ###########################################################
1984
1985def ReadConfig(file, name):
1986        """
1987        Parse the config file
1988        """
1989        if not os.path.isfile(file):
1990                print 'File %s does not exist' %file
1991                sys.exit(1)
1992
1993        config = trac_config.Configuration(file)
1994
1995        # Use given project name else use defaults
1996        #
1997        if name:
1998                sections = config.sections()
1999                if not name in sections:
2000                        print "Not a valid project name: %s" %name
2001                        print "Valid names: %s" %sections
2002                        sys.exit(1)
2003
2004                project =  dict()
2005                for option, value in  config.options(name):
2006                        project[option] = value
2007
2008        else:
2009                # use some trac internals to get the defaults
2010                #
2011                project = config.parser.defaults()
2012
2013        return project
2014
2015
2016if __name__ == '__main__':
2017        # Default config file
2018        #
2019        configfile = '@email2trac_conf@'
2020        project = ''
2021        component = ''
2022        ticket_prefix = 'default'
2023        dry_run = None
2024        verbose = None
2025
2026        ENABLE_SYSLOG = 0
2027
2028        SHORT_OPT = 'chf:np:t:v'
2029        LONG_OPT  =  ['component=', 'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=', 'verbose']
2030
2031        try:
2032                opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
2033        except getopt.error,detail:
2034                print __doc__
2035                print detail
2036                sys.exit(1)
2037       
2038        project_name = None
2039        for opt,value in opts:
2040                if opt in [ '-h', '--help']:
2041                        print __doc__
2042                        sys.exit(0)
2043                elif opt in ['-c', '--component']:
2044                        component = value
2045                elif opt in ['-f', '--file']:
2046                        configfile = value
2047                elif opt in ['-n', '--dry-run']:
2048                        dry_run = True
2049                elif opt in ['-p', '--project']:
2050                        project_name = value
2051                elif opt in ['-t', '--ticket_prefix']:
2052                        ticket_prefix = value
2053                elif opt in ['-v', '--version']:
2054                        verbose = True
2055       
2056        settings = ReadConfig(configfile, project_name)
2057        if not settings.has_key('project'):
2058                print __doc__
2059                print 'No Trac project is defined in the email2trac config file.'
2060                sys.exit(1)
2061       
2062        if component:
2063                settings['component'] = component
2064
2065        # The default prefix for ticket values in email2trac.conf
2066        #
2067        settings['ticket_prefix'] = ticket_prefix
2068        settings['dry_run'] = dry_run
2069        settings['verbose'] = verbose
2070
2071
2072        # Determine major trac version used to be in email2trac.conf
2073        # Quick hack for 0.12
2074        #
2075        version = '0.%s' %(trac_version.split('.')[1])
2076        if version.startswith('0.12'):
2077                version = '0.12'
2078
2079        if verbose:
2080                print "Found trac version: %s" %(version)
2081       
2082        #debug HvB
2083        #print settings
2084
2085        try:
2086                if version == '0.9':
2087                        from trac import attachment
2088                        from trac.env import Environment
2089                        from trac.ticket import Ticket
2090                        from trac.web.href import Href
2091                        from trac import util
2092                        from trac.Notify import TicketNotifyEmail
2093                elif version == '0.10':
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 util
2099                        #
2100                        # return  util.text.to_unicode(str)
2101                        #
2102                        # see http://projects.edgewall.com/trac/changeset/2799
2103                        from trac.ticket.notification import TicketNotifyEmail
2104                        from trac import config as trac_config
2105                        from trac.core import TracError
2106
2107                elif version == '0.11':
2108                        from trac import attachment
2109                        from trac.env import Environment
2110                        from trac.ticket import Ticket
2111                        from trac.web.href import Href
2112                        from trac import config as trac_config
2113                        from trac import util
2114                        from trac.core import TracError
2115
2116                        #
2117                        # return  util.text.to_unicode(str)
2118                        #
2119                        # see http://projects.edgewall.com/trac/changeset/2799
2120                        from trac.ticket.notification import TicketNotifyEmail
2121
2122                elif version == '0.12':
2123                        from trac import attachment
2124                        from trac.env import Environment
2125                        from trac.ticket import Ticket
2126                        from trac.web.href import Href
2127                        from trac import config as trac_config
2128                        from trac import util
2129                        from trac.core import TracError
2130
2131                        #
2132                        # return  util.text.to_unicode(str)
2133                        #
2134                        # see http://projects.edgewall.com/trac/changeset/2799
2135                        from trac.ticket.notification import TicketNotifyEmail
2136
2137
2138                else:
2139                        print 'TRAC version %s is not supported' %version
2140                        sys.exit(1)
2141                       
2142                if settings.has_key('enable_syslog'):
2143                        if SYSLOG_AVAILABLE:
2144                                ENABLE_SYSLOG =  float(settings['enable_syslog'])
2145
2146
2147                # Must be set before environment is created
2148                #
2149                if settings.has_key('python_egg_cache'):
2150                        python_egg_cache = str(settings['python_egg_cache'])
2151                        os.environ['PYTHON_EGG_CACHE'] = python_egg_cache
2152
2153       
2154                if int(settings['debug']) > 0:
2155                        print 'Loading environment', settings['project']
2156
2157                env = Environment(settings['project'], create=0)
2158
2159                tktparser = TicketEmailParser(env, settings, float(version))
2160                tktparser.parse(sys.stdin)
2161
2162        # Catch all errors ans log to SYSLOG if we have enabled this
2163        # else stdout
2164        #
2165        except Exception, error:
2166                if ENABLE_SYSLOG:
2167                        syslog.openlog('email2trac', syslog.LOG_NOWAIT)
2168
2169                        etype, evalue, etb = sys.exc_info()
2170                        for e in traceback.format_exception(etype, evalue, etb):
2171                                syslog.syslog(e)
2172
2173                        syslog.closelog()
2174                else:
2175                        traceback.print_exc()
2176
2177                if m:
2178                        tktparser.save_email_for_debug(m, True)
2179
2180
2181                sys.exit(1)
2182# EOB
Note: See TracBrowser for help on using the repository browser.