A session class

12 views
Skip to first unread message

bowman...@gmail.com

unread,
May 12, 2008, 4:51:20 PM5/12/08
to Google App Engine
Below is the session class and some examples of it's use I've come up
with. The purpose of this was to meet two goals. 1: I wanted a way to
handle sessions so I can move on to building a user auth system for my
site. I have chosen not to use the Google one supplied. 2: This is the
very first thing I've ever written in python, so it's a learning
experience.

I'm submitting this to the public domain. While you may feel free to
use it, I'm really looking for feedback on it from people more
experienced with appengine and python in general. I'm not submitting
that this is the best approach, just the best I came up with.

Two things I intend to implement into this is support for setting a
cookie path, and also handling for setting an expire time for cookies,
and then having expired data and session ids deleted. I thought I'd
throw out what I have working to the public now in an attempt to get
feedback while I move on to figuring out how to plug into the django
settings page.

sessions.py --- The classes

''' session.py - session class using google appengine datastore
by: Joe Bowman'''

# main python imports
import sha, Cookie, os

# google appengine import
from google.appengine.ext import db

class Sessions(db.Model):
sid = db.StringProperty()
ip = db.StringProperty()
ua = db.StringProperty()
lastActivity = db.DateTimeProperty(auto_now=True)
data = db.BlobProperty()

class SessionsData(db.Model):
sid = db.ReferenceProperty(Sessions)
keyname = db.StringProperty()
content = db.StringProperty()

class Session(object):
''' Session data is kept in the datastore, with a cookie installed
on the browser with a
session id used to reference. The session also stores the user
agent and ip of the
browser to help limit session spoofing. Session data is stored
in a referenced entity
for each item in the datastore.'''
def __init__(self):
''' When instantiated, always check the cookie and create a
new one if necessary.'''
string_cookie = os.environ.get('HTTP_COOKIE', '')
self.cookie = Cookie.SimpleCookie()
self.cookie.load(string_cookie)

if self.cookie.get('sid'):
sid = self.cookie['sid'].value
if self.validateSid(sid) != True:
sid = self.newSid()
self.cookie['sid'] = sid
print self.cookie
else:
sid = self.newSid()
self.cookie['sid'] = sid
print self.cookie

''' This put is to update the lastActivity field in the
datastore. So that every time
the sessions is accessed, the lastActivity gets
updated.'''
self.sess.put()

def newSid(self):
''' newSid will create a new session id, and store it in a
cookie in the browser and then
instantiate the session in the database.'''
sid = sha.new(repr(time.time())).hexdigest()
self.cookie['sid'] = sid
self.sess = Sessions()
self.sess.ua = os.environ['HTTP_USER_AGENT']
self.sess.ip = os.environ['REMOTE_ADDR']
self.sess.sid = sid
self.sess.put()
return sid

def validateSid(self, sid = None):
''' validateSid is used to determine if a session cookie
passed from the browser is valid. It
confirms the session id exists in the data store, and the
compares the user agent and ip
information stored against the browser to validate it.'''
if sid == None:
return None
self.sess = self.__getSession(sid)
if self.sess == None or self.sess.ua !=
os.environ['HTTP_USER_AGENT'] or self.sess.ip !=
os.environ['REMOTE_ADDR']:
return None
else:
return True


def __getSession(self, sid = None):
''' __getSession uses a session id to return a session from
the datastore.'''
if sid == None:
return None
sessions = Sessions.gql("WHERE sid = :1 AND ua = :2 LIMIT 1",
sid, os.environ['HTTP_USER_AGENT'])
if sessions.count() == 0:
return None
else:
return sessions[0]

def validateSid(self, sid = None):
''' validateSid is used to determine if a session cookie
passed from the browser is valid. It
confirms the session id exists in the data store, and the
compares the user agent and ip
information stored against the browser to validate it.'''
if sid == None:
return None
self.sess = self.__getSession(sid)
if self.sess == None or self.sess.ua !=
os.environ['HTTP_USER_AGENT'] or self.sess.ip !=
os.environ['REMOTE_ADDR']:
return None
else:
return True


def __getSession(self, sid = None):
''' __getSession uses a session id to return a session from
the datastore.'''
if sid == None:
return None
sessions = Sessions.gql("WHERE sid = :1 AND ua = :2 LIMIT 1",
sid, os.environ['HTTP_USER_AGENT'])
if sessions.count() == 0:
return None
else:
return sessions[0]





