001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2022 Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jgrapes.mail;
020
021import com.sun.mail.imap.IMAPFolder;
022import jakarta.mail.Authenticator;
023import jakarta.mail.Flags.Flag;
024import jakarta.mail.Folder;
025import jakarta.mail.Message;
026import jakarta.mail.MessagingException;
027import jakarta.mail.NoSuchProviderException;
028import jakarta.mail.PasswordAuthentication;
029import jakarta.mail.Session;
030import jakarta.mail.Store;
031import jakarta.mail.event.MessageCountAdapter;
032import jakarta.mail.event.MessageCountEvent;
033import java.time.Duration;
034import java.util.Map;
035import java.util.Optional;
036import java.util.logging.Level;
037import java.util.logging.Logger;
038import org.jgrapes.core.Channel;
039import org.jgrapes.core.Components;
040import org.jgrapes.core.Components.Timer;
041import org.jgrapes.core.Event;
042import org.jgrapes.core.Manager;
043import org.jgrapes.core.annotation.Handler;
044import org.jgrapes.core.annotation.HandlerDefinition.ChannelReplacements;
045import org.jgrapes.core.events.Start;
046import org.jgrapes.core.events.Stop;
047import org.jgrapes.mail.events.ReceivedMailMessage;
048
049/**
050 * A component that monitors the INBOX of a mail account.
051 * The component uses [Jakarta Mail](https://eclipse-ee4j.github.io/mail/)
052 * to connect to a mail server.
053 */
054@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
055public class SimpleMailMonitor extends MailComponent {
056
057    @SuppressWarnings("PMD.FieldNamingConventions")
058    private static final Logger logger
059        = Logger.getLogger(SimpleMailMonitor.class.getName());
060
061    private String password;
062    private Duration maxIdleTime = Duration.ofMinutes(25);
063    private Duration pollInterval = Duration.ofMinutes(5);
064    private Store store;
065    private Thread monitorThread;
066    private boolean running;
067
068    /**
069     * Creates a new component with its channel set to itself.
070     */
071    public SimpleMailMonitor() {
072        // Nothing to do.
073    }
074
075    /**
076     * Creates a new component base with its channel set to the given 
077     * channel. As a special case {@link Channel#SELF} can be
078     * passed to the constructor to make the component use itself
079     * as channel. The special value is necessary as you 
080     * obviously cannot pass an object to be constructed to its 
081     * constructor.
082     *
083     * @param componentChannel the channel that the component's
084     * handlers listen on by default and that 
085     * {@link Manager#fire(Event, Channel...)} sends the event to
086     */
087    public SimpleMailMonitor(Channel componentChannel) {
088        super(componentChannel);
089    }
090
091    /**
092     * Creates a new component base like {@link #SimpleMailMonitor(Channel)}
093     * but with channel mappings for {@link Handler} annotations.
094     *
095     * @param componentChannel the channel that the component's
096     * handlers listen on by default and that 
097     * {@link Manager#fire(Event, Channel...)} sends the event to
098     * @param channelReplacements the channel replacements to apply
099     * to the `channels` elements of the {@link Handler} annotations
100     */
101    public SimpleMailMonitor(Channel componentChannel,
102            ChannelReplacements channelReplacements) {
103        super(componentChannel, channelReplacements);
104    }
105
106    /**
107     * Sets the password.
108     *
109     * @param password the new password
110     */
111    public SimpleMailMonitor setPassword(String password) {
112        this.password = password;
113        return this;
114    }
115
116    /**
117     * Sets the mail properties. See 
118     * [the Jakarta Mail](https://jakarta.ee/specifications/mail/2.0/apidocs/jakarta.mail/jakarta/mail/package-summary.html)
119     * documentation for available settings.
120     *
121     * @param props the props
122     * @return the mail monitor
123     */
124    public SimpleMailMonitor setMailProperties(Map<String, String> props) {
125        mailProps.putAll(props);
126        return this;
127    }
128
129    /**
130     * Sets the maximum idle time. A running {@link IMAPFolder#idle()}
131     * is terminated and renewed after this time.
132     *
133     * @param maxIdleTime the new max idle time
134     */
135    public SimpleMailMonitor setMaxIdleTime(Duration maxIdleTime) {
136        this.maxIdleTime = maxIdleTime;
137        return this;
138    }
139
140    /**
141     * Returns the max idle time.
142     *
143     * @return the duration
144     */
145    public Duration maxIdleTime() {
146        return maxIdleTime;
147    }
148
149    /**
150     * Sets the poll interval. Polling is used when the idle command
151     * is not available.
152     *
153     * @param pollInterval the pollInterval to set
154     */
155    public void setPollInterval(Duration pollInterval) {
156        this.pollInterval = pollInterval;
157    }
158
159    /**
160     * Returns the poll interval.
161     *
162     * @return the pollInterval
163     */
164    public Duration pollInterval() {
165        return pollInterval;
166    }
167
168    @Override
169    protected void configureComponent(Map<String, String> values) {
170        Optional.ofNullable(values.get("password"))
171            .ifPresent(this::setPassword);
172        Optional.ofNullable(values.get("maxIdleTime"))
173            .map(Integer::parseInt).map(Duration::ofSeconds)
174            .ifPresent(d -> setMaxIdleTime(d));
175        Optional.ofNullable(values.get("pollInterval"))
176            .map(Integer::parseInt).map(Duration::ofSeconds)
177            .ifPresent(d -> setPollInterval(d));
178    }
179
180    /**
181     * Run the monitor.
182     *
183     * @param event the event
184     * @throws NoSuchProviderException the no such provider exception
185     */
186    @Handler
187    public void onStart(Start event) throws NoSuchProviderException {
188        Session session
189            = Session.getInstance(mailProps, new Authenticator() {
190                @Override
191                protected PasswordAuthentication
192                        getPasswordAuthentication() {
193                    return new PasswordAuthentication(
194                        mailProps.getProperty("mail.user"), password);
195                }
196            });
197        store = session.getStore();
198
199        // Start monitoring
200        running = true;
201        monitorThread = new Thread(() -> monitor());
202        monitorThread.setDaemon(true);
203        monitorThread.start();
204        registerAsGenerator();
205    }
206
207    @SuppressWarnings({ "PMD.GuardLogStatement", "PMD.AvoidDuplicateLiterals" })
208    private void monitor() {
209        Thread.currentThread().setName(Components.objectName(this));
210        while (running) {
211            try {
212                store.connect();
213                Folder folder = store.getFolder("INBOX");
214                if (folder == null || !folder.exists()) {
215                    logger.log(Level.SEVERE, "Store has no INBOX.");
216                    throw new IllegalStateException("No INBOX");
217                }
218                folder.open(Folder.READ_WRITE);
219                processMessages(folder);
220            } catch (MessagingException e) {
221                logger.log(Level.WARNING,
222                    "Cannot open INBOX, will retry: " + e.getMessage(), e);
223                closeStore();
224                try {
225                    Thread.sleep(5000);
226                } catch (InterruptedException e1) {
227                    Thread.currentThread().interrupt();
228                    break;
229                }
230            }
231        }
232        closeStore();
233    }
234
235    @SuppressWarnings({ "PMD.GuardLogStatement", "PMD.AvoidDuplicateLiterals" })
236    private void closeStore() {
237        if (store.isConnected()) {
238            try {
239                store.close();
240            } catch (MessagingException e) {
241                logger.log(Level.WARNING,
242                    "Cannot close store: " + e.getMessage(), e);
243            }
244        }
245    }
246
247    @SuppressWarnings({ "PMD.GuardLogStatement", "PMD.CognitiveComplexity",
248        "PMD.NPathComplexity" })
249    private void processMessages(Folder folder) {
250        try {
251            // Process existing (and newly arrived while processing)
252            int start = 1;
253            int end = folder.getMessageCount();
254            while (running && start <= end) {
255                Message[] msgs = folder.getMessages(start, end);
256                for (Message msg : msgs) {
257                    processMessage(msg);
258                }
259                // Check if more messages have arrived
260                start = end + 1;
261                end = folder.getMessageCount();
262            }
263
264            // Add MessageCountListener to listen for new messages.
265            // The listener will only be invoked when we do another
266            // operation such as idle().
267            folder.addMessageCountListener(new MessageCountAdapter() {
268                @Override
269                public void messagesAdded(MessageCountEvent countEvent) {
270                    Message[] msgs = countEvent.getMessages();
271                    for (Message msg : msgs) {
272                        processMessage(msg);
273                    }
274                }
275            });
276            boolean canIdle = true;
277            while (running) {
278                if (canIdle) {
279                    @SuppressWarnings("PMD.EmptyCatchBlock")
280                    Timer idleTimout = Components.schedule(timer -> {
281                        try {
282                            folder.getMessageCount();
283                        } catch (MessagingException e) {
284                            // Just trying to be nice here.
285                        }
286                    }, maxIdleTime);
287                    try {
288                        ((IMAPFolder) folder).idle();
289                    } catch (MessagingException e) {
290                        canIdle = false;
291                    } finally {
292                        idleTimout.cancel();
293                    }
294                }
295                if (!canIdle) {
296                    try {
297                        Thread.sleep(pollInterval.toMillis());
298                    } catch (InterruptedException ex) {
299                        Thread.currentThread().interrupt();
300                        continue;
301                    }
302                    folder.getMessageCount();
303                }
304            }
305        } catch (MessagingException e) {
306            logger.log(Level.WARNING,
307                "Problem processing messages: " + e.getMessage(), e);
308        }
309    }
310
311    @SuppressWarnings("PMD.GuardLogStatement")
312    private void processMessage(Message msg) {
313        try {
314            if (msg.getFlags().contains(Flag.DELETED)) {
315                return;
316            }
317            fire(new ReceivedMailMessage(msg));
318        } catch (MessagingException e) {
319            logger.log(Level.WARNING,
320                "Problem processing message: " + e.getMessage(), e);
321        }
322    }
323
324    /**
325     * Stop the monitor.
326     *
327     * @param event the event
328     */
329    @Handler
330    @SuppressWarnings("PMD.GuardLogStatement")
331    public void onStop(Stop event) {
332        running = false;
333        // interrupt() does not terminate idle(), closing the store does.
334        if (store != null && store.isConnected()) {
335            try {
336                store.close();
337            } catch (MessagingException e) {
338                logger.log(Level.WARNING,
339                    "Cannot close store: " + e.getMessage(), e);
340            }
341        }
342        // In case we don't use idle() but are sleeping.
343        monitorThread.interrupt();
344        try {
345            monitorThread.join(1000);
346        } catch (InterruptedException e) {
347            // Ignored
348        }
349        unregisterAsGenerator();
350    }
351
352}