source: trunk/email2trac.py.in @ 376

Last change on this file since 376 was 376, checked in by bas, 11 years ago

Fixed some UnicodeEncodeErrors? when in debug mode, see #206.

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