MailClient.java

/*
  MailClena - Copyright (C) 2018, Aiki IT
  <p>
  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 3 of the License, or
  (at your option) any later version.
  <p>
  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.
  <p>
  You should have received a copy of the GNU General Public License
  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package de.aikiit.mailclena.mail;

import com.google.common.base.Strings;
import de.aikiit.mailclena.MailConfiguration;
import jakarta.mail.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.extern.log4j.Log4j2;
import me.tongfei.progressbar.ProgressBar;
import org.apache.commons.lang3.tuple.Pair;
import org.assertj.core.util.VisibleForTesting;

import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicLong;

import static de.aikiit.mailclena.mail.MailClient.MailClientCommands.LIST;
import static de.aikiit.mailclena.mail.MailClient.MailClientCommands.parse;

/**
 * Encapsulates technical access to mail inbox based on the given application/mail configuration.
 */
@AllArgsConstructor
@Log4j2
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class MailClient {

    private static final String INBOX = "INBOX";
    private static final String POP3S = "pop3s";

    @VisibleForTesting
    private MailConfiguration mailConfiguration;

    private Properties getProperties() {
        Properties properties = new Properties();
        properties.put("mail.pop3s.host", mailConfiguration.getHost());
        properties.put("mail.pop3.starttls.enable", "true");
        properties.put("mail.pop3s.port", "995");
        properties.put("mail.store.protocol", "pop3");
        return properties;
    }

    /**
     * Opens a mail folder in the given mode.
     *
     * @param mode see @{@link Folder#open(int)} for available options.
     * @return pair of @{@link Store} and @{@link Folder} if available.
     * @throws MessagingException if folder cannot be opened or store is inaccessible.
     */
    @VisibleForTesting
    Optional<Pair<Store, Folder>> openFolder(int mode) throws MessagingException {
        Session emailSession = Session.getDefaultInstance(getProperties());
        // emailSession.setDebug(true);

        Store store = emailSession.getStore(POP3S);
        store.connect(mailConfiguration.getHost(), mailConfiguration.getUsername(), mailConfiguration.getPassword());

        Folder emailFolder = store.getFolder(INBOX);
        emailFolder.open(mode);
        return Optional.of(Pair.of(store, emailFolder));
    }

    /**
     * Shows a list of messages in the mailbox root folder. It accesses the folder in read-only mode.
     *
     * @return messages in given folder, -1 in case of errors.
     */
    // TODO show date of mails YYYYMMDD
    @VisibleForTesting
    long list() {
        try {
            Optional<Pair<Store, Folder>> folder = openFolder(Folder.READ_ONLY);

            if (!folder.isPresent()) {
                log.error("Unable to open folder in read-only mode to list mails, will abort.");
                return -1;
            }

            Pair<Store, Folder> storeAndFolder = folder.get();
            List<Message> messages = Arrays.asList(storeAndFolder.getRight().getMessages());
            final int size = messages.size();

            if (size == 0) {
                log.info("No messages found - nothing to be done here.");
            } else {

                log.info("Found {} messages.", size);

                for (Message m : ProgressBar.wrap(messages, "Listing")) {
                    try {
                        log.info("{} bytes / {} / Message: {} / From: {}", m.getSize(), m.getSentDate(), m.getSubject(), Arrays.toString(m.getFrom()));
                    } catch (MessagingException e) {
                        log.error("Error while traversing messages", e);
                    }
                }
            }

            storeAndFolder.getLeft().close();
            return size;
        } catch (MessagingException e) {
            log.error(e);
        }
        return -1;
    }

    /**
     * Application option to delete existing messages.
     *
     * @return number of messages deleted, if any. Empty otherwise.
     */
    @VisibleForTesting
    Optional<Long> delete() {
        try {
            Optional<Pair<Store, Folder>> folder = openFolder(Folder.READ_WRITE);

            if (!folder.isPresent()) {
                log.error("Unable to open folder in write mode to remove mails, will abort.");
                return Optional.empty();
            }

            Pair<Store, Folder> storeAndFolder = folder.get();
            final Folder f = storeAndFolder.getRight();
            List<Message> messages = Arrays.asList(f.getMessages());

            final int count = messages.size();
            final AtomicLong mailSize = new AtomicLong(0L);
            if (count == 0) {
                log.info("Folder is empty already - nothing to be done here.");
            } else {
                log.info("Starting to delete {} messages.", count);

                for (Message message : ProgressBar.wrap(messages, "Deleting")) {
                    try {
                        long messageSize = message.getSize();
                        log.info("Marking for deletion {} bytes with subject: {}", messageSize, message.getSubject());
                        message.setFlag(Flags.Flag.DELETED, true);
                        mailSize.addAndGet(messageSize);
                    } catch (MessagingException e) {
                        log.error("Error while traversing messages for deletion", e);
                    }
                }

                f.close(true);
                log.info("Expunge folder to actually remove messages.");
                log.info("Finished to delete {} messages, set {} bytes free", count, mailSize.get());
            }
            storeAndFolder.getLeft().close();

            return Optional.of(mailSize.longValue());
        } catch (MessagingException e) {
            log.error(e);
        }
        return Optional.empty();
    }

    /**
     * Execute the given command or print an error message if the command is unknown.
     *
     * @param command command to execute, should be one of {@link MailClientCommands}.
     */
    public void execute(String command) {
        Optional<MailClientCommands> cmd = parse(command);
        if (!cmd.isPresent()) {
            log.info("No explicit command given, will fallback to 'list'");
            cmd = Optional.of(LIST);
        }

        switch (cmd.get()) {
            case CLEAN:
                long messages = list();
                if (messages > 0) {
                    delete();
                }
                break;
            case LIST:
                list();
                break;
            default:
                log.error("If you see this message, please report a bug since the CLI parser has new commands.");
                // NOOP: avoid findbugs warning
                break;
        }
    }

    /**
     * Encapsulates available application commands for MailClena.
     */
    @VisibleForTesting
    enum MailClientCommands {
        /**
         * Option to list available mails.
         */
        LIST,
        /**
         * Option to purge existing mails.
         */
        CLEAN;

        /**
         * Tries to convert a given command into an available application command option.
         *
         * @param command alphanumeric representation of the command.
         * @return a valid application command or empty if invalid.
         */
        static Optional<MailClientCommands> parse(String command) {
            if (!Strings.isNullOrEmpty(command)) {
                String normalized = command.trim();
                for (MailClientCommands cmd : values()) {
                    if (normalized.equalsIgnoreCase(cmd.toString())) {
                        return Optional.of(cmd);
                    }
                }
            }

            return Optional.empty();
        }

    }

}