views.py -- the time demo updated to include some sessions testing as
well

from django.template.loader import get_template
from django.template import Context
from django.http import HttpResponse
from utilities import session
import datetime

def current_datetime(request):
sess = session.Session()
now = datetime.datetime.now()
sess.putData("testkey", "I am testkey")
sess.putData("testkey2", "I am testkey2")
testkey = sess.getData("testkey").content
testkey2 = sess.getData("testkey2")
datalist = sess.getData()
t = get_template('time.html')
html = t.render(Context({'current_date': now, "testkey": testkey,
"testkey2": testkey2, "datalist": datalist}))
return HttpResponse(html)




time.html --- the time template

{% block content %}
<p>It is now {{ current_date }}.</p>
Testkey just content: {{ testkey }}<br />
Testkey2 attributes:
<ul>
<li>keyname: testkey2.keyname</li>
<li>content: testkey2.content</li>
</ul>

Testkeys: <ul>
{% for data in datalist %}
<li>{{data.keyname }} = {{data.content }}</li>
{% endfor %}
</ul>
{% endblock %}

bowman...@gmail.com

unread,
May 12, 2008, 5:25:22 PM5/12/08
to Google App Engine
Ok, one thing I missed before I posted. The BlobProperty data can be
removed from the Sessions class. It's unused. I was going to try and
serialize data and put it in there, before I realized the whole thing
could be a lot more functionaly if I stored each property as it's own
entity. Now searching can be done across session data.

On May 12, 4:51 pm, "bowman.jos...@gmail.com"

bowman...@gmail.com

unread,
May 12, 2008, 8:28:22 PM5/12/08
to Google App Engine
Ok, while I don't think I'll plan on sharing all the code for the
personal project I'm working on, I imagine there will be some like
this session class that might be helpful for other people. I'm started
a new project on google code for these utilities as I come up with
them.

http://code.google.com/p/appengine-utitlies/

Here's the direct svn link

svn checkout http://appengine-utitlies.googlecode.com/svn/trunk/
appengine-utitlies-read-only

On May 12, 5:25 pm, "bowman.jos...@gmail.com"

Cesar D. Rodas

unread,
May 12, 2008, 9:22:39 PM5/12/08
to google-a...@googlegroups.com
Great initiative! congratulations man!

Just put here some things that are missing and count with me (if you want) I can help you too.

Regards




--
Best Regards

Cesar D. Rodas
http://www.cesarodas.com
http://www.thyphp.com
http://www.phpajax.org
Phone: +595-961-974165

bowman...@gmail.com

unread,
May 12, 2008, 11:57:41 PM5/12/08
to Google App Engine
Not sure I understand what you mean by "Just put here some things that
are missing and count with me (if you want)"?

I've updated the svn with a newer version that fixes some bugfixes in
the code posted in the original post of this thread as well as adds
the following

- added support for a cookie_path
- added support for setting the expiration time server side for
sessions, and setting the expiration time on the cookie
- randomly checks for and deletes stale session data (15 percent of
all requests, this is configurable)

I believe this makes it a fully functional, albeit entirely untested,
sessions library.

svn checkout http://appengine-utitlies.googlecode.com/svn/trunk/

On May 12, 9:22 pm, "Cesar D. Rodas" <sad...@gmail.com> wrote:
> Great initiative! congratulations man!
>
> Just put here some things that are missing and count with me (if you want) I
> can help you too.
>
> Regards
>
> 2008/5/12 bowman.jos...@gmail.com <bowman.jos...@gmail.com>:
>
>
>
>
>
>
>
> > Ok, while I don't think I'll plan on sharing all the code for the
> > personal project I'm working on, I imagine there will be some like
> > this session class that might be helpful for other people. I'm started
> > a new project on google code for these utilities as I come up with
> > them.
>
> >http://code.google.com/p/appengine-utitlies/
>
> > Here's the direct svn link
>
> > svn checkouthttp://appengine-utitlies.googlecode.com/svn/trunk/
> > appengine-utitlies-read-only<http://appengine-utitlies.googlecode.com/svn/trunk/appengine-utitlies...>

Cesar D. Rodas

unread,
May 13, 2008, 12:21:32 AM5/13/08
to google-a...@googlegroups.com




