##############################################################################
# Copyright (c) 2005 The Open Planning Project, Rob Miller, Alec Mitchell
# All Rights Reserved.
#
# This software is subject to the provisions of the GNU General Public
# License, Version 2.0 (GPL).  A copy of the GPL should accompany this
# distribution.  THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL
# EXPRESS OR IMPLIED WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF TITLE, MERCHANTABILITY,
# AGAINST INFRINGEMENT, AND FITNESS FOR A PARTICULAR PURPOSE.
##############################################################################

import re
from cStringIO import StringIO
from DateTime import DateTime
from random import random

from AccessControl import ClassSecurityInfo, Unauthorized
from BTrees.OOBTree import OOBTree
from ZODB.POSException import ConflictError

from Products.CMFCore.utils import UniqueObject, getToolByName
from Products.CMFPlone.PloneTool import AllowSendto
from Products.CMFPlone.utils import transaction_note
import Products.Archetypes.public as atapi

# match opening quotes in mails
quote_regex = re.compile('^((?:> )+)')

schema = atapi.BaseContent.schema.copy() + \
         atapi.Schema((

    atapi.BooleanField('useAnonByDefault',
                       mode='rw',
                       default=True,
                       widget=atapi.BooleanWidget(
                           label='Anonymize by default?',
                           label_msgid='label_useAnonByDefault',
                           description="Site wide setting determining whether or not " \
                                       "form generated emails will by anonymized by " \
                                       "default.  Can be overridden by a member property.",
                           description_msgid='help_useAnonByDefault',
                           ),
                       ),

    atapi.IntegerField('timeoutDays',
                       mode='rw',
                       default=14, # 2 weeks
                       required=1,
                       widget=atapi.IntegerWidget(
                           label='Message Timeout (in days)',
                           label_msgid='label_timeoutDays',
                           description="The number of days a mail message will be stored for " \
                                       "reply quoting before being removed from the cache.",
                           description_msgid='help_timeoutDays',
                           size=5,
                           ),
                       ),

    atapi.StringField('returnAddress',
                      mode='rw',
                      default='noreply@localhost',
                      required=1,
                      widget=atapi.StringWidget(
                          label='Anonymous Return Address',
                          label_msgid='label_returnAddress',
                          description="When senders anonymize their return address " \
                                      "this address will be used as the 'From' header " \
                                      "for the message that is sent.  This must be a well " \
                                      "formed email address, but it is recommended that " \
                                      "this NOT actualy be a valid address (e.g. " \
                                      "'noreply@yourdomain.com' is a good choice).",
                          description_msgid='help_returnAddress',
                          size=50,
                          ),
                      ),

    atapi.IntegerField('reflowWidth',
                       mode='rw',
                       default=78,
                       required=1,
                       widget=atapi.IntegerWidget(
                           label='Reply Text Line Length',
                           label_msgid='label_reflowWidth',
                           description="When anonymously sent messages are being quoted" \
                                       "for reply, this is the maximum line length that " \
                                       "will be allowed before the line is split and " \
                                       "flowed on to the next line.",
                           description_msgid='help_reflowWidth',
                           size=3,
                           ),
                       ),
    ))


