CommandParser.java
/*
* Copyright (c) 2014 Wael Chatila / Icegreen Technologies. All Rights Reserved.
* This software is released under the Apache license 2.0
* This file has been modified by the copyright holder.
* Original file can be found at http://james.apache.org
*/
package com.icegreen.greenmail.imap.commands;
import com.icegreen.greenmail.imap.ImapConstants;
import com.icegreen.greenmail.imap.ImapRequestLineReader;
import com.icegreen.greenmail.imap.ProtocolException;
import com.icegreen.greenmail.store.MessageFlags;
import org.eclipse.angus.mail.imap.protocol.BASE64MailboxDecoder;
import jakarta.mail.Flags;
import java.nio.charset.Charset;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/**
* @author Darrell DeBoer <darrell@apache.org>
* @version $Revision: 109034 $
*/
public class CommandParser {
/**
* SPACE character
*/
static final char CHR_SPACE = ' ';
/**
* Carriage-Return '\r' character
*/
static final char CHR_CR = '\r';
/**
* Reads an argument of type "atom" from the request.
*/
public String atom(ImapRequestLineReader request) throws ProtocolException {
return consumeWord(request, new AtomCharValidator());
}
/**
* Reads an argument of type "atom" from the request. Stops reading when non-atom chars are read.
*/
public String atomOnly(ImapRequestLineReader request) throws ProtocolException {
return consumeWordOnly(request, new AtomCharValidator());
}
/**
* Reads a command "tag" from the request.
*/
public String tag(ImapRequestLineReader request) throws ProtocolException {
CharacterValidator validator = new TagCharValidator();
return consumeWord(request, validator);
}
/**
* Reads an argument of type "astring" from the request.
*/
public String astring(ImapRequestLineReader request) throws ProtocolException {
char next = request.nextWordChar();
switch (next) {
case '"':
return consumeQuoted(request);
case '{':
return consumeLiteral(request);
default:
return atom(request);
}
}
/**
* Reads an argument of type "string" from the request.
* <p>
* string = quoted / literal
*/
public String string(ImapRequestLineReader request, Charset charset) throws ProtocolException {
char next = request.nextWordChar();
switch (next) {
case '"':
return consumeQuoted(request);
case '{':
return new String(consumeLiteralAsBytes(request), charset);
default:
return consumeWord(request);
}
}
/**
* Reads an argument of type "nstring" from the request.
* https://tools.ietf.org/html/rfc3501#page-88 :
* nstring = string / nil
* nil = "NIL"
*/
public String nstring(ImapRequestLineReader request) throws ProtocolException {
char next = request.nextWordChar();
switch (next) {
case '"':
return consumeQuoted(request);
case '{':
return consumeLiteral(request);
default:
String value = consumeWord(request);
if ("NIL".equals(value)) {
return null;
} else {
throw new ProtocolException("Invalid nstring value: valid values are '\"...\"', '{12} CRLF *CHAR8', and 'NIL'.");
}
}
}
/**
* Reads a "mailbox" argument from the request. Not implemented *exactly* as per spec,
* since a quoted or literal "inbox" still yeilds "INBOX"
* (ie still case-insensitive if quoted or literal). I think this makes sense.
* <p>
* mailbox ::= "INBOX" / astring
* ;; INBOX is case-insensitive. All case variants of
* ;; INBOX (e.g. "iNbOx") MUST be interpreted as INBOX
* ;; not as an astring.
*/
public String mailbox(ImapRequestLineReader request) throws ProtocolException {
String mailbox = astring(request);
if (mailbox.equalsIgnoreCase(ImapConstants.INBOX_NAME)) {
return ImapConstants.INBOX_NAME;
} else {
return BASE64MailboxDecoder.decode(mailbox);
}
}
/**
* Reads a "date-time" argument from the request.
*/
public Date dateTime(ImapRequestLineReader request) throws ProtocolException {
char next = request.nextWordChar();
String dateString;
// From https://tools.ietf.org/html/rfc3501 :
// date-time = DQUOTE date-day-fixed "-" date-month "-" date-year
// SP time SP zone DQUOTE
// zone = ("+" / "-") 4DIGIT
if (next == '"') {
dateString = consumeQuoted(request);
} else {
throw new ProtocolException("DateTime values must be quoted.");
}
try {
// You can use Z or zzzz
return new SimpleDateFormat("dd-MMM-yyyy hh:mm:ss Z", Locale.US).parse(dateString);
} catch (ParseException e) {
throw new ProtocolException("Invalid date format <" + dateString + ">, should comply to dd-MMM-yyyy hh:mm:ss Z");
}
}
/**
* Reads the next "word" from the request, comprising all characters up to the next SPACE.
*/
protected String consumeWord(ImapRequestLineReader request) throws ProtocolException {
return consumeWord(request, new NoopCharValidator());
}
/**
* Reads the next "word" from the request, comprising all characters up to the next SPACE.
* Characters are tested by the supplied CharacterValidator, and an exception is thrown
* if invalid characters are encountered.
*/
protected String consumeWord(ImapRequestLineReader request,
CharacterValidator validator)
throws ProtocolException {
StringBuilder atom = new StringBuilder();
char next = request.nextWordChar();
while (!isWhitespace(next)) {
if (validator.isValid(next)) {
atom.append(next);
request.consume();
} else {
throw new ProtocolException("Invalid character: '" + next + '\'');
}
next = request.nextChar();
}
return atom.toString();
}
/**
* Reads the next "word from the request, comprising all characters up to the next SPACE.
* Characters are tested by the supplied CharacterValidator, and
* if invalid characters are encountered these are not consumed.
*/
protected String consumeWordOnly(ImapRequestLineReader request,
CharacterValidator validator)
throws ProtocolException {
StringBuilder atom = new StringBuilder();
char next = request.nextWordChar();
while (!isWhitespace(next)) {
if (validator.isValid(next)) {
atom.append(next);
request.consume();
} else {
return atom.toString();
}
next = request.nextChar();
}
return atom.toString();
}
private boolean isWhitespace(char next) {
return next == ' ' || next == '\n' || next == '\r' || next == '\t';
}
public long consumeLong(ImapRequestLineReader request) throws ProtocolException {
StringBuilder atom = new StringBuilder();
char next = request.nextWordChar();
while (Character.isDigit(next)) {
atom.append(next);
request.consume();
next = request.nextChar();
}
return Long.parseLong(atom.toString());
}
/**
* Reads an argument of type "literal" from the request, in the format:
* "{" charCount "}" CRLF *CHAR8
* Note before calling, the request should be positioned so that nextChar
* is '{'. Leading whitespace is not skipped in this method.
*/
protected String consumeLiteral(ImapRequestLineReader request)
throws ProtocolException {
return new String(consumeLiteralAsBytes(request));
}
protected byte[] consumeLiteralAsBytes(ImapRequestLineReader request)
throws ProtocolException {
// The 1st character must be '{'
consumeChar(request, '{');
StringBuilder digits = new StringBuilder();
char next = request.nextChar();
while (next != '}' && next != '+') {
digits.append(next);
request.consume();
next = request.nextChar();
}
// If the number is *not* suffixed with a '+', we *are* using a synchronized literal,
// and we need to send command continuation request before reading data.
boolean synchronizedLiteral = true;
// '+' indicates a non-synchronized literal (no command continuation request)
if (next == '+') {
synchronizedLiteral = false;
consumeChar(request, '+');
}
// Consume the '}' and the newline
consumeChar(request, '}');
consumeCRLF(request);
if (synchronizedLiteral) {
request.commandContinuationRequest();
}
int size = Integer.parseInt(digits.toString());
byte[] buffer = new byte[size];
request.read(buffer);
return buffer;
}
/**
* Consumes a CRLF from the request.
* TODO we're being liberal, the spec insists on \r\n for new lines.
*
* @param request the imap request
*/
private void consumeCRLF(ImapRequestLineReader request)
throws ProtocolException {
char next = request.nextChar();
if (next != '\n') {
consumeChar(request, '\r');
}
consumeChar(request, '\n');
}
/**
* Consumes the next character in the request, checking that it matches the
* expected one. This method should be used when the
*/
protected void consumeChar(ImapRequestLineReader request, char expected)
throws ProtocolException {
char consumed = request.consume();
if (consumed != expected) {
throw new ProtocolException("Expected:'" + expected + "' found:'" + consumed + '\'');
}
}
/**
* Reads a quoted string value from the request.
*/
protected String consumeQuoted(ImapRequestLineReader request)
throws ProtocolException {
// The 1st character must be '"'
consumeChar(request, '"');
StringBuilder quoted = new StringBuilder();
char next = request.nextChar();
while (next != '"') {
if (next == '\\') {
request.consume();
next = request.nextChar();
if (!isQuotedSpecial(next)) {
throw new ProtocolException("Invalid escaped character in quote: '" +
next + '\'');
}
}
quoted.append(next);
request.consume();
next = request.nextChar();
}
consumeChar(request, '"');
return quoted.toString();
}
/**
* Reads a "flags" argument from the request.
*/
public Flags flagList(ImapRequestLineReader request) throws ProtocolException {
Flags flags = new Flags();
request.nextWordChar();
consumeChar(request, '(');
CharacterValidator validator = new NoopCharValidator();
String nextWord = consumeWord(request, validator);
while (!nextWord.endsWith(")")) {
setFlag(nextWord, flags);
nextWord = consumeWord(request, validator);
}
// Got the closing ")", may be attached to a word.
if (nextWord.length() > 1) {
setFlag(nextWord.substring(0, nextWord.length() - 1), flags);
}
return flags;
}
public void setFlag(String flagString, Flags flags) {
if (flagString.equalsIgnoreCase(MessageFlags.ANSWERED)) {
flags.add(Flags.Flag.ANSWERED);
} else if (flagString.equalsIgnoreCase(MessageFlags.DELETED)) {
flags.add(Flags.Flag.DELETED);
} else if (flagString.equalsIgnoreCase(MessageFlags.DRAFT)) {
flags.add(Flags.Flag.DRAFT);
} else if (flagString.equalsIgnoreCase(MessageFlags.FLAGGED)) {
flags.add(Flags.Flag.FLAGGED);
} else if (flagString.equalsIgnoreCase(MessageFlags.SEEN)) {
flags.add(Flags.Flag.SEEN);
} else if (flagString.equalsIgnoreCase(MessageFlags.RECENT)) {
flags.add(Flags.Flag.RECENT);
} else {
// User flag
flags.add(flagString);
}
}
/**
* Reads an argument of type "number" from the request.
*/
public long number(ImapRequestLineReader request) throws ProtocolException {
String digits = consumeWord(request, new DigitCharValidator());
try {
return Long.parseLong(digits);
} catch (NumberFormatException ex) {
throw new ProtocolException("Can not parse '" + digits + "' as number", ex);
}
}
/**
* Reads an argument of type "nznumber" (a non-zero number)
* (NOTE this isn't strictly as per the spec, since the spec disallows
* numbers such as "0123" as nzNumbers (although it's ok as a "number".
* I think the spec is a bit shonky.)
*/
public long nzNumber(ImapRequestLineReader request) throws ProtocolException {
long number = number(request);
if (number == 0) {
throw new ProtocolException("Zero value not permitted.");
}
return number;
}
private boolean isCHAR(char chr) {
return chr >= 0x01 && chr <= 0x7f;
}
protected boolean isListWildcard(char chr) {
return chr == '*' || chr == '%';
}
private boolean isQuotedSpecial(char chr) {
return chr == '"' || chr == '\\';
}
/**
* Checks if character is either CR or LF.
*
* @param chr the character
* @return true, if either CR or LF.
*/
public static boolean isCrOrLf(final char chr) {
return '\r' == chr || '\n' == chr;
}
/**
* Consumes the request up to and including the eno-of-line.
*
* @param request The request
* @throws ProtocolException If characters are encountered before the endLine.
*/
public void endLine(ImapRequestLineReader request) throws ProtocolException {
request.eol();
}
/**
* Reads a "message set" argument, and parses into an IdSet.
* Currently only supports a single range of values.
*/
public IdRange[] parseIdRange(ImapRequestLineReader request)
throws ProtocolException {
CharacterValidator validator = new MessageSetCharValidator();
String nextWord = consumeWord(request, validator);
int commaPos = nextWord.indexOf(',');
if (commaPos == -1) {
return new IdRange[]{IdRange.parseRange(nextWord)};
}
List<IdRange> rangeList = new ArrayList<>();
int pos = 0;
while (commaPos != -1) {
String range = nextWord.substring(pos, commaPos);
IdRange set = IdRange.parseRange(range);
rangeList.add(set);
pos = commaPos + 1;
commaPos = nextWord.indexOf(',', pos);
}
String range = nextWord.substring(pos);
rangeList.add(IdRange.parseRange(range));
return rangeList.toArray(new IdRange[0]);
}
/**
* Provides the ability to ensure characters are part of a permitted set.
*/
protected interface CharacterValidator {
/**
* Validates the supplied character.
*
* @param chr The character to validate.
* @return <code>true</code> if chr is valid, <code>false</code> if not.
*/
boolean isValid(char chr);
}
protected static class NoopCharValidator implements CharacterValidator {
@Override
public boolean isValid(char chr) {
return true;
}
}
protected class AtomCharValidator implements CharacterValidator {
@Override
public boolean isValid(char chr) {
return isCHAR(chr) && !isAtomSpecial(chr) &&
!isListWildcard(chr) && !isQuotedSpecial(chr);
}
private boolean isAtomSpecial(char chr) {
return chr == '(' ||
chr == ')' ||
chr == '{' ||
chr == ' ' ||
chr == Character.CONTROL;
}
}
protected static class DigitCharValidator implements CharacterValidator {
@Override
public boolean isValid(char chr) {
return (chr >= '0' && chr <= '9') ||
chr == '*';
}
}
protected boolean isAtomSpecial(final char next) {
// atom-specials = "(" / ")" / "{" / SP / CTL / list-wildcards /
// quoted-specials / resp-specials
return next == '(' || next == ')' || next == '{'
|| next == ' ' // SP
|| next == '%' || next == '*' // list-wildcards
|| next <= 1F || next == 7F // CTL
|| next == '"' // quoted-specials = DQUOTE / "\"
|| next == ']' // resp-specials
;
}
private class TagCharValidator extends AtomCharValidator {
@Override
public boolean isValid(char chr) {
return chr != '+' && super.isValid(chr);
}
}
private static class MessageSetCharValidator implements CharacterValidator {
@Override
public boolean isValid(char chr) {
return Character.isDigit(chr) ||
chr == ':' ||
chr == '*' ||
chr == ',';
}
}
}