[CalendarServer-changes] [10760] CalendarServer/branches/users/gaya/sharedgroups/twistedcaldav/ scheduling/imip

source_changes at macosforge.org source_changes at macosforge.org
Tue Feb 19 12:42:18 PST 2013


Revision: 10760
          http://trac.calendarserver.org//changeset/10760
Author:   gaya at apple.com
Date:     2013-02-19 12:42:18 -0800 (Tue, 19 Feb 2013)
Log Message:
-----------
add imip files

Added Paths:
-----------
    CalendarServer/branches/users/gaya/sharedgroups/twistedcaldav/scheduling/imip/inbound.py
    CalendarServer/branches/users/gaya/sharedgroups/twistedcaldav/scheduling/imip/outbound.py
    CalendarServer/branches/users/gaya/sharedgroups/twistedcaldav/scheduling/imip/smtpsender.py

Added: CalendarServer/branches/users/gaya/sharedgroups/twistedcaldav/scheduling/imip/inbound.py
===================================================================
--- CalendarServer/branches/users/gaya/sharedgroups/twistedcaldav/scheduling/imip/inbound.py	                        (rev 0)
+++ CalendarServer/branches/users/gaya/sharedgroups/twistedcaldav/scheduling/imip/inbound.py	2013-02-19 20:42:18 UTC (rev 10760)
@@ -0,0 +1,632 @@
+##
+# Copyright (c) 2005-2013 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+##
+
+"""
+Inbound IMIP mail handling for Calendar Server
+"""
+import datetime
+from calendarserver.tap.util import FakeRequest
+import email.utils
+from twext.enterprise.dal.record import fromTable
+from twext.enterprise.queue import WorkItem
+from twext.python.log import Logger, LoggingMixIn
+from twisted.application import service
+from twisted.internet import protocol, defer, ssl
+from twisted.internet.defer import inlineCallbacks, returnValue, succeed
+from twisted.mail import pop3client, imap4
+from twisted.mail.smtp import messageid
+from twistedcaldav.config import config
+from twistedcaldav.ical import Property, Component
+from twistedcaldav.scheduling.imip.scheduler import IMIPScheduler
+from twistedcaldav.scheduling.imip.smtpsender import SMTPSender
+from twistedcaldav.scheduling.itip import iTIPRequestStatus
+from txdav.common.datastore.sql_tables import schema
+from twext.internet.gaiendpoint import GAIEndpoint
+
+
+log = Logger()
+
+#
+# Monkey patch imap4.log so it doesn't emit useless logging,
+# specifically, "Unhandled unsolicited response" nonsense.
+#
+class IMAPLogger(Logger):
+    def emit(self, level, message, *args, **kwargs):
+        if message.startswith("Unhandled unsolicited response:"):
+            return
+
+        Logger.emit(self, level, message, *args, **kwargs)
+
+imap4.log = IMAPLogger()
+
+
+""" SCHEMA:
+create table IMIP_REPLY_WORK (
+  WORK_ID                       integer primary key default nextval('WORKITEM_SEQ') not null,
+  NOT_BEFORE                    timestamp    default timezone('UTC', CURRENT_TIMESTAMP),
+  ORGANIZER                     varchar(255) not null,
+  ATTENDEE                      varchar(255) not null,
+  ICALENDAR_TEXT                text         not null
+);
+create table IMIP_POLLING_WORK (
+  WORK_ID                       integer primary key default nextval('WORKITEM_SEQ') not null,
+  NOT_BEFORE                    timestamp    default timezone('UTC', CURRENT_TIMESTAMP)
+);
+"""
+
+class IMIPReplyWork(WorkItem, fromTable(schema.IMIP_REPLY_WORK)):
+
+    @inlineCallbacks
+    def doWork(self):
+        rootResource = self.transaction._rootResource
+        calendar = Component.fromString(self.icalendarText)
+        yield injectMessage(self.transaction, rootResource, self.organizer, self.attendee,
+            calendar)
+
+class IMIPPollingWork(WorkItem, fromTable(schema.IMIP_POLLING_WORK)):
+
+    # FIXME: delete all other polling work items
+    # FIXME: purge all old tokens here
+
+    @inlineCallbacks
+    def doWork(self):
+        mailRetriever = self.transaction._mailRetriever
+        if mailRetriever is not None:
+            try:
+                yield mailRetriever.fetchMail()
+            except Exception, e:
+                log.error("Failed to fetch mail (%s)" % (e,))
+            finally:
+                yield mailRetriever.scheduleNextPoll()
+
+
+class MailRetriever(service.Service):
+
+    def __init__(self, store, directory, settings, reactor=None):
+        self.store = store
+        self.settings = settings
+        if reactor is None:
+            from twisted.internet import reactor
+        self.reactor = reactor
+
+        self.mailReceiver = MailReceiver(store, directory)
+        mailType = settings['Type']
+        if mailType.lower().startswith('pop'):
+            self.factory = POP3DownloadFactory
+        else:
+            self.factory = IMAP4DownloadFactory
+
+        contextFactory = None
+        if settings["UseSSL"]:
+            contextFactory = ssl.ClientContextFactory()
+        self.point = GAIEndpoint(self.reactor, settings.Server,
+            settings.Port, contextFactory=contextFactory)
+
+    def startService(self):
+        return self.scheduleNextPoll(seconds=0)
+
+
+    def fetchMail(self):
+        return self.point.connect(self.factory(self.settings, self.mailReceiver))
+
+    @inlineCallbacks
+    def scheduleNextPoll(self, seconds=None):
+        if seconds is None:
+            seconds = self.settings["PollingSeconds"]
+        txn = self.store.newTransaction()
+        notBefore = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds)
+        yield txn.enqueue(IMIPPollingWork, notBefore=notBefore)
+        yield txn.commit()
+
+
+
+class MailReceiver(object):
+
+    NO_TOKEN = 0
+    UNKNOWN_TOKEN = 1
+    MALFORMED_TO_ADDRESS = 2
+    NO_ORGANIZER_ADDRESS = 3
+    REPLY_FORWARDED_TO_ORGANIZER = 4
+    INJECTION_SUBMITTED = 5
+
+    # What about purge( ) and lowercase( )
+    def __init__(self, store, directory):
+        self.store = store
+        self.directory = directory
+
+    def checkDSN(self, message):
+        # returns (isdsn, action, icalendar attachment)
+
+        report = deliveryStatus = calBody = None
+
+        for part in message.walk():
+            contentType = part.get_content_type()
+            if contentType == "multipart/report":
+                report = part
+                continue
+            elif contentType == "message/delivery-status":
+                deliveryStatus = part
+                continue
+            elif contentType == "message/rfc822":
+                #original = part
+                continue
+            elif contentType == "text/calendar":
+                calBody = part.get_payload(decode=True)
+                continue
+
+        if report is not None and deliveryStatus is not None:
+            # we have what appears to be a dsn
+
+            lines = str(deliveryStatus).split("\n")
+            for line in lines:
+                lower = line.lower()
+                if lower.startswith("action:"):
+                    # found action:
+                    action = lower.split(' ')[1]
+                    break
+            else:
+                action = None
+
+            return True, action, calBody
+
+        else:
+            # not a dsn
+            return False, None, None
+
+
+    def _extractToken(self, text):
+        try:
+            pre, _ignore_post = text.split('@')
+            pre, token = pre.split('+')
+            return token
+        except ValueError:
+            return None
+
+
+    @inlineCallbacks
+    def processDSN(self, calBody, msgId):
+        calendar = Component.fromString(calBody)
+        # Extract the token (from organizer property)
+        organizer = calendar.getOrganizer()
+        token = self._extractToken(organizer)
+        if not token:
+            log.error("Mail gateway can't find token in DSN %s" % (msgId,))
+            return
+
+        txn = self.store.newTransaction()
+        result = (yield txn.imipLookupByToken(token))
+        yield txn.commit()
+        try:
+            # Note the results are returned as utf-8 encoded strings
+            organizer, attendee, icaluid = result[0]
+        except:
+            # This isn't a token we recognize
+            log.error("Mail gateway found a token (%s) but didn't "
+                           "recognize it in message %s"
+                           % (token, msgId))
+            returnValue(self.UNKNOWN_TOKEN)
+
+        calendar.removeAllButOneAttendee(attendee)
+        calendar.getOrganizerProperty().setValue(organizer)
+        for comp in calendar.subcomponents():
+            if comp.name() == "VEVENT":
+                comp.addProperty(Property("REQUEST-STATUS",
+                    ["5.1", "Service unavailable"]))
+                break
+        else:
+            # no VEVENT in the calendar body.
+            # TODO: what to do in this case?
+            pass
+
+        log.warn("Mail gateway processing DSN %s" % (msgId,))
+        txn = self.store.newTransaction()
+        yield txn.enqueue(IMIPReplyWork, organizer=organizer, attendee=attendee,
+            icalendarText=str(calendar))
+        yield txn.commit()
+        returnValue(self.INJECTION_SUBMITTED)
+
+
+    @inlineCallbacks
+    def processReply(self, msg):
+        # extract the token from the To header
+        _ignore_name, addr = email.utils.parseaddr(msg['To'])
+        if addr:
+            # addr looks like: server_address+token at example.com
+            token = self._extractToken(addr)
+            if not token:
+                log.error("Mail gateway didn't find a token in message "
+                               "%s (%s)" % (msg['Message-ID'], msg['To']))
+                returnValue(self.NO_TOKEN)
+        else:
+            log.error("Mail gateway couldn't parse To: address (%s) in "
+                           "message %s" % (msg['To'], msg['Message-ID']))
+            returnValue(self.MALFORMED_TO_ADDRESS)
+
+        txn = self.store.newTransaction()
+        result = (yield txn.imipLookupByToken(token))
+        yield txn.commit()
+        try:
+            # Note the results are returned as utf-8 encoded strings
+            organizer, attendee, icaluid = result[0]
+        except:
+            # This isn't a token we recognize
+            log.error("Mail gateway found a token (%s) but didn't "
+                           "recognize it in message %s"
+                           % (token, msg['Message-ID']))
+            returnValue(self.UNKNOWN_TOKEN)
+
+        for part in msg.walk():
+            if part.get_content_type() == "text/calendar":
+                calBody = part.get_payload(decode=True)
+                break
+        else:
+            # No icalendar attachment
+            log.warn("Mail gateway didn't find an icalendar attachment "
+                          "in message %s" % (msg['Message-ID'],))
+
+            toAddr = None
+            fromAddr = attendee[7:]
+            if organizer.startswith("mailto:"):
+                toAddr = organizer[7:]
+            elif organizer.startswith("urn:uuid:"):
+                guid = organizer[9:]
+                record = self.directory.recordWithGUID(guid)
+                if record and record.emailAddresses:
+                    toAddr = list(record.emailAddresses)[0]
+
+            if toAddr is None:
+                log.error("Don't have an email address for the organizer; "
+                               "ignoring reply.")
+                returnValue(self.NO_ORGANIZER_ADDRESS)
+
+            settings = config.Scheduling["iMIP"]["Sending"]
+            smtpSender = SMTPSender(settings.Username, settings.Password,
+                settings.UseSSL, settings.Server, settings.Port)
+
+            del msg["From"]
+            msg["From"] = fromAddr
+            del msg["Reply-To"]
+            msg["Reply-To"] = fromAddr
+            del msg["To"]
+            msg["To"] = toAddr
+            log.warn("Mail gateway forwarding reply back to organizer")
+            yield smtpSender.sendMessage(fromAddr, toAddr, messageid(), msg)
+            returnValue(self.REPLY_FORWARDED_TO_ORGANIZER)
+
+        # Process the imip attachment; inject to calendar server
+
+        log.debug(calBody)
+        calendar = Component.fromString(calBody)
+        event = calendar.mainComponent()
+
+        calendar.removeAllButOneAttendee(attendee)
+        organizerProperty = calendar.getOrganizerProperty()
+        if organizerProperty is None:
+            # ORGANIZER is required per rfc2446 section 3.2.3
+            log.warn("Mail gateway didn't find an ORGANIZER in REPLY %s"
+                          % (msg['Message-ID'],))
+            event.addProperty(Property("ORGANIZER", organizer))
+        else:
+            organizerProperty.setValue(organizer)
+
+        if not calendar.getAttendees():
+            # The attendee we're expecting isn't there, so add it back
+            # with a SCHEDULE-STATUS of SERVICE_UNAVAILABLE.
+            # The organizer will then see that the reply was not successful.
+            attendeeProp = Property("ATTENDEE", attendee,
+                params={
+                    "SCHEDULE-STATUS": iTIPRequestStatus.SERVICE_UNAVAILABLE,
+                }
+            )
+            event.addProperty(attendeeProp)
+
+            # TODO: We have talked about sending an email to the reply-to
+            # at this point, to let them know that their reply was missing
+            # the appropriate ATTENDEE.  This will require a new localizable
+            # email template for the message.
+
+        txn = self.store.newTransaction()
+        yield txn.enqueue(IMIPReplyWork, organizer=organizer, attendee=attendee,
+            icalendarText=str(calendar))
+        yield txn.commit()
+        returnValue(self.INJECTION_SUBMITTED)
+
+
+    # returns a deferred
+    def inbound(self, message):
+
+        try:
+            msg = email.message_from_string(message)
+
+            isDSN, action, calBody = self.checkDSN(msg)
+            if isDSN:
+                if action == 'failed' and calBody:
+                    # This is a DSN we can handle
+                    return self.processDSN(calBody, msg['Message-ID'])
+                else:
+                    # It's a DSN without enough to go on
+                    log.error("Mail gateway can't process DSN %s"
+                                   % (msg['Message-ID'],))
+                    return succeed(None)
+
+            log.info("Mail gateway received message %s from %s to %s" %
+                (msg['Message-ID'], msg['From'], msg['To']))
+
+            return self.processReply(msg)
+
+        except Exception, e:
+            # Don't let a failure of any kind stop us
+            log.error("Failed to process message: %s" % (e,))
+        return succeed(None)
+
+ at inlineCallbacks
+def injectMessage(txn, root, organizer, attendee, calendar):
+
+    request = FakeRequest(root, None, "/", transaction=txn)
+    resource = root.getChild("principals")
+    scheduler = IMIPScheduler(request, resource)
+    scheduler.originator = attendee
+    scheduler.recipients = [organizer,]
+    scheduler.calendar = calendar
+
+    try:
+        results = (yield scheduler.doScheduling())
+        log.info("Successfully injected iMIP response from %s to %s" %
+            (attendee, organizer))
+    except Exception, e:
+        log.error("Failed to inject iMIP response (%s)" % (e,))
+        raise
+
+    returnValue(results)
+
+
+    
+
+
+#
+# POP3
+#
+
+
+class POP3DownloadProtocol(pop3client.POP3Client, LoggingMixIn):
+    allowInsecureLogin = False
+
+    def serverGreeting(self, greeting):
+        self.log_debug("POP servergreeting")
+        pop3client.POP3Client.serverGreeting(self, greeting)
+        login = self.login(self.factory.settings["Username"],
+            self.factory.settings["Password"])
+        login.addCallback(self.cbLoggedIn)
+        login.addErrback(self.cbLoginFailed)
+
+
+    def cbLoginFailed(self, reason):
+        self.log_error("POP3 login failed for %s" %
+            (self.factory.settings["Username"],))
+        return self.quit()
+
+
+    def cbLoggedIn(self, result):
+        self.log_debug("POP loggedin")
+        return self.listSize().addCallback(self.cbGotMessageSizes)
+
+
+    def cbGotMessageSizes(self, sizes):
+        self.log_debug("POP gotmessagesizes")
+        downloads = []
+        for i in range(len(sizes)):
+            downloads.append(self.retrieve(i).addCallback(self.cbDownloaded, i))
+        return defer.DeferredList(downloads).addCallback(self.cbFinished)
+
+
+    def cbDownloaded(self, lines, id):
+        self.log_debug("POP downloaded message %d" % (id,))
+        self.factory.handleMessage("\r\n".join(lines))
+        self.log_debug("POP deleting message %d" % (id,))
+        self.delete(id)
+
+
+    def cbFinished(self, results):
+        self.log_debug("POP finished")
+        return self.quit()
+
+
+
+class POP3DownloadFactory(protocol.ClientFactory, LoggingMixIn):
+    protocol = POP3DownloadProtocol
+
+    def __init__(self, settings, mailReceiver):
+        self.mailReceiver = mailReceiver
+        self.noisy = False
+
+
+    def clientConnectionLost(self, connector, reason):
+        self.connector = connector
+        self.log_debug("POP factory connection lost")
+
+
+    def clientConnectionFailed(self, connector, reason):
+        self.connector = connector
+        self.log_info("POP factory connection failed")
+
+
+    def handleMessage(self, message):
+        self.log_debug("POP factory handle message")
+        self.log_debug(message)
+        return self.mailReceiver.inbound(message)
+
+
+
+#
+# IMAP4
+#
+
+
+class IMAP4DownloadProtocol(imap4.IMAP4Client, LoggingMixIn):
+
+    def serverGreeting(self, capabilities):
+        self.log_debug("IMAP servergreeting")
+        return self.authenticate(self.factory.settings["Password"]
+            ).addCallback(self.cbLoggedIn
+            ).addErrback(self.ebAuthenticateFailed)
+
+
+    def ebLogError(self, error):
+        self.log_error("IMAP Error: %s" % (error,))
+
+
+    def ebAuthenticateFailed(self, reason):
+        self.log_debug("IMAP authenticate failed for %s, trying login" %
+            (self.factory.settings["Username"],))
+        return self.login(self.factory.settings["Username"],
+            self.factory.settings["Password"]
+            ).addCallback(self.cbLoggedIn
+            ).addErrback(self.ebLoginFailed)
+
+
+    def ebLoginFailed(self, reason):
+        self.log_error("IMAP login failed for %s" %
+            (self.factory.settings["Username"],))
+        self.transport.loseConnection()
+
+
+    def cbLoggedIn(self, result):
+        self.log_debug("IMAP logged in [%s]" % (self.state,))
+        self.select("Inbox").addCallback(self.cbInboxSelected)
+
+
+    def cbInboxSelected(self, result):
+        self.log_debug("IMAP Inbox selected [%s]" % (self.state,))
+        allMessages = imap4.MessageSet(1, None)
+        self.fetchUID(allMessages, True).addCallback(self.cbGotUIDs)
+
+
+    def cbGotUIDs(self, results):
+        self.log_debug("IMAP got uids [%s]" % (self.state,))
+        self.messageUIDs = [result['UID'] for result in results.values()]
+        self.messageCount = len(self.messageUIDs)
+        self.log_debug("IMAP Inbox has %d messages" % (self.messageCount,))
+        if self.messageCount:
+            self.fetchNextMessage()
+        else:
+            # No messages; close it out
+            self.close().addCallback(self.cbClosed)
+
+
+    def fetchNextMessage(self):
+        self.log_debug("IMAP in fetchnextmessage [%s]" % (self.state,))
+        if self.messageUIDs:
+            nextUID = self.messageUIDs.pop(0)
+            messageListToFetch = imap4.MessageSet(nextUID)
+            self.log_debug("Downloading message %d of %d (%s)" %
+                (self.messageCount - len(self.messageUIDs), self.messageCount,
+                nextUID))
+            self.fetchMessage(messageListToFetch, True).addCallback(
+                self.cbGotMessage, messageListToFetch).addErrback(
+                    self.ebLogError)
+        else:
+            self.log_debug("Seeing if anything new has arrived")
+            # Go back and see if any more messages have come in
+            self.expunge().addCallback(self.cbInboxSelected)
+
+
+    def cbGotMessage(self, results, messageList):
+        self.log_debug("IMAP in cbGotMessage [%s]" % (self.state,))
+        try:
+            messageData = results.values()[0]['RFC822']
+        except IndexError:
+            # results will be empty unless the "twistedmail-imap-flags-anywhere"
+            # patch from http://twistedmatrix.com/trac/ticket/1105 is applied
+            self.log_error("Skipping empty results -- apply twisted patch!")
+            self.fetchNextMessage()
+            return
+
+        d = self.factory.handleMessage(messageData)
+        if isinstance(d, defer.Deferred):
+            d.addCallback(self.cbFlagDeleted, messageList)
+        else:
+            # No deferred returned, so no need for addCallback( )
+            self.cbFlagDeleted(None, messageList)
+
+
+    def cbFlagDeleted(self, results, messageList):
+        self.addFlags(messageList, ("\\Deleted",),
+            uid=True).addCallback(self.cbMessageDeleted, messageList)
+
+
+    def cbMessageDeleted(self, results, messageList):
+        self.log_debug("IMAP in cbMessageDeleted [%s]" % (self.state,))
+        self.log_debug("Deleted message")
+        self.fetchNextMessage()
+
+
+    def cbClosed(self, results):
+        self.log_debug("IMAP in cbClosed [%s]" % (self.state,))
+        self.log_debug("Mailbox closed")
+        self.logout().addCallback(
+            lambda _: self.transport.loseConnection())
+
+
+    def rawDataReceived(self, data):
+        self.log_debug("RAW RECEIVED: %s" % (data,))
+        imap4.IMAP4Client.rawDataReceived(self, data)
+
+
+    def lineReceived(self, line):
+        self.log_debug("RECEIVED: %s" % (line,))
+        imap4.IMAP4Client.lineReceived(self, line)
+
+
+    def sendLine(self, line):
+        self.log_debug("SENDING: %s" % (line,))
+        imap4.IMAP4Client.sendLine(self, line)
+
+
+
+class IMAP4DownloadFactory(protocol.ClientFactory, LoggingMixIn):
+    protocol = IMAP4DownloadProtocol
+
+    def __init__(self, settings, mailReceiver):
+        self.log_debug("Setting up IMAPFactory")
+
+        self.settings = settings
+        self.mailReceiver = mailReceiver
+        self.noisy = False
+
+
+    def buildProtocol(self, addr):
+        p = protocol.ClientFactory.buildProtocol(self, addr)
+        username = self.settings["Username"]
+        p.registerAuthenticator(imap4.CramMD5ClientAuthenticator(username))
+        p.registerAuthenticator(imap4.LOGINAuthenticator(username))
+        p.registerAuthenticator(imap4.PLAINAuthenticator(username))
+        return p
+
+
+    def handleMessage(self, message):
+        self.log_debug("IMAP factory handle message")
+        self.log_debug(message)
+        return self.mailReceiver.inbound(message)
+
+
+    def clientConnectionLost(self, connector, reason):
+        self.connector = connector
+        self.log_debug("IMAP factory connection lost")
+
+
+    def clientConnectionFailed(self, connector, reason):
+        self.connector = connector
+        self.log_warn("IMAP factory connection failed")

