diff options
| author | Barry Warsaw | 2009-02-19 22:23:42 -0500 |
|---|---|---|
| committer | Barry Warsaw | 2009-02-19 22:23:42 -0500 |
| commit | 8644b80168066c8fd11a7e2440ed8566453f0cd4 (patch) | |
| tree | f68f709fc52140ee135b5fb1862690edbe69d572 /src | |
| parent | fcec8479c1f07576094102128408f4c23b278bb5 (diff) | |
| download | mailman-8644b80168066c8fd11a7e2440ed8566453f0cd4.tar.gz mailman-8644b80168066c8fd11a7e2440ed8566453f0cd4.tar.zst mailman-8644b80168066c8fd11a7e2440ed8566453f0cd4.zip | |
Diffstat (limited to 'src')
| -rw-r--r-- | src/mailman/database/mailinglist.py | 12 | ||||
| -rw-r--r-- | src/mailman/database/mailman.sql | 12 | ||||
| -rw-r--r-- | src/mailman/inject.py | 12 | ||||
| -rw-r--r-- | src/mailman/interfaces/autorespond.py | 15 | ||||
| -rw-r--r-- | src/mailman/pipeline/docs/replybot.txt | 81 | ||||
| -rw-r--r-- | src/mailman/pipeline/replybot.py | 150 | ||||
| -rw-r--r-- | src/mailman/queue/docs/command.txt | 1 | ||||
| -rw-r--r-- | src/mailman/queue/docs/incoming.txt | 2 | ||||
| -rw-r--r-- | src/mailman/queue/lmtp.py | 12 | ||||
| -rw-r--r-- | src/mailman/styles/default.py | 15 |
10 files changed, 154 insertions, 158 deletions
diff --git a/src/mailman/database/mailinglist.py b/src/mailman/database/mailinglist.py index 2c5869978..9141ad64c 100644 --- a/src/mailman/database/mailinglist.py +++ b/src/mailman/database/mailinglist.py @@ -79,13 +79,15 @@ class MailingList(Model): archive = Bool() archive_private = Bool() archive_volume_frequency = Int() - autorespond_admin = Bool() - autorespond_postings = Bool() - autorespond_requests = Int() - autoresponse_admin_text = Unicode() - autoresponse_graceperiod = TimeDelta() + # Automatic responses. + autoresponse_grace_period = TimeDelta() + autorespond_owner = Enum() + autoresponse_owner_text = Unicode() + autorespond_postings = Enum() autoresponse_postings_text = Unicode() + autorespond_requests = Enum() autoresponse_request_text = Unicode() + # Bounces and bans. ban_list = Pickle() bounce_info_stale_after = TimeDelta() bounce_matching_headers = Unicode() diff --git a/src/mailman/database/mailman.sql b/src/mailman/database/mailman.sql index 4c1477a33..c46fb193e 100644 --- a/src/mailman/database/mailman.sql +++ b/src/mailman/database/mailman.sql @@ -67,13 +67,15 @@ CREATE TABLE mailinglist ( archive BOOLEAN, archive_private BOOLEAN, archive_volume_frequency INTEGER, - autorespond_admin BOOLEAN, - autorespond_postings BOOLEAN, - autorespond_requests INTEGER, - autoresponse_admin_text TEXT, - autoresponse_graceperiod TEXT, + -- Automatic responses. + autorespond_owner INTEGER, + autoresponse_owner_text TEXT, + autorespond_postings INTEGER, autoresponse_postings_text TEXT, + autorespond_requests INTEGER, autoresponse_request_text TEXT, + autoresponse_grace_period TEXT, + -- Bounce and ban. ban_list BLOB, bounce_info_stale_after TEXT, bounce_matching_headers TEXT, diff --git a/src/mailman/inject.py b/src/mailman/inject.py index bd30d116e..ac2072fa1 100644 --- a/src/mailman/inject.py +++ b/src/mailman/inject.py @@ -32,7 +32,7 @@ from mailman.email.message import Message -def inject_message(mlist, msg, recips=None, switchboard=None): +def inject_message(mlist, msg, recips=None, switchboard=None, **kws): """Inject a message into a queue. :param mlist: The mailing list this message is destined for. @@ -45,6 +45,8 @@ def inject_message(mlist, msg, recips=None, switchboard=None): :param switchboard: Optional name of switchboard to inject this message into. If not given, the 'in' switchboard is used. :type switchboard: string + :param kws: Additional values for the message metadata. + :type kws: dictionary """ if switchboard is None: switchboard = 'in' @@ -55,14 +57,14 @@ def inject_message(mlist, msg, recips=None, switchboard=None): # Ditto for Date: as required by RFC 2822. if 'date' not in msg: msg['Date'] = formatdate(localtime=True) - kws = dict( + msgdata = dict( listname=mlist.fqdn_listname, - tolist=True, original_size=getattr(msg, 'original_size', len(msg.as_string())), ) + msgdata.update(kws) if recips is not None: - kws['recips'] = recips - config.switchboards[switchboard].enqueue(msg, **kws) + msgdata['recips'] = recips + config.switchboards[switchboard].enqueue(msg, **msgdata) diff --git a/src/mailman/interfaces/autorespond.py b/src/mailman/interfaces/autorespond.py index f0b2f88bd..c2c2189b5 100644 --- a/src/mailman/interfaces/autorespond.py +++ b/src/mailman/interfaces/autorespond.py @@ -21,15 +21,20 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ + 'ALWAYS_REPLY', 'IAutoResponseRecord', 'IAutoResponseSet', 'Response', + 'ResponseAction', ] +from datetime import timedelta from munepy import Enum from zope.interface import Interface, Attribute +ALWAYS_REPLY = timedelta() + class Response(Enum): @@ -44,6 +49,16 @@ class Response(Enum): +class ResponseAction(Enum): + # No automatic response. + none = 0 + # Respond, but discard the original message. + respond_and_discard = 1 + # Respond and continue processing the message. + respond_and_continue = 2 + + + class IAutoResponseRecord(Interface): """An auto-response record. diff --git a/src/mailman/pipeline/docs/replybot.txt b/src/mailman/pipeline/docs/replybot.txt index 696570401..6c4d896a4 100644 --- a/src/mailman/pipeline/docs/replybot.txt +++ b/src/mailman/pipeline/docs/replybot.txt @@ -1,20 +1,14 @@ -Auto-reply handler -================== +Automatic response handler +========================== -Mailman has an auto-reply handler that sends automatic responses to messages -it receives on its posting address, owner address, or robot address. -Automatic responses are subject to various conditions, such as headers in the -original message or the amount of time since the last auto-response. +Mailman has a replybot handler that sends automatic responses to messages it +receives on its posting address, owner address, or robot address. Automatic +responses are subject to various conditions, such as headers in the original +message or the amount of time since the last auto-response. >>> mlist = create_list(u'_xtest@example.com') >>> mlist.real_name = u'XTest' - >>> # Ensure that the virgin queue is empty, since we'll be checking this - >>> # for new auto-response messages. - >>> virginq = config.switchboards['virgin'] - >>> virginq.files - [] - Basic automatic responding -------------------------- @@ -26,9 +20,11 @@ automatic response grace period which specifies how much time must pass before a second response will be sent, with 0 meaning "there is no grace period". >>> import datetime - >>> mlist.autorespond_admin = True - >>> mlist.autoresponse_graceperiod = datetime.timedelta() - >>> mlist.autoresponse_admin_text = u'admin autoresponse text' + >>> from mailman.interfaces.autorespond import ResponseAction + + >>> mlist.autorespond_owner = ResponseAction.respond_and_continue + >>> mlist.autoresponse_grace_period = datetime.timedelta() + >>> mlist.autoresponse_owner_text = u'owner autoresponse text' >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -40,9 +36,10 @@ a second response will be sent, with 0 meaning "there is no grace period". The preceding message to the mailing list's owner will trigger an automatic response. - >>> from mailman.pipeline.replybot import process >>> from mailman.testing.helpers import get_queue_messages - >>> process(mlist, msg, dict(toowner=True)) + + >>> handler = config.handlers['replybot'] + >>> handler.process(mlist, msg, dict(to_owner=True)) >>> messages = get_queue_messages('virgin') >>> len(messages) 1 @@ -68,7 +65,7 @@ response. Date: ... Precedence: bulk <BLANKLINE> - admin autoresponse text + owner autoresponse text Short circuiting @@ -85,7 +82,7 @@ header, no auto-response is sent. ... help me ... """) - >>> process(mlist, msg, dict(toowner=True)) + >>> handler.process(mlist, msg, dict(to_owner=True)) >>> get_queue_messages('virgin') [] @@ -98,7 +95,7 @@ internally crafted messages, by setting the 'noack' metadata key. ... help for you ... """) - >>> process(mlist, msg, dict(noack=True, toowner=True)) + >>> handler.process(mlist, msg, dict(noack=True, to_owner=True)) >>> get_queue_messages('virgin') [] @@ -112,17 +109,17 @@ If there is a Precedence: header with any of the values 'bulk', 'junk', or ... hey! ... """) - >>> process(mlist, msg, dict(toowner=True)) + >>> handler.process(mlist, msg, dict(to_owner=True)) >>> get_queue_messages('virgin') [] >>> msg.replace_header('precedence', 'junk') - >>> process(mlist, msg, dict(toowner=True)) + >>> handler.process(mlist, msg, dict(to_owner=True)) >>> get_queue_messages('virgin') [] >>> msg.replace_header('precedence', 'list') - >>> process(mlist, msg, dict(toowner=True)) + >>> handler.process(mlist, msg, dict(to_owner=True)) >>> get_queue_messages('virgin') [] @@ -130,7 +127,7 @@ Unless the X-Ack: header has a value of "yes", in which case, the Precedence header is ignored. >>> msg['X-Ack'] = 'yes' - >>> process(mlist, msg, dict(toowner=True)) + >>> handler.process(mlist, msg, dict(to_owner=True)) >>> messages = get_queue_messages('virgin') >>> len(messages) 1 @@ -156,7 +153,7 @@ header is ignored. Date: ... Precedence: bulk <BLANKLINE> - admin autoresponse text + owner autoresponse text Available auto-responses @@ -166,7 +163,7 @@ As shown above, a message sent to the -owner address will get an auto-response with the text set for owner responses. Two other types of email will get auto-responses: those sent to the -request address... - >>> mlist.autorespond_requests = True + >>> mlist.autorespond_requests = ResponseAction.respond_and_continue >>> mlist.autoresponse_request_text = u'robot autoresponse text' >>> msg = message_from_string("""\ @@ -176,7 +173,7 @@ auto-responses: those sent to the -request address... ... help me ... """) - >>> process(mlist, msg, dict(torequest=True)) + >>> handler.process(mlist, msg, dict(to_request=True)) >>> messages = get_queue_messages('virgin') >>> len(messages) 1 @@ -198,7 +195,7 @@ auto-responses: those sent to the -request address... ...and those sent to the posting address. - >>> mlist.autorespond_postings = True + >>> mlist.autorespond_postings = ResponseAction.respond_and_continue >>> mlist.autoresponse_postings_text = u'postings autoresponse text' >>> msg = message_from_string("""\ @@ -208,7 +205,7 @@ auto-responses: those sent to the -request address... ... help me ... """) - >>> process(mlist, msg, {}) + >>> handler.process(mlist, msg, dict(to_list=True)) >>> messages = get_queue_messages('virgin') >>> len(messages) 1 @@ -236,7 +233,7 @@ Automatic responses have a grace period, during which no additional responses will be sent. This is so as not to bombard the sender with responses. The grace period is measured in days. - >>> mlist.autoresponse_graceperiod = datetime.timedelta(days=10) + >>> mlist.autoresponse_grace_period = datetime.timedelta(days=10) When a response is sent to a person via any of the owner, request, or postings addresses, the response date is recorded. The grace period is usually @@ -251,14 +248,14 @@ measured in days. This is the first response to bperson, so it gets sent. - >>> process(mlist, msg, dict(toowner=True)) + >>> handler.process(mlist, msg, dict(to_owner=True)) >>> print len(get_queue_messages('virgin')) 1 But with a grace period greater than zero, no subsequent response will be sent right now. - >>> process(mlist, msg, dict(toowner=True)) + >>> handler.process(mlist, msg, dict(to_owner=True)) >>> print len(get_queue_messages('virgin')) 0 @@ -267,14 +264,14 @@ Fast forward 9 days and you still don't get a response. >>> from mailman.utilities.datetime import factory >>> factory.fast_forward(days=9) - >>> process(mlist, msg, dict(toowner=True)) + >>> handler.process(mlist, msg, dict(to_owner=True)) >>> print len(get_queue_messages('virgin')) 0 But tomorrow, the sender will get a new auto-response. >>> factory.fast_forward() - >>> process(mlist, msg, dict(toowner=True)) + >>> handler.process(mlist, msg, dict(to_owner=True)) >>> print len(get_queue_messages('virgin')) 1 @@ -288,21 +285,21 @@ address, even if the sender is the same person... ... help ... """) - >>> process(mlist, msg, dict(torequest=True)) + >>> handler.process(mlist, msg, dict(to_request=True)) >>> print len(get_queue_messages('virgin')) 1 - >>> process(mlist, msg, dict(torequest=True)) + >>> handler.process(mlist, msg, dict(to_request=True)) >>> print len(get_queue_messages('virgin')) 0 >>> factory.fast_forward(days=9) - >>> process(mlist, msg, dict(torequest=True)) + >>> handler.process(mlist, msg, dict(to_request=True)) >>> print len(get_queue_messages('virgin')) 0 >>> factory.fast_forward() - >>> process(mlist, msg, dict(torequest=True)) + >>> handler.process(mlist, msg, dict(to_request=True)) >>> print len(get_queue_messages('virgin')) 1 @@ -315,20 +312,20 @@ address, even if the sender is the same person... ... help ... """) - >>> process(mlist, msg, {}) + >>> handler.process(mlist, msg, dict(to_list=True)) >>> print len(get_queue_messages('virgin')) 1 - >>> process(mlist, msg, {}) + >>> handler.process(mlist, msg, dict(to_list=True)) >>> print len(get_queue_messages('virgin')) 0 >>> factory.fast_forward(days=9) - >>> process(mlist, msg, {}) + >>> handler.process(mlist, msg, dict(to_list=True)) >>> print len(get_queue_messages('virgin')) 0 >>> factory.fast_forward() - >>> process(mlist, msg, {}) + >>> handler.process(mlist, msg, dict(to_list=True)) >>> print len(get_queue_messages('virgin')) 1 diff --git a/src/mailman/pipeline/replybot.py b/src/mailman/pipeline/replybot.py index 7cecaa7cd..5a560bcbf 100644 --- a/src/mailman/pipeline/replybot.py +++ b/src/mailman/pipeline/replybot.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License along with # GNU Mailman. If not, see <http://www.gnu.org/licenses/>. -"""Handler for auto-responses.""" +"""Handler for automatic responses.""" from __future__ import absolute_import, unicode_literals @@ -34,93 +34,14 @@ from mailman import Utils from mailman.config import config from mailman.email.message import Message, UserNotification from mailman.i18n import _ -from mailman.interfaces.autorespond import IAutoResponseSet, Response +from mailman.interfaces.autorespond import ( + ALWAYS_REPLY, IAutoResponseSet, Response, ResponseAction) from mailman.interfaces.handler import IHandler from mailman.utilities.datetime import today from mailman.utilities.string import expand log = logging.getLogger('mailman.error') -NODELTA = datetime.timedelta() - - - -def process(mlist, msg, msgdata): - # Normally, the replybot should get a shot at this message, but there are - # some important short-circuits, mostly to suppress 'bot storms, at least - # for well behaved email bots (there are other governors for misbehaving - # 'bots). First, if the original message has an "X-Ack: No" header, we - # skip the replybot. Then, if the message has a Precedence header with - # values bulk, junk, or list, and there's no explicit "X-Ack: yes" header, - # we short-circuit. Finally, if the message metadata has a true 'noack' - # key, then we skip the replybot too. - ack = msg.get('x-ack', '').lower() - if ack == 'no' or msgdata.get('noack'): - return - precedence = msg.get('precedence', '').lower() - if ack <> 'yes' and precedence in ('bulk', 'junk', 'list'): - return - # Check to see if the list is even configured to autorespond to this email - # message. Note: the mailowner script sets the `toadmin' or `toowner' key - # (which for replybot purposes are equivalent), and the mailcmd script - # sets the `torequest' key. - toadmin = msgdata.get('toowner') - torequest = msgdata.get('torequest') - if ((toadmin and not mlist.autorespond_admin) or - (torequest and not mlist.autorespond_requests) or \ - (not toadmin and not torequest and not mlist.autorespond_postings)): - return - # Now see if we're in the grace period for this sender. graceperiod <= 0 - # means always autorespond, as does an "X-Ack: yes" header (useful for - # debugging). - response_set = IAutoResponseSet(mlist) - address = config.db.user_manager.get_address(msg.sender) - if address is None: - address = config.db.user_manager.create_address(msg.sender) - grace_period = mlist.autoresponse_graceperiod - if grace_period > NODELTA and ack <> 'yes': - if toadmin: - last = response_set.last_response(address, Response.owner) - elif torequest: - last = response_set.last_response(address, Response.command) - else: - last = response_set.last_response(address, Response.postings) - if last is not None and last.date_sent + grace_period > today(): - return - # Okay, we know we're going to auto-respond to this sender, craft the - # message, send it, and update the database. - realname = mlist.real_name - subject = _( - 'Auto-response for your message to the "$realname" mailing list') - # Do string interpolation into the autoresponse text - d = dict(listname = realname, - listurl = mlist.script_url('listinfo'), - requestemail = mlist.request_address, - owneremail = mlist.owner_address, - ) - if toadmin: - rtext = mlist.autoresponse_admin_text - elif torequest: - rtext = mlist.autoresponse_request_text - else: - rtext = mlist.autoresponse_postings_text - # Interpolation and Wrap the response text. - text = Utils.wrap(expand(rtext, d)) - outmsg = UserNotification(msg.sender, mlist.bounces_address, - subject, text, mlist.preferred_language) - outmsg['X-Mailer'] = _('The Mailman Replybot') - # prevent recursions and mail loops! - outmsg['X-Ack'] = 'No' - outmsg.send(mlist) - # update the grace period database - if grace_period > NODELTA: - # graceperiod is in days, we need # of seconds - if toadmin: - response_set.response_sent(address, Response.owner) - elif torequest: - response_set.response_sent(address, Response.command) - else: - response_set.response_sent(address, Response.postings) @@ -134,4 +55,67 @@ class Replybot: def process(self, mlist, msg, msgdata): """See `IHandler`.""" - process(mlist, msg, msgdata) + # There are several cases where the replybot is short-circuited: + # * the original message has an "X-Ack: No" header + # * the message has a Precedence header with values bulk, junk, or + # list, and there's no explicit "X-Ack: yes" header + # * the message metadata has a true 'noack' key + ack = msg.get('x-ack', '').lower() + if ack == 'no' or msgdata.get('noack'): + return + precedence = msg.get('precedence', '').lower() + if ack <> 'yes' and precedence in ('bulk', 'junk', 'list'): + return + # Check to see if the list is even configured to autorespond to this + # email message. Note: the incoming message processors should set the + # destination key in the message data. + if msgdata.get('to_owner'): + if mlist.autorespond_owner is ResponseAction.none: + return + response_type = Response.owner + response_text = mlist.autoresponse_owner_text + elif msgdata.get('to_request'): + if mlist.autorespond_requests is ResponseAction.none: + return + response_type = Response.command + response_text = mlist.autoresponse_request_text + elif msgdata.get('to_list'): + if mlist.autorespond_postings is ResponseAction.none: + return + response_type = Response.postings + response_text = mlist.autoresponse_postings_text + else: + # There are no automatic responses for any other destination. + return + # Now see if we're in the grace period for this sender. grace_period + # = 0 means always automatically respond, as does an "X-Ack: yes" + # header (useful for debugging). + response_set = IAutoResponseSet(mlist) + address = config.db.user_manager.get_address(msg.sender) + if address is None: + address = config.db.user_manager.create_address(msg.sender) + grace_period = mlist.autoresponse_grace_period + if grace_period > ALWAYS_REPLY and ack <> 'yes': + last = response_set.last_response(address, response_type) + if last is not None and last.date_sent + grace_period > today(): + return + # Okay, we know we're going to respond to this sender, craft the + # message, send it, and update the database. + realname = mlist.real_name + subject = _( + 'Auto-response for your message to the "$realname" mailing list') + # Do string interpolation into the autoresponse text + d = dict(listname = realname, + listurl = mlist.script_url('listinfo'), + requestemail = mlist.request_address, + owneremail = mlist.owner_address, + ) + # Interpolation and Wrap the response text. + text = Utils.wrap(expand(response_text, d)) + outmsg = UserNotification(msg.sender, mlist.bounces_address, + subject, text, mlist.preferred_language) + outmsg['X-Mailer'] = _('The Mailman Replybot') + # prevent recursions and mail loops! + outmsg['X-Ack'] = 'No' + outmsg.send(mlist) + response_set.response_sent(address, response_type) diff --git a/src/mailman/queue/docs/command.txt b/src/mailman/queue/docs/command.txt index 0b384de01..c9a55d2a9 100644 --- a/src/mailman/queue/docs/command.txt +++ b/src/mailman/queue/docs/command.txt @@ -6,7 +6,6 @@ Commands are extensible using the Mailman plugin system, but Mailman comes with a number of email commands out of the box. These are processed when a message is sent to the list's -request address. - >>> from mailman.app.lifecycle import create_list >>> mlist = create_list(u'test@example.com') diff --git a/src/mailman/queue/docs/incoming.txt b/src/mailman/queue/docs/incoming.txt index d150cf3cf..7e1a98f6b 100644 --- a/src/mailman/queue/docs/incoming.txt +++ b/src/mailman/queue/docs/incoming.txt @@ -73,8 +73,6 @@ And now the message is in the pipeline queue. _parsemsg : False envsender : noreply@example.com ... - tolist : True - ... Held messages diff --git a/src/mailman/queue/lmtp.py b/src/mailman/queue/lmtp.py index 8befa72b4..68a86eeb3 100644 --- a/src/mailman/queue/lmtp.py +++ b/src/mailman/queue/lmtp.py @@ -170,25 +170,25 @@ class LMTPRunner(Runner, smtpd.SMTPServer): if subaddress in ('bounces', 'admin'): queue = 'bounce' elif subaddress == 'confirm': - msgdata['toconfirm'] = True + msgdata['to_confirm'] = True queue = 'command' elif subaddress in ('join', 'subscribe'): - msgdata['tojoin'] = True + msgdata['to_join'] = True queue = 'command' elif subaddress in ('leave', 'unsubscribe'): - msgdata['toleave'] = True + msgdata['to_leave'] = True queue = 'command' elif subaddress == 'owner': msgdata.update(dict( - toowner=True, + to_owner=True, envsender=config.mailman.site_owner, )) queue = 'in' elif subaddress is None: - msgdata['tolist'] = True + msgdata['to_list'] = True queue = 'in' elif subaddress == 'request': - msgdata['torequest'] = True + msgdata['to_request'] = True queue = 'command' else: elog.error('Unknown sub-address: %s', subaddress) diff --git a/src/mailman/styles/default.py b/src/mailman/styles/default.py index b5bbe877d..842517c9f 100644 --- a/src/mailman/styles/default.py +++ b/src/mailman/styles/default.py @@ -33,6 +33,7 @@ from zope.interface import implements from mailman.config import config from mailman.i18n import _ from mailman.interfaces import Action, NewsModeration +from mailman.interfaces.autorespond import ResponseAction from mailman.interfaces.mailinglist import ( DigestFrequency, Personalization, ReplyToMunging) from mailman.interfaces.styles import IStyle @@ -166,17 +167,13 @@ ${listinfo_page} # automatic discarding mlist.max_days_to_hold = 0 # Autoresponder - mlist.autorespond_postings = False - mlist.autorespond_admin = False - # this value can be - # 0 - no autoresponse on the -request line - # 1 - autorespond, but discard the original message - # 2 - autorespond, and forward the message on to be processed - mlist.autorespond_requests = 0 + mlist.autorespond_owner = ResponseAction.none + mlist.autoresponse_owner_text = '' + mlist.autorespond_postings = ResponseAction.none mlist.autoresponse_postings_text = '' - mlist.autoresponse_admin_text = '' + mlist.autorespond_requests = ResponseAction.none mlist.autoresponse_request_text = '' - mlist.autoresponse_graceperiod = datetime.timedelta(days=90) + mlist.autoresponse_grace_period = datetime.timedelta(days=90) # Bounces mlist.bounce_processing = True mlist.bounce_score_threshold = 5.0 |
