[CalendarServer-changes] [3053] CalendarServer/branches/users/sagen/principal-property-search-3051
source_changes at macosforge.org
source_changes at macosforge.org
Wed Sep 24 16:14:18 PDT 2008
Revision: 3053
http://trac.macosforge.org/projects/calendarserver/changeset/3053
Author: sagen at apple.com
Date: 2008-09-24 16:14:18 -0700 (Wed, 24 Sep 2008)
Log Message:
-----------
Merged trunk changes into branch
Modified Paths:
--------------
CalendarServer/branches/users/sagen/principal-property-search-3051/lib-patches/Twisted/twisted.web2.dav.method.report_principal_property_search.patch
CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/customxml.py
CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/aggregate.py
CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/apache.py
CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/appleopendirectory.py
CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/directory.py
CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/principal.py
CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/sqldb.py
CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/sudo.py
CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/xmlaccountsparser.py
CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/xmlfile.py
CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/resource.py
Modified: CalendarServer/branches/users/sagen/principal-property-search-3051/lib-patches/Twisted/twisted.web2.dav.method.report_principal_property_search.patch
===================================================================
--- CalendarServer/branches/users/sagen/principal-property-search-3051/lib-patches/Twisted/twisted.web2.dav.method.report_principal_property_search.patch 2008-09-24 23:13:24 UTC (rev 3052)
+++ CalendarServer/branches/users/sagen/principal-property-search-3051/lib-patches/Twisted/twisted.web2.dav.method.report_principal_property_search.patch 2008-09-24 23:14:18 UTC (rev 3053)
@@ -22,7 +22,51 @@
# Get a single DAV:prop element from the REPORT request body
propertiesForResource = None
propElement = None
-@@ -93,73 +99,155 @@
+@@ -71,10 +77,22 @@
+ elif child.qname() == (dav_namespace, "property-search"):
+ props = child.childOfType(davxml.PropertyContainer)
+ props.removeWhitespaceNodes()
++
+ match = child.childOfType(davxml.Match)
+- propertySearches.append((props.children, str(match).lower()))
++ caseless = match.attributes.get("caseless", "yes")
++ if caseless not in ("yes", "no"):
++ raise ValueError("Unknown value for caseless attribute: %s" %
++ (caseless,))
++ caseless = True if caseless == "yes" else False
++ matchType = match.attributes.get("match-type", "contains")
++ if matchType not in ("starts-with", "contains"):
++ raise ValueError("Unknown value for match-type attribute: %s" %
++ (matchType,))
++
++ propertySearches.append((props.children, str(match),
++ caseless, matchType))
+
+- def nodeMatch(node, match):
++ def nodeMatch(node, match, caseless, matchType):
+ """
+ See if the content of the supplied node matches the supplied text.
+ Try to follow the matching guidance in rfc3744 section 9.4.1.
+@@ -85,81 +103,174 @@
+ node.removeWhitespaceNodes()
+ for child in node.children:
+ if isinstance(child, davxml.PCDATAElement):
+- comp = str(child).lower()
+- if comp.find(match) != -1:
++ if caseless:
++ comp = str(child).lower()
++ match = match.lower()
++ else:
++ comp = str(child)
++
++ if matchType == "starts-with":
++ if comp.find(match) == 0:
++ return True
++ elif comp.find(match) != -1:
+ return True
++
+ else:
+ return nodeMatch(child, match)
else:
return False
@@ -46,7 +90,7 @@
- if propvalue and not nodeMatch(propvalue, match):
+
+ if operand == "and":
-+ for props, match in propertySearches:
++ for props, match, caseless, matchType in propertySearches:
+ # Test each property
+ for prop in props:
+ try:
@@ -54,7 +98,8 @@
+ yield propvalue
+ propvalue = propvalue.getResult()
+ if propvalue:
-+ if not nodeMatch(propvalue, match):
++ if not nodeMatch(propvalue, match, caseless,
++ matchType):
+ yield False
+ return
+ except HTTPError:
@@ -71,7 +116,7 @@
+ yield True
+ else: # "or"
-+ for props, match in propertySearches:
++ for props, match, caseless, matchType in propertySearches:
+ # Test each property
+ for prop in props:
+ try:
@@ -79,7 +124,7 @@
+ yield propvalue
+ propvalue = propvalue.getResult()
+ if propvalue:
-+ if nodeMatch(propvalue, match):
++ if nodeMatch(propvalue, match, caseless, matchType):
+ yield True
+ return
+ except HTTPError:
@@ -99,7 +144,7 @@
+ # See if we can take advantage of the directory
+ fields = []
+ nonDirectorySearches = []
-+ for props, match in propertySearches:
++ for props, match, caseless, matchType in propertySearches:
+ nonDirectoryProps = []
+ for prop in props:
+ try:
@@ -107,11 +152,12 @@
+ except AttributeError:
+ fieldName = None
+ if fieldName:
-+ fields.append((fieldName, match))
++ fields.append((fieldName, match, caseless, matchType))
+ else:
+ nonDirectoryProps.append(prop)
+ if nonDirectoryProps:
-+ nonDirectorySearches.append((nonDirectoryProps, match))
++ nonDirectorySearches.append((nonDirectoryProps, match,
++ caseless, matchType))
+
+ matchingResources = []
matchcount = 0
@@ -224,7 +270,7 @@
yield d
d = d.getResult()
if d:
-@@ -167,18 +255,26 @@
+@@ -167,18 +278,26 @@
matchcount += 1
if matchcount > max_number_of_matches:
raise NumberOfMatchesWithinLimits
Modified: CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/customxml.py
===================================================================
--- CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/customxml.py 2008-09-24 23:13:24 UTC (rev 3052)
+++ CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/customxml.py 2008-09-24 23:14:18 UTC (rev 3053)
@@ -315,7 +315,7 @@
name = "last-name"
protected = True
-class EMailProperty (davxml.WebDAVTextElement):
+class EmailAddressProperty (davxml.WebDAVTextElement):
"""
A property representing email address of a principal
"""
@@ -323,6 +323,16 @@
name = "email-address"
protected = True
+class EmailAddressSet (davxml.WebDAVElement):
+ """
+ The list of email addresses of a principal
+ """
+ namespace = calendarserver_namespace
+ name = "email-address-set"
+ hidden = True
+
+ allowed_children = { (calendarserver_namespace, "email-address"): (0, None) }
+
class IScheduleInbox (davxml.WebDAVEmptyElement):
"""
Denotes the resourcetype of a iSchedule Inbox.
Modified: CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/aggregate.py
===================================================================
--- CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/aggregate.py 2008-09-24 23:13:24 UTC (rev 3052)
+++ CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/aggregate.py 2008-09-24 23:14:18 UTC (rev 3053)
@@ -103,8 +103,7 @@
def recordWithCalendarUserAddress(self, address):
return self._queryAll("recordWithCalendarUserAddress", address)
- def recordsMatchingFields(self, fields, caseInsensitive=True, operand="or",
- recordType=None):
+ def recordsMatchingFields(self, fields, operand="or", recordType=None):
if recordType:
services = (self.serviceForRecordType(recordType),)
else:
@@ -112,8 +111,7 @@
for service in services:
for record in service.recordsMatchingFields(fields,
- caseInsensitive=caseInsensitive, operand=operand,
- recordType=recordType):
+ operand=operand, recordType=recordType):
yield record
def serviceForRecordType(self, recordType):
Modified: CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/apache.py
===================================================================
--- CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/apache.py 2008-09-24 23:13:24 UTC (rev 3052)
+++ CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/apache.py 2008-09-24 23:14:18 UTC (rev 3053)
@@ -131,7 +131,7 @@
fullName = None,
firstName = None,
lastName = None,
- emailAddress = None,
+ emailAddresses = set(),
calendarUserAddresses = set(),
autoSchedule = False,
)
Modified: CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/appleopendirectory.py
===================================================================
--- CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/appleopendirectory.py 2008-09-24 23:13:24 UTC (rev 3052)
+++ CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/appleopendirectory.py 2008-09-24 23:14:18 UTC (rev 3053)
@@ -465,7 +465,7 @@
'fullName' : dsattributes.kDS1AttrDistinguishedName,
'firstName' : dsattributes.kDS1AttrFirstName,
'lastName' : dsattributes.kDS1AttrLastName,
- 'emailAddress' : dsattributes.kDSNAttrEMailAddress,
+ 'emailAddresses' : dsattributes.kDSNAttrEMailAddress,
}
_toODRecordTypes = {
@@ -481,17 +481,23 @@
_fromODRecordTypes = dict([(b, a) for a, b in _toODRecordTypes.iteritems()])
- def recordsMatchingFields(self, fields, caseInsensitive=True, operand="or",
- recordType=None):
+ def recordsMatchingFields(self, fields, operand="or", recordType=None):
- comparison = dsattributes.eDSStartsWith
+ # Note that OD applies case-sensitivity globally across the entire
+ # query, not per expression, so the current code uses whatever is
+ # specified in the last field in the fields list
+
operand = (dsquery.expression.OR if operand == "or"
else dsquery.expression.AND)
expressions = []
- for field, value in fields:
+ for field, value, caseless, matchType in fields:
if field in self._ODFields:
ODField = self._ODFields[field]
+ if matchType == "starts-with":
+ comparison = dsattributes.eDSStartsWith
+ else:
+ comparison = dsattributes.eDSContains
expressions.append(dsquery.match(ODField, value, comparison))
@@ -503,12 +509,11 @@
for recordType in recordTypes:
try:
- self.log_info("Calling OD: %s %s %s" % (recordType, operand,
- fields))
+ self.log_info("Calling OD: Type %s, Operand %s, Caseless %s, %s" % (recordType, operand, caseless, fields))
results = opendirectory.queryRecordsWithAttributes(
self.directory,
dsquery.expression(operand, expressions).generate(),
- caseInsensitive,
+ caseless,
recordType,
[
dsattributes.kDS1AttrGeneratedUID,
@@ -522,7 +527,19 @@
self.log_info("Got back %d records from OD" % (len(results),))
for key, val in results.iteritems():
self.log_debug("OD result: %s %s" % (key, val))
+
try:
+
+ # Email field from OD can either be a string or a list
+ emailAddresses = set()
+ addrs = val.get(dsattributes.kDSNAttrEMailAddress, None)
+ if isinstance(addrs, str):
+ emailAddresses.add(addrs)
+ elif isinstance(addrs, list):
+ for addr in addrs:
+ emailAddresses.add(addr)
+
+ # TODO: Review this code...
calendarUserAddresses = set()
enabledForCalendaring = False
if val.has_key(dsattributes.kDSNAttrEMailAddress):
@@ -537,7 +554,7 @@
fullName = val.get(dsattributes.kDS1AttrDistinguishedName, ""),
firstName = val.get(dsattributes.kDS1AttrFirstName, ""),
lastName = val.get(dsattributes.kDS1AttrLastName, ""),
- emailAddress = val.get(dsattributes.kDSNAttrEMailAddress, ""),
+ emailAddresses = emailAddresses,
calendarUserAddresses = calendarUserAddresses,
autoSchedule = False,
enabledForCalendaring = enabledForCalendaring,
@@ -643,6 +660,14 @@
else:
calendarUserAddresses = ()
+ # Get email address from directory record
+ emailAddresses = set()
+ if isinstance(recordEmailAddress, str):
+ emailAddresses.add(recordEmailAddress)
+ elif isinstance(recordEmailAddress, list):
+ for addr in emailAddresses:
+ emailAddresses.add(addr)
+
# Special case for groups, which have members.
if recordType == DirectoryService.recordType_groups:
memberGUIDs = value.get(dsattributes.kDSNAttrGroupMembers)
@@ -680,7 +705,7 @@
fullName = recordFullName,
firstName = recordFirstName,
lastName = recordLastName,
- emailAddress = recordEmailAddress,
+ emailAddresses = emailAddresses,
calendarUserAddresses = calendarUserAddresses,
autoSchedule = autoSchedule,
enabledForCalendaring = enabledForCalendaring,
@@ -929,7 +954,7 @@
"""
def __init__(
self, service, recordType, guid, nodeName, shortName, fullName,
- firstName, lastName, emailAddress,
+ firstName, lastName, emailAddresses,
calendarUserAddresses, autoSchedule, enabledForCalendaring,
memberGUIDs, proxyGUIDs, readOnlyProxyGUIDs,
):
@@ -941,7 +966,7 @@
fullName = fullName,
firstName = firstName,
lastName = lastName,
- emailAddress = emailAddress,
+ emailAddresses = emailAddresses,
calendarUserAddresses = calendarUserAddresses,
autoSchedule = autoSchedule,
enabledForCalendaring = enabledForCalendaring,
Modified: CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/directory.py
===================================================================
--- CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/directory.py 2008-09-24 23:13:24 UTC (rev 3052)
+++ CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/directory.py 2008-09-24 23:14:18 UTC (rev 3053)
@@ -138,8 +138,7 @@
for record in self.listRecords(recordType):
yield record
- def recordsMatchingFields(self, fields, caseInsensitive=True, operand="or",
- recordType=None):
+ def recordsMatchingFields(self, fields, operand="or", recordType=None):
# Default, bruteforce method; override with one optimized for each
# service
@@ -153,18 +152,27 @@
else:
recordTypes = (recordType,)
- def fieldMatches(fieldValue, value):
- if caseInsensitive:
+ def fieldMatches(fieldValue, value, caseless, matchType):
+ if caseless:
+ fieldValue = fieldValue.lower()
+ value = value.lower()
+
+ if matchType == 'starts-with':
return fieldValue.lower().startswith(value.lower())
else:
- return fieldValue.startswith(value)
+ try:
+ discard = fieldValue.lower().index(value.lower())
+ return True
+ except ValueError:
+ return False
def recordMatches(record):
if operand == "and":
- for fieldName, value in fields:
+ for fieldName, value, caseless, matchType in fields:
try:
fieldValue = getattr(record, fieldName)
- if not fieldMatches(fieldValue, value):
+ if not fieldMatches(fieldValue, value, caseless,
+ matchType):
return False
except AttributeError:
# No property => no match
@@ -172,10 +180,11 @@
# we hit on every property
return True
else: # "or"
- for fieldName, value in fields:
+ for fieldName, value, caseless, matchType in fields:
try:
fieldValue = getattr(record, fieldName)
- if fieldMatches(fieldValue, value):
+ if fieldMatches(fieldValue, value, caseless,
+ matchType):
return True
except AttributeError:
# No value
@@ -209,7 +218,7 @@
def __init__(
self, service, recordType, guid, shortName, fullName,
- firstName, lastName, emailAddress,
+ firstName, lastName, emailAddresses,
calendarUserAddresses, autoSchedule, enabledForCalendaring=True,
):
assert service.realmName is not None
@@ -231,7 +240,7 @@
self.fullName = fullName
self.firstName = firstName
self.lastName = lastName
- self.emailAddress = emailAddress
+ self.emailAddresses = emailAddresses
self.enabledForCalendaring = enabledForCalendaring
self.calendarUserAddresses = calendarUserAddresses
self.autoSchedule = autoSchedule
Modified: CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/principal.py
===================================================================
--- CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/principal.py 2008-09-24 23:13:24 UTC (rev 3052)
+++ CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/principal.py 2008-09-24 23:14:18 UTC (rev 3053)
@@ -134,7 +134,7 @@
("DAV:" , "displayname") : "fullName",
(_cs_ns, "first-name") : "firstName",
(_cs_ns, "last-name") : "lastName",
- (_cs_ns, "email-address") : "emailAddress",
+ (_cs_ns, "email-address-set") : "emailAddresses",
}
def propertyToField(self, property):
@@ -462,7 +462,7 @@
"""Full name: %s\n""" % (self.record.fullName,),
"""First name: %s\n""" % (self.record.firstName,),
"""Last name: %s\n""" % (self.record.lastName,),
- """Email address: %s\n""" % (self.record.emailAddress,),
+ """Email addresses:\n""" , format_list(self.record.emailAddresses),
"""Principal UID: %s\n""" % (self.principalUID(),),
"""Principal URL: %s\n""" % (format_link(self.principalURL()),),
"""\nAlternate URIs:\n""" , format_list(format_link(u) for u in self.alternateURIs()),
@@ -625,7 +625,7 @@
"""Full name: %s\n""" % (self.record.fullName,),
"""First name: %s\n""" % (self.record.firstName,),
"""Last name: %s\n""" % (self.record.lastName,),
- """Email address: %s\n""" % (self.record.emailAddress,),
+ """Email addresses:\n""" , format_list(self.record.emailAddresses),
"""Principal UID: %s\n""" % (self.principalUID(),),
"""Principal URL: %s\n""" % (format_link(self.principalURL()),),
"""\nAlternate URIs:\n""" , format_list(format_link(u) for u in self.alternateURIs()),
Modified: CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/sqldb.py
===================================================================
--- CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/sqldb.py 2008-09-24 23:13:24 UTC (rev 3052)
+++ CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/sqldb.py 2008-09-24 23:14:18 UTC (rev 3053)
@@ -352,7 +352,7 @@
fullName = name,
firstName = None,
lastName = None,
- emailAddress = None,
+ emailAddresses = set(),
calendarUserAddresses = calendarUserAddresses,
autoSchedule = autoSchedule,
)
Modified: CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/sudo.py
===================================================================
--- CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/sudo.py 2008-09-24 23:13:24 UTC (rev 3052)
+++ CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/sudo.py 2008-09-24 23:14:18 UTC (rev 3053)
@@ -134,7 +134,7 @@
fullName=shortName,
firstName="",
lastName="",
- emailAddress="",
+ emailAddresses=set(),
calendarUserAddresses=set(),
autoSchedule=False,
enabledForCalendaring=False)
Modified: CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/xmlaccountsparser.py
===================================================================
--- CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/xmlaccountsparser.py 2008-09-24 23:13:24 UTC (rev 3052)
+++ CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/xmlaccountsparser.py 2008-09-24 23:14:18 UTC (rev 3053)
@@ -168,7 +168,7 @@
self.name = None
self.firstName = None
self.lastName = None
- self.emailAddress = None
+ self.emailAddresses = set()
self.members = set()
self.groups = set()
self.calendarUserAddresses = set()
@@ -209,10 +209,12 @@
lastName = self.lastName % ctr
else:
lastName = self.lastName
- if self.emailAddress and self.emailAddress.find("%") != -1:
- emailAddress = self.emailAddress % ctr
- else:
- emailAddress = self.emailAddress
+ emailAddresses = set()
+ for emailAddr in self.emailAddresses:
+ if emailAddr.find("%") != -1:
+ emailAddresses.add(emailAddr % ctr)
+ else:
+ emailAddresses.add(emailAddr)
calendarUserAddresses = set()
for cuaddr in self.calendarUserAddresses:
if cuaddr.find("%") != -1:
@@ -227,7 +229,7 @@
result.name = name
result.firstName = firstName
result.lastName = lastName
- result.emailAddress = emailAddress
+ result.emailAddresses = emailAddresses
result.members = self.members
result.calendarUserAddresses = calendarUserAddresses
result.autoSchedule = self.autoSchedule
@@ -266,7 +268,7 @@
self.lastName = child.firstChild.data.encode("utf-8")
elif child_name == ELEMENT_EMAIL_ADDRESS:
if child.firstChild is not None:
- self.emailAddress = child.firstChild.data.encode("utf-8")
+ self.emailAddresses.add(child.firstChild.data.encode("utf-8"))
elif child_name == ELEMENT_MEMBERS:
self._parseMembers(child, self.members)
elif child_name == ELEMENT_CUADDR:
Modified: CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/xmlfile.py
===================================================================
--- CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/xmlfile.py 2008-09-24 23:13:24 UTC (rev 3052)
+++ CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/directory/xmlfile.py 2008-09-24 23:14:18 UTC (rev 3053)
@@ -116,7 +116,7 @@
fullName = xmlPrincipal.name,
firstName = xmlPrincipal.firstName,
lastName = xmlPrincipal.lastName,
- emailAddress = xmlPrincipal.emailAddress,
+ emailAddresses = xmlPrincipal.emailAddresses,
calendarUserAddresses = xmlPrincipal.calendarUserAddresses,
autoSchedule = xmlPrincipal.autoSchedule,
enabledForCalendaring = xmlPrincipal.enabledForCalendaring,
Modified: CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/resource.py
===================================================================
--- CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/resource.py 2008-09-24 23:13:24 UTC (rev 3052)
+++ CalendarServer/branches/users/sagen/principal-property-search-3051/twistedcaldav/resource.py 2008-09-24 23:14:18 UTC (rev 3053)
@@ -695,7 +695,7 @@
(caldav_namespace, "schedule-outbox-URL" ),
(calendarserver_namespace, "first-name" ),
(calendarserver_namespace, "last-name" ),
- (calendarserver_namespace, "email-address" ),
+ (calendarserver_namespace, "email-address-set"),
)
@classmethod
@@ -765,14 +765,11 @@
else:
return None
- if name == "email-address":
- emailAddress = self.record.emailAddress
- if emailAddress:
- return customxml.EMailProperty(emailAddress)
- else:
- return None
+ if name == "email-address-set":
+ return succeed(customxml.EmailAddressSet(
+ *[customxml.EmailAddressProperty(addr) for addr in self.record.emailAddresses]
+ ))
-
return super(CalendarPrincipalResource, self).readProperty(property, request)
return maybeDeferred(defer)
-------------- next part --------------
An HTML attachment was scrubbed...
URL: http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20080924/487d0539/attachment-0001.html
More information about the calendarserver-changes
mailing list