Added: CalendarServer/branches/users/gaya/sharedgroups/twistedcaldav/scheduling/imip/outbound.py
===================================================================
--- CalendarServer/branches/users/gaya/sharedgroups/twistedcaldav/scheduling/imip/outbound.py	                        (rev 0)
+++ CalendarServer/branches/users/gaya/sharedgroups/twistedcaldav/scheduling/imip/outbound.py	2013-02-19 20:42:18 UTC (rev 10760)
@@ -0,0 +1,789 @@
+##
+# Copyright (c) 2005-2013 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+##
+
+"""
+Outbound IMIP mail handling for Calendar Server
+"""
+
+from __future__ import with_statement
+
+from cStringIO import StringIO
+import os
+
+from email.mime.image import MIMEImage
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+import email.utils
+from pycalendar.datetime import PyCalendarDateTime
+from pycalendar.duration import PyCalendarDuration
+from twext.enterprise.dal.record import fromTable
+from twext.enterprise.queue import WorkItem
+from twext.python.log import Logger, LoggingMixIn
+from twisted.internet.defer import inlineCallbacks, returnValue
+from twisted.mail.smtp import messageid, rfc822date
+from twisted.web.microdom import Text as DOMText, Element as DOMElement
+from twisted.web.microdom import parseString
+from twisted.web.template import XMLString, TEMPLATE_NAMESPACE, Element, renderer, flattenString, tags
+from twistedcaldav.config import config
+from twistedcaldav.ical import Component
+from twistedcaldav.localization import translationTo, _
+from twistedcaldav.scheduling.cuaddress import normalizeCUAddr
+from twistedcaldav.scheduling.imip.smtpsender import SMTPSender
+from txdav.common.datastore.sql_tables import schema
+
+
+
+log = Logger()
+
+
+""" SCHEMA:
+create sequence WORKITEM_SEQ;
+
+create table IMIP_INVITATION_WORK (
+  WORK_ID         integer primary key default nextval('WORKITEM_SEQ') not null,
+  NOT_BEFORE      timestamp    default timezone('UTC', CURRENT_TIMESTAMP),
+  FROM_ADDR       varchar(255) not null,
+  TO_ADDR         varchar(255) not null,
+  ICALENDAR_TEXT  text         not null
+);
+"""
+
+
+class IMIPInvitationWork(WorkItem, fromTable(schema.IMIP_INVITATION_WORK)):
+    """
+    Sends outbound IMIP messages
+    """
+
+    mailSender = None
+
+    @classmethod
+    def getMailSender(cls):
+        """
+        Instantiate and return a singleton MailSender object
+        @return: a MailSender
+        """
+        if cls.mailSender is None:
+            if config.Scheduling.iMIP.Enabled:
+                settings = config.Scheduling.iMIP.Sending
+                smtpSender = SMTPSender(settings.Username, settings.Password,
+                    settings.UseSSL, settings.Server, settings.Port)
+                cls.mailSender = MailSender(settings.Address,
+                    settings.SuppressionDays, smtpSender=smtpSender)
+        return cls.mailSender
+
+    @inlineCallbacks
+    def doWork(self):
+        """
+        Send an outbound IMIP message
+        """
+        mailSender = self.getMailSender()
+        if mailSender is not None:
+            calendar = Component.fromString(self.icalendarText)
+            yield mailSender.outbound(self.transaction,
+                self.fromAddr, self.toAddr, calendar)
+
+#
+# Templates
+#
+
+plainCancelTemplate = u"""%(subject)s
+
+%(orgLabel)s: %(plainOrganizer)s
+%(dateLabel)s: %(dateInfo)s %(recurrenceInfo)s
+%(timeLabel)s: %(timeInfo)s %(durationInfo)s
+"""
+
+plainInviteTemplate = u"""%(subject)s
+
+%(orgLabel)s: %(plainOrganizer)s
+%(locLabel)s: %(location)s
+%(dateLabel)s: %(dateInfo)s %(recurrenceInfo)s
+%(timeLabel)s: %(timeInfo)s %(durationInfo)s
+%(descLabel)s: %(description)s
+%(urlLabel)s: %(url)s
+%(attLabel)s: %(plainAttendees)s
+"""
+
+
+htmlCancelTemplate = u"""<html>
+    <body><div>
+
+    <h1>%(subject)s</h1>
+    <p>
+    <h3>%(orgLabel)s:</h3> %(htmlOrganizer)s
+    </p>
+    <p>
+    <h3>%(dateLabel)s:</h3> %(dateInfo)s %(recurrenceInfo)s
+    </p>
+    <p>
+    <h3>%(timeLabel)s:</h3> %(timeInfo)s %(durationInfo)s
+    </p>
+    """.encode("utf-8")
+
+
+htmlInviteTemplate = u"""<html>
+    <body><div>
+    <p>%(inviteLabel)s</p>
+
+    <h1>%(summary)s</h1>
+    <p>
+    <h3>%(orgLabel)s:</h3> %(htmlOrganizer)s
+    </p>
+    <p>
+    <h3>%(locLabel)s:</h3> %(location)s
+    </p>
+    <p>
+    <h3>%(dateLabel)s:</h3> %(dateInfo)s %(recurrenceInfo)s
+    </p>
+    <p>
+    <h3>%(timeLabel)s:</h3> %(timeInfo)s %(durationInfo)s
+    </p>
+    <p>
+    <h3>%(descLabel)s:</h3> %(description)s
+    </p>
+    <p>
+    <h3>%(urlLabel)s:</h3> <a href="%(url)s">%(url)s</a>
+    </p>
+    <p>
+    <h3>%(attLabel)s:</h3> %(htmlAttendees)s
+    </p>
+    """.encode("utf-8")
+
+def _visit(document, node):
+    if isinstance(node, DOMText):
+        idx = node.parentNode.childNodes.index(node)
+        splitted = node.data.split("%(")
+        firstTextNode = document.createTextNode(splitted[0])
+        firstTextNode.parentNode = node.parentNode
+        replacements = [firstTextNode]
+        for moreText in splitted[1:]:
+            slotName, extra = moreText.split(')', 1)
+            extra = extra[1:]
+            slotElement = document.createElement('t:slot')
+            slotElement.setAttribute("name", slotName)
+            slotElement.parentNode = node.parentNode
+            textNode = document.createTextNode(extra)
+            textNode.parentNode = node.parentNode
+            replacements.append(slotElement)
+            replacements.append(textNode)
+        node.parentNode.childNodes[idx:idx + 1] = replacements
+
+    elif isinstance(node, DOMElement):
+        for attrName, attrVal in node.attributes.items():
+            if '%(' in attrVal:
+                del node.attributes[attrName]
+                elem = document.createElement('t:attr')
+                elem.setAttribute('name', attrName)
+                textNode = document.createTextNode(attrVal)
+                elem.appendChild(textNode)
+                node.appendChild(elem)
+
+
+
+def _walk(document, n):
+    _visit(document, n)
+    for subn in n.childNodes:
+        _walk(document, subn)
+
+
+
+def _fixup(data, rendererName):
+    document = parseString(data, beExtremelyLenient=True)
+    document.documentElement.setAttribute(
+        "xmlns:t", TEMPLATE_NAMESPACE
+    )
+    document.doctype = (
+        'html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" '
+        '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"'
+    )
+    document.documentElement.setAttribute(
+        "t:render", rendererName
+    )
+    _walk(document, document)
+    result = document.toxml()
+    return result
+
+
+
+class StringFormatTemplateLoader(object):
+    """
+    Loader for twisted.web.template that converts a template with %()s slots.
+    """
+    def __init__(self, fileFactory, rendererName):
+        """
+        @param fileFactory: a 1-argument callable which returns a file-like
+            object that contains the %()s-format template.
+
+        @param rendererName: the name of the renderer.
+
+        @type rendererName: C{str}
+        """
+        self.fileFactory = fileFactory
+        self.rendererName = rendererName
+
+
+    def load(self):
+        html = _fixup(self.fileFactory().read(), self.rendererName)
+        return XMLString(html).load()
+
+
+
+def localizedLabels(language, canceled, inviteState):
+    """
+    Generate localized labels for an email in the given language.
+
+    @param language: a 2-letter language code
+
+    @type language: C{str}
+
+    @return: a 2-tuple of (subjectFormatString, labelDict), where the first is a
+        format string for use in the subject, and the latter is a dictionary
+        with labels suitable for filling out HTML and plain-text templates.  All
+        values are C{str}s.
+    """
+    with translationTo(language):
+        if canceled:
+            subjectFormatString = _("Event canceled: %(summary)s")
+        elif inviteState == "new":
+            subjectFormatString = _("Event invitation: %(summary)s")
+        elif inviteState == "update":
+            subjectFormatString = _("Event update: %(summary)s")
+        else:
+            subjectFormatString = _("Event reply: %(summary)s")
+
+        if canceled:
+            inviteLabel = _("Event Canceled")
+        else:
+            if inviteState == "new":
+                inviteLabel = _("Event Invitation")
+            elif inviteState == "update":
+                inviteLabel = _("Event Update")
+            else:
+                inviteLabel = _("Event Reply")
+
+        labels = dict(
+            dateLabel=_("Date"),
+            timeLabel=_("Time"),
+            durationLabel=_("Duration"),
+            recurrenceLabel=_("Occurs"),
+            descLabel=_("Description"),
+            urlLabel=_("URL"),
+            orgLabel=_("Organizer"),
+            attLabel=_("Attendees"),
+            locLabel=_("Location"),
+            inviteLabel=inviteLabel,
+        )
+
+        # The translations we get back from gettext are utf-8 encoded
+        # strings, so convert to unicode
+        for key in labels.keys():
+            if isinstance(labels[key], str):
+                labels[key] = labels[key].decode("utf-8")
+
+    return subjectFormatString.decode("utf-8"), labels
+
+
+
+class MailSender(LoggingMixIn):
+    """
+    Generates outbound IMIP messages and sends them.
+    """
+
+    def __init__(self, address, suppressionDays, smtpSender):
+        self.address = address
+        self.suppressionDays = suppressionDays
+        self.smtpSender = smtpSender
+
+
+    @inlineCallbacks
+    def outbound(self, txn, originator, recipient, calendar, language='en',
+        onlyAfter=None):
+        """
+        Generates and sends an outbound IMIP message.
+
+        @param txn: the transaction to use for looking up/creating tokens
+        @type txn: L{CommonStoreTransaction}
+        """
+
+        if onlyAfter is None:
+            duration = PyCalendarDuration(days=self.suppressionDays)
+            onlyAfter = PyCalendarDateTime.getNowUTC() - duration
+
+        component = calendar.masterComponent()
+        if component is None:
+            component = calendar.mainComponent(True)
+        icaluid = component.propertyValue("UID")
+        method = calendar.propertyValue("METHOD")
+
+        # Clean up the attendee list which is purely used within the human
+        # readable email message (not modifying the calendar body)
+        attendees = []
+        for attendeeProp in calendar.getAllAttendeeProperties():
+            cutype = attendeeProp.parameterValue("CUTYPE", "INDIVIDUAL")
+            if cutype == "INDIVIDUAL":
+                cn = attendeeProp.parameterValue("CN", None)
+                if cn is not None:
+                    cn = cn.decode("utf-8")
+                cuaddr = normalizeCUAddr(attendeeProp.value())
+                if cuaddr.startswith("mailto:"):
+                    mailto = cuaddr[7:]
+                    if not cn:
+                        cn = mailto
+                else:
+                    emailAddress = attendeeProp.parameterValue("EMAIL", None)
+                    if emailAddress:
+                        mailto = emailAddress
+                    else:
+                        mailto = None
+
+                if cn or mailto:
+                    attendees.append((cn, mailto))
+
+        toAddr = recipient
+        if not recipient.lower().startswith("mailto:"):
+            raise ValueError("ATTENDEE address '%s' must be mailto: for iMIP "
+                             "operation." % (recipient,))
+        recipient = recipient[7:]
+
+        if method != "REPLY":
+            # Invites and cancellations:
+
+            # Reuse or generate a token based on originator, toAddr, and
+            # event uid
+            token = (yield txn.imipGetToken(originator, toAddr.lower(), icaluid))
+            if token is None:
+
+                # Because in the past the originator was sometimes in mailto:
+                # form, lookup an existing token by mailto: as well
+                organizerProperty = calendar.getOrganizerProperty()
+                organizerEmailAddress = organizerProperty.parameterValue("EMAIL", None)
+                if organizerEmailAddress is not None:
+                    token = (yield txn.imipGetToken("mailto:%s" % (organizerEmailAddress.lower(),), toAddr.lower(), icaluid))
+
+            if token is None:
+                token = (yield txn.imipCreateToken(originator, toAddr.lower(), icaluid))
+                self.log_debug("Mail gateway created token %s for %s "
+                               "(originator), %s (recipient) and %s (icaluid)"
+                               % (token, originator, toAddr, icaluid))
+                inviteState = "new"
+
+            else:
+                self.log_debug("Mail gateway reusing token %s for %s "
+                               "(originator), %s (recipient) and %s (icaluid)"
+                               % (token, originator, toAddr, icaluid))
+                inviteState = "update"
+
+            fullServerAddress = self.address
+            _ignore_name, serverAddress = email.utils.parseaddr(fullServerAddress)
+            pre, post = serverAddress.split('@')
+            addressWithToken = "%s+%s@%s" % (pre, token, post)
+
+            organizerProperty = calendar.getOrganizerProperty()
+            organizerEmailAddress = organizerProperty.parameterValue("EMAIL",
+                                                                     None)
+            organizerValue = organizerProperty.value()
+            organizerProperty.setValue("mailto:%s" % (addressWithToken,))
+
+            # If the organizer is also an attendee, update that attendee value
+            # to match
+            organizerAttendeeProperty = calendar.getAttendeeProperty(
+                [organizerValue])
+            if organizerAttendeeProperty is not None:
+                organizerAttendeeProperty.setValue("mailto:%s" %
+                                                   (addressWithToken,))
+
+            # The email's From will include the originator's real name email
+            # address if available.  Otherwise it will be the server's email
+            # address (without # + addressing)
+            if organizerEmailAddress:
+                orgEmail = fromAddr = organizerEmailAddress
+            else:
+                fromAddr = serverAddress
+                orgEmail = None
+            cn = calendar.getOrganizerProperty().parameterValue('CN', None)
+            if cn is None:
+                cn = u'Calendar Server'
+                orgCN = orgEmail
+            else:
+                orgCN = cn = cn.decode("utf-8")
+
+            # a unicode cn (rather than an encode string value) means the
+            # from address will get properly encoded per rfc2047 within the
+            # MIMEMultipart in generateEmail
+            formattedFrom = "%s <%s>" % (cn, fromAddr)
+
+            # Reply-to address will be the server+token address
+
+        else: # REPLY
+            inviteState = "reply"
+
+            # Look up the attendee property corresponding to the originator
+            # of this reply
+            originatorAttendeeProperty = calendar.getAttendeeProperty(
+                [originator])
+            formattedFrom = fromAddr = originator = ""
+            if originatorAttendeeProperty:
+                originatorAttendeeEmailAddress = (
+                    originatorAttendeeProperty.parameterValue("EMAIL", None)
+                )
+                if originatorAttendeeEmailAddress:
+                    formattedFrom = fromAddr = originator = (
+                        originatorAttendeeEmailAddress
+                    )
+
+            organizerMailto = str(calendar.getOrganizer())
+            if not organizerMailto.lower().startswith("mailto:"):
+                raise ValueError("ORGANIZER address '%s' must be mailto: "
+                                 "for REPLY." % (organizerMailto,))
+            orgEmail = organizerMailto[7:]
+
+            orgCN = calendar.getOrganizerProperty().parameterValue('CN', None)
+            addressWithToken = formattedFrom
+
+        # At the point we've created the token in the db, which we always
+        # want to do, but if this message is for an event completely in
+        # the past we don't want to actually send an email.
+        if not calendar.hasInstancesAfter(onlyAfter):
+            self.log_debug("Skipping IMIP message for old event")
+            returnValue(True)
+
+        # Now prevent any "internal" CUAs from being exposed by converting
+        # to mailto: if we have one
+        for attendeeProp in calendar.getAllAttendeeProperties():
+            cutype = attendeeProp.parameterValue('CUTYPE', None)
+            if cutype == "INDIVIDUAL":
+                cuaddr = normalizeCUAddr(attendeeProp.value())
+                if not cuaddr.startswith("mailto:"):
+                    emailAddress = attendeeProp.parameterValue("EMAIL", None)
+                    if emailAddress:
+                        attendeeProp.setValue("mailto:%s" % (emailAddress,))
+
+        msgId, message = self.generateEmail(inviteState, calendar, orgEmail,
+            orgCN, attendees, formattedFrom, addressWithToken, recipient,
+            language=language)
+
+        try:
+            success = (yield self.smtpSender.sendMessage(fromAddr, toAddr,
+                msgId, message))
+            returnValue(success)
+        except Exception, e:
+            self.log_error("Failed to send IMIP message (%s)" % (str(e),))
+            returnValue(False)
+
+
+    def getIconPath(self, details, canceled, language='en'):
+        iconDir = config.Scheduling.iMIP.MailIconsDirectory.rstrip("/")
+
+        if canceled:
+            iconName = "canceled.png"
+            iconPath = os.path.join(iconDir, iconName)
+            if os.path.exists(iconPath):
+                return iconPath
+            else:
+                return None
+
+        else:
+            month = int(details['month'])
+            day = int(details['day'])
+            with translationTo(language) as trans:
+                monthName = trans.monthAbbreviation(month)
+            iconName = "%02d.png" % (day,)
+            iconPath = os.path.join(iconDir, monthName.encode("utf-8"), iconName)
+            if not os.path.exists(iconPath):
+                # Try the generic (numeric) version
+                iconPath = os.path.join(iconDir, "%02d" % (month,), iconName)
+                if not os.path.exists(iconPath):
+                    return None
+            return iconPath
+
+
+    def generateEmail(self, inviteState, calendar, orgEmail, orgCN,
+                      attendees, fromAddress, replyToAddress, toAddress,
+                      language='en'):
+        """
+        Generate MIME text containing an iMIP invitation, cancellation, update
+        or reply.
+
+        @param inviteState: 'new', 'update', or 'reply'.
+
+        @type inviteState: C{str}
+
+        @param calendar: the iCalendar component to attach to the email.
+
+        @type calendar: L{twistedcaldav.ical.Component}
+
+        @param orgEmail: The email for the organizer, in C{localhost at domain}
+            format, or C{None} if the organizer has no email address.
+
+        @type orgEmail: C{str} or C{NoneType}
+
+        @param orgCN: Common name / display name for the organizer.
+
+        @type orgCN: C{unicode}
+
+        @param attendees: A C{list} of 2-C{tuple}s of (common name, email
+            address) similar to (orgEmail, orgCN).
+
+        @param fromAddress: the address to use in the C{From:} header of the
+            email.
+
+        @type fromAddress: C{str}
+
+        @param replyToAddress: the address to use in the C{Reply-To} header.
+
+        @type replyToAddress: C{str}
+
+        @param toAddress: the address to use in the C{To} header.
+
+        @type toAddress: C{str}
+
+        @param language: a 2-letter language code describing the target
+            language that the email should be generated in.
+
+        @type language: C{str}
+
+        @return: a 2-tuple of C{str}s: (message ID, message text).  The message
+            ID is the value of the C{Message-ID} header, and the message text is
+            the full MIME message, ready for transport over SMTP.
+        """
+
+        details = self.getEventDetails(calendar, language=language)
+        canceled = (calendar.propertyValue("METHOD") == "CANCEL")
+        iconPath = self.getIconPath(details, canceled, language=language)
+
+        subjectFormat, labels = localizedLabels(language, canceled, inviteState)
+        details.update(labels)
+
+        details['subject'] = subjectFormat % {'summary' : details['summary']}
+        details['iconName'] = iconName = "calicon.png"
+
+        plainText = self.renderPlainText(details, (orgCN, orgEmail),
+                                         attendees, canceled)
+
+        [addIcon, htmlText] = self.renderHTML(details, (orgCN, orgEmail),
+                                              attendees, canceled)
+
+        msg = MIMEMultipart()
+        msg["From"] = fromAddress
+        msg["Subject"] = details['subject']
+        msg["Reply-To"] = replyToAddress
+        msg["To"] = toAddress
+        msg["Date"] = rfc822date()
+        msgId = messageid()
+        msg["Message-ID"] = msgId
+
+        msgAlt = MIMEMultipart("alternative")
+        msg.attach(msgAlt)
+
+        # plain version
+        msgPlain = MIMEText(plainText, "plain", "UTF-8")
+        msgAlt.attach(msgPlain)
+
+        # html version
+        msgHtmlRelated = MIMEMultipart("related", type="text/html")
+        msgAlt.attach(msgHtmlRelated)
+
+        msgHtml = MIMEText(htmlText, "html", "UTF-8")
+        msgHtmlRelated.attach(msgHtml)
+
+        # an image for html version
+        if addIcon and iconPath != None and os.path.exists(iconPath):
+
+            with open(iconPath) as iconFile:
+                msgIcon = MIMEImage(iconFile.read(),
+                    _subtype='png;x-apple-mail-type=stationery;name="%s"' %
+                    (iconName,))
+
+            msgIcon.add_header("Content-ID", "<%s>" % (iconName,))
+            msgIcon.add_header("Content-Disposition", "inline;filename=%s" %
+                (iconName,))
+            msgHtmlRelated.attach(msgIcon)
+
+        calendarText = str(calendar)
+        # the icalendar attachment
+        self.log_debug("Mail gateway sending calendar body: %s"
+                       % (calendarText,))
+        msgIcal = MIMEText(calendarText, "calendar", "UTF-8")
+        method = calendar.propertyValue("METHOD").lower()
+        msgIcal.set_param("method", method)
+        msgIcal.add_header("Content-ID", "<invitation.ics>")
+        msgIcal.add_header("Content-Disposition",
+            "inline;filename=invitation.ics")
+        msg.attach(msgIcal)
+
+        return msgId, msg.as_string()
+
+
+    def renderPlainText(self, details, (orgCN, orgEmail), attendees, canceled):
+        """
+        Render text/plain message part based on invitation details and a flag
+        indicating whether the message is a cancellation.
+
+        @return: UTF-8 encoded text.
+
+        @rtype: C{str}
+        """
+        plainAttendeeList = []
+        for cn, mailto in attendees:
+            if cn:
+                plainAttendeeList.append(cn if not mailto else
+                    "%s <%s>" % (cn, mailto))
+            elif mailto:
+                plainAttendeeList.append("<%s>" % (mailto,))
+
+        details['plainAttendees'] = ", ".join(plainAttendeeList)
+
+        details['plainOrganizer'] = (orgCN if not orgEmail else
+            "%s <%s>" % (orgCN, orgEmail))
+
+        # plain text version
+        if canceled:
+            plainTemplate = plainCancelTemplate
+        else:
+            plainTemplate = plainInviteTemplate
+
+        return (plainTemplate % details).encode("UTF-8")
+
+
+    def renderHTML(self, details, organizer, attendees, canceled):
+        """
+        Render HTML message part based on invitation details and a flag
+        indicating whether the message is a cancellation.
+
+        @return: a 2-tuple of (should add icon (C{bool}), html text (C{str},
+            representing utf-8 encoded bytes)).  The first element indicates
+            whether the MIME generator needs to add a C{cid:} icon image part to
+            satisfy the HTML links.
+        """
+        orgCN, orgEmail = organizer
+
+        # TODO: htmlAttendees needs to be a separate element with a separate
+        # template fragment.  Luckily that fragment is the same regardless
+        # of the rest of the template.
+        htmlAttendees = []
+        first = True
+        for cn, mailto in attendees:
+            if not first:
+                htmlAttendees.append(u", ")
+            else:
+                first = False
+
+            if mailto:
+                if not cn:
+                    cn = mailto
+                htmlAttendees.append(
+                    tags.a(href="mailto:%s" % (mailto,))(cn)
+                )
+            else:
+                htmlAttendees.append(cn)
+
+        details['htmlAttendees'] = htmlAttendees
+
+        # TODO: htmlOrganizer is also some HTML that requires additional
+        # template stuff, and once again, it's just a 'mailto:'.
+        # tags.a(href="mailto:"+email)[cn]
+        if orgEmail:
+            details['htmlOrganizer'] = tags.a(href="mailto:%s" % (orgEmail,))(
+                orgCN)
+        else:
+            details['htmlOrganizer'] = orgCN
+
+        templateDir = config.Scheduling.iMIP.MailTemplatesDirectory.rstrip("/")
+        templateName = "cancel.html" if canceled else "invite.html"
+        templatePath = os.path.join(templateDir, templateName)
+
+        if not os.path.exists(templatePath):
+            # Fall back to built-in simple templates:
+            if canceled:
+                htmlTemplate = htmlCancelTemplate
+            else:
+                htmlTemplate = htmlInviteTemplate
+        else: # HTML template file exists
+
+            with open(templatePath) as templateFile:
+                htmlTemplate = templateFile.read()
+
+        class EmailElement(Element):
+            loader = StringFormatTemplateLoader(lambda : StringIO(htmlTemplate),
+                                                "email")
+
+            @renderer
+            def email(self, request, tag):
+                return tag.fillSlots(**details)
+
+        textCollector = []
+        flattenString(None, EmailElement()).addCallback(textCollector.append)
+        htmlText = textCollector[0]
+
+        # If the template refers to an icon in a cid: link, it needs to be added
+        # in the MIME.
+        addIcon = (htmlTemplate.find("cid:%(iconName)s") != -1)
+        return (addIcon, htmlText)
+
+
+    def getEventDetails(self, calendar, language='en'):
+        """
+        Create a dictionary mapping slot names - specifically: summary,
+        description, location, dateInfo, timeInfo, durationInfo, recurrenceInfo,
+        url - with localized string values that should be placed into the HTML
+        and plain-text templates.
+
+        @param calendar: a L{Component} upon which to base the language.
+        @type calendar: L{Component}
+
+        @param language: a 2-letter language code.
+        @type language: C{str}
+
+        @return: a mapping from template slot name to localized text.
+        @rtype: a C{dict} mapping C{bytes} to C{unicode}.
+        """
+
+        # Get the most appropriate component
+        component = calendar.masterComponent()
+        if component is None:
+            component = calendar.mainComponent(True)
+
+        results = {}
+
+        dtStart = component.propertyValue('DTSTART')
+        results['month'] = dtStart.getMonth()
+        results['day'] = dtStart.getDay()
+
+        for propertyToResult in ['summary', 'description', 'location', 'url']:
+            result = component.propertyValue(propertyToResult.upper())
+            if result is None:
+                result = u""
+            else:
+                result = result.decode('utf-8')
+            results[propertyToResult] = result
+
+        with translationTo(language) as trans:
+            results['dateInfo'] = trans.date(component).decode('utf-8')
+            results['timeInfo'], duration = (x.decode('utf-8') for x in trans.time(component))
+            results['durationInfo'] = u"(%s)" % (duration,) if duration else u""
+
+            for propertyName in ('RRULE', 'RDATE', 'EXRULE', 'EXDATE',
+                                 'RECURRENCE-ID'):
+                if component.hasProperty(propertyName):
+                    results['recurrenceInfo'] = _("(Repeating)").decode('utf-8')
+                    break
+            else:
+                results['recurrenceInfo'] = u""
+
+        return results
+
+
+

