source: trunk/email2trac.py.in @ 625

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

changed the short option for virtualenv to -E, else we have conflict with run_email2trac

  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 87.6 KB
Line 
1#!@PYTHON@
2#
3# This file is part of the email2trac utils
4#
5#       Copyright 2002 SARA
6#
7#       Licensed under the Apache License, Version 2.0 (the "License");
8#       you may not use this file except in compliance with the License.
9#       You may obtain a copy of the License at
10#
11#       http://www.apache.org/licenses/LICENSE-2.0
12#
13#       Unless required by applicable law or agreed to in writing, software
14#       distributed under the License is distributed on an "AS IS" BASIS,
15#       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16#       See the License for the specific language governing permissions and
17#       limitations under the License.
18#
19# For vi/emacs or other use tabstop=4 (vi: set ts=4)
20#
21"""
22email2trac.py -- Email -> TRAC tickets
23
24A MTA filter to create Trac tickets from inbound emails.
25
26first proof of concept from:
27 Copyright 2005, Daniel Lundin <daniel@edgewall.com>
28 Copyright 2005, Edgewall Software
29
30Authors:
31  Bas van der Vlies <basv@sara.nl>
32  Walter de Jong <walter@sara.nl>
33
34How to use
35----------
36 * See https://subtrac.sara.nl/oss/email2trac/
37
38 * Commandline opions:
39                -h,--help
40                -A, --agilo
41                -d, --debug
42                -E, --virtualenv <path>
43                -f,--file  <configuration file>
44                -n,--dry-run
45                -p, --project <project name>
46                -t, --ticket_prefix <name>
47
48SVN Info:
49        $Id: email2trac.py.in 625 2013-06-18 08:59:35Z bas $
50"""
51import os
52import sys
53import string
54import getopt
55import time
56import email
57import email.Iterators
58import email.Header
59import re
60import urllib
61import unicodedata
62import mimetypes
63import traceback
64import logging
65import logging.handlers
66import UserDict
67import tempfile
68
69from datetime import timedelta, datetime
70from stat import *
71
72## Some global variables
73#
74m = None
75
76# This is to for the function AlwaysNotifyReporter
77sender_email = None
78
79class SaraDict(UserDict.UserDict):
80    def __init__(self, dictin = None):
81        UserDict.UserDict.__init__(self)
82        self.name = None
83       
84        if dictin:
85            if dictin.has_key('name'):
86                self.name = dictin['name']
87                del dictin['name']
88            self.data = dictin
89           
90    def get_value(self, name):
91        if self.has_key(name):
92            return self[name]
93        else:
94            return None
95               
96    def __repr__(self):
97        return repr(self.data)
98
99    def __str__(self):
100        return str(self.data)
101           
102    def __getattr__(self, name):
103        """
104        override the class attribute get method. Return the value
105        from the dictionary
106        """
107        if self.data.has_key(name):
108            return self.data[name]
109        else:
110            return None
111           
112    def __setattr__(self, name, value):
113        """
114        override the class attribute set method only when the UserDict
115        has set its class attribute
116        """
117        if self.__dict__.has_key('data'):
118            self.data[name] = value
119        else:
120            self.__dict__[name] = value
121
122    def __iter__(self):
123        return iter(self.data.keys())
124
125class TicketEmailParser(object):
126    env = None
127    comment = '> '
128
129    def __init__(self, env, parameters, logger, version):
130        self.env = env
131
132        # Database connection
133        #
134        self.db = None
135
136        # Save parameters
137        #
138        self.parameters = parameters
139        self.logger = logger
140
141        # Some useful mail constants
142        #
143        self.email_name = None
144        self.email_addr = None
145        self.email_from = None
146        self.author     = None
147        self.id         = None
148
149        ## Will be set to True if the user has ben registered
150        #
151        self.allow_registered_user = False
152       
153        self.STRIP_CONTENT_TYPES = list()
154
155        ## fields properties via body_text
156        #
157        self.properties = dict()
158
159        self.VERSION = version
160
161        self.get_config = self.env.config.get
162
163        ## init function ##
164        #
165        self.setup_parameters()
166
167    def setup_parameters(self):
168        if self.parameters.umask:
169            os.umask(self.parameters.umask)
170
171        if not self.parameters.spam_level:
172            self.parameters.spam_level = 0
173
174        if not self.parameters.spam_header:
175            self.parameters.spam_header = 'X-Spam-Score'
176
177        if not self.parameters.email_quote:
178            self.parameters.email_quote = '^> .*'
179            self.parameters.skip_line_regex = '^> .*'
180
181        if not self.parameters.ticket_update_by_subject_lookback:
182            self.parameters.ticket_update_by_subject_lookback = 30
183
184        if self.parameters.verbatim_format == None:
185            self.parameters.verbatim_format = 1
186
187        if self.parameters.reflow == None:
188            self.parameters.reflow = 1
189
190        if self.parameters.binhex:
191            self.STRIP_CONTENT_TYPES.append('application/mac-binhex40')
192
193        if self.parameters.applesingle:
194            self.STRIP_CONTENT_TYPES.append('application/applefile')
195
196        if self.parameters.appledouble:
197            self.STRIP_CONTENT_TYPES.append('application/applefile')
198
199        if self.parameters.strip_content_types:
200            items = self.parameters.strip_content_types.split(',')
201            for item in items:
202                self.STRIP_CONTENT_TYPES.append(item.strip())
203
204        if self.parameters.tmpdir:
205            self.parameters.tmpdir = os.path.normcase(str(self.parameters['tmpdir']))
206        else:
207            self.parameters.tmpdir = os.path.normcase('/tmp')
208
209        if self.parameters.email_triggers_workflow == None:
210            self.parameters.email_triggers_workflow = 1
211
212        if not self.parameters.subject_field_separator:
213            self.parameters.subject_field_separator = '&'
214        else:
215            self.parameters.subject_field_separator = self.parameters.subject_field_separator.strip()
216
217        if not self.parameters.strip_signature_regex:
218            self.parameters.strip_signature_regex = '^-- $'
219            self.parameters.cut_line_regex = '^-- $'
220
221        self.trac_smtp_from = self.get_config('notification', 'smtp_from')
222        self.smtp_default_domain = self.get_config('notification', 'smtp_default_domain')
223
224
225        self.system = None
226
227########## Email Header Functions ###########################################################
228
229    def spam(self, message):
230        """
231        # X-Spam-Score: *** (3.255) BAYES_50,DNS_FROM_AHBL_RHSBL,HTML_
232        # Note if Spam_level then '*' are included
233        """
234        self.logger.debug('function spam')
235
236        spam = False
237        if message.has_key(self.parameters.spam_header):
238            spam_l = string.split(message[self.parameters.spam_header])
239            #self.logger.info('Spam header: %s' %(message[self.parameters.spam_header]))
240
241            try:
242                number = spam_l[0].count('*')
243            except IndexError, detail:
244                number = 0
245               
246            if number >= self.parameters.spam_level:
247                spam = True
248               
249        ## treat virus mails as spam
250        #
251        elif message.has_key('X-Virus-found'):         
252            spam = True
253
254        ## Astaro firewall spam handling
255        #
256        elif message.get('X-Spam-Flag') == "YES" and message.get('X-Spam-Result') == "Spam":
257            spam = True
258
259        ## How to handle SPAM messages
260        #
261        if self.parameters.drop_spam and spam:
262
263            self.logger.info('Message is a SPAM. Automatic ticket insertion refused (SPAM level > %d)' %self.parameters.spam_level)
264            return 'drop'   
265
266        elif spam:
267
268            return 'Spam'   
269        else:
270
271            return False
272
273    def email_header_acl(self, keyword, message_field, default):
274        """
275        This function wil check if the email address is allowed or denied
276        to send mail to the ticket list
277        """
278        self.logger.debug('function email_header_acl: %s' %keyword)
279
280        try:
281            mail_addresses = self.parameters[keyword]
282
283            # Check if we have an empty string
284            #
285            if not mail_addresses:
286                return default
287
288        except KeyError, detail:
289            self.logger.debug('\t %s not defined, all messages are allowed.' %(keyword))
290
291            return default
292
293        mail_addresses = string.split(mail_addresses, ',')
294        message_addresses = string.split(message_field, ',')
295
296        for entry in mail_addresses:
297            entry = entry.strip()
298            TO_RE = re.compile(entry, re.VERBOSE|re.IGNORECASE)
299
300            for addr in message_addresses:
301                addr = addr.strip()
302
303                if self.parameters.compare_function_list in [ 'matches', 'match']:
304                    s = '\t%s matches %s' %(addr, entry)
305                    result =  TO_RE.match(addr)
306                else:
307                    s = '\t%s contains %s' %(addr, entry)
308                    result =  TO_RE.search(addr)
309
310                if result:
311                    self.logger.debug(s)
312                    return True
313
314        return False
315
316    def email_header_txt(self, m):
317        """
318        Display To and CC addresses in description field
319        """
320        s = ''
321
322        if m['To'] and len(m['To']) > 0:
323            s = "'''To:''' %s\r\n" %(m['To'])
324        if m['Cc'] and len(m['Cc']) > 0:
325            s = "%s'''Cc:''' %s\r\n" % (s, m['Cc'])
326
327        return  self.email_to_unicode(s)
328
329
330    def get_sender_info(self, message):
331        """
332        Get the default author name and email address from the message
333        """
334        self.logger.debug('function get_sender_info')
335
336        to_addrs = email.Utils.getaddresses( message.get_all('to', []) )
337        self.email_to_addrs = list()
338
339        for n,e in to_addrs:
340            self.email_to_addrs.append(e)
341
342        self.email_to_addrs = ','.join(self.email_to_addrs)
343
344
345        self.email_name, self.email_addr  = email.Utils.parseaddr(message['from'])
346
347        ## decode email name can contain charset
348        #
349        self.email_name = self.email_to_unicode(self.email_name)
350
351        dstr = '\t email name: %s, email address: %s' %(self.email_name, self.email_addr) 
352        self.logger.debug(dstr)
353
354        ## Trac can not handle author's name that contains spaces
355        #
356        if self.email_addr.lower() == self.trac_smtp_from.lower():
357            if self.email_name:
358                self.author = self.email_name
359            else:
360                self.author = "email2trac"
361        else:
362            self.author = self.email_addr
363
364        if self.parameters.ignore_trac_user_settings:
365            return
366
367        # Is this a registered user, use email address as search key:
368        # result:
369        #   u : login name
370        #   n : Name that the user has set in the settings tab
371        #   e : email address that the user has set in the settings tab
372        #
373        users = [ (u,n,e) for (u, n, e) in self.env.get_known_users(self.db)
374            if (
375                (e and (e.lower() == self.email_addr.lower())) or
376                (u.lower() + '@' + self.smtp_default_domain.lower() == self.email_addr.lower())
377            )
378            ]
379
380        if len(users) >= 1:
381            self.email_from = users[0][2]
382            self.author = users[0][0]
383
384            self.logger.debug('\tget_sender_info: found registered user: %s' %(self.author) )
385
386            if self.parameters.white_list_registered_users:
387                self.logger.debug('\tget_sender_info: added registered user to white list')
388                self.allow_registered_user = True
389                   
390
391    def set_cc_fields(self, ticket, message, update=False):
392        """
393        Set all the right fields for a new ticket
394        """
395        self.logger.debug('function set_cc_fields')
396
397
398        # Put all CC-addresses in ticket CC field
399        #
400        if self.parameters.reply_all:
401
402            email_cc = ''
403
404            cc_addrs = email.Utils.getaddresses( message.get_all('cc', []) )
405
406            if not cc_addrs:
407                return
408
409            ## Build a list of forbidden CC addresses
410            #
411            #to_addrs = email.Utils.getaddresses( message.get_all('to', []) )
412            #to_list = list()
413            #for n,e in to_addrs:
414            #   to_list.append(e)
415
416            if update:
417                self.logger.debug("\tupdate cc-fields")
418
419            for name,addr in cc_addrs:
420       
421                ## Prevent mail loop
422                #
423                if addr == self.trac_smtp_from:
424                    self.logger.debug("\tSkipping %s email address for CC-field, same as smtp_from address in trac.ini " %(addr))
425                    continue
426
427                ## Alwyas remove reporter email address
428                #
429                elif addr == self.email_addr:
430                    self.logger.debug("\tSkipping reporter email address for CC-field")
431                    continue
432
433                else:
434                    if email_cc:
435                        email_cc = '%s, %s' %(email_cc, addr)
436                    else:
437                        email_cc = addr
438
439            if email_cc:
440                self.logger.debug('\tCC fields: %s' %email_cc)
441                ticket['cc'] = self.email_to_unicode(email_cc)
442
443
444    def acl_list_from_file(self, f, keyword):
445        """
446        Read the email address from a file
447        """
448        self.logger.debug('function acl_list_from_file %s : %s' %(f, keyword))
449       
450        if  not os.path.isfile(f):
451            self.logger.error('%s_file: %s does not exists' %(keyword, f) )
452        else:
453            ## read whole file and replace '\n' with ''
454            #
455            addr_l = open(f, 'r').readlines()
456            s = ','.join(addr_l).replace('\n','')
457
458            try:
459                self.parameters[keyword] = "%s,%s" %(self.parameters[keyword], s)
460            except KeyError, detail:
461                self.parameters[keyword] = s
462
463########## DEBUG functions  ###########################################################
464
465    def debug_body(self, message_body, temp_file):
466        """
467        """
468        self.logger.debug('function debug_body:')
469
470        body_file = "%s.body" %(temp_file)
471        fx = open(body_file, 'wb')
472
473        if self.parameters.dry_run:
474            self.logger.info('DRY-RUN: not saving body to %s' %(body_file))
475            return
476
477        self.logger.debug('writing body to %s' %(body_file))
478        if not message_body:
479                message_body = '(None)'
480
481        message_body = message_body.encode('utf-8')
482
483        fx.write(message_body)
484        fx.close()
485        try:
486            os.chmod(body_file,S_IRWXU|S_IRWXG|S_IRWXO)
487        except OSError:
488            pass
489
490    def debug_attachments(self, message_parts, temp_file):
491        """
492        """
493        self.logger.debug('function debug_attachments')
494       
495        n = 0
496        for item in message_parts:
497
498            ## Skip inline text parts
499            #
500            if not isinstance(item, tuple):
501                continue
502               
503            (original, filename, part) = item
504            self.logger.debug('\t part%d: Content-Type: %s' % (n, part.get_content_type()) )
505
506            dummy_filename, ext = os.path.splitext(filename)
507
508            n = n + 1
509            part_name = 'part%d%s' %(n, ext)
510            part_file = "%s.%s" %(temp_file, part_name)
511
512            s = 'writing %s: filename: %s' %(part_file, filename)
513            self.print_unicode(s)
514
515            ## Forbidden chars, just write part files instead of names
516            #
517            #filename = filename.replace('\\', '_')
518            #filename = filename.replace('/', '_')
519            #filename = filename + '.att_email2trac'
520            # part_file = os.path.join(self.parameters.tmpdir, filename)
521            #part_file = util.text.unicode_quote(part_file)
522            #self.print_unicode(part_file)
523
524            if self.parameters.dry_run:
525                self.logger.info('DRY_RUN: NOT saving attachments')
526                continue
527
528            fx = open(part_file, 'wb')
529            text = part.get_payload(decode=1)
530
531            if not text:
532                text = '(None)'
533
534            fx.write(text)
535            fx.close()
536
537            try:
538                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
539            except OSError:
540                pass
541
542    def save_email_for_debug(self, message, project_name, create_tempfile=False):
543
544        if self.parameters.dry_run:
545            self.logger.debug('DRY_RUN: NOT saving email message')
546            return
547
548        if create_tempfile:
549            (fd, tmp_file) = tempfile.mkstemp('.%s.email2trac' %project_name)
550            fx = os.fdopen(fd, 'wb')
551        else:
552            tmp_file = os.path.join(self.parameters.tmpdir, 'msg.%s.email2trac' %project_name)
553            fx = open(tmp_file, 'wb')
554
555
556        self.logger.debug('saving email to %s' %(tmp_file))
557        fx.write('%s' % message)
558        fx.close()
559       
560        try:
561            os.chmod(tmp_file, S_IRWXU|S_IRWXG|S_IRWXO)
562        except OSError:
563            pass
564
565        message_parts = self.get_message_parts(message)
566        message_parts = self.unique_attachment_names(message_parts)
567        body_text = self.get_body_text(message_parts)
568        self.debug_body(body_text, tmp_file)
569        self.debug_attachments(message_parts, tmp_file)
570
571########## Conversion functions  ###########################################################
572
573    def email_to_unicode(self, message_str):
574        """
575        Email has 7 bit ASCII code, convert it to unicode with the charset
576        that is encoded in 7-bit ASCII code and encode it as utf-8 so Trac
577        understands it.
578        """
579        self.logger.debug("function email_to_unicode")
580
581        results =  email.Header.decode_header(message_str)
582
583        s = None
584        for text,format in results:
585            if format:
586                try:
587                    temp = unicode(text, format)
588                except UnicodeError, detail:
589                    # This always works
590                    #
591                    msg = 'ERROR: Could not find charset: %s, please install' %format
592                    self.logger.error(msg)
593                    temp = unicode(text, 'iso-8859-15')
594                except LookupError, detail:
595                    msg = 'ERROR: Could not find charset: %s, please install' %format
596                    self.logger.error(msg)
597                    #temp = unicode(text, 'iso-8859-15')
598                    temp = message_str
599                       
600            else:
601                temp = string.strip(text)
602                temp = unicode(text, 'iso-8859-15')
603
604            if s:
605                s = '%s %s' %(s, temp)
606            else:
607                s = '%s' %temp
608
609        return s
610
611    def str_to_dict(self, s):
612        """
613        Transfrom a string of the form [<key>=<value>]+ to dict[<key>] = <value>
614        """
615        self.logger.debug("function str_to_dict")
616
617        fields = string.split(s, self.parameters.subject_field_separator)
618
619        result = dict()
620        for field in fields:
621            try:
622                index, value = string.split(field, '=')
623
624                # We can not change the description of a ticket via the subject
625                # line. The description is the body of the email
626                #
627                if index.lower() in ['description']:
628                    continue
629
630                if value:
631                    result[index.lower()] = value
632
633            except ValueError:
634                pass
635        return result
636
637    def print_unicode(self,s):
638        """
639        This function prints unicode strings if possible else it will quote it
640        """
641        try:
642            self.logger.debug(s)
643        except UnicodeEncodeError, detail:
644            self.logger.debug(util.text.unicode_quote(s))
645
646
647    def html_2_txt(self, data):
648        """
649        Various routines to convert html syntax to valid trac wiki syntax
650        """
651        self.logger.debug('function html_2_txt')
652
653        ## This routine make an safe html that can be include
654        #  in trac, but no further text processing can be done
655        #
656#       try:
657#           from lxml.html.clean import Cleaner
658#           tags_rm = list()
659#           tags_rm.append('body')
660#
661#           cleaner = Cleaner(remove_tags=tags_rm )
662#           parsed_data = cleaner.clean_html(data)
663#           parsed_data = '\n{{{\n#!html\n' + parsed_data + '\n}}}\n'
664#
665#           return parsed_data
666#           
667#       except ImportError::
668#           pass
669
670        parsed_data = None
671        if self.parameters.html2text_cmd:
672            (fd, tmp_file) = tempfile.mkstemp('email2trac.html')
673            f = os.fdopen(fd, 'w')
674
675            cmd = '%s %s' %(self.parameters.html2text_cmd, tmp_file)
676            self.logger.debug('\t html2text conversion %s'%(cmd))
677   
678            if self.parameters.dry_run:
679                self.logger.info('DRY_RUN: html2text conversion command: %s\n' %(cmd))
680
681            else:
682                f.write(data)
683                f.close()
684
685                lines = os.popen(cmd).readlines()
686                parsed_data =  ''.join(lines)
687
688                os.unlink(tmp_file)
689
690        else:
691            self.logger.debug('\t No html2text conversion tool specified in email2trac.conf')
692
693        return parsed_data
694
695    def check_filename_length(self, name):
696        """
697        To bypass a bug in Trac
698        check if the filename lenght is not larger then OS limit.
699          yes : return truncated filename
700          no  : return unmodified filename
701        """
702        self.logger.debug('function check_filename_length: ')
703
704        if not name:
705            return 'None'
706       
707        dummy_filename, ext = os.path.splitext(name)
708
709        ## Trac uses this format
710        #
711        try:
712            quote_format = util.text.unicode_quote(dummy_filename)
713
714        except UnicodeDecodeError, detail:
715            ## last resort convert to unicode
716            #
717            dummy_filename = util.text.to_unicode(dummy_filename)
718            quote_format = util.text.unicode_quote(dummy_filename)
719
720        ## Determine max filename lenght
721        #
722        try:
723            filemax_length = os.pathconf('/', 'PC_NAME_MAX')
724        except AttributeError, detail:
725            filemax_lenght = 240
726
727        if len(quote_format) <= filemax_length:
728            return name
729
730        else:
731            ## Truncate file to filemax_length and reserve room for extension
732            #  We must break on a boundry
733            #
734            length = filemax_length - 6
735
736            for i in range(0,10):
737
738                truncated = quote_format[ : (length - i)]
739
740                try:
741                    unqoute_truncated = util.text.unicode_unquote(truncated)
742                    unqoute_truncated = unqoute_truncated + ext
743
744                    self.print_unicode('\t ' + unqoute_truncated)
745
746                    break
747
748                except UnicodeDecodeError, detail:
749                    continue
750
751
752            return unqoute_truncated
753
754########## TRAC ticket functions  ###########################################################
755
756    def mail_workflow(self, tkt):
757        """
758        """
759        self.logger.debug('function mail_workflow: ')
760       
761        req = Mock(authname=self.author, perm=MockPerm(), args={})
762        ticket_system = TicketSystem(self.env)
763       
764        try:
765            workflow = self.parameters['workflow_%s' %tkt['status'].lower()]
766
767        except KeyError:
768            ## fallback for compability (Will be deprecated)
769            #  workflow can be none.
770            #
771            workflow = None
772            if tkt['status'] in ['closed']:
773                workflow = self.parameters.workflow
774
775        if workflow:
776
777            ## process all workflow implementations
778            #
779            tkt_module = TicketModule(self.env)
780            field_changes, problems = tkt_module.get_ticket_changes(req, tkt, workflow)
781
782            for field in field_changes.keys():
783
784                ## We have already processed these fields
785                #
786                if not field in ['summary', 'description']:
787                    s = 'workflow : %s, field %s : %s, by %s' \
788                       %(workflow, field, field_changes[field]['new'],field_changes[field]['by'] )
789                    self.logger.debug(s)
790
791                    tkt[field] = field_changes[field]['new']
792
793            return True
794
795        else:
796            return False
797
798    def check_permission_participants(self, tkt, action):
799        """
800        Check if the mailer is allowed to update the ticket
801        """
802        self.logger.debug('function check_permission_participants %s')
803
804        if tkt['reporter'].lower() in [self.author.lower(), self.email_addr.lower()]:
805            self.logger.debug('ALLOW, %s is the ticket reporter' %(self.email_addr))
806
807            return True
808
809        perm = PermissionSystem(self.env)
810        if perm.check_permission(action, self.author):
811            self.logger.debug('ALLOW, %s has trac permission to update the ticket' %(self.author))
812
813            return True
814       
815        # Is the updater in the CC?
816        try:
817            cc_list = tkt['cc'].split(',')
818            for cc in cc_list:
819                if self.email_addr.lower() in cc.lower.strip():
820                    self.logger.debug('ALLOW, %s is in the CC' %(self.email_addr))
821
822                    return True
823
824        except KeyError:
825            pass
826
827        return False
828
829    def check_permission(self, tkt, action):
830        """
831        check if the reporter has the right permission for the action:
832          - TICKET_CREATE
833          - TICKET_MODIFY
834          - TICKET_APPEND
835          - TICKET_CHGPROP
836
837        There are three models:
838            - None      : no checking at all
839            - trac      : check the permission via trac permission model
840            - email2trac: ....
841        """
842        self.logger.debug("function check_permission: %s" %(action))
843
844        if self.parameters.ticket_permission_system in ['trac']:
845
846            perm = PermissionCache(self.env, self.author)
847            if perm.has_permission(action):
848                return True
849            else:
850                return False
851
852        elif self.parameters.ticket_permission_system in ['update_restricted_to_participants']:
853            return (self.check_permission_participants(tkt, action))   
854
855        ## Default is to allow everybody ticket updates and ticket creation
856        #
857        else:
858                return True
859
860
861    def update_ticket_fields(self, ticket, user_dict, new=None):
862        """
863        This will update the ticket fields. It will check if the
864        given fields are known and if the right values are specified
865        It will only update the ticket field value:
866            - If the field is known
867            - If the value supplied is valid for the ticket field.
868              If not then there are two options:
869               1) Skip the value (new=None)
870               2) Set default value for field (new=1)
871        """
872        self.logger.debug("function update_ticket_fields")
873
874        ## Check only permission model on ticket updates
875        #
876        if not new:
877            if self.parameters.ticket_permission_system:
878                if not self.check_permission(ticket, 'TICKET_CHGPROP'):
879                    self.logger.info('Reporter: %s has no permission to change ticket properties' %self.author)
880                    return False
881
882        ## Build a system dictionary from the ticket fields
883        #  with field as index and option as value
884        #
885        sys_dict = dict()
886
887        for field in ticket.fields:
888
889            try:
890                sys_dict[field['name']] = field['options']
891
892            except KeyError:
893                sys_dict[field['name']] = None
894                pass
895
896        ## Check user supplied fields an compare them with the
897        #  system one's
898        #
899        for field,value in user_dict.items():
900       
901            s = 'user_field\t %s = %s' %(field,value)
902            self.print_unicode(s)
903
904            if not field in sys_dict.keys(): 
905                self.logger.debug('%s is not a valid field for tickets' %(field))
906                continue
907
908            ## To prevent mail loop
909            #
910            if field == 'cc':
911
912                cc_list = user_dict['cc'].split(',')
913
914                if self.trac_smtp_from in cc_list:
915                    self.logger.debug('MAIL LOOP: %s is not allowed as CC address' %(self.trac_smtp_from))
916
917                    cc_list.remove(self.trac_smtp_from)
918
919                value = ','.join(cc_list)
920               
921
922            ## Check if every value is allowed for this field
923            #
924            if sys_dict[field]:
925
926                if value in sys_dict[field]:
927                    ticket[field] = value
928                else:
929                    ## Must we set a default if value is not allowed
930                    #
931                    if new:
932                        value = self.get_config('ticket', 'default_%s' %(field) )
933
934            else:
935
936                ticket[field] = value
937
938            s = 'ticket_field\t %s = %s' %(field,  ticket[field])
939            self.print_unicode(s)
940
941    def ticket_update(self, m, id, spam):
942        """
943        If the current email is a reply to an existing ticket, this function
944        will append the contents of this email to that ticket, instead of
945        creating a new one.
946        """
947        self.logger.debug("function ticket_update")
948
949        if not self.parameters.ticket_update:
950            self.logger.debug("ticket_update disabled")
951            return False
952
953        ## Must we update ticket fields
954        #
955        update_fields = dict()
956        try:
957            id, keywords = string.split(id, '?')
958
959            update_fields = self.str_to_dict(keywords)
960
961            ## Strip '#'
962            #
963            self.id = int(id[1:])
964
965        except ValueError:
966
967            ## Strip '#'
968            #
969            self.id = int(id[1:])
970
971        self.logger.debug("\tticket id: %s" %id)
972
973        ## When is the change committed
974        #
975        if self.VERSION < 0.11:
976            when = int(time.time())
977        else:
978            when = datetime.now(util.datefmt.utc)
979
980        try:
981            tkt = Ticket(self.env, self.id, self.db)
982
983        except util.TracError, detail:
984
985            ## Not a valid ticket
986            #
987            self.logger.info("\tCreating a new ticket, ticket id: %s does not exists" %id)
988            self.id = None
989            return False
990
991        ## Check the permission of the reporter
992        #
993        if self.parameters.ticket_permission_system:
994            if not self.check_permission(tkt, 'TICKET_APPEND'):
995                self.logger.info('Reporter: %s has no permission to add comments or attachments to tickets' %self.author)
996                return False
997
998        ## How many changes has this ticket
999        #
1000        grouped = TicketModule(self.env).grouped_changelog_entries(tkt, self.db)
1001        cnum = sum(1 for e in grouped) + 1
1002
1003
1004        ## reopen the ticket if it is was closed
1005        #  We must use the ticket workflow framework
1006        #
1007        if self.parameters.email_triggers_workflow and (self.VERSION >= 0.11):
1008
1009            if not self.mail_workflow(tkt):
1010
1011                if tkt['status'] in ['closed']:
1012                    tkt['status'] = 'reopened'
1013                    tkt['resolution'] = ''
1014
1015        ## Old pre 0.11 situation
1016        #
1017        elif self.parameters.email_triggers_workflow:
1018
1019            self.logger.debug('\temail triggers workflow pre trac 0.11')
1020
1021            if tkt['status'] in ['closed']:
1022                tkt['status'] = 'reopened'
1023                tkt['resolution'] = ''
1024
1025        else:
1026            self.logger.debug('\temail triggers workflow disabled')
1027
1028        ## Must we update some ticket fields properties via subject line
1029        #
1030        if update_fields:
1031            self.update_ticket_fields(tkt, update_fields)
1032
1033
1034        message_parts = self.get_message_parts(m)
1035        message_parts = self.unique_attachment_names(message_parts)
1036
1037        ## Must we update some ticket fields properties via inline comments
1038        # in body_text
1039        #
1040        if self.properties:
1041                self.update_ticket_fields(tkt, self.properties)
1042
1043        ## Must we update the CC ticket field
1044        #
1045        self.set_cc_fields(tkt, m, update=True)
1046
1047        if self.parameters.email_header:
1048            message_parts.insert(0, self.email_header_txt(m))
1049
1050        body_text = self.get_body_text(message_parts)
1051
1052        error_with_attachments = self.attach_attachments(message_parts)
1053
1054        if body_text.strip() or update_fields or self.properties:
1055
1056            if self.parameters.dry_run:
1057                s = 'DRY_RUN: tkt.save_changes(self.author, body_text, ticket_change_number) %s %s' %(self.author, cnum)
1058                self.logger.info(s)
1059
1060            else:
1061                if error_with_attachments:
1062                    body_text = '%s\\%s' %(error_with_attachments, body_text)
1063
1064                self.logger.debug('\ttkt.save_changes(%s, %d)' %(self.author, cnum))
1065                tkt.save_changes(self.author, body_text, when, None, str(cnum))
1066           
1067        if not spam:
1068            self.notify(tkt, False, when)
1069
1070        return True
1071
1072    def set_ticket_fields(self, ticket):
1073        """
1074        set the ticket fields to value specified
1075            - /etc/email2trac.conf with <prefix>_<field>
1076            - trac default values, trac.ini
1077        """
1078        self.logger.debug('function set_ticket_fields')
1079
1080        user_dict = dict()
1081
1082        for field in ticket.fields:
1083
1084            name = field['name']
1085
1086            ## default trac value
1087            #
1088            if not field.get('custom'):
1089                value = self.get_config('ticket', 'default_%s' %(name) )
1090
1091                ## skip this field can only be set by email2trac.conf
1092                #
1093                if name in ['resolution']:
1094                    value = None
1095
1096            else:
1097                ##  Else get the default value for reporter
1098                #
1099                value = field.get('value')
1100                options = field.get('options')
1101
1102                if value and options and (value not in options):
1103                     value = options[int(value)]
1104   
1105            s = 'trac[%s] = %s' %(name, value)
1106            self.print_unicode(s)
1107
1108            ## email2trac.conf settings
1109            #
1110            prefix = self.parameters.ticket_prefix
1111            try:
1112                value = self.parameters['%s_%s' %(prefix, name)]
1113
1114                s = 'email2trac[%s] = %s ' %(name, value)
1115                self.print_unicode(s)
1116
1117            except KeyError, detail:
1118                pass
1119       
1120            if value:
1121                user_dict[name] = value
1122
1123                s = 'used %s = %s' %(name, value)
1124                self.print_unicode(s)
1125
1126        self.update_ticket_fields(ticket, user_dict, new=1)
1127
1128        if 'status' not in user_dict.keys():
1129            ticket['status'] = 'new'
1130
1131
1132    def ticket_update_by_subject(self, subject):
1133        """
1134        This list of Re: prefixes is probably incomplete. Taken from
1135        wikipedia. Here is how the subject is matched
1136          - Re: <subject>
1137          - Re: (<Mail list label>:)+ <subject>
1138
1139        So we must have the last column
1140        """
1141        self.logger.debug('function ticket_update_by_subject')
1142
1143        found_id = None
1144        if self.parameters.ticket_update and self.parameters.ticket_update_by_subject:
1145               
1146            SUBJECT_RE = re.compile(r'^(?:(?:RE|AW|VS|SV|FW|FWD):\s*)+(.*)', re.IGNORECASE)
1147            result = SUBJECT_RE.search(subject)
1148
1149            if result:
1150                ## This is a reply
1151                #
1152                orig_subject = result.group(1)
1153
1154                self.logger.debug('subject search string: %s' %(orig_subject))
1155
1156                cursor = self.db.cursor()
1157                summaries = [orig_subject, '%%: %s' % orig_subject]
1158
1159                ## Time resolution is in micoseconds
1160                #
1161                search_date = datetime.now(util.datefmt.utc) - timedelta(days=self.parameters.ticket_update_by_subject_lookback)
1162
1163                if self.VERSION < 0.12:
1164                    lookback = util.datefmt.to_timestamp(search_date)
1165                else:
1166                    lookback = util.datefmt.to_utimestamp(search_date)
1167
1168                for summary in summaries:
1169                    self.logger.debug('Looking for summary matching: "%s"' % summary)
1170
1171                    sql = """SELECT id, reporter FROM ticket
1172                            WHERE changetime >= %s AND summary LIKE %s
1173                            ORDER BY changetime DESC"""
1174
1175                    cursor.execute(sql, [lookback, summary.strip()])
1176
1177                    for row in cursor:
1178
1179                        (matched_id, sql_reporter) = row
1180
1181                        ## Save first entry.
1182                        #
1183                        if not found_id:
1184                            found_id = matched_id
1185                           
1186                        ## If subject and reporter are the same. The we certainly have found the right ticket
1187                        #
1188                        if sql_reporter == self.author:
1189                            self.logger.debug('Found matching reporter: %s with ticket id: %d' %(sql_reporter, matched_id))
1190                            found_id = matched_id
1191                            break
1192
1193                    if found_id:
1194                        self.logger.debug('Found matching ticket id: %d' % found_id)
1195                        found_id = '#%d' % found_id
1196                        return (found_id, orig_subject)
1197                   
1198                    # obsolete !?? 12 Aug 2011
1199                    #subject = orig_subject
1200
1201        return (found_id, subject)
1202
1203
1204    def new_ticket(self, msg, subject, spam, set_fields = None):
1205        """
1206        Create a new ticket
1207        """
1208        self.logger.debug('function new_ticket')
1209
1210        tkt = Ticket(self.env)
1211
1212        ## self.author can be email address of an username
1213        #
1214        tkt['reporter'] = self.author
1215
1216        self.set_cc_fields(tkt, msg)
1217
1218        self.set_ticket_fields(tkt)
1219
1220        ## Check the permission of the reporter
1221        #
1222        if self.parameters.ticket_permission_system:
1223            if not self.check_permission(tkt, 'TICKET_CREATE'):
1224                self.logger.info('Reporter: %s has no permission to create tickets' %self.author)
1225                return False
1226
1227        ## Old style setting for component, will be removed
1228        #
1229        if spam:
1230            tkt['component'] = 'Spam'
1231
1232        elif self.parameters.has_key('component'):
1233            tkt['component'] = self.parameters['component']
1234
1235        if not msg['Subject']:
1236            tkt['summary'] = u'(No subject)'
1237        else:
1238            tkt['summary'] = subject
1239
1240
1241        if set_fields:
1242            rest, keywords = string.split(set_fields, '?')
1243
1244            if keywords:
1245                update_fields = self.str_to_dict(keywords)
1246                self.update_ticket_fields(tkt, update_fields)
1247
1248
1249        message_parts = self.get_message_parts(msg, True)
1250
1251        ## Must we update some ticket fields properties via body_text
1252        #
1253        if self.properties:
1254                self.update_ticket_fields(tkt, self.properties)
1255
1256        message_parts = self.unique_attachment_names(message_parts)
1257       
1258        ## produce e-mail like header
1259        #
1260        head = ''
1261        if self.parameters.email_header:
1262            head = self.email_header_txt(msg)
1263            message_parts.insert(0, head)
1264           
1265        body_text = self.get_body_text(message_parts)
1266
1267        tkt['description'] = body_text
1268
1269        ## When is the change committed
1270        #
1271        if self.VERSION < 0.11:
1272            when = int(time.time())
1273        else:
1274            when = datetime.now(util.datefmt.utc)
1275
1276        if self.parameters.dry_run:
1277            self.logger.info('DRY_RUN: tkt.insert()')
1278        else:
1279            self.id = tkt.insert()
1280   
1281        changed = False
1282        comment = ''
1283
1284        ## some routines in trac are dependend on ticket id
1285        #  like alternate notify template
1286        #
1287        if self.parameters.alternate_notify_template:
1288            tkt['id'] = self.id
1289            changed = True
1290
1291        ## Rewrite the description if we have mailto enabled
1292        #
1293        if self.parameters.mailto_link:
1294            changed = True
1295            comment = u'\nadded mailto line\n'
1296
1297            #mailto = self.html_mailto_link( m['Subject'])
1298            mailto = self.html_mailto_link(subject)
1299
1300            tkt['description'] = u'%s\r\n%s%s\r\n' \
1301                %(head, mailto, body_text)
1302   
1303        ## Save the attachments to the ticket   
1304        #
1305        error_with_attachments =  self.attach_attachments(message_parts)
1306
1307        if error_with_attachments:
1308            changed = True
1309            comment = '%s\n%s\n' %(comment, error_with_attachments)
1310
1311        if self.parameters.email_triggers_workflow and (self.VERSION >= 0.11):
1312            if self.mail_workflow(tkt):
1313                changed = True
1314
1315        if changed:
1316            if self.parameters.dry_run:
1317                s = 'DRY_RUN: tkt.save_changes(%s, comment) real reporter = %s' %( tkt['reporter'], self.author)
1318                self.logger.info(s)
1319
1320            else:
1321                tkt.save_changes(tkt['reporter'], comment)
1322
1323        if not spam:
1324            self.notify(tkt, True)
1325
1326
1327    def attach_attachments(self, message_parts, update=False):
1328        '''
1329        save any attachments as files in the ticket's directory
1330        '''
1331        self.logger.debug('function attach_attachments()')
1332
1333        if self.parameters.dry_run:
1334            self.logger.debug("DRY_RUN: no attachments attached to tickets")
1335            return ''
1336
1337        count = 0
1338
1339        ## Get Maxium attachment size
1340        #
1341        max_size = int(self.get_config('attachment', 'max_size'))
1342        status   = None
1343       
1344        for item in message_parts:
1345            ## Skip body parts
1346            #
1347            if not isinstance(item, tuple):
1348                continue
1349               
1350            (original, filename, part) = item
1351
1352            ## We have to determine the size so we use this temporary solution.
1353            #
1354            path, fd =  util.create_unique_file(os.path.join(self.parameters.tmpdir, 'email2trac_tmp.att'))
1355            text = part.get_payload(decode=1)
1356            if not text:
1357                text = '(None)'
1358            fd.write(text)
1359            fd.close()
1360
1361            ## get the file_size
1362            #
1363            stats = os.lstat(path)
1364            file_size = stats[ST_SIZE]
1365
1366            ## Check if the attachment size is allowed
1367            #
1368            if (max_size != -1) and (file_size > max_size):
1369                status = '%s\nFile %s is larger then allowed attachment size (%d > %d)\n\n' \
1370                    %(status, original, file_size, max_size)
1371
1372                os.unlink(path)
1373                continue
1374            else:
1375                count = count + 1
1376                   
1377            ## Insert the attachment
1378            #
1379            fd = open(path, 'rb')
1380            if self.system == 'discussion':
1381                att = attachment.Attachment(self.env, 'discussion', 'topic/%s' % (self.id,))
1382            elif self.system == 'blog':
1383                att = attachment.Attachment(self.env, 'blog', '%s' % (self.id,))
1384            else:
1385                s = 'Attach %s to ticket %d' %(filename, self.id)
1386                self.print_unicode(s)
1387                att = attachment.Attachment(self.env, 'ticket', self.id)
1388 
1389            ## This will break the ticket_update system, the body_text is vaporized
1390            #  ;-(
1391            #
1392            if not update:
1393                att.author = self.author
1394                att.description = self.email_to_unicode('Added by email2trac')
1395
1396            try:
1397
1398                self.logger.debug('Insert atachment')
1399                att.insert(filename, fd, file_size)
1400
1401            except OSError, detail:
1402
1403                self.logger.info('%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail))
1404                status = '%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail)
1405
1406            ## Remove the created temporary filename
1407            #
1408            fd.close()
1409            os.unlink(path)
1410
1411        ## return error
1412        #
1413        return status
1414
1415########## Fullblog functions  #################################################
1416
1417    def blog(self, msg, subject, id, params):
1418        """
1419        The blog create/update function
1420        """
1421        ## import the modules
1422        #
1423        from tracfullblog.core import FullBlogCore
1424        from tracfullblog.model import BlogPost, BlogComment
1425
1426        ## instantiate blog core
1427        #
1428        blog = FullBlogCore(self.env)
1429        req = Mock(authname='anonymous', perm=MockPerm(), args={})
1430
1431        ## parameters from the subject
1432        #
1433        params = self.str_to_dict((params or '').lstrip('?'))
1434
1435        ## preferably get the time from the email date header, or else
1436        #  use current date and time
1437        date = email.Utils.parsedate_tz(msg.get('date'))
1438        if date:
1439            dt = util.datefmt.to_datetime(email.Utils.mktime_tz(date), util.datefmt.utc)
1440        else:
1441            self.logger.warn("No valid date header found")
1442            dt = util.datefmt.to_datetime(None, util.datefmt.utc)
1443
1444        ## blog entry affected
1445        #
1446        self.id = id or util.datefmt.format_datetime(dt, "%Y%m%d%H%M%S", util.datefmt.utc)
1447
1448        ## check wether a blog post exists
1449        #
1450        post = BlogPost(self.env, self.id)
1451        force_update = self.properties.get('update', params.get('update'))
1452
1453        ## message parts
1454        #
1455        message_parts = self.get_message_parts(msg)
1456        message_parts = self.unique_attachment_names(message_parts)
1457
1458        if post.get_versions() and not force_update:
1459
1460            ## add comment to blog entry
1461            #
1462            comment = BlogComment(self.env, self.id)
1463            comment.author = self.properties.get('author', params.get('author', self.author))
1464            comment.comment = self.get_body_text(message_parts)
1465            comment.time = dt
1466
1467            if self.parameters.dry_run:
1468                self.logger.info('DRY-RUN: not adding comment for blog entry "%s"' % id)
1469                return
1470            warnings = blog.create_comment(req, comment)
1471
1472        else:
1473            ## create or update blog entry
1474            #
1475            post.author = self.properties.get('author', params.get('author', self.author))
1476            post.categories = self.properties.get('categories', params.get('categories', ''))
1477            post.title = subject.strip()
1478            post.publish_time = dt
1479            post.body = self.get_body_text(message_parts)
1480           
1481            if self.parameters.dry_run:
1482                self.logger.info('DRY-RUN: not creating blog entry "%s"' % post.title)
1483                return
1484            warnings = blog.create_post(req, post, self.author, u'Created by email2trac', False)
1485
1486        ## check for problems
1487        #
1488        if warnings:
1489            raise TracError(', '.join('blog:%s:%s' % (w[0], w[1]) for w in warnings))
1490       
1491        ## all seems well, attach attachments
1492        #
1493        self.attach_attachments(message_parts)
1494
1495
1496########## Discussion functions  ##############################################
1497
1498    def discussion_topic(self, content, subject):
1499
1500        ## Import modules.
1501        #
1502        from tracdiscussion.api import DiscussionApi
1503        from trac.util.datefmt import to_timestamp, utc
1504
1505        self.logger.debug('Creating a new topic in forum:', self.id)
1506
1507        ## Get dissussion API component.
1508        #
1509        api = self.env[DiscussionApi]
1510        context = self._create_context(content, subject)
1511
1512        ## Get forum for new topic.
1513        #
1514        forum = api.get_forum(context, self.id)
1515
1516        if not forum:
1517            self.logger.error("ERROR: Replied forum doesn't exist")
1518
1519        ## Prepare topic.
1520        #
1521        topic = {'forum' : forum['id'],
1522                 'subject' : context.subject,
1523                 'time': to_timestamp(datetime.now(utc)),
1524                 'author' : self.author,
1525                 'subscribers' : [self.email_addr],
1526                 'body' : self.get_body_text(context.content_parts)}
1527
1528        ## Add topic to DB and commit it.
1529        #
1530        self._add_topic(api, context, topic)
1531        self.db.commit()
1532
1533    def discussion_topic_reply(self, content, subject):
1534
1535        ## Import modules.
1536        #
1537        from tracdiscussion.api import DiscussionApi
1538        from trac.util.datefmt import to_timestamp, utc
1539
1540        self.logger.debug('Replying to discussion topic', self.id)
1541
1542        ## Get dissussion API component.
1543        #
1544        api = self.env[DiscussionApi]
1545        context = self._create_context(content, subject)
1546
1547        ## Get replied topic.
1548        #
1549        topic = api.get_topic(context, self.id)
1550
1551        if not topic:
1552            self.logger.error("ERROR: Replied topic doesn't exist")
1553
1554        ## Prepare message.
1555        #
1556        message = {'forum' : topic['forum'],
1557                   'topic' : topic['id'],
1558                   'replyto' : -1,
1559                   'time' : to_timestamp(datetime.now(utc)),
1560                   'author' : self.author,
1561                   'body' : self.get_body_text(context.content_parts)}
1562
1563        ## Add message to DB and commit it.
1564        #
1565        self._add_message(api, context, message)
1566        self.db.commit()
1567
1568    def discussion_message_reply(self, content, subject):
1569
1570        ## Import modules.
1571        #
1572        from tracdiscussion.api import DiscussionApi
1573        from trac.util.datefmt import to_timestamp, utc
1574
1575        self.logger.debug('Replying to discussion message', self.id)
1576
1577        ## Get dissussion API component.
1578        #
1579        api = self.env[DiscussionApi]
1580        context = self._create_context(content, subject)
1581
1582        ## Get replied message.
1583        #
1584        message = api.get_message(context, self.id)
1585
1586        if not message:
1587            self.logger.error("ERROR: Replied message doesn't exist")
1588
1589        ## Prepare message.
1590        #
1591        message = {'forum' : message['forum'],
1592                   'topic' : message['topic'],
1593                   'replyto' : message['id'],
1594                   'time' : to_timestamp(datetime.now(utc)),
1595                   'author' : self.author,
1596                   'body' : self.get_body_text(context.content_parts)}
1597
1598        ## Add message to DB and commit it.
1599        #
1600        self._add_message(api, context, message)
1601        self.db.commit()
1602
1603    def _create_context(self, content, subject):
1604
1605        ## Import modules.
1606        #
1607        from trac.mimeview import Context
1608        from trac.web.api import Request
1609        from trac.perm import PermissionCache
1610
1611        ## TODO: Read server base URL from config.
1612        #  Create request object to mockup context creation.
1613        #
1614        environ = {'SERVER_PORT' : 80,
1615                   'SERVER_NAME' : 'test',
1616                   'REQUEST_METHOD' : 'POST',
1617                   'wsgi.url_scheme' : 'http',
1618                   'wsgi.input' : sys.stdin}
1619        chrome =  {'links': {},
1620                   'scripts': [],
1621                   'ctxtnav': [],
1622                   'warnings': [],
1623                   'notices': []}
1624
1625        if self.env.base_url_for_redirect:
1626            environ['trac.base_url'] = self.env.base_url
1627
1628        req = Request(environ, None)
1629        req.chrome = chrome
1630        req.tz = 'missing'
1631        req.authname = self.author
1632        req.perm = PermissionCache(self.env, self.author)
1633        req.locale = None
1634
1635        ## Create and return context.
1636        #
1637        context = Context.from_request(req)
1638        context.realm = 'discussion-email2trac'
1639        context.cursor = self.db.cursor()
1640        context.content = content
1641        context.subject = subject
1642
1643        ## Read content parts from content.
1644        #
1645        context.content_parts = self.get_message_parts(content)
1646        context.content_parts = self.unique_attachment_names(
1647          context.content_parts)
1648
1649        return context
1650
1651    def _add_topic(self, api, context, topic):
1652        context.req.perm.assert_permission('DISCUSSION_APPEND')
1653
1654        ## Filter topic.
1655        #
1656        for discussion_filter in api.discussion_filters:
1657            accept, topic_or_error = discussion_filter.filter_topic(
1658              context, topic)
1659            if accept:
1660                topic = topic_or_error
1661            else:
1662                raise TracError(topic_or_error)
1663
1664        ## Add a new topic.
1665        #
1666        api.add_topic(context, topic)
1667
1668        ## Get inserted topic with new ID.
1669        #
1670        topic = api.get_topic_by_time(context, topic['time'])
1671
1672        ## Attach attachments.
1673        #
1674        self.id = topic['id']
1675        self.attach_attachments(context.content_parts, True)
1676
1677        ## Notify change listeners.
1678        #
1679        for listener in api.topic_change_listeners:
1680            listener.topic_created(context, topic)
1681
1682    def _add_message(self, api, context, message):
1683        context.req.perm.assert_permission('DISCUSSION_APPEND')
1684
1685        ## Filter message.
1686        #
1687        for discussion_filter in api.discussion_filters:
1688            accept, message_or_error = discussion_filter.filter_message(
1689              context, message)
1690            if accept:
1691                message = message_or_error
1692            else:
1693                raise TracError(message_or_error)
1694
1695        ## Add message.
1696        #
1697        api.add_message(context, message)
1698
1699        ## Get inserted message with new ID.
1700        #
1701        message = api.get_message_by_time(context, message['time'])
1702
1703        ## Attach attachments.
1704        #
1705
1706        self.attach_attachments(context.content_parts, True)
1707
1708        ## Notify change listeners.
1709        #
1710        for listener in api.message_change_listeners:
1711            listener.message_created(context, message)
1712
1713########## MAIN function  ######################################################
1714
1715    def parse(self, fp):
1716        """
1717        """
1718        self.logger.debug('Main function parse')
1719        global m
1720
1721        m = email.message_from_file(fp)
1722       
1723        if not m:
1724            self.logger.debug('This is not a valid email message format')
1725            return
1726           
1727        ## Work around lack of header folding in Python; see http://bugs.python.org/issue4696
1728        #
1729        try:
1730            m.replace_header('Subject', m['Subject'].replace('\r', '').replace('\n', ''))
1731        except AttributeError, detail:
1732            pass
1733
1734        if self.parameters.debug:     # save the entire e-mail message text
1735            self.save_email_for_debug(m, self.parameters.project_name, True)
1736
1737        if self.VERSION <= 0.11:
1738            self.db = self.env.get_db_cnx()
1739        else:
1740            self.db = self.env.get_read_db()
1741
1742        self.get_sender_info(m)
1743
1744
1745        if self.parameters.white_list_file:
1746            self.acl_list_from_file(self.parameters.white_list_file, 'white_list')
1747
1748        if not ( self.email_header_acl('white_list', self.email_addr, True) or self.allow_registered_user ) :
1749
1750            self.logger.info('Message rejected : %s not in white list' %(self.email_addr))
1751            return False
1752
1753        if self.email_header_acl('black_list', self.email_addr, False):
1754            self.logger.info('Message rejected : %s in black list' %(self.email_addr))
1755            return False
1756
1757        if not self.email_header_acl('recipient_list', self.email_to_addrs, True):
1758            self.logger.info('Message rejected : %s not in recipient list' %(self.email_to_addrs))
1759            return False
1760
1761        ## If spam drop the message
1762        #
1763        if self.spam(m) == 'drop':
1764            return False
1765
1766        elif self.spam(m) == 'spam':
1767            spam_msg = True
1768        else:
1769            spam_msg = False
1770
1771        if not m['Subject']:
1772            subject  = 'No Subject'
1773        else:
1774            subject  = self.email_to_unicode(m['Subject'])
1775
1776        ## Bug in python logging module <2.6
1777        #
1778        self.logger.info('subject: %s' %repr(subject))
1779
1780        ## Ticket id is in Delivered-To Field. Trac notify must send this
1781        #
1782        #try:
1783        #    print self.parameters.project_name
1784        #    print 'Delivered To', m['Delivered-To']
1785        #    print self.parameters.to_parse_address_delimiter
1786        #    id = m['Delivered-To'].split(self.parameters.to_parse_address_delimiter)[1]
1787        #    print id
1788        #    id = id.split('@')[0]
1789        #    print id
1790#
1791#        except KeyError, detail:
1792#            pass
1793
1794
1795        ## [hic] #1529: Re: LRZ
1796        #  [hic] #1529?owner=bas,priority=medium: Re: LRZ
1797        #
1798        ticket_regex = r'''
1799            (?P<new_fields>[#][?].*)
1800            |(?P<reply>(?P<id>[#][\d]+)(?P<fields>\?.*)?:)
1801            '''
1802        ## Check if  FullBlogPlugin is installed
1803        #
1804        blog_enabled = None
1805        blog_regex = ''
1806        if self.get_config('components', 'tracfullblog.*') in ['enabled']:
1807            self.logger.debug('Trac BLOG support enabled')
1808            blog_enabled = True
1809            blog_regex = '''|(?P<blog>blog(?P<blog_params>[?][^:]*)?:(?P<blog_id>\S*))'''
1810
1811
1812        ## Check if DiscussionPlugin is installed
1813        #
1814        discussion_enabled = None
1815        discussion_regex = ''
1816        if self.get_config('components', 'tracdiscussion.api.discussionapi') in ['enabled']:
1817            self.logger.debug('Trac Discussion support enabled')
1818            discussion_enabled = True
1819            discussion_regex = r'''
1820            |(?P<forum>Forum[ ][#](?P<forum_id>\d+)[ ]-[ ]?)
1821            |(?P<topic>Topic[ ][#](?P<topic_id>\d+)[ ]-[ ]?)
1822            |(?P<message>Message[ ][#](?P<message_id>\d+)[ ]-[ ]?)
1823            '''
1824
1825
1826        regex_str = ticket_regex + blog_regex + discussion_regex
1827        SYSTEM_RE = re.compile(regex_str, re.VERBOSE)
1828
1829        ## Find out if this is a ticket, a blog or a discussion
1830        #
1831        result =  SYSTEM_RE.search(subject)
1832
1833        if result:
1834            ## update ticket + fields
1835            #
1836            if result.group('reply'):
1837                self.system = 'ticket'
1838
1839                ## Skip the last ':' character
1840                #
1841                if not self.ticket_update(m, result.group('reply')[:-1], spam_msg):
1842                    self.new_ticket(m, subject, spam_msg)
1843
1844            ## New ticket + fields
1845            #
1846            elif result.group('new_fields'):
1847                self.system = 'ticket'
1848                self.new_ticket(m, subject[:result.start('new_fields')], spam_msg, result.group('new_fields'))
1849
1850            if blog_enabled:
1851                if result.group('blog'):
1852                    self.system = 'blog'
1853                    self.blog(m, subject[result.end('blog'):], result.group('blog_id'), result.group('blog_params'))
1854
1855            if discussion_enabled:
1856                ## New topic.
1857                #
1858                if result.group('forum'):
1859                    self.system = 'discussion'
1860                    self.id = int(result.group('forum_id'))
1861                    self.discussion_topic(m, subject[result.end('forum'):])
1862
1863                ## Reply to topic.
1864                #
1865                elif result.group('topic'):
1866                    self.system = 'discussion'
1867                    self.id = int(result.group('topic_id'))
1868                    self.discussion_topic_reply(m, subject[result.end('topic'):])
1869
1870                ## Reply to topic message.
1871                #
1872                elif result.group('message'):
1873                    self.system = 'discussion'
1874                    self.id = int(result.group('message_id'))
1875                    self.discussion_message_reply(m, subject[result.end('message'):])
1876
1877        else:
1878
1879            self.system = 'ticket'
1880            (matched_id, subject) = self.ticket_update_by_subject(subject)
1881
1882            if matched_id:
1883
1884                if not self.ticket_update(m, matched_id, spam_msg):
1885                    self.new_ticket(m, subject, spam_msg)
1886
1887            else:
1888                ## No update by subject, so just create a new ticket
1889                #
1890                self.new_ticket(m, subject, spam_msg)
1891
1892
1893########## BODY TEXT functions  ###########################################################
1894
1895    def strip_signature(self, text):
1896        """
1897        Strip signature from message, inspired by Mailman software
1898        """
1899        self.logger.debug('function strip_signature: %s' %self.parameters.strip_signature_regex)
1900
1901        body = []
1902
1903        STRIP_RE = re.compile( self.parameters.strip_signature_regex )
1904        for line in text.splitlines():
1905
1906            match = STRIP_RE.match(line)
1907            if match:
1908                self.logger.debug('\t"%s "  matched, skiping rest of message' %line)
1909                break
1910
1911            body.append(line)
1912
1913        return ('\n'.join(body))
1914
1915    def reflow(self, text, delsp = 0):
1916        """
1917        Reflow the message based on the format="flowed" specification (RFC 3676)
1918        """
1919        flowedlines = []
1920        quotelevel = 0
1921        prevflowed = 0
1922
1923        for line in text.splitlines():
1924            from re import match
1925           
1926            ## Figure out the quote level and the content of the current line
1927            #
1928            m = match('(>*)( ?)(.*)', line)
1929            linequotelevel = len(m.group(1))
1930            line = m.group(3)
1931
1932            ## Determine whether this line is flowed
1933            #
1934            if line and line != '-- ' and line[-1] == ' ':
1935                flowed = 1
1936            else:
1937                flowed = 0
1938
1939            if flowed and delsp and line and line[-1] == ' ':
1940                line = line[:-1]
1941
1942            ## If the previous line is flowed, append this line to it
1943            #
1944            if prevflowed and line != '-- ' and linequotelevel == quotelevel:
1945                flowedlines[-1] += line
1946
1947            ## Otherwise, start a new line
1948            #
1949            else:
1950                flowedlines.append('>' * linequotelevel + line)
1951
1952            prevflowed = flowed
1953           
1954
1955        return '\n'.join(flowedlines)
1956
1957    def strip_quotes(self, text):
1958        """
1959        Strip quotes from message by Nicolas Mendoza
1960        """
1961        self.logger.debug('function strip_quotes: %s' %self.parameters.email_quote)
1962
1963        body = []
1964
1965        STRIP_RE = re.compile( self.parameters.email_quote )
1966
1967        for line in text.splitlines():
1968
1969            try:
1970
1971                match = STRIP_RE.match(line)
1972                if match:
1973                    print line
1974                    continue
1975
1976            except UnicodeDecodeError:
1977
1978                tmp_line = self.email_to_unicode(line)
1979
1980                match = STRIP_RE.match(tmp_line)
1981                if match:
1982                    print line
1983                    continue
1984               
1985            body.append(line)
1986
1987        return ('\n'.join(body))
1988
1989    def inline_properties(self, text):
1990        """
1991        Parse text if we use inline keywords to set ticket fields
1992        """
1993        self.logger.debug('function inline_properties')
1994
1995        properties = dict()
1996        body = list()
1997
1998        INLINE_EXP = re.compile('\s*[@]\s*(\w+)\s*:(.*)$')
1999
2000        for line in text.splitlines():
2001            match = INLINE_EXP.match(line)
2002            if match:
2003                keyword, value = match.groups()
2004
2005                if self.parameters.inline_properties_first_wins:
2006                    if keyword in self.properties.keys():
2007                        continue
2008
2009                self.properties[keyword] = value.strip()
2010                self.logger.debug('\tinline properties: %s : %s' %(keyword,value))
2011
2012            else:
2013                body.append(line)
2014               
2015        return '\n'.join(body)
2016
2017
2018    def wrap_text(self, text, replace_whitespace = False):
2019        """
2020        Will break a lines longer then given length into several small
2021        lines of size given length
2022        """
2023        import textwrap
2024
2025        LINESEPARATOR = '\n'
2026        reformat = ''
2027
2028        for s in text.split(LINESEPARATOR):
2029            tmp = textwrap.fill(s, self.parameters.use_textwrap)
2030            if tmp:
2031                reformat = '%s\n%s' %(reformat,tmp)
2032            else:
2033                reformat = '%s\n' %reformat
2034
2035        return reformat
2036
2037        # Python2.4 and higher
2038        #
2039        #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
2040        #
2041
2042########## EMAIL attachements functions ###########################################################
2043
2044    def inline_part(self, part):
2045        """
2046        """
2047        self.logger.debug('function inline_part()')
2048
2049        return part.get_param('inline', None, 'Content-Disposition') == '' or not part.has_key('Content-Disposition')
2050
2051    def get_message_parts(self, msg, new_email=False):
2052        """
2053        parses the email message and returns a list of body parts and attachments
2054        body parts are returned as strings, attachments are returned as tuples of (filename, Message object)
2055        """
2056        self.logger.debug('function get_message_parts()')
2057
2058        message_parts = list()
2059   
2060        ALTERNATIVE_MULTIPART = False
2061
2062        for part in msg.walk():
2063            content_maintype = part.get_content_maintype()
2064            content_type =  part.get_content_type()
2065
2066            self.logger.debug('\t Message part: Main-Type: %s' % content_maintype)
2067            self.logger.debug('\t Message part: Content-Type: %s' % content_type)
2068
2069            ## Check content type
2070            #
2071            if content_type in self.STRIP_CONTENT_TYPES:
2072                self.logger.debug("\t A %s attachment named '%s' was skipped" %(content_type, part.get_filename()))
2073                continue
2074
2075            ## Catch some mulitpart execptions
2076            #
2077            if content_type == 'multipart/alternative':
2078                ALTERNATIVE_MULTIPART = True
2079                continue
2080
2081            ## Skip multipart containers
2082            #
2083            if content_maintype == 'multipart':
2084                self.logger.debug("\t Skipping multipart container")
2085                continue
2086           
2087            ## Check if this is an inline part. It's inline if there is co Cont-Disp header,
2088            #  or if there is one and it says "inline"
2089            #
2090            inline = self.inline_part(part)
2091
2092            ## Drop HTML message
2093            #
2094            if ALTERNATIVE_MULTIPART and self.parameters.drop_alternative_html_version:
2095
2096                if content_type == 'text/html':
2097                    self.logger.debug('\t Skipping alternative HTML message')
2098                    ALTERNATIVE_MULTIPART = False
2099                    continue
2100
2101            filename = part.get_filename()
2102            s = '\t unicode filename: %s' %(filename)
2103            self.print_unicode(s)
2104            self.logger.debug('\t raw filename: %s' %repr(filename))
2105
2106            filename = self.check_filename_length(filename)
2107
2108            ## Save all non plain text message as attachment
2109            #
2110            if not content_type in ['text/plain']:
2111
2112                message_parts.append( (filename, part) )
2113
2114                ## We only convert html messages
2115                #
2116                if not content_type == 'text/html':
2117                    self.logger.debug('\t Appending %s (%s)' %(repr(filename), content_type))
2118                    continue
2119
2120
2121            ## We have an text or html message
2122            #
2123            if not inline:
2124                    self.logger.debug('\t Appending %s (%s), not an inline messsage part' %(repr(filename), content_type))
2125                    message_parts.append( (filename, part) )
2126                    continue
2127               
2128            ## Try to decode message part. We have a html or plain text messafe
2129            #
2130            body_text = part.get_payload(decode=1)
2131            if not body_text:           
2132                body_text = part.get_payload(decode=0)
2133
2134            ## Try to convert html message
2135            #
2136            if content_type == 'text/html':
2137
2138                body_text = self.html_2_txt(body_text)
2139                if not body_text:
2140                    continue
2141
2142            format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
2143            delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
2144
2145            if self.parameters.reflow and not self.parameters.verbatim_format and format == 'flowed':
2146                body_text = self.reflow(body_text, delsp == 'yes')
2147
2148            if new_email and self.parameters.only_strip_on_update:
2149                self.logger.debug('Skip signature/quote stripping for new messages')
2150            else:
2151                if self.parameters.strip_signature:
2152                    body_text = self.strip_signature(body_text)
2153
2154                if self.parameters.strip_quotes:
2155                    body_text = self.strip_quotes(body_text)
2156
2157            if self.parameters.inline_properties:
2158                body_text = self.inline_properties(body_text)
2159
2160            if self.parameters.use_textwrap:
2161                body_text = self.wrap_text(body_text)
2162
2163            ## Get contents charset (iso-8859-15 if not defined in mail headers)
2164            #
2165            charset = part.get_content_charset()
2166            if not charset:
2167                charset = 'iso-8859-15'
2168
2169            try:
2170                ubody_text = unicode(body_text, charset)
2171
2172            except UnicodeError, detail:
2173                ubody_text = unicode(body_text, 'iso-8859-15')
2174
2175            except LookupError, detail:
2176                ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
2177
2178            if self.parameters.verbatim_format:
2179                message_parts.append('{{{\r\n%s\r\n}}}' %ubody_text)
2180            else:
2181                message_parts.append('%s' %ubody_text)
2182
2183        return message_parts
2184       
2185    def unique_attachment_names(self, message_parts):
2186        """
2187        Make sure we have unique names attachments:
2188          - check if it contains illegal characters
2189          - Rename "None" filenames to "untitled-part"
2190        """
2191        self.logger.debug('function unique_attachment_names()')
2192        renamed_parts = []
2193        attachment_names = set()
2194
2195        for item in message_parts:
2196           
2197            ## If not an attachment, leave it alone
2198            #
2199            if not isinstance(item, tuple):
2200                renamed_parts.append(item)
2201                continue
2202               
2203            (filename, part) = item
2204
2205            ## If filename = None, use a default one
2206            #
2207            if filename in [ 'None']:
2208                filename = 'untitled-part'
2209                self.logger.info('\t Rename filename "None" to: %s' %filename)
2210
2211                ## Guess the extension from the content type, use non strict mode
2212                #  some additional non-standard but commonly used MIME types
2213                #  are also recognized
2214                #
2215                ext = mimetypes.guess_extension(part.get_content_type(), False)
2216                if not ext:
2217                    ext = '.bin'
2218
2219                filename = '%s%s' % (filename, ext)
2220
2221            ## Discard relative paths for windows/unix in attachment names
2222            #
2223            filename = filename.replace('\\', '_')
2224            filename = filename.replace('/', '_')
2225
2226            ## remove linefeed char
2227            #
2228            for forbidden_char in ['\r', '\n']:
2229                filename = filename.replace(forbidden_char,'')
2230
2231            ## We try to normalize the filename to utf-8 NFC if we can.
2232            #  Files uploaded from OS X might be in NFD.
2233            #  Check python version and then try it
2234            #
2235            #if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
2236            #   try:
2237            #       filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
2238            #   except TypeError:
2239            #       pass
2240
2241            ## Make the filename unique for this ticket
2242            #
2243            num = 0
2244            unique_filename = filename
2245            dummy_filename, ext = os.path.splitext(filename)
2246
2247            while (unique_filename in attachment_names) or self.attachment_exists(unique_filename):
2248                num += 1
2249                unique_filename = "%s-%s%s" % (dummy_filename, num, ext)
2250               
2251            s = '\t Attachment with filename %s will be saved as %s' % (filename, unique_filename)
2252            self.print_unicode(s)
2253
2254            attachment_names.add(unique_filename)
2255
2256            renamed_parts.append((filename, unique_filename, part))
2257   
2258        return renamed_parts
2259           
2260           
2261    def attachment_exists(self, filename):
2262
2263        self.logger.debug("function attachment_exists")
2264
2265        s = '\t check if attachment already exists: Id : %s, Filename : %s' %(self.id, filename)
2266        self.print_unicode(s)
2267
2268        ## Do we have a valid ticket id
2269        #
2270        if not self.id:
2271            return False
2272
2273        try:
2274            if self.system == 'discussion':
2275
2276                att = attachment.Attachment(self.env, 'discussion', 'ticket/%s' % (self.id,), filename)
2277
2278            elif self.system == 'blog':
2279
2280                att = attachment.Attachment(self.env, 'blog', '%s' % (self.id,), filename)
2281
2282            else:
2283
2284                att = attachment.Attachment(self.env, 'ticket', self.id, filename)
2285
2286            return True
2287
2288        except attachment.ResourceNotFound:
2289
2290            return False
2291
2292########## TRAC Ticket Text ###########################################################
2293           
2294    def get_body_text(self, message_parts):
2295        """
2296        """
2297        self.logger.debug('function get_body_text()')
2298
2299        body_text = []
2300       
2301        for part in message_parts:
2302       
2303            ## Plain text part, append it
2304            #
2305            if not isinstance(part, tuple):
2306                body_text.extend(part.strip().splitlines())
2307                body_text.append("")
2308                continue
2309
2310            (original, filename, part) = part
2311            inline = self.inline_part(part)
2312
2313            ## Skip generation of attachment link if html is converted to text
2314            #
2315            if part.get_content_type() == 'text/html' and self.parameters.html2text_cmd and inline:
2316                s = 'Skipping attachment link for html part: %s' %(filename)
2317                self.print_unicode(s)
2318                continue
2319           
2320            if part.get_content_maintype() == 'image' and inline:
2321
2322                if self.system != 'discussion':
2323                    s = 'wiki image link for: %s' %(filename)
2324                    self.print_unicode(s)
2325                    body_text.append('[[Image(%s)]]' % filename)
2326
2327                body_text.append("")
2328
2329            else:
2330
2331                if self.system != 'discussion':
2332
2333                    s = 'wiki attachment link for: %s' %(filename)
2334                    self.print_unicode(s)
2335                    body_text.append('[attachment:"%s"]' % filename)
2336
2337                body_text.append("")
2338
2339        ## Convert list body_texts to string
2340        #
2341        body_text = '\r\n'.join(body_text)
2342        return body_text
2343
2344    def html_mailto_link(self, subject):
2345        """
2346        This function returns a HTML mailto tag with the ticket id and author email address
2347        """
2348        self.logger.debug("function html_mailto_link")
2349
2350        if not self.author:
2351            author = self.email_addr
2352        else:   
2353            author = self.author
2354
2355        if not self.parameters.mailto_cc:
2356            self.parameters.mailto_cc = ''
2357           
2358        ## Bug in urllib.quote function
2359        #
2360        if isinstance(subject, unicode):
2361            subject = subject.encode('utf-8')
2362
2363        ## use urllib to escape the chars
2364        #
2365        s = '%s?Subject=%s&cc=%s' %(
2366               urllib.quote(self.email_addr),
2367               urllib.quote('Re: #%s: %s' %(self.id, subject)),
2368               urllib.quote(self.parameters.mailto_cc)
2369               )
2370
2371        if self.VERSION in [ 0.10 ]:
2372            s = '\r\n{{{\r\n#!html\r\n<a\r\n href="mailto:%s">Reply to: %s\r\n</a>\r\n}}}\r\n' %(s, author)
2373        else:
2374            s = '[mailto:"%s" Reply to: %s]' %(s, author)
2375
2376        self.logger.debug("\tmailto link %s" %s)
2377        return s
2378
2379########## TRAC notify section ###########################################################
2380
2381    def notify(self, tkt, new=True, modtime=0):
2382        """
2383        A wrapper for the TRAC notify function. So we can use templates
2384        """
2385        self.logger.debug('function notify()')
2386
2387        if self.parameters.notify_reporter:
2388            self.logger.debug('\t Notify reporter set')
2389            global sender_email
2390            sender_email = self.email_addr
2391 
2392            self.logger.debug('\t Using Email2TracNotification function AlwaysNotifyReporter')
2393            import trac.notification as Email2TracNotification
2394            Email2TracNotification.Notify.notify = AlwaysNotifyReporter
2395
2396        if self.parameters.dry_run  :
2397                self.logger.info('DRY_RUN: self.notify(tkt, True) reporter = %s' %tkt['reporter'])
2398                return
2399        try:
2400
2401            #from trac.ticket.notification import TicketNotificationSystem
2402            #tn_sys = TicketNotificationSystem(self.env)
2403            #print tn_sys
2404            #print tn_sys.__dict__
2405            #sys.exit(0)
2406
2407            ## create false {abs_}href properties, to trick Notify()
2408            #
2409            if self.VERSION in [0.10]:
2410                self.env.abs_href = Href(self.get_config('project', 'url'))
2411                self.env.href = Href(self.get_config('project', 'url'))
2412
2413            tn = TicketNotifyEmail(self.env)
2414
2415            if self.parameters.alternate_notify_template:
2416
2417                if self.VERSION >= 0.11:
2418
2419                    from trac.web.chrome import Chrome
2420
2421                    if  self.parameters.alternate_notify_template_update and not new:
2422                        tn.template_name = self.parameters.alternate_notify_template_update
2423                    else:
2424                        tn.template_name = self.parameters.alternate_notify_template
2425
2426                    tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
2427                       
2428                else:
2429
2430                    tn.template_name = self.parameters.alternate_notify_template
2431
2432            tn.notify(tkt, new, modtime)
2433
2434        except Exception, e:
2435            self.logger.error('Failure sending notification on creation of ticket #%s: %s' %(self.id, e))
2436
2437########## END Class Definition  ########################################################
2438
2439########## Global Notificaition Function ################################################
2440def AlwaysNotifyReporter(self, resid):
2441    """
2442    Copy of def notify() to manipulate recipents to always include reporter for the
2443    notification.
2444    """
2445    #print sender_email, resid
2446    (torcpts, ccrcpts) = self.get_recipients(resid)
2447    #print "get_recipients finished"
2448
2449    if not tktparser.email_header_acl('notify_reporter_black_list', sender_email, False):
2450        ## additionally append sender (regardeless of settings in trac.ini)
2451        #
2452        if not sender_email in torcpts:
2453            torcpts.append(sender_email)
2454
2455    self.begin_send()
2456    self.send(torcpts, ccrcpts)
2457    self.finish_send()
2458
2459########## Parse Config File  ###########################################################
2460
2461def ReadConfig(file, name):
2462    """
2463    Parse the config file
2464    """
2465    if not os.path.isfile(file):
2466        print 'File %s does not exist' %file
2467        sys.exit(1)
2468
2469    config = trac_config.Configuration(file)
2470   
2471    parentdir = config.get('DEFAULT', 'parentdir')
2472    sections = config.sections()
2473
2474    ## use some trac internals to get the defaults
2475    #
2476    tmp = config.parser.defaults()
2477    project =  SaraDict()
2478
2479    for option, value in tmp.items():
2480        try:
2481            project[option] = int(value)
2482        except ValueError:
2483            project[option] = value
2484
2485    if name:
2486        if name in sections:
2487            project =  SaraDict()
2488            for option, value in  config.options(name):
2489                try:
2490                    project[option] = int(value)
2491                except ValueError:
2492                    project[option] = value
2493
2494        elif not parentdir:
2495            print "Not a valid project name: %s, valid names are: %s" %(name, sections)
2496            print "or set parentdir in the [DEFAULT] section"
2497            sys.exit(1)
2498
2499    ## If parentdir then set project dir to parentdir + name
2500    #
2501    if not project.has_key('project'):
2502        if not parentdir:
2503            print "You must set project or parentdir in your configuration file"
2504            sys.exit(1)
2505        elif not name:
2506            print "You must configure a  project section in your configuration file"
2507        else:
2508            project['project'] = os.path.join(parentdir, name)
2509
2510    ##
2511    # Save the project name
2512    #
2513    project['project_name'] = os.path.basename(project['project'])
2514
2515    return project
2516
2517########## Setup Logging ###############################################################
2518
2519def setup_log(parameters, project_name, interactive=None):
2520    """
2521    Setup logging
2522
2523    Note for log format the usage of `$(...)s` instead of `%(...)s` as the latter form
2524    would be interpreted by the ConfigParser itself.
2525    """
2526    logger = logging.getLogger('email2trac %s' %project_name)
2527
2528    if interactive:
2529        parameters.log_type = 'stderr'
2530
2531    if not parameters.log_type:
2532        if sys.platform in ['win32', 'cygwin']:
2533            parameters.log_type = 'eventlog'
2534        else:
2535            parameters.log_type = 'syslog'
2536
2537    if parameters.log_type == 'file':
2538
2539        if not parameters.log_file:
2540            parameters.log_file = 'email2trac.log'
2541
2542        if not os.path.isabs(parameters.log_file):
2543            parameters.log_file = os.path.join(tempfile.gettempdir(), parameters.log_file)
2544
2545        log_handler = logging.FileHandler(parameters.log_file)
2546
2547    elif parameters.log_type in ('winlog', 'eventlog', 'nteventlog'):
2548        ## Requires win32 extensions
2549        #
2550        logid = "email2trac"
2551        log_handler = logging.handlers.NTEventLogHandler(logid, logtype='Application')
2552
2553    elif parameters.log_type in ('syslog', 'unix'):
2554        log_handler = logging.handlers.SysLogHandler('/dev/log')
2555
2556    elif parameters.log_type in ('stderr'):
2557        log_handler = logging.StreamHandler(sys.stderr)
2558
2559    else:
2560        log_handler = logging.handlers.BufferingHandler(0)
2561
2562    if parameters.log_format:
2563        parameters.log_format = parameters.log_format.replace('$(', '%(')
2564    else:
2565        parameters.log_format = '%(name)s: %(message)s'
2566        if parameters.log_type in ('file', 'stderr'):
2567            parameters.log_format = '%(asctime)s ' + parameters.log_format
2568
2569    log_formatter = logging.Formatter(parameters.log_format)
2570    log_handler.setFormatter(log_formatter)
2571    logger.addHandler(log_handler)
2572
2573    if (parameters.log_level in ['DEBUG', 'ALL']) or (parameters.debug > 0):
2574        logger.setLevel(logging.DEBUG)
2575        parameters.debug = 1
2576
2577    elif parameters.log_level in ['INFO'] or parameters.verbose:
2578        logger.setLevel(logging.INFO)
2579
2580    elif parameters.log_level in ['WARNING']:
2581        logger.setLevel(logging.WARNING)
2582
2583    elif parameters.log_level in ['ERROR']:
2584        logger.setLevel(logging.ERROR)
2585
2586    elif parameters.log_level in ['CRITICAL']:
2587        logger.setLevel(logging.CRITICAL)
2588
2589    else:
2590        logger.setLevel(logging.INFO)
2591
2592    return logger
2593
2594
2595if __name__ == '__main__':
2596    ## Default config file
2597    #
2598    agilo = False
2599    configfile = '@email2trac_conf@'
2600    project = ''
2601    component = ''
2602    ticket_prefix = 'default'
2603    dry_run = None
2604    verbose = None
2605    debug_interactive = None
2606    virtualenv = '@virtualenv@'
2607
2608    SHORT_OPT = 'Acde:hf:np:t:v'
2609    LONG_OPT  =  ['agilo', 'component=', 'debug', 'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=', 'virtualenv=', 'verbose']
2610
2611    try:
2612        opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
2613    except getopt.error,detail:
2614        print __doc__
2615        print detail
2616        sys.exit(1)
2617   
2618    project_name = None
2619    for opt,value in opts:
2620        if opt in [ '-h', '--help']:
2621            print __doc__
2622            sys.exit(0)
2623        elif opt in ['-A', '--agilo']:
2624            agilo = True
2625        elif opt in ['-c', '--component']:
2626            component = value
2627        elif opt in ['-d', '--debug']:
2628            debug_interactive = 1
2629        elif opt in ['-E', '--virtualenv']:
2630            virtualenv = value
2631        elif opt in ['-f', '--file']:
2632            configfile = value
2633        elif opt in ['-n', '--dry-run']:
2634            dry_run = True
2635        elif opt in ['-p', '--project']:
2636            project_name = value
2637        elif opt in ['-t', '--ticket_prefix']:
2638            ticket_prefix = value
2639        elif opt in ['-v', '--verbose']:
2640            verbose = True
2641
2642    if virtualenv and os.path.exists(virtualenv):
2643        activate_this = os.path.join(virtualenv, 'bin/activate_this.py')
2644        if os.path.exists(activate_this):
2645            execfile(activate_this, dict(__file__=activate_this))
2646
2647    try:
2648        from trac import __version__ as trac_version
2649        from trac import config as trac_config
2650
2651    except ImportError, detail:
2652        print "Can not find a a valid trac installation, solutions could be:"
2653        print "\tset PYTHONPATH"
2654        print "\tuse the --virtualenv <dir> option"
2655        sys.exit(1)
2656   
2657    settings = ReadConfig(configfile, project_name)
2658
2659    ## The default prefix for ticket values in email2trac.conf
2660    #
2661    settings.ticket_prefix = ticket_prefix
2662    settings.dry_run = dry_run
2663    settings.verbose = verbose
2664
2665    if not settings.debug and debug_interactive:
2666        settings.debug = debug_interactive
2667
2668    if not settings.project:
2669        print __doc__
2670        print 'No Trac project is defined in the email2trac config file.'
2671        sys.exit(1)
2672
2673    logger = setup_log(settings, os.path.basename(settings.project), debug_interactive)
2674   
2675    if component:
2676        settings['component'] = component
2677
2678    ## We are only interested in the major versions
2679    # 0.12.3 --> 0.12
2680    # 1.0.2  --> 1.0
2681    #
2682    l = trac_version.split('.')
2683    version = '.'.join(l[0:2])
2684
2685    logger.debug("Found trac version: %s" %(version))
2686   
2687    try:
2688        if version == '0.10':
2689            from trac import attachment
2690            from trac.env import Environment
2691            from trac.ticket import Ticket
2692            from trac.web.href import Href
2693            from trac import util
2694            from trac.ticket.web_ui import TicketModule
2695
2696            #
2697            # return  util.text.to_unicode(str)
2698            #
2699            # see http://projects.edgewall.com/trac/changeset/2799
2700            from trac.ticket.notification import TicketNotifyEmail
2701            from trac import config as trac_config
2702            from trac.core import TracError
2703
2704        elif version in ['0.11', '0.12', '0.13', '1.0']:
2705            from trac import attachment
2706            from trac import config as trac_config
2707            from trac import util
2708            from trac.core import TracError
2709            from trac.env import Environment
2710            from trac.perm import PermissionSystem
2711            from trac.perm import PermissionCache
2712            from trac.test import Mock, MockPerm
2713            from trac.ticket.api import TicketSystem
2714            from trac.ticket.web_ui import TicketModule
2715            from trac.web.href import Href
2716
2717            if agilo:
2718
2719                try:
2720                    #from agilo.utils.config import AgiloConfig
2721                    #if AgiloConfig(self.env).is_agilo_enabled:
2722                    from agilo.ticket.model import Ticket
2723                except ImportError, detail:
2724                    logger.error('Could not find Trac  Agilo environemnt')
2725                    sys.exit(0)
2726
2727            else:
2728
2729                from trac.ticket import Ticket
2730
2731            #
2732            # return  util.text.to_unicode(str)
2733            #
2734            # see http://projects.edgewall.com/trac/changeset/2799
2735            from trac.ticket.notification import TicketNotifyEmail
2736
2737        else:
2738            logger.error('TRAC version %s is not supported' %version)
2739            sys.exit(0)
2740
2741        ## Must be set before environment is created
2742        #
2743        if settings.has_key('python_egg_cache'):
2744            python_egg_cache = str(settings['python_egg_cache'])
2745            os.environ['PYTHON_EGG_CACHE'] = python_egg_cache
2746
2747        if settings.debug > 0:
2748            logger.debug('Loading environment %s', settings.project)
2749
2750        try:
2751            env = Environment(settings['project'], create=0)
2752        except IOError, detail:
2753            logger.error("trac error: %s" %detail)
2754            sys.exit(0)
2755        except TracError, detail:
2756            logger.error("trac error: %s" %detail)
2757            sys.exit(0)
2758
2759        tktparser = TicketEmailParser(env, settings, logger, float(version))
2760        tktparser.parse(sys.stdin)
2761
2762    ## Catch all errors and use the logging module
2763    #
2764    except Exception, error:
2765
2766        etype, evalue, etb = sys.exc_info()
2767        for e in traceback.format_exception(etype, evalue, etb):
2768            logger.critical(e)
2769
2770        if m:
2771            tktparser.save_email_for_debug(m, settings.project_name, True)
2772
2773        sys.exit(1)
2774# EOB
Note: See TracBrowser for help on using the repository browser.