source: trunk/email2trac.py.in @ 374

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

Some minor code re-arrangements to determine trac version

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