JSIEVE-103 Implement vacation command
diff --git a/core/src/main/java/org/apache/jsieve/CommandStateManager.java b/core/src/main/java/org/apache/jsieve/CommandStateManager.java
index e4eda37..43ca621 100644
--- a/core/src/main/java/org/apache/jsieve/CommandStateManager.java
+++ b/core/src/main/java/org/apache/jsieve/CommandStateManager.java
@@ -46,6 +46,11 @@
     private boolean fieldHasActions = false;
 
     /**
+     * Did vacation instruction already executed for this script ?
+     */
+    private boolean vacationProcessed = false;
+
+    /**
      * Constructor for CommandStateManager.
      */
     public CommandStateManager() {
@@ -139,4 +144,20 @@
         fieldImplicitKeep = implicitKeep;
     }
 
+    /**
+     * Returns whether a vacation command was processed.
+     *
+     * RFC 5230 section 4.7 : Vacation can only be executed once per script.  A script MUST fail
+     * with an appropriate error if it attempts to execute two or more
+     * vacation actions.
+     *
+     * @return false if no vacation command was executed before in this script, false otherwise
+     */
+    public boolean getVacationProcessed() {
+        return vacationProcessed;
+    }
+
+    public void setVacationProcessed(boolean vacationProcessed) {
+        this.vacationProcessed = vacationProcessed;
+    }
 }
