source: trunk/email2trac.py.in @ 360

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

Added an extra option ticket_update_by_subject_lookback in combo with
ticket_update_by_subject. Default value is 30 days, see #188

  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 53.7 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    trac_version : 0.10              # OPTIONAL, default is 0.11
45
46    [jouvin]                         # OPTIONAL project declaration, if set both fields necessary
47    project      : /data/trac/jouvin # use -p|--project jouvin. 
48       
49 * default config file is : /etc/email2trac.conf
50
51 * Commandline opions:
52                -h,--help
53                -f,--file  <configuration file>
54                -n,--dry-run
55                -p, --project <project name>
56                -t, --ticket_prefix <name>
57
58SVN Info:
59        $Id: email2trac.py.in 360 2010-05-20 06:52:13Z bas $
60"""
61import os
62import sys
63import string
64import getopt
65import stat
66import time
67import email
68import email.Iterators
69import email.Header
70import re
71import urllib
72import unicodedata
73from stat import *
74import mimetypes
75import traceback
76
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 '#' and ':'
743                        #
744                        self.id = int(id[1:-1])
745
746
747                # When is the change committed
748                #
749                if self.VERSION == 0.11:
750                        utc = UTC()
751                        when = datetime.now(utc)
752                else:
753                        when = int(time.time())
754
755                try:
756                        tkt = Ticket(self.env, self.id, self.db)
757                except util.TracError, detail:
758                        # Not a valid ticket
759                        self.id = None
760                        return False
761
762                # How many changes has this ticket
763                cnum = len(tkt.get_changelog())
764
765
766                # reopen the ticket if it is was closed
767                # We must use the ticket workflow framework
768                #
769                if tkt['status'] in ['closed'] and self.EMAIL_TRIGGERS_WORKFLOW:
770
771                        #print controller.actions['reopen']
772                        #
773                        # As reference 
774                        # req = Mock(href=Href('/'), abs_href=Href('http://www.example.com/'), authname='anonymous', perm=MockPerm(), args={})
775                        #
776                        #a = controller.render_ticket_action_control(req, tkt, 'reopen')
777                        #print 'controller : ', a
778                        #
779                        #b = controller.get_all_status()
780                        #print 'get all status: ', b
781                        #
782                        #b = controller.get_ticket_changes(req, tkt, 'reopen')
783                        #print 'get_ticket_changes :', b
784
785                        if self.WORKFLOW and (self.VERSION in [0.11]) :
786                                from trac.ticket.default_workflow import ConfigurableTicketWorkflow
787                                from trac.test import Mock, MockPerm
788
789                                req = Mock(authname='anonymous', perm=MockPerm(), args={})
790
791                                controller = ConfigurableTicketWorkflow(self.env)
792                                fields = controller.get_ticket_changes(req, tkt, self.WORKFLOW)
793
794                                if self.DEBUG:
795                                        print 'TD: Workflow ticket update fields: ', fields
796
797                                for key in fields.keys():
798                                        tkt[key] = fields[key]
799
800                        else:
801                                tkt['status'] = 'reopened'
802                                tkt['resolution'] = ''
803
804                # Must we update some ticket fields properties via subjectline
805                #
806                if update_fields:
807                        self.update_ticket_fields(tkt, update_fields)
808
809                message_parts = self.get_message_parts(m)
810                message_parts = self.unique_attachment_names(message_parts)
811
812                # Must we update some ticket fields properties via body_text
813                #
814                if self.properties:
815                                self.update_ticket_fields(tkt, self.properties)
816
817                if self.EMAIL_HEADER:
818                        message_parts.insert(0, self.email_header_txt(m))
819
820                body_text = self.body_text(message_parts)
821
822                if self.VERSION  == 0.9:
823                        error_with_attachments = self.attach_attachments(message_parts, True)
824                else:
825                        error_with_attachments = self.attach_attachments(message_parts)
826
827                if body_text.strip() or update_fields or self.properties:
828                        if self.DRY_RUN:
829                                print 'DRY_RUN: tkt.save_changes(self.author, body_text, ticket_change_number) ', self.author, cnum
830                        else:
831                                if error_with_attachments:
832                                        body_text = '%s\\%s' %(error_with_attachments, body_text)
833                               
834                                tkt.save_changes(self.author, body_text, when, None, str(cnum))
835                       
836
837                #if self.VERSION  == 0.9:
838                #       error_with_attachments = self.attach_attachments(message_parts, True)
839                #else:
840                #       error_with_attachments = self.attach_attachments(message_parts)
841
842                if self.notification and not spam:
843                        self.notify(tkt, False, when)
844
845                return True
846
847        def set_ticket_fields(self, ticket):
848                """
849                set the ticket fields to value specified
850                        - /etc/email2trac.conf with <prefix>_<field>
851                        - trac default values, trac.ini
852                """
853                user_dict = dict()
854
855                for field in ticket.fields:
856
857                        name = field['name']
858
859                        ## default trac value
860                        #
861                        if not field.get('custom'):
862                                value = self.get_config('ticket', 'default_%s' %(name) )
863                        else:
864                                ##  Else we get the default value for reporter
865                                #
866                                value = field.get('value')
867                                options = field.get('options')
868
869                                if value and options and (value not in options):
870                                         value = options[int(value)]
871       
872                        if self.DEBUG > 10:
873                                print 'trac.ini name %s = %s' %(name, value)
874
875                        ## email2trac.conf settings
876                        #
877                        prefix = self.parameters['ticket_prefix']
878                        try:
879                                value = self.parameters['%s_%s' %(prefix, name)]
880                                if self.DEBUG > 10:
881                                        print 'email2trac.conf %s = %s ' %(name, value)
882
883                        except KeyError, detail:
884                                pass
885               
886                        if self.DEBUG:
887                                print 'user_dict[%s] = %s' %(name, value)
888
889                        if value:
890                                user_dict[name] = value
891
892                self.update_ticket_fields(ticket, user_dict, use_default=1)
893
894                if 'status' not in user_dict.keys():
895                        ticket['status'] = 'new'
896
897
898        def ticket_update_by_subject(self, subject):
899                """
900                This list of Re: prefixes is probably incomplete. Taken from
901                wikipedia. Here is how the subject is matched
902                  - Re: <subject>
903                  - Re: (<Mail list label>:)+ <subject>
904
905                So we must have the last column
906                """
907                if self.VERBOSE:
908                        print "VB: ticket_update_by_subject()"
909
910                matched_id = None
911                if self.TICKET_UPDATE and self.TICKET_UPDATE_BY_SUBJECT:
912                               
913                        SUBJECT_RE = re.compile(r'^(RE|AW|VS|SV):(.*:)*\s*(.*)', re.IGNORECASE)
914                        result = SUBJECT_RE.search(subject)
915
916                        if result:
917                                # This is a reply
918                                orig_subject = result.group(3)
919
920                                if self.DEBUG:
921                                        print 'TD: subject search string: %s' %(orig_subject)
922
923                                cursor = self.db.cursor()
924                                summaries = [orig_subject, '%%: %s' % orig_subject]
925
926                                ##
927                                # Convert days to seconds
928                                lookback = int(time.mktime(time.gmtime())) - \
929                                                self.TICKET_UPDATE_BY_SUBJECT_LOOKBACK * 24 * 3600
930
931
932                                for summary in summaries:
933                                        if self.DEBUG:
934                                                print 'TD: Looking for summary matching: "%s"' % summary
935                                        sql = """SELECT id FROM ticket
936                                                        WHERE changetime >= %s AND summary LIKE %s
937                                                        ORDER BY changetime DESC"""
938                                        cursor.execute(sql, [lookback, summary.strip()])
939
940                                        for row in cursor:
941                                                (matched_id,) = row
942                                                if self.DEBUG:
943                                                        print 'TD: Found matching ticket id: %d' % matched_id
944                                                break
945
946                                        if matched_id:
947                                                matched_id = '#%d:' % matched_id
948                                                return matched_id
949
950                return matched_id
951
952
953        def new_ticket(self, msg, subject, spam, set_fields = None):
954                """
955                Create a new ticket
956                """
957                if self.VERBOSE:
958                        print "VB: function new_ticket()"
959
960                tkt = Ticket(self.env)
961
962                self.set_reply_fields(tkt, msg)
963
964                self.set_ticket_fields(tkt)
965
966                # Old style setting for component, will be removed
967                #
968                if spam:
969                        tkt['component'] = 'Spam'
970
971                elif self.parameters.has_key('component'):
972                        tkt['component'] = self.parameters['component']
973
974                if not msg['Subject']:
975                        tkt['summary'] = u'(No subject)'
976                else:
977                        tkt['summary'] = subject
978
979
980                if set_fields:
981                        rest, keywords = string.split(set_fields, '?')
982
983                        if keywords:
984                                update_fields = self.str_to_dict(keywords)
985                                self.update_ticket_fields(tkt, update_fields)
986
987                # produce e-mail like header
988                #
989                head = ''
990                if self.EMAIL_HEADER > 0:
991                        head = self.email_header_txt(msg)
992
993                message_parts = self.get_message_parts(msg)
994
995                # Must we update some ticket fields properties via body_text
996                #
997                if self.properties:
998                                self.update_ticket_fields(tkt, self.properties)
999
1000                if self.DEBUG:
1001                        print 'TD: self.get_message_parts ',
1002                        print message_parts
1003
1004                message_parts = self.unique_attachment_names(message_parts)
1005                if self.DEBUG:
1006                        print 'TD: self.unique_attachment_names',
1007                        print message_parts
1008               
1009                if self.EMAIL_HEADER > 0:
1010                        message_parts.insert(0, self.email_header_txt(msg))
1011                       
1012                body_text = self.body_text(message_parts)
1013
1014                tkt['description'] = body_text
1015
1016                #when = int(time.time())
1017                #
1018                utc = UTC()
1019                when = datetime.now(utc)
1020
1021                if not self.DRY_RUN:
1022                        self.id = tkt.insert()
1023       
1024                changed = False
1025                comment = ''
1026
1027                # some routines in trac are dependend on ticket id     
1028                # like alternate notify template
1029                #
1030                if self.notify_template:
1031                        tkt['id'] = self.id
1032                        changed = True
1033
1034                ## Rewrite the description if we have mailto enabled
1035                #
1036                if self.MAILTO:
1037                        changed = True
1038                        comment = u'\nadded mailto line\n'
1039                        mailto = self.html_mailto_link( m['Subject'])
1040
1041                        tkt['description'] = u'%s\r\n%s%s\r\n' \
1042                                %(head, mailto, body_text)
1043       
1044                ## Save the attachments to the ticket   
1045                #
1046                error_with_attachments =  self.attach_attachments(message_parts)
1047
1048                if error_with_attachments:
1049                        changed = True
1050                        comment = '%s\n%s\n' %(comment, error_with_attachments)
1051
1052                if changed:
1053                        if self.DRY_RUN:
1054                                print 'DRY_RUN: tkt.save_changes(%s, comment) real reporter = %s' %( tkt['reporter'], self.author)
1055                        else:
1056                                tkt.save_changes(tkt['reporter'], comment)
1057                                #print tkt.get_changelog(self.db, when)
1058
1059                if self.notification and not spam:
1060                        self.notify(tkt, True)
1061
1062
1063        def attach_attachments(self, message_parts, update=False):
1064                '''
1065                save any attachments as files in the ticket's directory
1066                '''
1067                if self.VERBOSE:
1068                        print "VB: attach_attachments()"
1069
1070                if self.DRY_RUN:
1071                        print "DRY_RUN: no attachments attached to tickets"
1072                        return ''
1073
1074                count = 0
1075
1076                # Get Maxium attachment size
1077                #
1078                max_size = int(self.get_config('attachment', 'max_size'))
1079                status   = None
1080               
1081                for item in message_parts:
1082                        # Skip body parts
1083                        if not isinstance(item, tuple):
1084                                continue
1085                               
1086                        (original, filename, part) = item
1087                        #
1088                        # Must be tuneables HvB
1089                        #
1090                        path, fd =  util.create_unique_file(os.path.join(self.TMPDIR, filename))
1091                        text = part.get_payload(decode=1)
1092                        if not text:
1093                                text = '(None)'
1094                        fd.write(text)
1095                        fd.close()
1096
1097                        # get the file_size
1098                        #
1099                        stats = os.lstat(path)
1100                        file_size = stats[stat.ST_SIZE]
1101
1102                        # Check if the attachment size is allowed
1103                        #
1104                        if (max_size != -1) and (file_size > max_size):
1105                                status = '%s\nFile %s is larger then allowed attachment size (%d > %d)\n\n' \
1106                                        %(status, original, file_size, max_size)
1107
1108                                os.unlink(path)
1109                                continue
1110                        else:
1111                                count = count + 1
1112                                       
1113                        # Insert the attachment
1114                        #
1115                        fd = open(path, 'rb')
1116                        if self.system == 'discussion':
1117                                att = attachment.Attachment(self.env, 'discussion', 'topic/%s'
1118                                  % (self.id,))
1119                        else:
1120                                att = attachment.Attachment(self.env, 'ticket', self.id)
1121 
1122                        # This will break the ticket_update system, the body_text is vaporized
1123                        # ;-(
1124                        #
1125                        if not update:
1126                                att.author = self.author
1127                                att.description = self.email_to_unicode('Added by email2trac')
1128
1129                        try:
1130                                att.insert(filename, fd, file_size)
1131                        except OSError, detail:
1132                                status = '%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail)
1133
1134                        # Remove the created temporary filename
1135                        #
1136                        fd.close()
1137                        os.unlink(path)
1138
1139                ## return error
1140                #
1141                return status
1142
1143########## Fullblog functions  #################################################
1144
1145        def blog(self, id):
1146                """
1147                The blog create/update function
1148                """
1149                # import the modules
1150                #
1151                from tracfullblog.core import FullBlogCore
1152                from tracfullblog.model import BlogPost, BlogComment
1153                from trac.test import Mock, MockPerm
1154
1155                # instantiate blog core
1156                blog = FullBlogCore(self.env)
1157                req = Mock(authname='anonymous', perm=MockPerm(), args={})
1158
1159                if id:
1160
1161                        # update blog
1162                        #
1163                        comment = BlogComment(self.env, id)
1164                        comment.author = self.author
1165
1166                        message_parts = self.get_message_parts(m)
1167                        comment.comment = self.body_text(message_parts)
1168
1169                        blog.create_comment(req, comment)
1170
1171                else:
1172                        # create blog
1173                        #
1174                        import time
1175                        post = BlogPost(self.env, 'blog_'+time.strftime("%Y%m%d%H%M%S", time.gmtime()))
1176
1177                        #post = BlogPost(self.env, blog._get_default_postname(self.env))
1178                       
1179                        post.author = self.author
1180                        post.title = self.email_to_unicode(m['Subject'])
1181
1182                        message_parts = self.get_message_parts(m)
1183                        post.body = self.body_text(message_parts)
1184                       
1185                        blog.create_post(req, post, self.author, u'Created by email2trac', False)
1186
1187
1188########## Discussion functions  ##############################################
1189
1190        def discussion_topic(self, content, subject):
1191
1192                # Import modules.
1193                from tracdiscussion.api import DiscussionApi
1194                from trac.util.datefmt import to_timestamp, utc
1195
1196                if self.DEBUG:
1197                        print 'TD: Creating a new topic in forum:', self.id
1198
1199                # Get dissussion API component.
1200                api = self.env[DiscussionApi]
1201                context = self._create_context(content, subject)
1202
1203                # Get forum for new topic.
1204                forum = api.get_forum(context, self.id)
1205
1206                if not forum and self.DEBUG:
1207                        print 'ERROR: Replied forum doesn\'t exist'
1208
1209                # Prepare topic.
1210                topic = {'forum' : forum['id'],
1211                                 'subject' : context.subject,
1212                                 'time': to_timestamp(datetime.now(utc)),
1213                                 'author' : self.author,
1214                                 'subscribers' : [self.email_addr],
1215                                 'body' : self.body_text(context.content_parts)}
1216
1217                # Add topic to DB and commit it.
1218                self._add_topic(api, context, topic)
1219                self.db.commit()
1220
1221        def discussion_topic_reply(self, content, subject):
1222
1223                # Import modules.
1224                from tracdiscussion.api import DiscussionApi
1225                from trac.util.datefmt import to_timestamp, utc
1226
1227                if self.DEBUG:
1228                        print 'TD: Replying to discussion topic', self.id
1229
1230                # Get dissussion API component.
1231                api = self.env[DiscussionApi]
1232                context = self._create_context(content, subject)
1233
1234                # Get replied topic.
1235                topic = api.get_topic(context, self.id)
1236
1237                if not topic and self.DEBUG:
1238                        print 'ERROR: Replied topic doesn\'t exist'
1239
1240                # Prepare message.
1241                message = {'forum' : topic['forum'],
1242                                   'topic' : topic['id'],
1243                                   'replyto' : -1,
1244                                   'time' : to_timestamp(datetime.now(utc)),
1245                                   'author' : self.author,
1246                                   'body' : self.body_text(context.content_parts)}
1247
1248                # Add message to DB and commit it.
1249                self._add_message(api, context, message)
1250                self.db.commit()
1251
1252        def discussion_message_reply(self, content, subject):
1253
1254                # Import modules.
1255                from tracdiscussion.api import DiscussionApi
1256                from trac.util.datefmt import to_timestamp, utc
1257
1258                if self.DEBUG:
1259                        print 'TD: Replying to discussion message', self.id
1260
1261                # Get dissussion API component.
1262                api = self.env[DiscussionApi]
1263                context = self._create_context(content, subject)
1264
1265                # Get replied message.
1266                message = api.get_message(context, self.id)
1267
1268                if not message and self.DEBUG:
1269                        print 'ERROR: Replied message doesn\'t exist'
1270
1271                # Prepare message.
1272                message = {'forum' : message['forum'],
1273                                   'topic' : message['topic'],
1274                                   'replyto' : message['id'],
1275                                   'time' : to_timestamp(datetime.now(utc)),
1276                                   'author' : self.author,
1277                                   'body' : self.body_text(context.content_parts)}
1278
1279                # Add message to DB and commit it.
1280                self._add_message(api, context, message)
1281                self.db.commit()
1282
1283        def _create_context(self, content, subject):
1284
1285                # Import modules.
1286                from trac.mimeview import Context
1287                from trac.web.api import Request
1288                from trac.perm import PermissionCache
1289
1290                # TODO: Read server base URL from config.
1291                # Create request object to mockup context creation.
1292                #
1293                environ = {'SERVER_PORT' : 80,
1294                                   'SERVER_NAME' : 'test',
1295                                   'REQUEST_METHOD' : 'POST',
1296                                   'wsgi.url_scheme' : 'http',
1297                                   'wsgi.input' : sys.stdin}
1298                chrome =  {'links': {},
1299                                   'scripts': [],
1300                                   'ctxtnav': [],
1301                                   'warnings': [],
1302                                   'notices': []}
1303
1304                if self.env.base_url_for_redirect:
1305                        environ['trac.base_url'] = self.env.base_url
1306
1307                req = Request(environ, None)
1308                req.chrome = chrome
1309                req.tz = 'missing'
1310                req.authname = self.author
1311                req.perm = PermissionCache(self.env, self.author)
1312
1313                # Create and return context.
1314                context = Context.from_request(req)
1315                context.realm = 'discussion-email2trac'
1316                context.cursor = self.db.cursor()
1317                context.content = content
1318                context.subject = subject
1319
1320                # Read content parts from content.
1321                context.content_parts = self.get_message_parts(content)
1322                context.content_parts = self.unique_attachment_names(
1323                  context.content_parts)
1324
1325                return context
1326
1327        def _add_topic(self, api, context, topic):
1328                context.req.perm.assert_permission('DISCUSSION_APPEND')
1329
1330                # Filter topic.
1331                for discussion_filter in api.discussion_filters:
1332                        accept, topic_or_error = discussion_filter.filter_topic(
1333                          context, topic)
1334                        if accept:
1335                                topic = topic_or_error
1336                        else:
1337                                raise TracError(topic_or_error)
1338
1339                # Add a new topic.
1340                api.add_topic(context, topic)
1341
1342                # Get inserted topic with new ID.
1343                topic = api.get_topic_by_time(context, topic['time'])
1344
1345                # Attach attachments.
1346                self.id = topic['id']
1347                self.attach_attachments(context.content_parts, self.VERSION == 0.9)
1348
1349                # Notify change listeners.
1350                for listener in api.topic_change_listeners:
1351                        listener.topic_created(context, topic)
1352
1353        def _add_message(self, api, context, message):
1354                context.req.perm.assert_permission('DISCUSSION_APPEND')
1355
1356                # Filter message.
1357                for discussion_filter in api.discussion_filters:
1358                        accept, message_or_error = discussion_filter.filter_message(
1359                          context, message)
1360                        if accept:
1361                                message = message_or_error
1362                        else:
1363                                raise TracError(message_or_error)
1364
1365                # Add message.
1366                api.add_message(context, message)
1367
1368                # Get inserted message with new ID.
1369                message = api.get_message_by_time(context, message['time'])
1370
1371                # Attach attachments.
1372                self.id = message['topic']
1373                self.attach_attachments(context.content_parts, self.VERSION == 0.9)
1374
1375                # Notify change listeners.
1376                for listener in api.message_change_listeners:
1377                        listener.message_created(context, message)
1378
1379########## MAIN function  ######################################################
1380
1381        def parse(self, fp):
1382                """
1383                """
1384                if self.VERBOSE:
1385                        print "VB: main function parse()"
1386                global m
1387
1388                m = email.message_from_file(fp)
1389               
1390                if not m:
1391                        if self.DEBUG:
1392                                print "TD: This is not a valid email message format"
1393                        return
1394                       
1395                # Work around lack of header folding in Python; see http://bugs.python.org/issue4696
1396                try:
1397                        m.replace_header('Subject', m['Subject'].replace('\r', '').replace('\n', ''))
1398                except AttributeError, detail:
1399                        pass
1400
1401                if self.DEBUG > 1:        # save the entire e-mail message text
1402                        self.save_email_for_debug(m, True)
1403
1404                self.db = self.env.get_db_cnx()
1405                self.get_sender_info(m)
1406
1407                if not self.email_header_acl('white_list', self.email_addr, True):
1408                        if self.DEBUG > 1 :
1409                                print 'Message rejected : %s not in white list' %(self.email_addr)
1410                        return False
1411
1412                if self.email_header_acl('black_list', self.email_addr, False):
1413                        if self.DEBUG > 1 :
1414                                print 'Message rejected : %s in black list' %(self.email_addr)
1415                        return False
1416
1417                if not self.email_header_acl('recipient_list', self.to_email_addr, True):
1418                        if self.DEBUG > 1 :
1419                                print 'Message rejected : %s not in recipient list' %(self.to_email_addr)
1420                        return False
1421
1422                # If drop the message
1423                #
1424                if self.spam(m) == 'drop':
1425                        return False
1426
1427                elif self.spam(m) == 'spam':
1428                        spam_msg = True
1429                else:
1430                        spam_msg = False
1431
1432                if self.get_config('notification', 'smtp_enabled') in ['true']:
1433                        self.notification = 1
1434                else:
1435                        self.notification = 0
1436
1437                if not m['Subject']:
1438                        subject  = 'No Subject'
1439                else:
1440                        subject  = self.email_to_unicode(m['Subject'])
1441
1442                if self.DEBUG:
1443                         print "TD:", subject
1444
1445                #
1446                # [hic] #1529: Re: LRZ
1447                # [hic] #1529?owner=bas,priority=medium: Re: LRZ
1448                #
1449                ticket_regex = r'''
1450                        (?P<new_fields>[#][?].*)
1451                        |(?P<reply>(?P<id>[#][\d]+)(?P<fields>\?.*?:)*)
1452                        '''
1453                # Check if  FullBlogPlugin is installed
1454                #
1455                blog_enabled = None
1456                blog_regex = ''
1457                if self.get_config('components', 'tracfullblog.*') in ['enabled']:
1458                        blog_enabled = True
1459                        blog_regex = '''|(?P<blog>blog:(?P<blog_id>\w*))'''
1460
1461
1462                # Check if DiscussionPlugin is installed
1463                #
1464                discussion_enabled = None
1465                discussion_regex = ''
1466                if self.get_config('components', 'tracdiscussion.api.*') in ['enabled']:
1467                        discussion_enabled = True
1468                        discussion_regex = r'''
1469                        |(?P<forum>Forum[ ][#](?P<forum_id>\d+)[ ]-[ ]?)
1470                        |(?P<topic>Topic[ ][#](?P<topic_id>\d+)[ ]-[ ]?)
1471                        |(?P<message>Message[ ][#](?P<message_id>\d+)[ ]-[ ]?)
1472                        '''
1473
1474
1475                regex_str = ticket_regex + blog_regex + discussion_regex
1476                SYSTEM_RE = re.compile(regex_str, re.VERBOSE)
1477
1478                # Find out if this is a ticket, a blog or a discussion
1479                #
1480                result =  SYSTEM_RE.search(subject)
1481                if result:
1482                        # update ticket + fields
1483                        #
1484                        if result.group('reply') and self.TICKET_UPDATE:
1485                                self.system = 'ticket'
1486                                self.ticket_update(m, result.group('reply'), spam_msg)
1487
1488                        # New ticket + fields
1489                        #
1490                        elif result.group('new_fields'):
1491                                self.system = 'ticket'
1492                                self.new_ticket(m, subject[:result.start('new_fields')], spam_msg, result.group('new_fields'))
1493
1494                        if blog_enabled:
1495                                if result.group('blog'):
1496                                        self.system = 'blog'
1497                                        self.blog(result.group('blog_id'))
1498
1499                        if discussion_enabled:
1500                                # New topic.
1501                                #
1502                                if result.group('forum'):
1503                                        self.system = 'discussion'
1504                                        self.id = int(result.group('forum_id'))
1505                                        self.discussion_topic(m, subject[result.end('forum'):])
1506
1507                                # Reply to topic.
1508                                #
1509                                elif result.group('topic'):
1510                                        self.system = 'discussion'
1511                                        self.id = int(result.group('topic_id'))
1512                                        self.discussion_topic_reply(m, subject[result.end('topic'):])
1513
1514                                # Reply to topic message.
1515                                #
1516                                elif result.group('message'):
1517                                        self.system = 'discussion'
1518                                        self.id = int(result.group('message_id'))
1519                                        self.discussion_message_reply(m, subject[result.end('message'):])
1520
1521                else:
1522                        self.system = 'ticket'
1523                        result = self.ticket_update_by_subject(subject)
1524                        if result:
1525                                self.ticket_update(m, result, spam_msg)
1526                        else:
1527                                # No update by subject, so just create a new ticket
1528                                self.new_ticket(m, subject, spam_msg)
1529
1530
1531########## BODY TEXT functions  ###########################################################
1532
1533        def strip_signature(self, text):
1534                """
1535                Strip signature from message, inspired by Mailman software
1536                """
1537                body = []
1538                for line in text.splitlines():
1539                        if line == '-- ':
1540                                break
1541                        body.append(line)
1542
1543                return ('\n'.join(body))
1544
1545        def reflow(self, text, delsp = 0):
1546                """
1547                Reflow the message based on the format="flowed" specification (RFC 3676)
1548                """
1549                flowedlines = []
1550                quotelevel = 0
1551                prevflowed = 0
1552
1553                for line in text.splitlines():
1554                        from re import match
1555                       
1556                        # Figure out the quote level and the content of the current line
1557                        m = match('(>*)( ?)(.*)', line)
1558                        linequotelevel = len(m.group(1))
1559                        line = m.group(3)
1560
1561                        # Determine whether this line is flowed
1562                        if line and line != '-- ' and line[-1] == ' ':
1563                                flowed = 1
1564                        else:
1565                                flowed = 0
1566
1567                        if flowed and delsp and line and line[-1] == ' ':
1568                                line = line[:-1]
1569
1570                        # If the previous line is flowed, append this line to it
1571                        if prevflowed and line != '-- ' and linequotelevel == quotelevel:
1572                                flowedlines[-1] += line
1573                        # Otherwise, start a new line
1574                        else:
1575                                flowedlines.append('>' * linequotelevel + line)
1576
1577                        prevflowed = flowed
1578                       
1579
1580                return '\n'.join(flowedlines)
1581
1582        def strip_quotes(self, text):
1583                """
1584                Strip quotes from message by Nicolas Mendoza
1585                """
1586                body = []
1587                for line in text.splitlines():
1588                        if line.startswith(self.EMAIL_QUOTE):
1589                                continue
1590                        body.append(line)
1591
1592                return ('\n'.join(body))
1593
1594        def inline_properties(self, text):
1595                """
1596                Parse text if we use inline keywords to set ticket fields
1597                """
1598                if self.DEBUG:
1599                        print 'TD: inline_properties function'
1600
1601                properties = dict()
1602                body = list()
1603
1604                INLINE_EXP = re.compile('\s*[@]\s*([a-zA-Z]+)\s*:(.*)$')
1605
1606                for line in text.splitlines():
1607                        match = INLINE_EXP.match(line)
1608                        if match:
1609                                keyword, value = match.groups()
1610                                self.properties[keyword] = value.strip()
1611                                if self.DEBUG:
1612                                        print "TD: inline properties: %s : %s" %(keyword,value)
1613                        else:
1614                                body.append(line)
1615                               
1616                return '\n'.join(body)
1617
1618
1619        def wrap_text(self, text, replace_whitespace = False):
1620                """
1621                Will break a lines longer then given length into several small
1622                lines of size given length
1623                """
1624                import textwrap
1625
1626                LINESEPARATOR = '\n'
1627                reformat = ''
1628
1629                for s in text.split(LINESEPARATOR):
1630                        tmp = textwrap.fill(s,self.USE_TEXTWRAP)
1631                        if tmp:
1632                                reformat = '%s\n%s' %(reformat,tmp)
1633                        else:
1634                                reformat = '%s\n' %reformat
1635
1636                return reformat
1637
1638                # Python2.4 and higher
1639                #
1640                #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
1641                #
1642
1643########## EMAIL attachements functions ###########################################################
1644
1645        def inline_part(self, part):
1646                """
1647                """
1648                if self.VERBOSE:
1649                        print "VB: inline_part()"
1650
1651                return part.get_param('inline', None, 'Content-Disposition') == '' or not part.has_key('Content-Disposition')
1652
1653        def get_message_parts(self, msg):
1654                """
1655                parses the email message and returns a list of body parts and attachments
1656                body parts are returned as strings, attachments are returned as tuples of (filename, Message object)
1657                """
1658                if self.VERBOSE:
1659                        print "VB: get_message_parts()"
1660
1661                message_parts = list()
1662       
1663                ALTERNATIVE_MULTIPART = False
1664
1665                for part in msg.walk():
1666                        if self.DEBUG:
1667                                print 'TD: Message part: Main-Type: %s' % part.get_content_maintype()
1668                                print 'TD: Message part: Content-Type: %s' % part.get_content_type()
1669
1670                        ## Check content type
1671                        #
1672                        if part.get_content_type() in self.STRIP_CONTENT_TYPES:
1673
1674                                if self.DEBUG:
1675                                        print "TD: A %s attachment named '%s' was skipped" %(part.get_content_type(), part.get_filename())
1676
1677                                continue
1678
1679                        ## Catch some mulitpart execptions
1680                        #
1681                        if part.get_content_type() == 'multipart/alternative':
1682                                ALTERNATIVE_MULTIPART = True
1683                                continue
1684
1685                        ## Skip multipart containers
1686                        #
1687                        if part.get_content_maintype() == 'multipart':
1688                                if self.DEBUG:
1689                                        print "TD: Skipping multipart container"
1690                                continue
1691                       
1692                        ## 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"
1693                        #
1694                        inline = self.inline_part(part)
1695
1696                        ## Drop HTML message
1697                        #
1698                        if ALTERNATIVE_MULTIPART and self.DROP_ALTERNATIVE_HTML_VERSION:
1699                                if part.get_content_type() == 'text/html':
1700                                        if self.DEBUG:
1701                                                print "TD: Skipping alternative HTML message"
1702
1703                                        ALTERNATIVE_MULTIPART = False
1704                                        continue
1705
1706                        ## Inline text parts are where the body is
1707                        #
1708                        if part.get_content_type() == 'text/plain' and inline:
1709                                if self.DEBUG:
1710                                        print 'TD:               Inline body part'
1711
1712                                # Try to decode, if fails then do not decode
1713                                #
1714                                body_text = part.get_payload(decode=1)
1715                                if not body_text:                       
1716                                        body_text = part.get_payload(decode=0)
1717
1718                                format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
1719                                delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
1720
1721                                if self.REFLOW and not self.VERBATIM_FORMAT and format == 'flowed':
1722                                        body_text = self.reflow(body_text, delsp == 'yes')
1723       
1724                                if self.STRIP_SIGNATURE:
1725                                        body_text = self.strip_signature(body_text)
1726
1727                                if self.STRIP_QUOTES:
1728                                        body_text = self.strip_quotes(body_text)
1729
1730                                if self.INLINE_PROPERTIES:
1731                                        body_text = self.inline_properties(body_text)
1732
1733                                if self.USE_TEXTWRAP:
1734                                        body_text = self.wrap_text(body_text)
1735
1736                                ## Get contents charset (iso-8859-15 if not defined in mail headers)
1737                                #
1738                                charset = part.get_content_charset()
1739                                if not charset:
1740                                        charset = 'iso-8859-15'
1741
1742                                try:
1743                                        ubody_text = unicode(body_text, charset)
1744
1745                                except UnicodeError, detail:
1746                                        ubody_text = unicode(body_text, 'iso-8859-15')
1747
1748                                except LookupError, detail:
1749                                        ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
1750
1751                                if self.VERBATIM_FORMAT:
1752                                        message_parts.append('{{{\r\n%s\r\n}}}' %ubody_text)
1753                                else:
1754                                        message_parts.append('%s' %ubody_text)
1755                        else:
1756                                if self.DEBUG:
1757                                        try:
1758                                                print 'TD:               Filename: %s' % part.get_filename()
1759                                        except UnicodeEncodeError, detail:
1760                                                print 'TD:               Filename: Can not be printed due to non-ascii characters'
1761
1762                                ## Convert 7-bit filename to 8 bits value
1763                                #
1764                                filename = self.email_to_unicode(part.get_filename())
1765                                message_parts.append((filename, part))
1766
1767                return message_parts
1768               
1769        def unique_attachment_names(self, message_parts):
1770                """
1771                """
1772                renamed_parts = []
1773                attachment_names = set()
1774
1775                for item in message_parts:
1776                       
1777                        ## If not an attachment, leave it alone
1778                        #
1779                        if not isinstance(item, tuple):
1780                                renamed_parts.append(item)
1781                                continue
1782                               
1783                        (filename, part) = item
1784
1785                        ## If no filename, use a default one
1786                        #
1787                        if not filename:
1788                                filename = 'untitled-part'
1789
1790                                # Guess the extension from the content type, use non strict mode
1791                                # some additional non-standard but commonly used MIME types
1792                                # are also recognized
1793                                #
1794                                ext = mimetypes.guess_extension(part.get_content_type(), False)
1795                                if not ext:
1796                                        ext = '.bin'
1797
1798                                filename = '%s%s' % (filename, ext)
1799
1800                        ## Discard relative paths for windows/unix in attachment names
1801                        #
1802                        #filename = filename.replace('\\', '/').replace(':', '/')
1803                        filename = filename.replace('\\', '_')
1804                        filename = filename.replace('/', '_')
1805
1806                        #
1807                        # We try to normalize the filename to utf-8 NFC if we can.
1808                        # Files uploaded from OS X might be in NFD.
1809                        # Check python version and then try it
1810                        #
1811                        #if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
1812                        #       try:
1813                        #               filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
1814                        #       except TypeError:
1815                        #               pass
1816
1817                        # Make the filename unique for this ticket
1818                        num = 0
1819                        unique_filename = filename
1820                        dummy_filename, ext = os.path.splitext(filename)
1821
1822                        while (unique_filename in attachment_names) or self.attachment_exists(unique_filename):
1823                                num += 1
1824                                unique_filename = "%s-%s%s" % (dummy_filename, num, ext)
1825                               
1826                        if self.DEBUG:
1827                                try:
1828                                        print 'TD: Attachment with filename %s will be saved as %s' % (filename, unique_filename)
1829                                except UnicodeEncodeError, detail:
1830                                        print 'Filename can not be printed due to non-ascii characters'
1831
1832                        attachment_names.add(unique_filename)
1833
1834                        renamed_parts.append((filename, unique_filename, part))
1835       
1836                return renamed_parts
1837                       
1838                       
1839        def attachment_exists(self, filename):
1840
1841                if self.DEBUG:
1842                        s = 'TD: attachment already exists: Id : '
1843                        try:
1844                                print "%s%s, Filename : %s" % (s, self.id, filename)
1845                        except UnicodeEncodeError, detail:
1846                                print "%s%s, Filename : Can not be printed due to non-ascii characters" %(s, self.id)
1847
1848                # We have no valid ticket id
1849                #
1850                if not self.id:
1851                        return False
1852
1853                try:
1854                        if self.system == 'discussion':
1855                                att = attachment.Attachment(self.env, 'discussion', 'ticket/%s'
1856                                  % (self.id,), filename)
1857                        else:
1858                                att = attachment.Attachment(self.env, 'ticket', self.id,
1859                                  filename)
1860                        return True
1861                except attachment.ResourceNotFound:
1862                        return False
1863
1864########## TRAC Ticket Text ###########################################################
1865                       
1866        def body_text(self, message_parts):
1867                body_text = []
1868               
1869                for part in message_parts:
1870                        # Plain text part, append it
1871                        if not isinstance(part, tuple):
1872                                body_text.extend(part.strip().splitlines())
1873                                body_text.append("")
1874                                continue
1875                               
1876                        (original, filename, part) = part
1877                        inline = self.inline_part(part)
1878                       
1879                        if part.get_content_maintype() == 'image' and inline:
1880                                if self.system != 'discussion':
1881                                        body_text.append('[[Image(%s)]]' % filename)
1882                                body_text.append("")
1883                        else:
1884                                if self.system != 'discussion':
1885                                        body_text.append('[attachment:"%s"]' % filename)
1886                                body_text.append("")
1887                               
1888                body_text = '\r\n'.join(body_text)
1889                return body_text
1890
1891        def html_mailto_link(self, subject):
1892                """
1893                This function returns a HTML mailto tag with the ticket id and author email address
1894                """
1895                if not self.author:
1896                        author = self.email_addr
1897                else:   
1898                        author = self.author
1899
1900                # use urllib to escape the chars
1901                #
1902                s = 'mailto:%s?Subject=%s&Cc=%s' %(
1903                       urllib.quote(self.email_addr),
1904                           urllib.quote('Re: #%s: %s' %(self.id, subject)),
1905                           urllib.quote(self.MAILTO_CC)
1906                           )
1907
1908                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)
1909                return s
1910
1911########## TRAC notify section ###########################################################
1912
1913        def notify(self, tkt, new=True, modtime=0):
1914                """
1915                A wrapper for the TRAC notify function. So we can use templates
1916                """
1917                if self.DRY_RUN:
1918                                print 'DRY_RUN: self.notify(tkt, True) reporter = %s' %tkt['reporter']
1919                                return
1920                try:
1921                        # create false {abs_}href properties, to trick Notify()
1922                        #
1923                        if not self.VERSION == 0.11:
1924                                self.env.abs_href = Href(self.get_config('project', 'url'))
1925                                self.env.href = Href(self.get_config('project', 'url'))
1926
1927                        tn = TicketNotifyEmail(self.env)
1928
1929                        if self.notify_template:
1930
1931                                if self.VERSION == 0.11:
1932
1933                                        from trac.web.chrome import Chrome
1934
1935                                        if self.notify_template_update and not new:
1936                                                tn.template_name = self.notify_template_update
1937                                        else:
1938                                                tn.template_name = self.notify_template
1939
1940                                        tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
1941                                               
1942                                else:
1943
1944                                        tn.template_name = self.notify_template;
1945
1946                        tn.notify(tkt, new, modtime)
1947
1948                except Exception, e:
1949                        print 'TD: Failure sending notification on creation of ticket #%s: %s' %(self.id, e)
1950
1951
1952
1953########## Parse Config File  ###########################################################
1954
1955def ReadConfig(file, name):
1956        """
1957        Parse the config file
1958        """
1959        if not os.path.isfile(file):
1960                print 'File %s does not exist' %file
1961                sys.exit(1)
1962
1963        config = trac_config.Configuration(file)
1964
1965        # Use given project name else use defaults
1966        #
1967        if name:
1968                sections = config.sections()
1969                if not name in sections:
1970                        print "Not a valid project name: %s" %name
1971                        print "Valid names: %s" %sections
1972                        sys.exit(1)
1973
1974                project =  dict()
1975                for option, value in  config.options(name):
1976                        project[option] = value
1977
1978        else:
1979                # use some trac internals to get the defaults
1980                #
1981                project = config.parser.defaults()
1982
1983        return project
1984
1985
1986if __name__ == '__main__':
1987        # Default config file
1988        #
1989        configfile = '@email2trac_conf@'
1990        project = ''
1991        component = ''
1992        ticket_prefix = 'default'
1993        dry_run = None
1994        verbose = None
1995
1996        ENABLE_SYSLOG = 0
1997
1998        SHORT_OPT = 'chf:np:t:v'
1999        LONG_OPT  =  ['component=', 'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=', 'verbose']
2000
2001        try:
2002                opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
2003        except getopt.error,detail:
2004                print __doc__
2005                print detail
2006                sys.exit(1)
2007       
2008        project_name = None
2009        for opt,value in opts:
2010                if opt in [ '-h', '--help']:
2011                        print __doc__
2012                        sys.exit(0)
2013                elif opt in ['-c', '--component']:
2014                        component = value
2015                elif opt in ['-f', '--file']:
2016                        configfile = value
2017                elif opt in ['-n', '--dry-run']:
2018                        dry_run = True
2019                elif opt in ['-p', '--project']:
2020                        project_name = value
2021                elif opt in ['-t', '--ticket_prefix']:
2022                        ticket_prefix = value
2023                elif opt in ['-v', '--version']:
2024                        verbose = True
2025       
2026        settings = ReadConfig(configfile, project_name)
2027        if not settings.has_key('project'):
2028                print __doc__
2029                print 'No Trac project is defined in the email2trac config file.'
2030                sys.exit(1)
2031       
2032        if component:
2033                settings['component'] = component
2034
2035        # The default prefix for ticket values in email2trac.conf
2036        #
2037        settings['ticket_prefix'] = ticket_prefix
2038        settings['dry_run'] = dry_run
2039        settings['verbose'] = verbose
2040       
2041        if settings.has_key('trac_version'):
2042                version = settings['trac_version']
2043        else:
2044                version = trac_default_version
2045
2046
2047        #debug HvB
2048        #print settings
2049
2050        try:
2051                if version == '0.9':
2052                        from trac import attachment
2053                        from trac.env import Environment
2054                        from trac.ticket import Ticket
2055                        from trac.web.href import Href
2056                        from trac import util
2057                        from trac.Notify import TicketNotifyEmail
2058                elif version == '0.10':
2059                        from trac import attachment
2060                        from trac.env import Environment
2061                        from trac.ticket import Ticket
2062                        from trac.web.href import Href
2063                        from trac import util
2064                        #
2065                        # return  util.text.to_unicode(str)
2066                        #
2067                        # see http://projects.edgewall.com/trac/changeset/2799
2068                        from trac.ticket.notification import TicketNotifyEmail
2069                        from trac import config as trac_config
2070                        from trac.core import TracError
2071
2072                elif version == '0.11':
2073                        from trac import attachment
2074                        from trac.env import Environment
2075                        from trac.ticket import Ticket
2076                        from trac.web.href import Href
2077                        from trac import config as trac_config
2078                        from trac import util
2079                        from trac.core import TracError
2080
2081
2082                        #
2083                        # return  util.text.to_unicode(str)
2084                        #
2085                        # see http://projects.edgewall.com/trac/changeset/2799
2086                        from trac.ticket.notification import TicketNotifyEmail
2087                else:
2088                        print 'TRAC version %s is not supported' %version
2089                        sys.exit(1)
2090                       
2091                if settings.has_key('enable_syslog'):
2092                        if SYSLOG_AVAILABLE:
2093                                ENABLE_SYSLOG =  float(settings['enable_syslog'])
2094
2095
2096                # Must be set before environment is created
2097                #
2098                if settings.has_key('python_egg_cache'):
2099                        python_egg_cache = str(settings['python_egg_cache'])
2100                        os.environ['PYTHON_EGG_CACHE'] = python_egg_cache
2101
2102       
2103                if int(settings['debug']) > 0:
2104                        print 'Loading environment', settings['project']
2105
2106                env = Environment(settings['project'], create=0)
2107
2108                tktparser = TicketEmailParser(env, settings, float(version))
2109                tktparser.parse(sys.stdin)
2110
2111        # Catch all errors ans log to SYSLOG if we have enabled this
2112        # else stdout
2113        #
2114        except Exception, error:
2115                if ENABLE_SYSLOG:
2116                        syslog.openlog('email2trac', syslog.LOG_NOWAIT)
2117
2118                        etype, evalue, etb = sys.exc_info()
2119                        for e in traceback.format_exception(etype, evalue, etb):
2120                                syslog.syslog(e)
2121
2122                        syslog.closelog()
2123                else:
2124                        traceback.print_exc()
2125
2126                if m:
2127                        tktparser.save_email_for_debug(m, True)
2128
2129
2130                sys.exit(1)
2131# EOB
Note: See TracBrowser for help on using the repository browser.