Not sure I understand what you mean by "Just put here some things that
are missing and count with me (if you want)"?
I mean, I think your class is great (at least it look likes, I could test it yet), I was thinking to add more functions (or class) to build a useful package for appengine's utilities



--
Best Regards

Edoardo Marcora

unread,
May 13, 2008, 3:47:08 AM5/13/08
to Google App Engine
please star my issue at http://code.google.com/p/googleappengine/issues/detail?id=293
to ask google to implement datastore-backed sessions. Perhaps they can
take your code!

On May 12, 8:57 pm, "bowman.jos...@gmail.com"
<bowman.jos...@gmail.com> wrote:
> Not sure I understand what you mean by "Just put here some things that
> are missing and count with me (if you want)"?
>
> I've updated the svn with a newer version that fixes some bugfixes in
> the code posted in the original post of this thread as well as adds
> the following
>
>  - added support for a cookie_path
>  - added support for setting the expiration time server side for
> sessions, and setting the expiration time on the cookie
>  - randomly checks for and deletes stale session data (15 percent of
> all requests, this is configurable)
>
> I believe this makes it a fully functional, albeit entirely untested,
> sessions library.
>
> svn checkouthttp://appengine-utitlies.googlecode.com/svn/trunk/
> ...
>
> read more »

Anarchofascist

unread,
May 13, 2008, 7:25:33 AM5/13/08
to Google App Engine
Haven't tried this myself yet, but have you considered using
__getitem__() and __setitem__() to allow container-style access?

bowman...@gmail.com

unread,
May 13, 2008, 8:56:49 AM5/13/08
to Google App Engine
Cesar, sure, feel free to grab it off the svn and modify it as you
wish. I'd definetly grab the latest from there.

Dado, starred. Not in the hopes of my library being chosen, but
because it would be nice if Google wrote it for us. Of course, I think
the idea of app engine might be they want us to start doing these
things ourselves, not sure.

Anarchofascist.. nope. Entirely new to python and had never heard of
those. So, I'm going to look into that and probably implement it
today, because if my basic understanding of that does what I think it
does, I think it's a really good idea.

On May 13, 7:25 am, Anarchofascist <pch...@gmail.com> wrote:
> Haven't tried this myself yet, but have you considered using
> __getitem__() and __setitem__() to allow container-style access?
>
>
>
> bowman.jos...@gmail.com wrote:
> > Not sure I understand what you mean by "Just put here some things that
> > are missing and count with me (if you want)"?
>
> > I've updated the svn with a newer version that fixes some bugfixes in
> > the code posted in the original post of this thread as well as adds
> > the following
>
> >  - added support for a cookie_path
> >  - added support for setting the expiration time server side for
> > sessions, and setting the expiration time on the cookie
> >  - randomly checks for and deletes stale session data (15 percent of
> > all requests, this is configurable)
>
> > I believe this makes it a fully functional, albeit entirely untested,
> > sessions library.
>
> > svn checkouthttp://appengine-utitlies.googlecode.com/svn/trunk/
> ...
>
> read more »

bowman...@gmail.com

unread,
May 13, 2008, 10:17:12 AM5/13/08
to Google App Engine
__getitem__() and __setitem__() added, thanks for the idea. This is
the first time Python has made me go oooo.

I did do some type checking, forcing the keys to be strings. I don't
think there's a good way to iterate over and return the sesion data
objects from the datastore in a way that makes sense for performance.
getData() will return all instances of session data for a session if
it's not supplied with a keyname, so that could be used when you want
to iterate over all session data.

BUGFIX: I left a print statement in the stale session cleaning call I
was using to make sure it fired off. I removed that so that when the
cleaning happens it doesn't mess up the output of your page.

Here's the new sample views.py and time.html to show the improvements
in action. The nice part is you can now pass the entire session object
to the template and then call the pieces of the session you want my
their key name directly from the template.

views.py -------------------------
from django.template.loader import get_template
from django.template import Context
from django.http import HttpResponse
from utilities import session
import datetime

def current_datetime(request):
sess = session.Session()
now = datetime.datetime.now()
sess.putData("testkey", "I am testkey")
sess.putData("testkey2", "I am testkey2")
sess["testkey3"] = "I am testkey 3"
testkey = sess.getData("testkey").content
testkey2 = sess.getData("testkey2")
datalist = sess.getData()
t = get_template('time.html')
html = t.render(Context({'current_date': now, "testkey": testkey,
"testkey2": testkey2, "sess": sess, "datalist": datalist}))
return HttpResponse(html)