diff --git a/core/src/main/java/org/apache/jsieve/commands/optional/Vacation.java b/core/src/main/java/org/apache/jsieve/commands/optional/Vacation.java
new file mode 100644
index 0000000..fc54d59
--- /dev/null
+++ b/core/src/main/java/org/apache/jsieve/commands/optional/Vacation.java
@@ -0,0 +1,96 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.jsieve.commands.optional;
+
+import org.apache.jsieve.Arguments;
+import org.apache.jsieve.Block;
+import org.apache.jsieve.SieveContext;
+import org.apache.jsieve.commands.AbstractActionCommand;
+import org.apache.jsieve.exception.CommandException;
+import org.apache.jsieve.exception.SieveException;
+import org.apache.jsieve.mail.MailAdapter;
+import org.apache.jsieve.mail.optional.ActionVacation;
+import org.apache.jsieve.utils.ArgumentParser;
+
+/**
+ * See https://tools.ietf.org/html/rfc5230
+ */
+public class Vacation extends AbstractActionCommand {
+
+    public static final String DAYS = ":days";
+    public static final String DAYS_EXCEPTION_MESSAGE = "Expecting a number argument setting the number of days after tag " + DAYS;
+    public static final String SUBJECT = ":subject";
+    public static final String SUBJECT_EXCEPTION_MESSAGE = "Expecting a string argument setting the subject after tag " + SUBJECT;
+    public static final String FROM = ":from";
+    public static final String FROM_EXCEPTION_MESSAGE = "Expecting a string argument setting the from field of sent mails after tag " + FROM;
+    public static final String ADDRESSES = ":addresses";
+    public static final String ADDRESSES_EXCEPTION_MESSAGE = "Expecting a string list argument setting the additional addresses this script is authorized to respond to after tag " + ADDRESSES;
+    public static final String MIME = ":mime";
+    public static final String MIME_EXCEPTION_MESSAGE = "Expecting a string argument setting a mime message instead of the reason string after tag " + MIME;
+    public static final String HANDLE = ":handle";
+    public static final String HANDLE_EXCEPTION_MESSAGE = "Expecting a string argument setting handle string after tag " + HANDLE;
+
+    @Override
+    protected Object executeBasic(MailAdapter mail, Arguments arguments, Block block, SieveContext context) throws SieveException {
+        mail.addAction(retrieveAction(arguments));
+        return null;
+    }
+
+    @Override
+    protected void validateState(SieveContext context) throws CommandException {
+        // RFC-5230 Section 4.7 : The vacation action is incompatible with the Sieve reject and refuse actions
+        super.validateState(context);
+        // RFC-5230 Section 4.7 : Vacation can only be executed once per script
+        if (context.getCommandStateManager().getVacationProcessed())
+            throw context.getCoordinate()
+                .commandException("The \"vacation\" command is not allowed to be executed after other vacation commands");
+    }
+
+    @Override
+    protected void updateState(SieveContext context) {
+        context.getCommandStateManager().setHasActions(true);
+        context.getCommandStateManager().setInProlog(false);
+        // RFC-5230 Section 4.7 : Vacation does not affect Sieve's implicit keep action.
+        context.getCommandStateManager().setVacationProcessed(true);
+        context.getCommandStateManager().setImplicitKeep(context.getCommandStateManager().isImplicitKeep());
+    }
+
+    @Override
+    protected void validateArguments(Arguments arguments, SieveContext context) throws SieveException {
+        retrieveAction(arguments);
+    }
+
+    private ActionVacation retrieveAction(Arguments arguments) throws SieveException {
+        ArgumentParser argumentParser = new ArgumentParser(arguments.getArgumentList());
+        argumentParser.throwOnUnvalidSeenSingleTag();
+        argumentParser.throwOnUnvalidSeenTagWithValue(FROM, SUBJECT, HANDLE, MIME, DAYS, ADDRESSES);
+
+        return ActionVacation.builder()
+            .addresses(argumentParser.getStringListForTag(ADDRESSES, ADDRESSES_EXCEPTION_MESSAGE))
+            .duration(argumentParser.getNumericValueForTag(DAYS, DAYS_EXCEPTION_MESSAGE))
+            .handle(argumentParser.getStringValueForTag(HANDLE, HANDLE_EXCEPTION_MESSAGE))
+            .mime(argumentParser.getStringValueForTag(MIME, MIME_EXCEPTION_MESSAGE))
+            .subject(argumentParser.getStringValueForTag(SUBJECT, SUBJECT_EXCEPTION_MESSAGE))
+            .from(argumentParser.getStringValueForTag(FROM, FROM_EXCEPTION_MESSAGE))
+            .reason(argumentParser.getRemainingStringValue("Expecting a single String value as a reason"))
+            .build();
+    }
+
+}
diff --git a/core/src/main/java/org/apache/jsieve/mail/optional/ActionVacation.java b/core/src/main/java/org/apache/jsieve/mail/optional/ActionVacation.java
new file mode 100644
index 0000000..9113cd7
--- /dev/null
+++ b/core/src/main/java/org/apache/jsieve/mail/optional/ActionVacation.java
@@ -0,0 +1,206 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.jsieve.mail.optional;
+
+import org.apache.jsieve.exception.SyntaxException;
+import org.apache.jsieve.mail.Action;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Class ActionVacation encapsulates the information required to reject a mail.
+ * See RFC 5230, Section 4.1.
+ */
+public class ActionVacation implements Action {
+
+    public static class ActionVacationBuilder {
+
+        private static final int SITE_DEFINED_DEFAULT_VACATION_DURATION = 7;
+        private static final int MINIMUM_VACATION_DURATION = 1;
+
+        private String subject;
+        private String from;
+        private List<String> addresses = new ArrayList<String>();
+        private String handle;
+        private String reason;
+        private String mime;
+        private Integer duration;
+
+        public ActionVacationBuilder() {
+            duration = SITE_DEFINED_DEFAULT_VACATION_DURATION;
+        }
+
+        public ActionVacationBuilder subject(String subject) {
+            this.subject = subject;
+            return this;
+        }
+
+        public ActionVacationBuilder from(String from) {
+            this.from = from;
+            return this;
+        }
+
+        public ActionVacationBuilder addresses(List<String> addresses) {
+            this.addresses = addresses;
+            return this;
+        }
+
+        public ActionVacationBuilder handle(String handle) {
+            this.handle = handle;
+            return this;
+        }
+
+        public ActionVacationBuilder reason(String reason) {
+            this.reason = reason;
+            return this;
+        }
+
+        public ActionVacationBuilder mime(String mime) {
+            this.mime = mime;
+            return this;
+        }
+
+        public ActionVacationBuilder duration(Integer duration) {
+            this.duration = duration;
+            return this;
+        }
+
+        public ActionVacation build() throws SyntaxException {
+            if (!eitherReasonOrMime()) {
+                throw new SyntaxException("vacation need you to set you either the reason string or a MIME message after tag :mime");
+            }
+            return new ActionVacation(subject, from, addresses, reason, computeDuration(duration), handle, mime);
+        }
+
+        private int computeDuration(Integer duration) {
+            if (duration == null) {
+                return SITE_DEFINED_DEFAULT_VACATION_DURATION;
+            }
+            if (duration < MINIMUM_VACATION_DURATION) {
+                return MINIMUM_VACATION_DURATION;
+            } else {
+                return duration;
+            }
+        }
+
+        private boolean eitherReasonOrMime() {
+            return (reason == null) ^ (mime == null);
+        }
+    }
+
+    public static ActionVacationBuilder builder() {
+        return new ActionVacationBuilder();
+    }
+
+    private final String subject;
+    private final String from;
+    private final List<String> addresses;
+    private final String handle;
+    private final String reason;
+    private final String mime;
+    private final int duration;
+
+    private ActionVacation(String subject, String from, List<String> addresses, String reason, int duration, String handle, String mime) {
+        this.subject = subject;
+        this.from = from;
+        this.addresses = Collections.unmodifiableList(addresses);
+        this.reason = reason;
+        this.duration = duration;
+        this.handle = handle;
+        this.mime = mime;
+    }
+
+    public String getSubject() {
+        return subject;
+    }
+
+    public String getFrom() {
+        return from;
+    }
+
+    public List<String> getAddresses() {
+        return addresses;
+    }
+
+    public String getHandle() {
+        return handle;
+    }
+
+    public String getReason() {
+        return reason;
+    }
+
+    public int getDuration() {
+        return duration;
+    }
+
+    public String getMime() {
+        return mime;
+    }
+
+    public String toString() {
+        return "Action: " + getClass().getName();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (o == null || getClass() != o.getClass())  {
+            return false;
+        }
+
+        ActionVacation that = (ActionVacation) o;
+
+        return duration == that.duration
+            && equalsNullProtected(this.subject, subject)
+            && equalsNullProtected(this.from, from)
+            && equalsNullProtected(this.handle, handle)
+            && equalsNullProtected(this.reason, reason)
+            && equalsNullProtected(this.mime, mime);
+    }
+
+    private boolean equalsNullProtected(Object object1, Object object2) {
+        if (object1 == null) {
+            return object2 == null;
+        }
+        return object1.equals(object2);
+    }
+
+    @Override
+    public int hashCode() {
+        int result = hashCodeNullProtected(subject);
+        result = 31 * result + hashCodeNullProtected(from);
+        result = 31 * result + hashCodeNullProtected(handle);
+        result = 31 * result + hashCodeNullProtected(reason);
+        result = 31 * result + hashCodeNullProtected(mime);
+        result = 31 * result + hashCodeNullProtected(subject);
+        result = 31 * result + duration;
+        return result;
+    }
+
+    public int hashCodeNullProtected(Object object) {
+        if (object == null) {
+            return 0;
+        }
+        return object.hashCode();
+    }
+
+}
diff --git a/core/src/test/java/org/apache/jsieve/VacationTest.java b/core/src/test/java/org/apache/jsieve/VacationTest.java
new file mode 100644
index 0000000..afe3f40
--- /dev/null
+++ b/core/src/test/java/org/apache/jsieve/VacationTest.java
@@ -0,0 +1,235 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.jsieve;
+
+import org.apache.jsieve.exception.SyntaxException;
+import org.apache.jsieve.mail.ActionKeep;
+import org.apache.jsieve.mail.optional.ActionVacation;
+import org.apache.jsieve.utils.JUnitUtils;
+import org.apache.jsieve.utils.SieveMailAdapter;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+public class VacationTest {
+
+    private SieveMailAdapter sieveMailAdapter;
+
+    @Before
+    public void setUp() {
+        sieveMailAdapter = mock(SieveMailAdapter.class);
+    }
+
+    @Test(expected = SyntaxException.class)
+    public void NoArgumentsShouldThrow() throws Exception {
+        String script = "vacation;";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+    }
+
+    @Test(expected = SyntaxException.class)
+    public void unknownTagsShouldThrow() throws Exception {
+        String script = "vacation :unknown;";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+    }
+
+    @Test(expected = SyntaxException.class)
+    public void uncompletedDaysTagShouldThrow() throws Exception {
+        String script = "vacation \"reason\" :days;";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+    }
+
+    @Test(expected = SyntaxException.class)
+    public void daysTagWithStringShouldThrow() throws Exception {
+        String script = "vacation \"reason\" :days \"string\";";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+    }
+
+    @Test(expected = SyntaxException.class)
+    public void uncompletedSubjectTagShouldThrow() throws Exception {
+        String script = "vacation \"reason\" :subject;";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+    }
+
+    @Test(expected = SyntaxException.class)
+    public void uncompletedFromTagShouldThrow() throws Exception {
+        String script = "vacation \"reason\" :from;";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+    }
+
+    @Test(expected = SyntaxException.class)
+    public void uncompletedAddressesTagShouldThrow() throws Exception {
+        String script = "vacation \"reason\" :addresses;";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+    }
+
+    @Test(expected = SyntaxException.class)
+    public void uncompletedHandleTagShouldThrow() throws Exception {
+        String script = "vacation \"reason\" :handle;";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+    }
+
+    @Test(expected = SyntaxException.class)
+    public void uncompletedMimeTagShouldThrow() throws Exception {
+        String script = "vacation :mime;";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+    }
+
+    @Test
+    public void vacationShouldWork() throws Exception {
+        String script = "vacation \"reason\";";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+
+        verify(sieveMailAdapter, times(2)).setContext(any(SieveContext.class));
+        verify(sieveMailAdapter).addAction(ActionVacation.builder().reason("reason").build());
+        verify(sieveMailAdapter, times(2)).addAction(any(ActionKeep.class));
+        verify(sieveMailAdapter).executeActions();
+        verifyNoMoreInteractions(sieveMailAdapter);
+    }
+
+    @Test
+    public void daysShouldWork() throws Exception {
+        String script = "vacation \"reason\" :days 3;";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+
+        verify(sieveMailAdapter, times(2)).setContext(any(SieveContext.class));
+        verify(sieveMailAdapter).addAction(ActionVacation.builder().reason("reason").duration(3).build());
+        verify(sieveMailAdapter, times(2)).addAction(any(ActionKeep.class));
+        verify(sieveMailAdapter).executeActions();
+        verifyNoMoreInteractions(sieveMailAdapter);
+    }
+
+    @Test
+    public void subjectShouldWork() throws Exception {
+        String script = "vacation \"reason\" :subject \"any\";";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+
+        verify(sieveMailAdapter, times(2)).setContext(any(SieveContext.class));
+        verify(sieveMailAdapter).addAction(ActionVacation.builder().reason("reason").subject("any").build());
+        verify(sieveMailAdapter, times(2)).addAction(any(ActionKeep.class));
+        verify(sieveMailAdapter).executeActions();
+        verifyNoMoreInteractions(sieveMailAdapter);
+    }
+
+    @Test
+    public void fromShouldWork() throws Exception {
+        String script = "vacation \"reason\" :from \"benwa@apache.org\";";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+
+        verify(sieveMailAdapter, times(2)).setContext(any(SieveContext.class));
+        verify(sieveMailAdapter).addAction(ActionVacation.builder().reason("reason").from("benwa@apache.org").build());
+        verify(sieveMailAdapter, times(2)).addAction(any(ActionKeep.class));
+        verify(sieveMailAdapter).executeActions();
+        verifyNoMoreInteractions(sieveMailAdapter);
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void addressesShouldWork() throws Exception {
+        String script = "vacation \"reason\" :addresses \"benwa@apache.org\";";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+
+        verify(sieveMailAdapter, times(2)).setContext(any(SieveContext.class));
+        verify(sieveMailAdapter).addAction(ActionVacation.builder().reason("reason").build());
+        verify(sieveMailAdapter, times(2)).addAction(any(ActionKeep.class));
+        verify(sieveMailAdapter).executeActions();
+        verifyNoMoreInteractions(sieveMailAdapter);
+    }
+
+    @Test
+    public void mimeShouldWork() throws Exception {
+        String script = "vacation :mime \"reason\";";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+
+        verify(sieveMailAdapter, times(2)).setContext(any(SieveContext.class));
+        verify(sieveMailAdapter).addAction(ActionVacation.builder().mime("reason").build());
+        verify(sieveMailAdapter, times(2)).addAction(any(ActionKeep.class));
+        verify(sieveMailAdapter).executeActions();
+        verifyNoMoreInteractions(sieveMailAdapter);
+    }
+
+    @Test
+    public void handleShouldWork() throws Exception {
+        String script = "vacation \"reason\" :handle \"plop\";";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+
+        verify(sieveMailAdapter, times(2)).setContext(any(SieveContext.class));
+        verify(sieveMailAdapter).addAction(ActionVacation.builder().reason("reason").handle("plop").build());
+        verify(sieveMailAdapter, times(2)).addAction(any(ActionKeep.class));
+        verify(sieveMailAdapter).executeActions();
+        verifyNoMoreInteractions(sieveMailAdapter);
+    }
+
+    @Test(expected = SyntaxException.class)
+    public void reasonAndMimeTogetherShouldThrow() throws Exception {
+        String script = "vacation \"reason\" :mime \"plop\";";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+    }
+
+    @Test(expected = SyntaxException.class)
+    public void reasonAsStringListWithMultipleEntriesShouldThrow() throws Exception {
+        String script = "vacation [\"reason\",\"too much\"];";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+    }
+
+
+    @Test(expected = SyntaxException.class)
+    public void aNumberAsReasonShouldThrow() throws Exception {
+        String script = "vacation 3;";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+    }
+
+    @Test
+    public void reasonAsStringListWithOneEntryShouldWork() throws Exception {
+        String script = "vacation [\"reason\"];";
+
+        JUnitUtils.interpret(sieveMailAdapter, script);
+
+        verify(sieveMailAdapter, times(2)).setContext(any(SieveContext.class));
+        verify(sieveMailAdapter).addAction(ActionVacation.builder().reason("reason").build());
+        verify(sieveMailAdapter, times(2)).addAction(any(ActionKeep.class));
+        verify(sieveMailAdapter).executeActions();
+        verifyNoMoreInteractions(sieveMailAdapter);
+    }
+
+}
diff --git a/core/src/test/resources/org/apache/jsieve/commandsmap.properties b/core/src/test/resources/org/apache/jsieve/commandsmap.properties
index 3dba4a1..6250404 100644
--- a/core/src/test/resources/org/apache/jsieve/commandsmap.properties
+++ b/core/src/test/resources/org/apache/jsieve/commandsmap.properties
@@ -29,6 +29,8 @@
 # RFC3082 - Implementations SHOULD support these
 reject=org.apache.jsieve.commands.optional.Reject
 fileinto=org.apache.jsieve.commands.optional.FileInto
+# Extension RFC 5230 MUST support these
+vacation=org.apache.jsieve.commands.optional.Vacation
 # JUnit Commands for Testing
 throwtestexception=org.apache.jsieve.commands.ThrowTestException
 # Extension Commands