source: trunk/email2trac.py.in @ 570

Last change on this file since 570 was 570, checked in by bas, 13 years ago

use all 'to-addresses for recipient_list instead of just one, closes #268

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