Added: CalendarServer/branches/users/gaya/sharedgroups/twistedcaldav/scheduling/imip/smtpsender.py
===================================================================
--- CalendarServer/branches/users/gaya/sharedgroups/twistedcaldav/scheduling/imip/smtpsender.py	                        (rev 0)
+++ CalendarServer/branches/users/gaya/sharedgroups/twistedcaldav/scheduling/imip/smtpsender.py	2013-02-19 20:42:18 UTC (rev 10760)
@@ -0,0 +1,75 @@
+##
+# Copyright (c) 2005-2013 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+##
+
+"""
+SMTP sending utility
+"""
+
+from cStringIO import StringIO
+
+from twext.internet.adaptendpoint import connect
+from twext.internet.gaiendpoint import GAIEndpoint
+from twext.python.log import Logger
+from twisted.internet import defer, ssl, reactor as _reactor
+from twisted.mail.smtp import ESMTPSenderFactory
+
+log = Logger()
+
+class SMTPSender(object):
+
+    def __init__(self, username, password, useSSL, server, port):
+        self.username = username
+        self.password = password
+        self.useSSL = useSSL
+        self.server = server
+        self.port = port
+
+    def sendMessage(self, fromAddr, toAddr, msgId, message):
+
+        log.debug("Sending: %s" % (message,))
+        def _success(result, msgId, fromAddr, toAddr):
+            log.info("Sent IMIP message %s from %s to %s" %
+                (msgId, fromAddr, toAddr))
+            return True
+
+        def _failure(failure, msgId, fromAddr, toAddr):
+            log.error("Failed to send IMIP message %s from %s "
+                           "to %s (Reason: %s)" %
+                           (msgId, fromAddr, toAddr,
+                            failure.getErrorMessage()))
+            return False
+
+        deferred = defer.Deferred()
+
+        if self.useSSL:
+            contextFactory = ssl.ClientContextFactory()
+        else:
+            contextFactory = None
+
+        factory = ESMTPSenderFactory(
+            self.username, self.password,
+            fromAddr, toAddr,
+            # per http://trac.calendarserver.org/ticket/416 ...
+            StringIO(message.replace("\r\n", "\n")), deferred,
+            contextFactory=contextFactory,
+            requireAuthentication=False,
+            requireTransportSecurity=self.useSSL)
+
+        connect(GAIEndpoint(_reactor, self.server, self.port),
+                factory)
+        deferred.addCallback(_success, msgId, fromAddr, toAddr)
+        deferred.addErrback(_failure, msgId, fromAddr, toAddr)
+        return deferred
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20130219/50774240/attachment-0001.html>


More information about the calendarserver-changes mailing list