/*
* $Id: IMAPConnection.java,v 1.1 2003/10/19 08:51:37 dog Exp $
* Copyright (C) 2003 The Free Software Foundation
*
* This file is part of GNU inetlib, a library.
*
* GNU inetlib 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.
*
* GNU inetlib 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 library; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*
* As a special exception, if you link this library with other files to
* produce an executable, this library does not by itself cause the
* resulting executable to be covered by the GNU General Public License.
* This exception does not however invalidate any other reasons why the
* executable file might be covered by the GNU General Public License.
*/
package gnu.inet.imap;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.InputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.ProtocolException;
import java.net.Socket;
import java.net.UnknownHostException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import javax.net.ssl.SSLSocketFactory;
import javax.security.auth.callback.CallbackHandler;
import javax.security.sasl.Sasl;
import javax.security.sasl.SaslClient;
import javax.security.sasl.SaslException;
import gnu.inet.util.BASE64;
import gnu.inet.util.CRLFOutputStream;
import gnu.inet.util.Logger;
import gnu.inet.util.SaslCallbackHandler;
import gnu.inet.util.SaslInputStream;
import gnu.inet.util.SaslOutputStream;
/**
* The protocol class implementing IMAP4rev1.
*
* @author Chris Burdess
* @version $Revision: 1.1 $ $Date: 2003/10/19 08:51:37 $
*/
public class IMAPConnection implements IMAPConstants
{
/**
* Prefix for tags.
*/
protected static final String TAG_PREFIX = "A";
/**
* The encoding used to create strings for IMAP commands.
*/
protected static final String US_ASCII = "US-ASCII";
/**
* The default IMAP port.
*/
protected static final int DEFAULT_PORT = 143;
/**
* The socket used for communication with the server.
*/
protected Socket socket;
/**
* The tokenizer used to read IMAP responses from.
*/
protected IMAPResponseTokenizer in;
/**
* The output stream.
*/
protected CRLFOutputStream out;
/**
* List of responses received asynchronously.
*/
protected List asyncResponses;
/**
* List of alert strings.
*/
private List alerts;
/*
* Used to generate new tags for tagged commands.
*/
private int tagIndex = 0;
/*
* Print debugging output to stderr.
*/
private boolean debug;
/*
* Print debugging output using ANSI colour escape sequences.
*/
private boolean ansiDebug = false;
/**
* Creates a new connection.
* @param host the name of the host to connect to
* @param port the port to connect to
*/
public IMAPConnection(String host, int port)
throws UnknownHostException, IOException
{
this(host, port, -1, -1, false);
}
/**
* Creates a new connection.
* @param host the name of the host to connect to
* @param port the port to connect to
*/
public IMAPConnection(String host, int port,
int connectionTimeout, int timeout, boolean debug)
throws UnknownHostException, IOException
{
this.debug = debug;
// TODO connectionTimeout
if (port<0)
port = DEFAULT_PORT;
// Set up socket
socket = new Socket(host, port);
if (timeout>0)
socket.setSoTimeout(timeout);
InputStream in = socket.getInputStream();
in = new BufferedInputStream(in);
this.in = new IMAPResponseTokenizer(in);
OutputStream out = socket.getOutputStream();
out = new BufferedOutputStream(out);
this.out = new CRLFOutputStream(out);
asyncResponses = new ArrayList();
alerts = new ArrayList();
}
/**
* Sets whether debugging output should use ANSI colour escape sequences.
*/
public void setAnsiDebug(boolean flag)
{
ansiDebug = flag;
}
/**
* Returns a new tag for a command.
*/
protected String newTag() {
return new StringBuffer(TAG_PREFIX).append(++tagIndex).toString();
}
/**
* Sends the specified IMAP tagged command to the server.
*/
protected void sendCommand(String tag, String command)
throws IOException
{
if (debug)
{
Logger logger = Logger.getInstance();
logger.log("imap", "> "+tag+" "+command);
}
String cmd = new StringBuffer(tag)
.append(' ')
.append(command)
.toString();
out.write(cmd);
out.writeln();
out.flush();
}
/**
* Sends the specified IMAP command.
* @param command the command
* @return true if OK was received, or false if NO was received
* @exception IOException if BAD was received or an I/O error occurred
*/
protected boolean invokeSimpleCommand(String command)
throws IOException
{
String tag = newTag();
sendCommand(tag, command);
while (true)
{
IMAPResponse response = readResponse();
String id = response.getID();
if (tag.equals(response.getTag()))
{
processAlerts(response);
if (id==OK)
return true;
else if (id==NO)
return false;
else
throw new IMAPException(id, response.getText());
}
else if (response.isUntagged())
asyncResponses.add(response);
else
throw new IMAPException(id, response.getText());
}
}
/**
* Reads an IMAP response from the server.
* The response will consist of either:
*
* - A tagged response corresponding to a pending command
* - An untagged error response
* - A continuation response
*/
protected IMAPResponse readResponse()
throws IOException
{
IMAPResponse response = in.next();
if (debug)
{
Logger logger = Logger.getInstance();
if (ansiDebug)
logger.log("imap", "< "+response.toANSIString());
else
logger.log("imap", "< "+response.toString());
}
return response;
}
// -- Alert notifications --
private void processAlerts(IMAPResponse response)
{
List code = response.getResponseCode();
if (code!=null && code.contains(ALERT))
alerts.add(response.getText());
}
boolean alertsPending()
{
return (alerts.size()>0);
}
String[] getAlerts()
{
String[] a = new String[alerts.size()];
alerts.toArray(a);
alerts.clear(); // flush
return a;
}
// -- IMAP commands --
/**
* Returns a list of the capabilities of the IMAP server.
*/
public List capability()
throws IOException
{
String tag = newTag();
sendCommand(tag, CAPABILITY);
while (true)
{
IMAPResponse response = readResponse();
String id = response.getID();
if (tag.equals(response.getTag()))
{
processAlerts(response);
if (id==OK)
{
// The capability "list" is actually contained in the response
// text.
String text = response.getText();
List capabilities = new ArrayList();
int si = text.indexOf(' ');
while (si!=-1)
{
capabilities.add(text.substring(0, si));
text = text.substring(si+1);
si = text.indexOf(' ');
}
if (text.length()>0)
capabilities.add(text);
return capabilities;
}
else
throw new IMAPException(id, response.getText());
}
else if (response.isUntagged())
asyncResponses.add(response);
else
throw new IMAPException(id, response.getText());
}
}
/**
* Ping the server.
* If a change in mailbox state is detected, a new mailbox status is
* returned, otherwise this method returns null.
*/
public MailboxStatus noop()
throws IOException
{
String tag = newTag();
sendCommand(tag, NOOP);
boolean changed = false;
MailboxStatus ms = new MailboxStatus();
Iterator asyncIterator = asyncResponses.iterator();
while (true)
{
IMAPResponse response;
// Process any asynchronous responses first
if (asyncIterator.hasNext())
{
response = (IMAPResponse)asyncIterator.next();
asyncIterator.remove();
}
else
response = readResponse();
String id = response.getID();
if (response.isUntagged())
{
changed = changed || updateMailboxStatus(ms, id, response);
}
else if (tag.equals(response.getTag()))
{
processAlerts(response);
if (id==OK)
return changed ? ms : null;
else
throw new IMAPException(id, response.getText());
}
else
throw new IMAPException(id, response.getText());
}
}
/**
* Attempts to start TLS on the specified connection.
* See RFC 2595 for details.
* @return true if successful, false otherwise
*/
public boolean starttls()
throws IOException
{
try
{
// Use SSLSocketFactory to negotiate a TLS session and wrap the
// current socket.
SSLSocketFactory factory =
(SSLSocketFactory)SSLSocketFactory.getDefault();
String tag = newTag();
sendCommand(tag, STARTTLS);
while (true)
{
IMAPResponse response = readResponse();
if (response.isTagged() && tag.equals(response.getTag()))
{
processAlerts(response);
String id = response.getID();
if (id==OK)
break; // negotiate TLS
else if (id==BAD)
return false;
}
else
asyncResponses.add(response);
}
socket = factory.createSocket(socket,
socket.getInetAddress().getHostName(),
socket.getPort(),
true);
InputStream in = socket.getInputStream();
in = new BufferedInputStream(in);
this.in = new IMAPResponseTokenizer(in);
OutputStream out = socket.getOutputStream();
out = new BufferedOutputStream(out);
this.out = new CRLFOutputStream(out);
return true;
}
catch (ClassNotFoundException e)
{
return false; // No javax.net classes in runtime
}
}
/**
* Login to the connection using the username and password method.
* @param username the authentication principal
* @param password the authentication credentials
* @return true if authentication was successful, false otherwise
*/
public boolean login(String username, String password)
throws IOException
{
StringBuffer cmd = new StringBuffer(LOGIN);
cmd.append(' ');
cmd.append(quote(username));
cmd.append(' ');
cmd.append(quote(password));
return invokeSimpleCommand(cmd.toString());
}
/**
* Authenticates the connection using the specified SASL mechanism,
* username, and password.
* @param mechanism a SASL authentication mechanism, e.g. LOGIN, PLAIN,
* CRAM-MD5, GSSAPI
* @param username the authentication principal
* @param password the authentication credentials
* @return true if authentication was successful, false otherwise
*/
public boolean authenticate(String mechanism, String username, String password)
throws IOException
{
try
{
String[] m = new String[] { mechanism };
CallbackHandler ch = new SaslCallbackHandler(username, password);
// Avoid lengthy callback procedure for GNU Crypto
Properties p = new Properties();
p.put("gnu.crypto.sasl.username", username);
p.put("gnu.crypto.sasl.password", password);
SaslClient sasl = Sasl.createSaslClient(m, null, "smtp",
socket.getInetAddress().getHostName(), p, ch);
StringBuffer cmd = new StringBuffer(AUTHENTICATE);
cmd.append(' ');
cmd.append(mechanism);
if (sasl.hasInitialResponse())
{
cmd.append(' ');
byte[] init = sasl.evaluateChallenge(new byte[0]);
cmd.append(new String(init, US_ASCII));
}
String tag = newTag();
sendCommand(tag, cmd.toString());
while (true)
{
IMAPResponse response = readResponse();
if (tag.equals(response.getTag()))
{
processAlerts(response);
String id = response.getID();
if (id==OK)
{
String qop = (String)sasl.getNegotiatedProperty(Sasl.QOP);
if ("auth-int".equalsIgnoreCase(qop)
|| "auth-conf".equalsIgnoreCase(qop))
{
InputStream in = socket.getInputStream();
in = new BufferedInputStream(in);
in = new SaslInputStream(sasl, in);
this.in = new IMAPResponseTokenizer(in);
OutputStream out = socket.getOutputStream();
out = new BufferedOutputStream(out);
out = new SaslOutputStream(sasl, out);
this.out = new CRLFOutputStream(out);
}
return true;
}
else if (id==NO)
return false;
else if (id==BAD)
throw new IMAPException(id, response.getText());
}
else if (response.isContinuation())
{
try
{
byte[] c0 = response.getText().getBytes(US_ASCII);
byte[] c1 = BASE64.decode(c0); // challenge
byte[] r0 = sasl.evaluateChallenge(c1);
byte[] r1 = BASE64.encode(r0); // response
out.write(r1);
out.write(0x0d);
out.flush();
}
catch (SaslException e)
{
// Error in SASL challenge evaluation - cancel exchange
out.write(0x2a);
out.write(0x0d);
out.flush();
}
}
else
asyncResponses.add(response);
}
}
catch (SaslException e)
{
return false; // No provider for mechanism
}
catch (ClassNotFoundException e)
{
return false; // No javax.security.sasl classes
}
}
/**
* Login to the connection using the CRAM-MD5 authorization extension.
* This method is fully documented in RFC 2195.
*
public boolean authenticate_CRAM_MD5(String username, String secret)
throws IOException
{
String tag = newTag();
sendCommand(tag, new StringBuffer(AUTHENTICATE)
.append(' ')
.append(CRAM_MD5)
.toString());
while (true)
{
IMAPResponse response = readResponse();
if (tag.equals(response.getTag()))
{
processAlerts(response);
String id = response.getID();
if (id==OK)
return true;
else if (id==NO)
return false;
else if (id==BAD)
throw new IMAPException(id, response.getText());
}
else if (response.isContinuation())
{
try
{
byte[] s = secret.getBytes(US_ASCII); // TODO encoding?
byte[] c0 = response.getText().getBytes(US_ASCII);
byte[] c1 = BASE64.decode(c0); // challenge
byte[] digest = hmac_md5(s, c1);
byte[] r0 = username.getBytes(US_ASCII); // username
byte[] r1 = new byte[r0.length+digest.length+1]; // response
System.arraycopy(r0, 0, r1, 0, r0.length); // add username
r1[r0.length] = 0x20; // SPACE
System.arraycopy(digest, 0, r1, r0.length+1, digest.length);
byte[] r2 = BASE64.encode(r1);
out.write(r2);
out.write(0x0d);
out.flush();
}
catch (NoSuchAlgorithmException e)
{
// No MD5 algorithm provider - cancel exchange
out.write(0x2a);
out.write(0x0d);
out.flush();
}
}
else
asyncResponses.add(response);
}
}*/
/**
* Computes a CRAM digest using the HMAC algorithm:
*
* MD5(key XOR opad, MD5(key XOR ipad, text))
*
.
* secret
is null-padded to a length of 64 bytes.
* If the shared secret is longer than 64 bytes, the MD5 digest of the
* shared secret is used as a 16 byte input to the keyed MD5 calculation.
* See RFC 2104 for details.
*
private static byte[] hmac_md5(byte[] key, byte[] text)
throws NoSuchAlgorithmException
{
byte[] k_ipad = new byte[64];
byte[] k_opad = new byte[64];
byte[] digest;
MessageDigest md5 = MessageDigest.getInstance("MD5");
// if key is longer than 64 bytes reset it to key=MD5(key)
if (key.length>64)
{
md5.update(key);
key = md5.digest();
}
// start out by storing key in pads
System.arraycopy(key, 0, k_ipad, 0, key.length);
System.arraycopy(key, 0, k_opad, 0, key.length);
// XOR key with ipad and opad values
for (int i=0; i<64; i++)
{
k_ipad[i] ^= 0x36;
k_opad[i] ^= 0x5c;
}
// perform inner MD5
md5.reset();
md5.update(k_ipad);
md5.update(text);
digest = md5.digest();
// perform outer MD5
md5.reset();
md5.update(k_opad);
md5.update(digest);
digest = md5.digest();
return digest;
}*/
/**
* Logout this connection.
* Underlying network resources will be freed.
*/
public void logout()
throws IOException
{
String tag = newTag();
sendCommand(tag, LOGOUT);
while (true)
{
IMAPResponse response = readResponse();
if (response.isTagged() && tag.equals(response.getTag()))
{
processAlerts(response);
String id = response.getID();
if (id==OK)
{
socket.close();
return;
}
else
throw new IMAPException(id, response.getText());
}
else
asyncResponses.add(response);
}
}
/**
* Selects the specified mailbox.
* The mailbox is identified as read-write if writes are permitted.
* @param mailbox the mailbox name
* @return a MailboxStatus containing the state of the selected mailbox
*/
public MailboxStatus select(String mailbox)
throws IOException
{
return selectImpl(mailbox, SELECT);
}
/**
* Selects the specified mailbox.
* The mailbox is identified as read-only.
* @param mailbox the mailbox name
* @return a MailboxStatus containing the state of the selected mailbox
*/
public MailboxStatus examine(String mailbox)
throws IOException
{
return selectImpl(mailbox, EXAMINE);
}
protected MailboxStatus selectImpl(String mailbox, String command)
throws IOException
{
String tag = newTag();
sendCommand(tag, new StringBuffer(command)
.append(' ')
.append(quote(UTF7imap.encode(mailbox)))
.toString());
MailboxStatus ms = new MailboxStatus();
while (true)
{
IMAPResponse response = readResponse();
String id = response.getID();
if (response.isUntagged())
{
if (!updateMailboxStatus(ms, id, response))
asyncResponses.add(response);
}
else if (tag.equals(response.getTag()))
{
processAlerts(response);
if (id==OK)
{
List rc = response.getResponseCode();
if (rc.size()>0 && rc.get(0)==READ_WRITE)
ms.readWrite = true;
return ms;
}
else
throw new IMAPException(id, response.getText());
}
else
throw new IMAPException(id, response.getText());
}
}
protected boolean updateMailboxStatus(MailboxStatus ms,
String id, IMAPResponse response)
throws IOException
{
if (id==OK)
{
boolean changed = false;
List rc = response.getResponseCode();
int len = (rc==null) ? 0 : rc.size();
for (int i=0; i0)
buffer.append(' ');
buffer.append(statusNames[i]);
}
buffer.append(')');
sendCommand(tag, buffer.toString());
MailboxStatus ms = new MailboxStatus();
while (true)
{
IMAPResponse response = readResponse();
String id = response.getID();
if (response.isUntagged())
{
if (id==STATUS)
{
List code = response.getResponseCode();
int last = code.size()-1;
for (int i=0; i0)
buffer.append(' ');
buffer.append(flags[i]);
}
buffer.append(')');
buffer.append(' ');
}
buffer.append('{');
buffer.append(content.length);
buffer.append('}');
sendCommand(tag, buffer.toString());
IMAPResponse response = readResponse();
if (!response.isContinuation())
throw new IMAPException(response.getID(), response.getText());
out.write(content); // write the message body
out.write(0x0a);
out.write(0x0d); // write CRLF
out.flush();
while (true)
{
response = readResponse();
String id = response.getID();
if (tag.equals(response.getTag())) {
processAlerts(response);
if (id==OK)
return true;
else if (id==NO)
return false;
else
throw new IMAPException(id, response.getText());
}
else if (response.isUntagged())
asyncResponses.add(response);
else
throw new IMAPException(id, response.getText());
}
}
/**
* Request a checkpoint of the currently selected mailbox.
*/
public void check()
throws IOException
{
invokeSimpleCommand(CHECK);
}
/**
* Permanently remove all messages that have the \Deleted flags set,
* and close the mailbox.
* @return true if successful, false if no mailbox was selected
*/
public boolean close()
throws IOException
{
return invokeSimpleCommand(CLOSE);
}
/**
* Permanently removes all messages that have the \Delete flag set.
* @return the numbers of the messages expunged
*/
public int[] expunge()
throws IOException
{
String tag = newTag();
sendCommand(tag, EXPUNGE);
List numbers = new ArrayList();
while (true)
{
IMAPResponse response = readResponse();
String id = response.getID();
if (response.isUntagged())
{
if (id==EXPUNGE)
numbers.add(new Integer(response.getCount()));
else
asyncResponses.add(response);
}
else if (tag.equals(response.getTag()))
{
processAlerts(response);
if (id==OK)
{
int len = numbers.size();
int[] mn = new int[len];
for (int i=0; i0)
buffer.append(' ');
buffer.append(criteria[i]);
}
sendCommand(tag, buffer.toString());
List list = new ArrayList();
while (true)
{
IMAPResponse response = readResponse();
String id = response.getID();
if (response.isUntagged())
{
if (id==SEARCH)
{
String text = response.getText();
try
{
int si = text.indexOf(' ');
while (si!=-1)
{
list.add(new Integer(text.substring(0, si)));
text = text.substring(si+1);
si = text.indexOf(' ');
}
list.add(new Integer(text));
}
catch (NumberFormatException e)
{
throw new IMAPException(id, "Expecting number: "+text);
}
}
else
asyncResponses.add(response);
}
else if (tag.equals(response.getTag()))
{
processAlerts(response);
if (id==OK)
{
int len = list.size();
int[] mn = new int[len];
for (int i=0; i0)
buffer.append(',');
buffer.append(messages[i]);
}
buffer.append(' ');
buffer.append('(');
for (int i=0; i0)
buffer.append(' ');
buffer.append(fetchCommands[i]);
}
buffer.append(')');
sendCommand(tag, buffer.toString());
List list = new ArrayList(messages.length);
while (true)
{
IMAPResponse response = readResponse();
String id = response.getID();
if (response.isUntagged())
{
if (id==FETCH)
{
int msgnum = response.getCount();
List code = response.getResponseCode();
MessageStatus status = new MessageStatus(msgnum, code);
list.add(status);
}
else
asyncResponses.add(response);
}
else if (tag.equals(response.getTag()))
{
processAlerts(response);
if (id==OK)
{
MessageStatus[] statuses = new MessageStatus[list.size()];
list.toArray(statuses);
return statuses;
}
else
throw new IMAPException(id, response.getText());
}
else
throw new IMAPException(id, response.getText());
}
}
/**
* Alters data associated with a message in the mailbox.
* @param messages the message numbers
* @param flagCommand FLAGS, +FLAGS, -FLAGS (or .SILENT versions)
* @param flags message flags to set
* @return a list of message-number to current flags
*/
public MessageStatus[] store(int[] messages,
String flagCommand,
String[] flags)
throws IOException
{
String tag = newTag();
StringBuffer buffer = new StringBuffer(STORE);
buffer.append(' ');
for (int i=0; i0)
buffer.append(',');
buffer.append(messages[i]);
}
buffer.append(' ');
buffer.append(flagCommand);
buffer.append(' ');
buffer.append('(');
for (int i=0; i0)
buffer.append(' ');
buffer.append(flags[i]);
}
buffer.append(')');
sendCommand(tag, buffer.toString());
List list = new ArrayList(messages.length);
while (true)
{
IMAPResponse response = readResponse();
String id = response.getID();
if (response.isUntagged())
{
int msgnum = response.getCount();
List code = response.getResponseCode();
// 2 different styles returned by server: FETCH or FETCH FLAGS
if (id==FETCH)
{
MessageStatus mf = new MessageStatus(msgnum, code);
list.add(mf);
}
else if (id==FETCH_FLAGS)
{
List base = new ArrayList();
base.add(FLAGS);
base.add(code);
MessageStatus mf = new MessageStatus(msgnum, base);
list.add(mf);
}
else
asyncResponses.add(response);
}
else if (tag.equals(response.getTag()))
{
processAlerts(response);
if (id==OK)
{
MessageStatus[] mf = new MessageStatus[list.size()];
list.toArray(mf);
return mf;
}
else
throw new IMAPException(id, response.getText());
}
else
throw new IMAPException(id, response.getText());
}
}
/**
* Copies the specified messages to the end of the destination mailbox.
* @param messages the message numbers
* @param mailbox the destination mailbox
*/
public boolean copy(int[] messages, String mailbox)
throws IOException
{
if (messages==null || messages.length<1)
return true;
StringBuffer buffer = new StringBuffer(COPY)
.append(' ');
for (int i=0; i0)
buffer.append(',');
buffer.append(messages[i]);
}
buffer.append(' ')
.append(quote(UTF7imap.encode(mailbox)));
return invokeSimpleCommand(buffer.toString());
}
// -- Utility methods --
/**
* Remove the quotes from each end of a string literal.
*/
static String stripQuotes(String text)
{
if (text.charAt(0)=='"')
{
int len = text.length();
if (text.charAt(len-1)=='"')
return text.substring(1, len-1);
}
return text;
}
/**
* Quote the specified text if necessary.
*/
static String quote(String text)
{
if (text.length()==0 || text.indexOf(' ')!=-1)
{
StringBuffer buffer = new StringBuffer();
buffer.append('"');
buffer.append(text);
buffer.append('"');
return buffer.toString();
}
return text;
}
}