source: trunk/email2trac.py.in @ 439

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

ported REPLY_ALL to UserDict?

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