time.html------------------

{% block content %}
<p>It is now {{ current_date }}.</p>
Testkey just content: {{ testkey }}<br />
Testkey2 attributes:
<ul>
<li>keyname: testkey2.keyname</li>
<li>content: testkey2.content</li>
</ul>

Testkeys: <ul>
{% for data in datalist %}
<li>{{data.keyname }} = {{data.content }}</li>
{% endfor %}
</ul>
<p>
Session variable as a container test:<br />
testkey3: {{ sess.testkey3 }}
</p>
{% endblock %}



On May 13, 8:56 am, "bowman.jos...@gmail.com"
> ...
>
> read more »

joh...@easypublisher.com

unread,
May 13, 2008, 1:43:29 PM5/13/08
to google-a...@googlegroups.com
Great initiative. I like to join it (when I have time).


--
Johan Carlsson
Colliberty Easy Publisher
http://www.easypublisher.com

bowman...@gmail.com

unread,
May 13, 2008, 2:12:52 PM5/13/08
to Google App Engine
I hear you on time, I get about 1-2 hours a week, most weeks, to work
on anything. Full time job, long commute, wife and 9 month old
daughter, oof. I wrote the session class because I needed it, and am
giving it away because it seems others need one too. I'm actually
going to start working on more internals for my site now, so unless
there's bugs in the session class, I believe I'm calling it done for
now.

Anyone interested in becoming a member of the utilities project, so
that they can add to the collection, are more than welcome.

On May 13, 1:43 pm, joh...@easypublisher.com wrote:
> Great initiative. I like to join it (when I have time).
>
> ...
>
> read more »

Cesar D. Rodas

unread,
May 13, 2008, 3:01:41 PM5/13/08
to google-a...@googlegroups.com




I hear you on time, I get about 1-2 hours a week, most weeks, to work
on anything. Full time job, long commute, wife and 9 month old
daughter, oof. I wrote the session class because I needed it, and am
giving it away because it seems others need one too. I'm actually
going to start working on more internals for my site now, so unless
there's bugs in the session class, I believe I'm calling it done for
now.

Anyone interested in becoming a member of the utilities project, so
that they can add to the collection, are more than welcome.
I want to join too!



--
Best Regards

Lee O

unread,
May 13, 2008, 9:54:21 PM5/13/08
to google-a...@googlegroups.com
I have a piece of app engine software coming out in the near future, mind if i integrate your code into it? Not sure to what extent it will be used (and/or modified), but i'd make note of it ofcourse. It'll be an opensource project anyhow.


Thanks for taking the time!
--
Lee Olayvar
http://www.leeolayvar.com

bowman...@gmail.com

unread,
May 14, 2008, 9:05:54 AM5/14/08
to Google App Engine
Lee O, it's up on google code at http://code.google.com/p/appengine-utitlies/

You'll need to pull from svn for now, when I have time I'll have it
set up to download.

Latest version should perform better, as it only needs to read from
the datastore once now, thanks to Paul's additions.

It's licensed under the new BSD license. I believe that's about as
free as you can get with google hosted projects. Please use it, I
wrote it for my own use and gave it to the community because there
appeared to be a need for it.

On May 13, 9:54 pm, "Lee O" <lee...@gmail.com> wrote:
> I have a piece of app engine software coming out in the near future, mind if
> i integrate your code into it? Not sure to what extent it will be used
> (and/or modified), but i'd make note of it ofcourse. It'll be an opensource
> project anyhow.
>
> Thanks for taking the time!
>
> On Tue, May 13, 2008 at 12:01 PM, Cesar D. Rodas <sad...@gmail.com> wrote:
>
>
>
> > 2008/5/13 bowman.jos...@gmail.com <bowman.jos...@gmail.com>:
> ...
>
> read more »

Ben the Indefatigable

unread,
May 14, 2008, 11:30:01 AM5/14/08
to Google App Engine
Nice contribution. A comment about this:

>''' This put is to update the lastActivity field in the datastore. So that every time
>the sessions is accessed, the lastActivity gets updated.'''
>self.sess.put()

