[Checkins] SVN: zope.browserresource/trunk/ Support the HTTP ETag header for file resources. ETag generation can be
Marius Gedminas
marius at pov.lt
Fri Aug 13 10:54:22 EDT 2010
Log message for revision 115667:
Support the HTTP ETag header for file resources. ETag generation can be
customized or disabled by providing an IETag multi-adapter on
(IFileResource, your-application-skin).
Merged mgedmin-etag-support branch with
svn merge -r 115595:115666 svn+ssh://svn.zope.org/repos/main/zope.browserresource/branches/mgedmin-etag-support .
Changed:
U zope.browserresource/trunk/CHANGES.txt
U zope.browserresource/trunk/setup.py
U zope.browserresource/trunk/src/zope/browserresource/configure.zcml
U zope.browserresource/trunk/src/zope/browserresource/file.py
U zope.browserresource/trunk/src/zope/browserresource/interfaces.py
U zope.browserresource/trunk/src/zope/browserresource/tests/test_file.py
U zope.browserresource/trunk/src/zope/browserresource/tests/test_i18nfile.py
-=-
Modified: zope.browserresource/trunk/CHANGES.txt
===================================================================
--- zope.browserresource/trunk/CHANGES.txt 2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/CHANGES.txt 2010-08-13 14:54:19 UTC (rev 115667)
@@ -2,9 +2,12 @@
CHANGES
=======
-3.10.4 (unreleased)
+3.11.0 (unreleased)
===================
+- Support the HTTP ETag header for file resources. ETag generation can be
+ customized or disabled by providing an IETag multi-adapter on
+ (IFileResource, your-application-skin).
3.10.3 (2010-04-30)
===================
Modified: zope.browserresource/trunk/setup.py
===================================================================
--- zope.browserresource/trunk/setup.py 2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/setup.py 2010-08-13 14:54:19 UTC (rev 115667)
@@ -19,7 +19,7 @@
open('CHANGES.txt').read())
setup(name='zope.browserresource',
- version = '3.10.4dev',
+ version = '3.11.0dev',
url='http://pypi.python.org/pypi/zope.browserresource/',
author='Zope Foundation and Contributors',
author_email='zope-dev at zope.org',
Modified: zope.browserresource/trunk/src/zope/browserresource/configure.zcml
===================================================================
--- zope.browserresource/trunk/src/zope/browserresource/configure.zcml 2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/src/zope/browserresource/configure.zcml 2010-08-13 14:54:19 UTC (rev 115667)
@@ -1,7 +1,8 @@
<configure xmlns="http://namespaces.zope.org/zope">
<adapter factory=".resource.AbsoluteURL" />
-
+ <adapter factory=".file.FileETag" />
+
<view
for="zope.component.interfaces.ISite"
type="zope.publisher.interfaces.browser.IDefaultBrowserLayer"
@@ -17,14 +18,14 @@
attributes="GET HEAD __call__"
/>
</class>
-
+
<class class=".i18nfile.I18nFileResource">
<allow
interface="zope.publisher.interfaces.browser.IBrowserPublisher"
attributes="GET HEAD __call__"
/>
</class>
-
+
<class class=".directory.DirectoryResource">
<allow
interface="zope.publisher.interfaces.browser.IBrowserPublisher"
Modified: zope.browserresource/trunk/src/zope/browserresource/file.py
===================================================================
--- zope.browserresource/trunk/src/zope/browserresource/file.py 2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/src/zope/browserresource/file.py 2010-08-13 14:54:19 UTC (rev 115667)
@@ -16,6 +16,7 @@
import os
import time
+import re
try:
from email.utils import formatdate, parsedate_tz, mktime_tz
except ImportError: # python 2.4
@@ -23,25 +24,105 @@
from zope.contenttype import guess_content_type
from zope.interface import implements, classProvides
+from zope.component import adapts, getMultiAdapter
from zope.publisher.browser import BrowserView
from zope.publisher.interfaces import NotFound
+from zope.publisher.interfaces.browser import IBrowserRequest
from zope.publisher.interfaces.browser import IBrowserPublisher
from zope.browserresource.resource import Resource
+from zope.browserresource.interfaces import IETag
+from zope.browserresource.interfaces import IFileResource
from zope.browserresource.interfaces import IResourceFactory
from zope.browserresource.interfaces import IResourceFactoryFactory
+ETAG_RX = re.compile(r'[*]|(?:W/)?"(?:[^"\\]|[\\].)*"')
+
+
+def parse_etags(value):
+ r"""Parse a list of entity tags.
+
+ HTTP/1.1 specifies the following syntax for If-Match/If-None-Match
+ headers::
+
+ If-Match = "If-Match" ":" ( "*" | 1#entity-tag )
+ If-None-Match = "If-None-Match" ":" ( "*" | 1#entity-tag )
+
+ entity-tag = [ weak ] opaque-tag
+
+ weak = "W/"
+ opaque-tag = quoted-string
+
+ quoted-string = ( <"> *(qdtext) <"> )
+ qdtext = <any TEXT except <">>
+
+ The backslash character ("\") may be used as a single-character
+ quoting mechanism only within quoted-string and comment constructs.
+
+ Examples:
+
+ >>> parse_etags('*')
+ ['*']
+
+ >>> parse_etags(r' "qwerty", ,"foo",W/"bar" , "baz","\""')
+ ['"qwerty"', '"foo"', 'W/"bar"', '"baz"', '"\\""']
+
+ Ill-formed headers are ignored
+
+ >>> parse_etags("not an etag at all")
+ []
+
+ """
+ return ETAG_RX.findall(value)
+
+
+def etag_matches(etag, tags):
+ """Check if the entity tag matches any of the given tags.
+
+ >>> etag_matches('"xyzzy"', ['"abc"', '"xyzzy"', 'W/"woof"'])
+ True
+
+ >>> etag_matches('"woof"', ['"abc"', 'W/"woof"'])
+ False
+
+ >>> etag_matches('"xyzzy"', ['*'])
+ True
+
+ Note that you pass quoted etags in both arguments!
+ """
+ for tag in tags:
+ if tag == etag or tag == '*':
+ return True
+ return False
+
+
+def quote_etag(etag):
+ r"""Quote an etag value
+
+ >>> quote_etag("foo")
+ '"foo"'
+
+ Special characters are escaped
+
+ >>> quote_etag('"')
+ '"\\""'
+ >>> quote_etag('\\')
+ '"\\\\"'
+
+ """
+ return '"%s"' % etag.replace('\\', '\\\\').replace('"', '\\"')
+
+
class File(object):
-
+
def __init__(self, path, name):
self.path = path
self.__name__ = name
-
f = open(path, 'rb')
- data = f.read()
+ self.data = f.read()
f.close()
- self.content_type = guess_content_type(path, data)[0]
+ self.content_type = guess_content_type(path, self.data)[0]
self.lmt = float(os.path.getmtime(path)) or time.time()
self.lmh = formatdate(self.lmt, usegmt=True)
@@ -49,14 +130,14 @@
class FileResource(BrowserView, Resource):
- implements(IBrowserPublisher)
+ implements(IFileResource, IBrowserPublisher)
cacheTimeout = 86400
def publishTraverse(self, request, name):
'''File resources can't be traversed further, so raise NotFound if
someone tries to traverse it.
-
+
>>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
>>> request = TestRequest()
>>> resource = factory(request)
@@ -87,73 +168,50 @@
True
>>> next == ()
True
-
+
'''
return getattr(self, request.method), ()
def chooseContext(self):
'''Choose the appropriate context.
-
+
This method can be overriden in subclasses, that need to choose
appropriate file, based on current request or other condition,
like, for example, i18n files.
-
+
'''
return self.context
def GET(self):
'''Return a file data for downloading with GET requests
-
+
>>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
>>> request = TestRequest()
>>> resource = factory(request)
- >>> resource.GET() == open(testFilePath, 'rb').read()
+ >>> resource.GET() == open(testFilePath, 'rb').read()
True
>>> request.response.getHeader('Content-Type') == 'text/plain'
True
-
- Let's test If-Modified-Since header support.
- >>> timestamp = time.time()
-
- >>> file = factory._FileResourceFactory__file # get mangled file
- >>> file.lmt = timestamp
- >>> file.lmh = formatdate(timestamp, usegmt=True)
-
- >>> before = timestamp - 1000
- >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(before, usegmt=True))
- >>> resource = factory(request)
- >>> bool(resource.GET())
- True
-
- >>> after = timestamp + 1000
- >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(after, usegmt=True))
- >>> resource = factory(request)
- >>> bool(resource.GET())
- False
- >>> request.response.getStatus()
- 304
-
- It won't fail on bad If-Modified-Since headers.
-
- >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE='bad header')
- >>> resource = factory(request)
- >>> bool(resource.GET())
- True
-
'''
file = self.chooseContext()
request = self.request
response = request.response
+ etag = getMultiAdapter((self, request), IETag)(file.lmt, file.data)
+
setCacheControl(response, self.cacheTimeout)
+ can_return_304 = False
+ all_cache_checks_passed = True
+
# HTTP If-Modified-Since header handling. This is duplicated
# from OFS.Image.Image - it really should be consolidated
# somewhere...
header = request.getHeader('If-Modified-Since', None)
if header is not None:
+ can_return_304 = True
header = header.split(';')[0]
# Some proxies seem to send invalid date strings for this
# header. If the date string is not valid, we ignore it
@@ -165,27 +223,42 @@
mod_since = long(mktime_tz(parsedate_tz(header)))
except:
mod_since = None
- if mod_since is not None:
- if getattr(file, 'lmt', None):
- last_mod = long(file.lmt)
- else:
- last_mod = 0L
- if last_mod > 0 and last_mod <= mod_since:
- response.setStatus(304)
- return ''
+ if getattr(file, 'lmt', None):
+ last_mod = long(file.lmt)
+ else:
+ last_mod = 0L
+ if mod_since is None or last_mod <= 0 or last_mod > mod_since:
+ all_cache_checks_passed = False
+ # HTTP If-None-Match header handling
+ header = request.getHeader('If-None-Match', None)
+ if header is not None:
+ can_return_304 = True
+ tags = parse_etags(header)
+ if not etag or not etag_matches(quote_etag(etag), tags):
+ all_cache_checks_passed = False
+
+ # 304 responses MUST contain ETag, if one would've been sent with
+ # a 200 response
+ if etag:
+ response.setHeader('ETag', quote_etag(etag))
+
+ if can_return_304 and all_cache_checks_passed:
+ response.setStatus(304)
+ return ''
+
+ # 304 responses SHOULD NOT or MUST NOT include other entity headers,
+ # depending on whether the conditional GET used a strong or a weak
+ # validator. We only use strong validators, which makes it SHOULD
+ # NOT.
response.setHeader('Content-Type', file.content_type)
response.setHeader('Last-Modified', file.lmh)
- f = open(file.path,'rb')
- data = f.read()
- f.close()
+ return file.data
- return data
-
def HEAD(self):
'''Return proper headers and no content for HEAD requests
-
+
>>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
>>> request = TestRequest()
>>> resource = factory(request)
@@ -196,9 +269,12 @@
'''
file = self.chooseContext()
+ etag = getMultiAdapter((self, self.request), IETag)(file.lmt, file.data)
response = self.request.response
response.setHeader('Content-Type', file.content_type)
response.setHeader('Last-Modified', file.lmh)
+ if etag:
+ response.setHeader('ETag', etag)
setCacheControl(response, self.cacheTimeout)
return ''
@@ -210,6 +286,19 @@
return data
+class FileETag(object):
+
+ adapts(IFileResource, IBrowserRequest)
+ implements(IETag)
+
+ def __init__(self, context, request):
+ self.context = context
+ self.request = request
+
+ def __call__(self, mtime, content):
+ return '%s-%s' % (mtime, len(content))
+
+
def setCacheControl(response, secs=86400):
# Cache for one day by default
response.setHeader('Cache-Control', 'public,max-age=%s' % secs)
Modified: zope.browserresource/trunk/src/zope/browserresource/interfaces.py
===================================================================
--- zope.browserresource/trunk/src/zope/browserresource/interfaces.py 2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/src/zope/browserresource/interfaces.py 2010-08-13 14:54:19 UTC (rev 115667)
@@ -23,22 +23,35 @@
def __call__():
"""return the absolute URL of this resource."""
+class IFileResource(IResource):
+ pass
+
class IResourceFactory(Interface):
-
+
def __call__(request):
"""Return an IResource object"""
class IResourceFactoryFactory(Interface):
"""A factory for IResourceFactory objects
-
+
These factories are registered as named utilities that can be selected
for creating resource factories in a pluggable way.
-
+
Resource directories and browser:resource directive use these utilities
to choose what resource to create, depending on the file extension, so
third-party packages could easily plug-in additional resource types.
-
+
"""
-
+
def __call__(path, checker, name):
"""Return an IResourceFactory"""
+
+class IETag(Interface):
+ """An adapter for computing resource ETags."""
+
+ def __call__(mtime, content):
+ """Compute an ETag for a resource.
+
+ May return None to disable the ETag header.
+ """
+
Modified: zope.browserresource/trunk/src/zope/browserresource/tests/test_file.py
===================================================================
--- zope.browserresource/trunk/src/zope/browserresource/tests/test_file.py 2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/src/zope/browserresource/tests/test_file.py 2010-08-13 14:54:19 UTC (rev 115667)
@@ -17,12 +17,46 @@
import doctest
import os
import unittest
+import time
+try:
+ from email.utils import formatdate, parsedate_tz, mktime_tz
+except ImportError: # python 2.4
+ from email.Utils import formatdate, parsedate_tz, mktime_tz
+
from zope.testing import cleanup
-
from zope.publisher.browser import TestRequest
+from zope.publisher.interfaces.browser import IBrowserRequest
from zope.security.checker import NamesChecker
+from zope.component import provideAdapter, adapts
+from zope.interface import implements
+from zope.interface.verify import verifyObject
+from zope.browserresource.file import FileResourceFactory, FileETag
+from zope.browserresource.interfaces import IFileResource, IETag
+
+class MyETag(object):
+ adapts(IFileResource, IBrowserRequest)
+ implements(IETag)
+
+ def __init__(self, context, request):
+ pass
+
+ def __call__(self, mtime, content):
+ return 'myetag'
+
+
+class NoETag(object):
+ adapts(IFileResource, IBrowserRequest)
+ implements(IETag)
+
+ def __init__(self, context, request):
+ pass
+
+ def __call__(self, mtime, content):
+ return None
+
+
def setUp(test):
cleanup.setUp()
data_dir = os.path.join(os.path.dirname(__file__), 'testfiles')
@@ -30,15 +64,227 @@
test.globs['testFilePath'] = os.path.join(data_dir, 'test.txt')
test.globs['nullChecker'] = NamesChecker()
test.globs['TestRequest'] = TestRequest
+ provideAdapter(MyETag)
def tearDown(test):
cleanup.tearDown()
+
+def doctest_FileETag():
+ """Tests for FileETag
+
+ >>> etag_maker = FileETag(object(), TestRequest())
+ >>> verifyObject(IETag, etag_maker)
+ True
+
+ By default we constuct an ETag from the file's mtime and size
+
+ >>> etag_maker(1234, 'abc')
+ '1234-3'
+
+ """
+
+
+def doctest_FileResource_GET_sets_cache_headers():
+ """Test caching headers set by FileResource.GET
+
+ >>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
+
+ >>> timestamp = time.time()
+
+ >>> file = factory._FileResourceFactory__file # get mangled file
+ >>> file.lmt = timestamp
+ >>> file.lmh = formatdate(timestamp, usegmt=True)
+
+ >>> request = TestRequest()
+ >>> resource = factory(request)
+ >>> bool(resource.GET())
+ True
+
+ >>> request.response.getHeader('Last-Modified') == file.lmh
+ True
+ >>> request.response.getHeader('ETag')
+ '"myetag"'
+ >>> request.response.getHeader('Cache-Control')
+ 'public,max-age=86400'
+ >>> bool(request.response.getHeader('Expires'))
+ True
+
+ """
+
+
+def doctest_FileResource_GET_if_modified_since():
+ """Test If-Modified-Since header support
+
+ >>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
+
+ >>> timestamp = time.time()
+
+ >>> file = factory._FileResourceFactory__file # get mangled file
+ >>> file.lmt = timestamp
+ >>> file.lmh = formatdate(timestamp, usegmt=True)
+
+ >>> before = timestamp - 1000
+ >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(before, usegmt=True))
+ >>> resource = factory(request)
+ >>> bool(resource.GET())
+ True
+
+ >>> after = timestamp + 1000
+ >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(after, usegmt=True))
+ >>> resource = factory(request)
+ >>> bool(resource.GET())
+ False
+ >>> request.response.getStatus()
+ 304
+
+ Cache control headers and ETag are set on 304 responses
+
+ >>> request.response.getHeader('ETag')
+ '"myetag"'
+ >>> request.response.getHeader('Cache-Control')
+ 'public,max-age=86400'
+ >>> bool(request.response.getHeader('Expires'))
+ True
+
+ Other entity headers are not
+
+ >>> request.response.getHeader('Last-Modified')
+ >>> request.response.getHeader('Content-Type')
+
+ It won't fail on bad If-Modified-Since headers.
+
+ >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE='bad header')
+ >>> resource = factory(request)
+ >>> bool(resource.GET())
+ True
+
+ it also won't fail if we don't have a last modification time for the
+ resource
+
+ >>> file.lmt = None
+ >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(after, usegmt=True))
+ >>> resource = factory(request)
+ >>> bool(resource.GET())
+ True
+
+ """
+
+
+def doctest_FileResource_GET_if_none_match():
+ """Test If-None-Match header support
+
+ >>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
+
+ >>> timestamp = time.time()
+
+ >>> file = factory._FileResourceFactory__file # get mangled file
+ >>> file.lmt = timestamp
+ >>> file.lmh = formatdate(timestamp, usegmt=True)
+
+ >>> request = TestRequest(HTTP_IF_NONE_MATCH='"othertag"')
+ >>> resource = factory(request)
+ >>> bool(resource.GET())
+ True
+
+ >>> request = TestRequest(HTTP_IF_NONE_MATCH='"myetag"')
+ >>> resource = factory(request)
+ >>> bool(resource.GET())
+ False
+ >>> request.response.getStatus()
+ 304
+
+ Cache control headers and ETag are set on 304 responses
+
+ >>> request.response.getHeader('ETag')
+ '"myetag"'
+ >>> request.response.getHeader('Cache-Control')
+ 'public,max-age=86400'
+ >>> bool(request.response.getHeader('Expires'))
+ True
+
+ Other entity headers are not
+
+ >>> request.response.getHeader('Last-Modified')
+ >>> request.response.getHeader('Content-Type')
+
+ It won't fail on bad If-None-Match headers.
+
+ >>> request = TestRequest(HTTP_IF_NONE_MATCH='bad header')
+ >>> resource = factory(request)
+ >>> bool(resource.GET())
+ True
+
+ it also won't fail if we don't have an etag for the resource
+
+ >>> provideAdapter(NoETag)
+ >>> request = TestRequest(HTTP_IF_NONE_MATCH='"someetag"')
+ >>> resource = factory(request)
+ >>> bool(resource.GET())
+ True
+
+ """
+
+
+def doctest_FileResource_GET_if_none_match_and_if_modified_since():
+ """Test combined If-None-Match and If-Modified-Since header support
+
+ >>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
+
+ >>> timestamp = time.time()
+
+ >>> file = factory._FileResourceFactory__file # get mangled file
+ >>> file.lmt = timestamp
+ >>> file.lmh = formatdate(timestamp, usegmt=True)
+
+ We've a match
+
+ >>> after = timestamp + 1000
+ >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(after, usegmt=True),
+ ... HTTP_IF_NONE_MATCH='"myetag"')
+ >>> resource = factory(request)
+ >>> bool(resource.GET())
+ False
+ >>> request.response.getStatus()
+ 304
+
+ Last-modified matches, but ETag doesn't
+
+ >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(after, usegmt=True),
+ ... HTTP_IF_NONE_MATCH='"otheretag"')
+ >>> resource = factory(request)
+ >>> bool(resource.GET())
+ True
+
+ ETag matches but last-modified doesn't
+
+ >>> before = timestamp - 1000
+ >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(before, usegmt=True),
+ ... HTTP_IF_NONE_MATCH='"myetag"')
+ >>> resource = factory(request)
+ >>> bool(resource.GET())
+ True
+
+ Both don't match
+
+ >>> before = timestamp - 1000
+ >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(before, usegmt=True),
+ ... HTTP_IF_NONE_MATCH='"otheretag"')
+ >>> resource = factory(request)
+ >>> bool(resource.GET())
+ True
+
+ """
+
+
def test_suite():
return unittest.TestSuite((
doctest.DocTestSuite(
'zope.browserresource.file',
setUp=setUp, tearDown=tearDown,
optionflags=doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE),
+ doctest.DocTestSuite(
+ setUp=setUp, tearDown=tearDown,
+ optionflags=doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE),
))
Modified: zope.browserresource/trunk/src/zope/browserresource/tests/test_i18nfile.py
===================================================================
--- zope.browserresource/trunk/src/zope/browserresource/tests/test_i18nfile.py 2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/src/zope/browserresource/tests/test_i18nfile.py 2010-08-13 14:54:19 UTC (rev 115667)
@@ -18,14 +18,17 @@
from zope.publisher.interfaces import NotFound
-from zope.component import provideAdapter, provideUtility
+from zope.interface import implements
+from zope.component import provideAdapter, provideUtility, adapts
from zope.testing import cleanup
from zope.i18n.interfaces import IUserPreferredCharsets, IUserPreferredLanguages
from zope.publisher.http import IHTTPRequest, HTTPCharsets
from zope.publisher.browser import BrowserLanguages, TestRequest
+from zope.publisher.interfaces.browser import IBrowserRequest
+from zope.browserresource.interfaces import IFileResource, IETag
from zope.browserresource.i18nfile import I18nFileResource
from zope.browserresource.i18nfile import I18nFileResourceFactory
from zope.browserresource.file import File
@@ -39,6 +42,17 @@
test_directory = os.path.dirname(p.__file__)
+class MyETag(object):
+ adapts(IFileResource, IBrowserRequest)
+ implements(IETag)
+
+ def __init__(self, context, request):
+ pass
+
+ def __call__(self, mtime, content):
+ return 'myetag'
+
+
class Test(cleanup.CleanUp, TestII18nAware):
def setUp(self):
@@ -48,8 +62,8 @@
provideAdapter(BrowserLanguages, (IHTTPRequest,), IUserPreferredLanguages)
# Setup the negotiator utility
provideUtility(negotiator, INegotiator)
+ provideAdapter(MyETag)
-
def _createObject(self):
obj = I18nFileResource({'en':None, 'lt':None, 'fr':None},
TestRequest(), 'fr')
More information about the checkins
mailing list