Click here to Skip to main content
Click here to Skip to main content
Articles » Languages » C# » General » Downloads
 
Add your own
alternative version

Gmail Agent API v0.5 / Mail Notifier & Address Importer

, 6 Jul 2004
Open source Gmail API in C#
gmailagent-src.zip
Johnvey.GmailAgent.Applet
App.ico
bin
Debug
Release
gmail-new.ico
gmail.ico
GmailAgent.exe.manifest
Johnvey.GmailAgent.Applet.csproj.user
obj
Debug
temp
TempPE
Release
temp
TempPE
Setup
Debug
Release
Setup.vdproj
Johnvey.GmailAgent
bin
Debug
Release
Johnvey.GmailAgent.csproj.user
obj
Debug
temp
TempPE
Release
temp
TempPE
gmailagentsetup.zip
GmailAgentSetup.msi
/**************************************************************************
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)
				+ "&notes=" + 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)));	
		}

	}
}

By viewing downloads associated with this article you agree to the Terms of Service and the article's licence.

If a file you wish to view isn't highlighted, and is a text file (not binary), please let us know and we'll add colourisation support for it.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here

Share

About the Author

Johnvey Hwang
Web Developer
United States United States
No Biography provided

| Advertise | Privacy | Mobile
Web02 | 2.8.141022.2 | Last Updated 7 Jul 2004
Article Copyright 2004 by Johnvey Hwang
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid