source: trunk/email2trac.py.in @ 448

Last change on this file since 448 was 448, checked in by bas, 12 years ago

converted STRIP_QUOTES and STRIP_SIGNATURES to UserDict?

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