source: trunk/email2trac.py.in @ 440

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

ported DROP_SPAM to UserDict?

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