source: trunk/email2trac.py.in @ 438

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

ported to notify_template and notify_template to UserDict?

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