From discussions I've been seeing here, writing on every access is not
advisable. Is LastActivity very useful/critical?

Ben the Indefatigable

unread,
May 14, 2008, 12:22:23 PM5/14/08
to Google App Engine
>Two things I intend to implement into this is support for setting a
cookie path, and also handling for setting an expire time for
cookies,
and then having expired data and session ids deleted.

Any ideas on how you can purge old session ids from the datastore
without background processes? Seems to me the only option is have
asynchronous javascript calls to trigger little mini-purges where they
select up to 3 expired sessions and delete them.

bowman...@gmail.com

unread,
May 14, 2008, 12:29:22 PM5/14/08
to Google App Engine
Both things actually tie together Ben.

The lastActivity is used to date the session. This way, if someone's
browser is handling cookie expiration properly, the backend is. The
session id validation uses the lastActivity date stamp to determine
the age of the session.

lastActivity is updated when the Sessions class is initialized. So,
not every action using the class causes a write, one write per page
view (that uses the session class). Depending on how dynamic your site
is, you may not require session information for every page view,
that's up to the developer.

It's modifiable in the class, but there's a cleanup routine that is
run 15% of the time of every session initialization that will go
through and delete stale sessions (based on lastActivity and the
configurable expiration age) from the datastore, so you don't end up
with data bloat.

Please check http://code.google.com/p/appengine-utitlies/ for more
information on this, there's also a group I've created for the project
which is linked to from that project page. The code originally posted
has been improved on (in fact because of my haste to post it, it won't
even run because of a missing import).

Ben the Indefatigable

unread,
May 14, 2008, 2:18:42 PM5/14/08
to Google App Engine
thanks for the detailed answers, it is good to see you have considered
these issues. My suggestions are:

1. the lastActivity should only be updated if the time difference is
greater than n minutes, keeping in mind you should use timeout t+n
when determining whether to purge due to innaccuracy of lastActivity.

2. purging during session access will add very significant overhead
that could put a request in danger of timing out and should instead be
kicked off by an asynchronous call or a background/exterior task.

bowman...@gmail.com

unread,
May 14, 2008, 2:47:45 PM5/14/08
to Google App Engine
For my purposes I'm not going to pursue option 1. I also see
lastActivity as a useful metric for displaying to users "Soandso was
last active 3 minutes ago..." as I've seen on other social sites. I'd
have to see some performance criteria that a write to the datastore is
much slower than the computation of doing a read then an age check to
determine if a write needs to be done. Sessions class initialization
should be done only once per pageview. I'm not discounting your notion
and I do believe it warrants some load testing. I plan on using one of
my application slots to set up a demo site for the utilities, so
hopefully I can see some real world load testing when i can get that
done. Probably not until this weekend or next week on that though.

The purging during session initialization is based on a method i've
seen work effectively in other session classes from other languages.
Basically

Sessions class is initialized
A random number between 1 and 100 generated.
If that number is =< the set percentage (defaults to 15), the cleanup
routine is run.
The cleanup routine does a query for sessions with a lastActivity
older than the expiration age acceptable and deletes them (also
deleting data from the sessionsData table attributed to those sessions
as well).

If for some reason this is causing a delay, then the solution would be
to increase the percentage of the time it's run. That way there are
not as many stale sessions to delete every time it's run.

Because of the environment provided by GAE, there's really no way to
set up background tasks that I'm aware of. I could set up a script
elsewhere to fire off a get request to a url that calls the session
clearing. But that means more work to implement the session library as
you'd have to modify your url handling infrastructure of your
application to support it. I don't believe that's necessary to enforce
unless you're possibly running a very large heavily used application.
I'm not sure I could generate the load required to test that
requirement without exceeding the quota of the application honestly.

Cesar D. Rodas

unread,
May 14, 2008, 3:49:43 PM5/14/08
to google-a...@googlegroups.com
Hello,

I just setup this wiki page http://code.google.com/p/appengine-utitlies/wiki/SubProjects

PD: Sorry if I have some mistakes on the wiki, I'm not a native English speaker

Ben the Indefatigable

unread,
May 15, 2008, 9:58:50 AM5/15/08
to Google App Engine
joseph, again, great response, I appreciate it.

>I'd have to see some performance criteria that a write to the datastore is
much slower than the computation of doing a read then an age check to
determine if a write needs to be done.

