/**************************************************************************
Gmail Agent API
Copyright (C) 2004 Johnvey Hwang
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
**************************************************************************/
using System;
using System.Collections;
using System.Data;
using System.Web;
using System.Net;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Diagnostics;
using Johnvey.GmailAgent;
namespace Johnvey.GmailAgent
{
/// <summary>
/// Represents a set of tools used to communicate with the Gmail system.
/// </summary>
public class GmailAdapter
{
#region Enumerations
/// <summary>
/// Defines the result of a Gmail request.
/// </summary>
public enum RequestResponseType {
/// <summary>
/// The request was successful.
/// </summary>
Success,
/// <summary>
/// The Google Accounts login information did not validate.
/// </summary>
LoginFailed,
/// <summary>
/// The DataPack request was not successful.
/// </summary>
RefreshFailed
}
/// <summary>
/// Defines the type of threads to retrieve.
/// </summary>
public enum ThreadFetchType {
/// <summary>
/// Unread threads (global).
/// </summary>
AllUnread,
/// <summary>
/// Inbox threads (read + unread).
/// </summary>
Inbox
}
#endregion
#region Constants
/// <summary>
/// Defines the URL to POST Google Accounts login information.
/// </summary>
public const string GOOGLE_LOGIN_URL = "https://www.google.com/accounts/ServiceLoginBoxAuth";
/// <summary>
/// Defines the URL to fake as the GOOGLE_LOGIN_URL's referrer. (I don't know if Google is checking this, but it can't hurt.)
/// </summary>
public const string GOOGLE_LOGIN_REFERRER_URL = "https://www.google.com/accounts/ServiceLoginBox?service=mail&continue=https%3A%2F%2Fgmail.google.com%2Fgmail";
/// <summary>
/// Defines the base URL for Gmail requests.
/// </summary>
public const string GMAIL_HOST_URL = "https://gmail.google.com";
#endregion
#region Properties
private string _jsVersion;
private GmailSession _session;
private string _rawLoginResponse;
private string _rawHomeFrameResponse;
private string _rawDataPackResponse;
private string _lastErrorMessage;
private ThreadFetchType _threadFetchMode;
/// <summary>
/// Gets or sets the Gmail JS engine version.
/// </summary>
public string JsVersion { get { return _jsVersion; } set { _jsVersion = value; } }
/// <summary>
/// Gets the raw HTML content returned from the Google Accounts login request.
/// </summary>
public string RawLoginResponse { get { return _rawLoginResponse; } }
/// <summary>
/// Gets the raw HTML content returned from the Gmail base launch request.
/// </summary>
public string RawHomeFrameResponse { get { return _rawHomeFrameResponse; } }
/// <summary>
/// Gets the raw HTML content returned from a DataPack request.
/// </summary>
public string RawDataPackResponse { get { return _rawDataPackResponse; } }
/// <summary>
/// Gets the last error message generated by the GmailAdapter methods. Will be null if there are no errors.
/// </summary>
public string LastErrorMessage { get { return _lastErrorMessage; } }
/// <summary>
/// Gets or sets the <see cref="ThreadFetchType"/> for the adapter. The default is <c>Inbox</c>.
/// </summary>
public ThreadFetchType ThreadFetchMode { get { return _threadFetchMode; } set { _threadFetchMode = value; } }
#endregion
/// <summary>
/// Initializes a new instance of the GmailAdapter class.
/// </summary>
public GmailAdapter() {
/**********************************************************************
* These ServicePointManager settings are here because the .NET
* Framework (1.0 and 1.1) don't like to play well with other web
* servers. Some of these are arbitrary hacks that have been
* suggested over the newsgroups. The one that seems to work
* constistently is using TLS instead of SSL3. Go figure.
* NOTE: the Expect100Continue property is not support in .NET 1.0.
* *******************************************************************/
// ServicePointManager.CertificatePolicy = new GmailCertificatePolicy();
ServicePointManager.Expect100Continue = false;
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls;
this._lastErrorMessage = null;
this._threadFetchMode = ThreadFetchType.Inbox;
}
/// <summary>
/// Represents a delegate for the <see cref="Refresh"/> method.
/// </summary>
public delegate RequestResponseType RefreshDelegate(GmailSession session);
/// <summary>
/// Queries Gmail to get latest mailbox information.
/// </summary>
/// <param name="session">The <see cref="GmailSession"/> object to query.</param>
/// <returns>The <see cref="RequestResponseType"/>.</returns>
public RequestResponseType Refresh(GmailSession session) {
// bring focus to active session
this._session = session;
// make sure there is proper login information
if(this._session.Username == "" || this._session.Username == null || this._session.Password == "" || this._session.Password == null) {
return RequestResponseType.LoginFailed;
}
// if it's been a while since we logged in, do it again to keep the cookie fresh
if(session.LastLoginTime == new DateTime(0) || session.LastLoginTime < DateTime.Now.AddHours(-1)) {
if(Login()) {
return RequestResponseType.Success;
} else {
return RequestResponseType.LoginFailed;
}
} else {
if(RefreshDataPack(false)) {
return RequestResponseType.Success;
} else {
return RequestResponseType.RefreshFailed;
}
}
}
/// <summary>
/// Sends Google Accounts login stored in the current <see cref="GmailSession"/> and establishes a session with Gmail.
/// </summary>
/// <returns>True if login was successful; false otherwise.</returns>
private bool Login() {
// grab the user's cookie store
CookieCollection cookieJar = this._session.Cookies;
// put some cookies in it
GmailCookieFactory tollHouse = new GmailCookieFactory();
cookieJar.Add(tollHouse.GenerateCookie("GMAIL_LOGIN"));
cookieJar.Add(tollHouse.GenerateCookie("TZ"));
// instantiate the key pieces
Uri location;
string rawResponse;
int currentCursor;
/**********************************************************************
* Login to Google Accounts
* -- parse response to get the GV cookie (don't know what it's for)
* *******************************************************************/
string loginPostData = "continue=" + System.Web.HttpUtility.UrlDecode(GOOGLE_LOGIN_URL) + "%2Fgmail&service=mail"
+ "&Email=" + this._session.Username
+ "&Passwd=" + this._session.Password
+ "&null=Sign+in";
location = new Uri(GOOGLE_LOGIN_URL);
// try the request; catch any unhandled exceptions
try {
rawResponse = MakeWebRequest(location , "POST", GOOGLE_LOGIN_REFERRER_URL, loginPostData, false);
} catch(Exception ex) {
this._session.HasConnectionError = true;
this._lastErrorMessage = "Unable to log in to Google Accounts: " + ex.Message;
return false;
}
// catch emtpy response and bad login
if(rawResponse == null || rawResponse == "") {
this._session.HasConnectionError = true;
this._lastErrorMessage = "Unable to log in to Google Accounts: empty response document!";
return false;
} else if(Regex.Match(rawResponse, "password.+not.+match", RegexOptions.Compiled).Success) {
this._session.HasConnectionError = true;
this._lastErrorMessage = "Unable to log in to Google Accounts: Username (" + this._session.Username + ") and password do not match.";
return false;
}
this._rawLoginResponse = rawResponse;
// get GV cookie value from login response
currentCursor = rawResponse.IndexOf("var cookieVal");
if(currentCursor > -1) {
int varDeclStart = rawResponse.IndexOf("\"",currentCursor,30) + 1;
int varDeclEnd = rawResponse.IndexOf("\"",varDeclStart+5,100);
string gvCookie = rawResponse.Substring(varDeclStart, varDeclEnd - varDeclStart);
cookieJar.Add(new Cookie("GV", gvCookie));
} else {
this._lastErrorMessage = "Unable to find GV cookie value.";
}
/**********************************************************************
* Request Gmail home application frame
* -- store GMAIL_AT and S cookies passed in the header
* -- parse response to get Gmail engine version (jsVersion)
* *******************************************************************/
location = new Uri(GMAIL_HOST_URL + "/gmail");
try {
rawResponse = MakeWebRequest(location, "GET", GOOGLE_LOGIN_URL, null, false);
} catch(Exception ex) {
this._lastErrorMessage = "Error retrieving Gmail home frame page: " + ex.Message;
return false;
}
this._rawHomeFrameResponse = rawResponse;
// get JS version ID
this._jsVersion = "jsVersionNotFound";
Match m = Regex.Match(rawResponse, @"\&ver=([a-z0-9]+)", RegexOptions.Compiled);
if(m.Success) {
this._jsVersion = m.Groups[1].Value;
} else {
this._lastErrorMessage = "Unable to find JS Gmail engine version.";
}
/**********************************************************************
* Request initial dataPack page and extract mailbox information
* *******************************************************************/
switch(this._threadFetchMode) {
case ThreadFetchType.AllUnread:
location = new Uri(GMAIL_HOST_URL + "/gmail?search=query&q=is%3Aunread&view=tl&start=0&init=1&zx=" + MakeUniqueUrl());
break;
case ThreadFetchType.Inbox:
location = new Uri(GMAIL_HOST_URL + "/gmail?search=inbox&view=tl&start=0&init=1&zx=" + MakeUniqueUrl());
break;
default:
break;
}
try {
rawResponse = MakeWebRequest(location, "GET", null, null, false);
} catch(Exception ex) {
this._session.HasConnectionError = true;
this._lastErrorMessage = "Unable to retrieve initial DataPack document: " + ex.Message;
return false;
}
if(rawResponse == "" || rawResponse == null) {
this._session.HasConnectionError = true;
this._lastErrorMessage = "Initial DataPack document did not contain any data.";
return false;
}
this._rawDataPackResponse = rawResponse;
// mark login time
this._session.LastLoginTime = DateTime.Now;
// parse the mailbox information
ParseDataPack();
this._session.LastRefreshTime = DateTime.Now;
this._session.HasConnectionError = false;
return true;
}
/// <summary>
/// Requests the auto-refresh DataPack.
/// </summary>
/// <remarks>
/// If the threadlist timestamp has not changed, Gmail will only send a short DataPack.
/// </remarks>
/// <param name="forceRefresh">DEBUG: Indicates whether to pass an old timestamp, which forces Gmail to resend a full DataPack.</param>
public bool RefreshDataPack(bool forceRefresh) {
string tlt;
// DEBUG: setting the timestamp to an older time forces Gmail to return a full DataPack
if(forceRefresh) {
tlt = "fd44c8cfc2";
} else {
tlt = this._session.ThreadListTimestamp;
}
string fp = this._session.Fingerprint;
Uri location = null;
switch(this._threadFetchMode) {
case ThreadFetchType.AllUnread:
location = new Uri(GMAIL_HOST_URL + "/gmail?view=tl&search=query&start=0&q=is%3Aunread&tlt=" + tlt + "&fp=" + fp + "&auto=1&zx=" + MakeUniqueUrl());
break;
case ThreadFetchType.Inbox:
location = new Uri(GMAIL_HOST_URL + "/gmail?view=tl&search=inbox&start=0&tlt=" + tlt + "&fp=" + fp + "&auto=1&zx=" + MakeUniqueUrl());
break;
default:
break;
}
try {
this._rawDataPackResponse = MakeWebRequest(location, "GET", "http://gmail.google.com/gmail/html/hist2.html", null, false);
} catch(Exception ex) {
this._session.HasConnectionError = true;
this._lastErrorMessage = "Unable to refresh DataPack document: " + ex.Message;
return false;
}
if(this._rawDataPackResponse == "" || this._rawDataPackResponse == null) {
this._lastErrorMessage = "Initial DataPack document did not contain any data.";
return false;
}
ParseDataPack();
this._session.LastRefreshTime = DateTime.Now;
return true;
}
/// <summary>
/// Retrieves all the contacts in the user's Gmail address book.
/// </summary>
/// <returns>A <see cref="GmailContactCollection"/> of contacts in address book.</returns>
public GmailContactCollection GetContacts() {
// instantiate output vars
GmailContactCollection output = new GmailContactCollection();
// create request to send to Gmail
Uri location = new Uri(GMAIL_HOST_URL + "/gmail?view=page&name=address&zx=" + MakeUniqueUrl());
this._rawDataPackResponse = MakeWebRequest(location, "GET", null, null, true);
// sanitize the incoming _rawDataPackResponse
this._rawDataPackResponse = this._rawDataPackResponse.Replace("\n", "");
if(this._rawDataPackResponse.Length > 128) {
// find the address block
int addressBlockStart = this._rawDataPackResponse.IndexOf("var addresses") + 13;
addressBlockStart = this._rawDataPackResponse.IndexOf("[", addressBlockStart);
int addressBlockEnd = this._rawDataPackResponse.IndexOf("]];", addressBlockStart) + 2;
string addressBlock = this._rawDataPackResponse.Substring(addressBlockStart, addressBlockEnd - addressBlockStart);
// parse the address block into an ArrayList
ArrayList addresses = Utilities.ParseJSArray(addressBlock);
// loop through ArrayList of contacts and insert into collection
foreach(ArrayList contact in addresses) {
if(contact.Count == 6) {
GmailContact tmpContact = new GmailContact();
tmpContact.Email = contact[1].ToString();
tmpContact.Name = contact[2].ToString();
tmpContact.Notes = contact[3].ToString();
tmpContact.IsFrequentlyMailed = (Convert.ToInt32(contact[4]) == 1 ? true : false);
tmpContact.EmailUnescaped = contact[5].ToString();
output.Add(tmpContact);
} else {
Debug.WriteLine("DataPack error: Contact did not have expected number of elements (6): " + contact[1].ToString());
}
}
}
return output;
}
/// <summary>
/// Adds a contact into the address book. Emails that already exist will be updated with the new information.
/// </summary>
/// <param name="name">Contact display name.</param>
/// <param name="email">Contact email address.</param>
/// <param name="notes">Optional notes.</param>
/// <returns>True if Gmail accepted the command; false otherwise.</returns>
public bool AddContact(string name, string email, string notes) {
if(name.Length > 100) name = name.Substring(0,100);
if(email.Length > 100) email = email.Substring(0,100);
string postData = "at=" + this._session.Cookies["GMAIL_AT"].Value
+ "&name=" + HttpUtility.UrlEncode(name)
+ "&email=" + HttpUtility.UrlEncode(email)
+ "¬es=" + HttpUtility.UrlEncode(notes)
+ "&ac=Add+Contact&operation=Edit";
Uri location = new Uri(GMAIL_HOST_URL + "/gmail?view=address&act=a");
this._rawDataPackResponse = MakeWebRequest(location, "POST", null, postData, false);
return true;
}
/// <summary>
/// Reads in the DataPack and extracts relevant mailbox data.
/// </summary>
private void ParseDataPack() {
// sanitize the incoming _rawDataPackResponse
_rawDataPackResponse = _rawDataPackResponse.Replace("\n", "");
_rawDataPackResponse = _rawDataPackResponse.Replace("D([", "\nD([");
_rawDataPackResponse = _rawDataPackResponse.Replace("]);", "]);\n");
// extract the fingerprint, i.e. var fp='6c8abc683047b5bc'
Match fp = Regex.Match(this._rawDataPackResponse, "var fp='([A-Za-z0-9]+)'", RegexOptions.Compiled);
if(fp.Success) {
this._session.Fingerprint = fp.Groups[1].Value;
// clear internal thread store
this._session.UnreadThreads.Clear();
} else {
Debug.WriteLine("DataPack error: Could not find the DataPack fingerprint.");
}
// capture all the dataItems
Regex r = new Regex(@"D\((?<dataItem>\[.+\])\)", RegexOptions.ExplicitCapture|RegexOptions.Compiled);
Match m = r.Match(_rawDataPackResponse);
// loop through all the dataItems and insert accordingly
while(m.Success) {
// get ArrayList version of dataPack JS array
ArrayList tmpArray = Utilities.ParseJSArray(m.Groups[1].Value);
// get name of DataItem
string settingName = (string)tmpArray[0];
// strip the name element; reindexes the array
tmpArray.RemoveAt(0);
SortedList sl;
switch(settingName) {
case "ds": // default searches
if(tmpArray.Count == 6) {
sl = this._session.DefaultSearchCounts;
sl["Inbox"] = Int32.Parse((string)tmpArray[0]);
sl["Starred"] = Int32.Parse((string)tmpArray[1]);
sl["Sent"] = Int32.Parse((string)tmpArray[2]);
sl["All"] = Int32.Parse((string)tmpArray[3]);
sl["Spam"] = Int32.Parse((string)tmpArray[4]);
sl["Trash"] = Int32.Parse((string)tmpArray[5]);
} else {
Debug.WriteLine("DataPack error: 'ds' did not have expected number of elements (6).");
}
break;
case "ct": // categories
sl = this._session.CategoryCounts;
foreach(ArrayList sub in (ArrayList)tmpArray[0]) {
sl[(string)sub[0]] = Int32.Parse((string)sub[1]);
}
break;
case "ts": // threadlist summary
if(tmpArray.Count == 7) {
this._session.ThreadListTimestamp = tmpArray[5].ToString();
this._session.TotalMessages = Int32.Parse(tmpArray[6].ToString());
} else {
Debug.WriteLine("DataPack error: 'ts' did not have expected number of elements (7).");
}
break;
case "t": // message listings *** NOTE: If Gmail changes their spec, this will definitely need to be updated. ***
foreach(ArrayList message in tmpArray) {
// we really only want the unread messages
if((string)message[1] == "1") {
GmailThread newMessage = new GmailThread();
newMessage.ThreadID = (string)message[0];
newMessage.IsRead = ((string)message[1] == "1" ? false : true); // Gmail reports isUnread so we swap
newMessage.IsStarred = ((string)message[2] == "1" ? true : false);
newMessage.DateHtml = (string)message[3];
newMessage.AuthorsHtml = (string)message[4];
newMessage.Flags = (string)message[5];
newMessage.SubjectHtml = (string)message[6];
newMessage.SnippetHtml = (string)message[7];
newMessage.Categories = (ArrayList)message[8];
newMessage.AttachHtml = (string)message[9];
newMessage.MatchingMessageID = (string)message[10];
this._session.UnreadThreads.Add(newMessage);
}
}
break;
default:
break;
}
// advance to next dataItem
m = m.NextMatch();
}
this._session.FinalizeUpdate();
}
/// <summary>
/// Attempts an HTTP request and returns the response document.
/// </summary>
/// <param name="location">Resource to request.</param>
/// <param name="method">"GET" or "POST".</param>
/// <param name="referrer">The HTTP referer (it's spelled 'referrer', dammit!).</param>
/// <param name="postData">If method if POST, pass the request document; null otherwise.</param>
/// <param name="allowAutoRedirect">Set to true to allow client to follow redirect.</param>
/// <returns></returns>
private string MakeWebRequest(Uri location, string method, string referrer, string postData, bool allowAutoRedirect) {
Debug.WriteLine("Initiating " + method + " request at: " + location.ToString());
// prepare HTTP request
HttpWebRequest webRequest = (HttpWebRequest)WebRequest.Create(location);
// if POSTing, add request page and modify the headers
byte[] encodedPostData = new byte[0];
if(method == "POST") {
ASCIIEncoding encoding = new ASCIIEncoding();
encodedPostData = encoding.GetBytes(postData);
webRequest.Method = "POST";
webRequest.ContentType="application/x-www-form-urlencoded";
webRequest.ContentLength = encodedPostData.Length;
} else {
webRequest.Method = "GET";
webRequest.ContentType = "text/html";
}
webRequest.AllowAutoRedirect = allowAutoRedirect;
webRequest.KeepAlive = false;
webRequest.Referer = referrer;
webRequest.CookieContainer = new CookieContainer();
webRequest.CookieContainer.Add(location, this._session.Cookies);
webRequest.UserAgent = "User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.7) Gecko/20040626 Firefox/0.8";
webRequest.Accept = "Accept: application/x-shockwave-flash,text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,video/x-mng,image/png,image/jpeg,image/gif;q=0.2,*/*;q=0.1";
//webRequest.UserAgent = "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) GmailAgent/0.5";
//webRequest.Accept = "text/xml,text/html;q=0.9,text/plain";
// Attempt to send request stream to server if POSTing
if(method == "POST") {
Stream requestStream = null;
try {
requestStream = webRequest.GetRequestStream();
requestStream.Write(encodedPostData, 0, encodedPostData.Length);
} catch(Exception ex) {
Debug.WriteLine(ex.Message);
} finally {
if(requestStream != null) {
requestStream.Close();
}
}
}
// Attempt to get response from server
HttpWebResponse webResponse = null;
string output = "";
try {
// get response
webResponse = (HttpWebResponse)webRequest.GetResponse();
// add new cookies to cookie jar
this._session.Cookies.Add(webResponse.Cookies);
foreach(Cookie ck in webResponse.Cookies) {
Debug.WriteLine(" Adding cookie: " + ck.ToString());
}
// read response stream and dump to string
Stream streamResponse = webResponse.GetResponseStream();
StreamReader streamRead = new StreamReader(streamResponse);
output = streamRead.ReadToEnd();
streamRead.Close();
streamResponse.Close();
Debug.WriteLine("Received response (" + output.Length + " char(s))");
} catch(Exception ex) {
Debug.WriteLine(ex.Message);
} finally {
if(webResponse != null) {
webResponse.Close();
}
}
// return the response document
return output;
}
/// <summary>
/// Generates a proxy defeating random string (passed as the 'zx' GET variable).
/// </summary>
/// <returns>Random string composed of JS version and random string.</returns>
private string MakeUniqueUrl() {
Random rnd = new Random();
// The significance of 2147483648 is that it's equal to 2^32, or 2GB.
return this._jsVersion + Convert.ToString((Math.Round(rnd.Next(1,999) * 2147483.648)));
}
}
}