class AnonymailerTool(UniqueObject, atapi.BaseContent):
    """
    Stores text of messages that are sent via the site's mail form.
    Messages are cached for a configurable period of time, to be used
    for quoting replies to the original message.
    """
    id = 'portal_anonymailer'
    meta_type = portal_type = archetype_name = 'AnonymailerTool'
    security = ClassSecurityInfo()
    schema = schema

    def __init__(self):
        self._msgcache = OOBTree()
        atapi.BaseContent.__init__(self, self.id)

    def validate_returnAddress(self, address):
        """
        Make sure a well formed email address has been submitted.
        """
        plone_utils = getToolByName(self, 'plone_utils')
        if not plone_utils.validateSingleEmailAddress(address):
            msg = "This must be a well formed email address " \
                  "(e.g. 'noreply@somedomain.com')."
            return self.translate(msg, default=msg)

    def cacheMessageVars(self, mem_id, msg_vars):
        """
        Cache the sent message variables for possible reply quoting.  Expects
        the 'msg_vars' argument to be the 'variables' dictionary defined in
        the sendto.cpy script.        
        """
        now = DateTime()
        time = '%s.%s' % (now.strftime('%Y-%m-%d'), str(now.millis())[7:])
        rand = str(random())[2:6]
        msg_key = '.'.join((time, rand, mem_id))
        self._msgcache[msg_key] = msg_vars
        self._p_changed = 1
        return msg_key

    def getCachedMessageVars(self, msg_key):
        """
        Return the cached message variables for the provided key.
        """
        return self._msgcache.get(msg_key)

    def sendMessage(self, form, msg_vars, template_id, state_failure='failure',
                    test_mode=False):
        """
        Send a message.

        o form - request input form containing data for the message;
                 should contain 'subject', 'message', 'anonymize',
                 possibly 'send_from_address' (only if sender is anon)

        o msg_vars - a dictionary that will be fed to the template
                      (and possibly cached) to populate dynamic values;
                      this method will compute add'l values to insert,
                      must contain 'recip_id' key w/ value of recipient
                      member id (can be a string or a list)

        o template_id - page template containing the message to be sent
                        (must be available via acquisition)

        o state_failure - string to use as the failure status if an error
                          needs to be passed back to the calling .cpy

        o test_mode - if true, don't actually send the message
        """
        plone_utils = getToolByName(self, 'plone_utils')
        mtool = getToolByName(self, 'portal_membership')
        site_properties = getToolByName(self, 'portal_properties').site_properties

        if not mtool.checkPermission(AllowSendto, self):
            return {'status': 'failure',
                    'portal_status_message':
                    'You are not allowed to send mail through this site.'}

        anonymize = form.get('anonymize', 0)
        try:
            anonymize = int(anonymize)
        except ValueError:
            pass

        # determine the correct reply address to use
        if mtool.isAnonymousUser():
            send_from_address = form.get('send_from_address')
            mem_id = ''
        else:
            mem = mtool.getAuthenticatedMember()
            mem_id = mem.getId()

            if anonymize:
                send_from_address = self.getReturnAddress()
            else:
                send_from_address = mem.getProperty('email')

        if send_from_address == '':
            psm = "Could not find a valid email address"
            return {'status': state_failure, 'portal_status_message': psm}

        # determine the correct address to send to
        recip_ids = msg_vars['recip_id']
        if type(recip_ids) == type(''):
            recip_ids = [recip_ids]

        send_to_address = []
        for recip_id in recip_ids:
            sendto_mem = mtool.getMemberById(recip_id)
            try:
                send_to_address.append(sendto_mem.getProperty('email'))
            except Unauthorized: # using CMFMember, explicit sec'ty checks
                send_to_address.append(sendto_mem.getEmail())

        msg_vars.update(
            {'subject'           : form.get('subject'),
             'message'           : form.get('message'),
             'envelope_from'     : site_properties.email_from_address,
             'sender_id'         : mem_id,
             }
            )

        if anonymize:
            msg_key = self.cacheMessageVars(mem_id, msg_vars.copy())
            msg_vars['msg_key'] = msg_key

        msg_vars['template'] = template_id
        comment = msg_vars['message']

        if test_mode:
            return msg_vars

        try:
            plone_utils.sendto(send_to_address, send_from_address,
                               comment, **msg_vars)
        except:
            exception = plone_utils.exceptionString()
            psm = self.translate("Unable to send mail: ${exception}",
                                 {'exception': exception})
            return {'status': state_failure, 'portal_status_message': psm}

        tmsg = "Sent mail to %s" % recip_id
        transaction_note(tmsg)

        return {'portal_status_message':'Mail sent.'}

    def anonReply(self, msg_key, form, test_mode=False):
        """
        Perform the anonymous reply delivery.  This code lives in the tool and
        not in the anon_reply.cpy script for security reasons.

        If test_mode=True then the mail will not actually be delivered, and the
        cached variables will be returned.
        """
        orig_msg_vars = self.getCachedMessageVars(msg_key)
        if not orig_msg_vars:
            return {'status': 'failure',
                    'portal_status_message':
                    'Message expired; unable to send reply.'}

        sendto_memid = orig_msg_vars.get('sender_id')

        msg_vars = {'recip_id': sendto_memid}

        template_id = 'anon_reply_template'
        result = self.sendMessage(form, msg_vars, template_id, test_mode)
        return result

    def rewrapMessage(self, body, add_quote=False):
        """
        Rewraps an email message at a specific number of chars,
        preserving quote characters, and optionally adding an extra
        level of quoting.

        For complete description of the behaviour, see the 'wordwrap.txt'
        doc test in the tests directory.
        """
        new_body = ''
        body = StringIO(body)
        combine = False
        for line in body:
            # Optionally, add a '> ' for quoting:
            if add_quote:
                line = '> %s'%line
            # Find the quote chars:
            quotes = quote_regex.match(line)
            if quotes:
                quotes = quotes.group(1)
            else:
                quotes = ''
            if combine:
                # Only reflow with the next line if they are at the same quote
                # level
                old_quotes = quote_regex.match(combine_line)
                old_quotes = old_quotes and old_quotes.group(1) or ''
                if old_quotes == quotes:
                    # Combine the two lines, removing the quotes from the second
                    line = '\n' + combine_line.replace('\n',' ') + quote_regex.sub('', line)
                else:
                    new_body += '\n' + combine_line
            new_lines = self.splitOnSpace(line, quotes)
            if len(new_lines) > 1:
                # If we split the line into multiple lines, we want to reflow the
                # last split line with the next line
                combine = True
                combine_line = new_lines[-1]
                new_text = '\n'.join(new_lines[:-1])
            else:
                combine = False
                new_text = new_lines[0]
            new_body += new_text
        # We may have missed a split line
        if combine:
            new_body += '\n' + combine_line
        return new_body

    def splitOnSpace(self, line, quote_chars):
        # XXX: rewrap poorly by breaking at the earliest word and using
        # that as the next line.
        # The length of the line minus any trailing whitespace
        max_mail_width = self.getReflowWidth()
        line_len = len(line.rstrip())
        # Return if the line is acceptable as is
        if line_len <= max_mail_width:
            return (line,)
        # find the next space, start at the character before the
        # newline, and quit at the quotes:
        for space_index in range(max_mail_width, len(quote_chars)-1, -1):
            if line[space_index] in [' ','\t']:
                break
        # If we didn't find a space then we have a long string with no
        # spaces, we need to ensure that there are no spaces after this
        # string to break on
        if space_index <= len(quote_chars):
            for space_index in range(max_mail_width+1, line_len):
                if line[space_index] in [' ','\t']:
                    break
        if space_index >= line_len-1:
            return (line,)
        else:
            first_line = line[:space_index]
            next_line = quote_chars + line[space_index+1:]
            # Split the newly split line if necessary
            next_lines = self.splitOnSpace(next_line, quote_chars)
        return (first_line ,) + next_lines

atapi.registerType(AnonymailerTool)