Actually, in both cases you've done the read, this is just a matter of
deciding whether to do the write.

> If that number is =< the set percentage (defaults to 15), the cleanup
routine is run.

Yes, this is what I understood, and it does make for a nicely
encapsulated session capability, unlike the somewhat messier solution
I am recommending.

luismgz

unread,
May 30, 2008, 12:16:59 PM5/30/08
to Google App Engine
I wonder if there is any way to implement sessions that could store
python types instead of only strings.
So far, if I want to store a python list, for example, I can only
store its string representation. So when I want to recover this data I
use "eval" to convert the string back to a list.
But I guess this is not ideal and it can hit performance. Am I right?

Or perhaps there's another approach I should be taking..?

Luis

On May 14, 3:47 pm, "bowman.jos...@gmail.com"
<bowman.jos...@gmail.com> wrote:
> For my purposes I'm not going to pursue option 1. I also see
> lastActivity as a useful metric for displaying to users "Soandso was
> last active 3 minutes ago..." as I've seen on other social sites. I'd
> have to see some performance criteria that a write to the datastore is
> much slower than the computation of doing a read then an age check to
> determine if a write needs to be done.Sessionsclass initialization
> should be done only once per pageview. I'm not discounting your notion
> and I do believe it warrants some load testing. I plan on using one of
> my application slots to set up a demo site for the utilities, so
> hopefully I can see some real world load testing when i can get that
> done. Probably not until this weekend or next week on that though.
>
> The purging during session initialization is based on a method i've
> seen work effectively in other session classes from other languages.
> Basically
>
> Sessionsclass is initialized
> A random number between 1 and 100 generated.
> If that number is =< the set percentage (defaults to 15), the cleanup
> routine is run.
> The cleanup routine does a query forsessionswith a lastActivity
> older than the expiration age acceptable and deletes them (also
> deleting data from the sessionsData table attributed to thosesessions
> as well).
>
> If for some reason this is causing a delay, then the solution would be
> to increase the percentage of the time it's run. That way there are
> not as many stalesessionsto delete every time it's run.

bowman...@gmail.com

unread,
May 30, 2008, 12:58:11 PM5/30/08
to Google App Engine
Actually, that's one improvement I intend to make for my session
class, as I figured out how to do something similar for caching using
pickle. The disadvantage to this would you have to use a BlobProperty
to hold the data in, which means you won't be able to search across
those properties.

Since the memcached api came out, I'm also debating on moving the
entire session storage to it, as sessions don't really need to be as
persistent. Thanks to the addition of memcached and my recent
understanding Django Middleware, I plan on doing some major work on my
existing classes. So as not to confuse anyone, Django will not be a
requirement for my classes, Middleware classes will be released for
Django users to directly plug these into their existing sites easing
the transition in and out of GAE if needed.

More to come later, I've not had time to sit and write any code all
week. The next iteration will include more standard naming conventions
for methods also. I'm a PHP guy learning the Python way.

luismgz

unread,
May 30, 2008, 1:09:51 PM5/30/08
to Google App Engine
One more question:
Is there any way to set the expiration of a session to the moment were
the browser is closed?
I believe that, for security reasons, this should be the default
behavior.
For example, lets say that you are checking your mails from a
cybercafe and then another person tries to check his emails using the
same service... you know what I mean?

On May 30, 1:58 pm, "bowman.jos...@gmail.com"

bowman...@gmail.com

unread,
May 30, 2008, 1:29:39 PM5/30/08
to Google App Engine
I'll see what I can do.

bowman...@gmail.com

unread,
May 30, 2008, 1:35:54 PM5/30/08
to Google App Engine
I believe I've captured your requests as issues on the project page:
http://code.google.com/p/appengine-utitlies/issues/list?ts=1212168823&thanks=14

If you have any more suggestions please create new issues there. That
way I don't miss anything posted in this group by mistake.

On May 30, 1:29 pm, "bowman.jos...@gmail.com"

Greg

unread,
Jun 3, 2008, 8:30:23 PM6/3/08
to Google App Engine
I have a secure lightweight memecache-backed session class at
http://code.google.com/p/gmemsess/. This deletes the session after a
given timeout, or when the browser is closed. Enjoy!

Greg.
Reply all
Reply to author
Forward
0 new messages