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 jakarta.mail.Authenticator;
022import jakarta.mail.Message;
023import jakarta.mail.MessagingException;
024import jakarta.mail.PasswordAuthentication;
025import jakarta.mail.Session;
026import jakarta.mail.Transport;
027import jakarta.mail.internet.MimeMessage;
028import java.time.Duration;
029import java.util.Date;
030import java.util.Map;
031import java.util.Optional;
032import java.util.Properties;
033import java.util.logging.Level;
034import org.jgrapes.core.Channel;
035import org.jgrapes.core.Components;
036import org.jgrapes.core.Components.Timer;
037import org.jgrapes.core.Event;
038import org.jgrapes.core.Manager;
039import org.jgrapes.core.Subchannel;
040import org.jgrapes.core.annotation.Handler;
041import org.jgrapes.core.events.Start;
042import org.jgrapes.mail.events.OpenMailSender;
043import org.jgrapes.mail.events.SendMailMessage;
044import org.jgrapes.util.Password;
045
046/**
047 * A component that sends mail using a system wide or user specific
048 * connection.
049 * 
050 * The system wide connection is created upon the start of the component.
051 * Additional connections can be created by firing events of type 
052 * {@link OpenMailSender}.
053 */
054@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
055public class MailSender
056        extends MailConnectionManager<MailSender.SenderChannel, Event<?>> {
057
058    private Duration maxIdleTime = Duration.ofMinutes(1);
059    private SenderChannel systemChannel;
060
061    /**
062     * Creates a new component base with its channel set to the given 
063     * channel. As a special case {@link Channel#SELF} can be
064     * passed to the constructor to make the component use itself
065     * as channel. The special value is necessary as you 
066     * obviously cannot pass an object to be constructed to its 
067     * constructor.
068     *
069     * @param componentChannel the channel that the component's
070     * handlers listen on by default and that 
071     * {@link Manager#fire(Event, Channel...)} sends the event to
072     */
073    public MailSender(Channel componentChannel) {
074        super(componentChannel);
075    }
076
077    @Override
078    protected boolean connectionsGenerate() {
079        return false;
080    }
081
082    /**
083     * Sets the mail properties. See the Jakarta Mail documentation 
084     * for available settings.
085     *
086     * @param props the props
087     * @return the mail monitor
088     */
089    public MailSender setMailProperties(Map<String, String> props) {
090        mailProps.putAll(props);
091        return this;
092    }
093
094    /**
095     * Sets the maximum idle time. An open connection to the mail server
096     * is closed after this time.
097     *
098     * @param maxIdleTime the new max idle time
099     */
100    public MailSender setMaxIdleTime(Duration maxIdleTime) {
101        this.maxIdleTime = maxIdleTime;
102        return this;
103    }
104
105    /**
106     * Returns the max idle time.
107     *
108     * @return the duration
109     */
110    public Duration maxIdleTime() {
111        return maxIdleTime;
112    }
113
114    @Override
115    protected void configureComponent(Map<String, String> values) {
116        Optional.ofNullable(values.get("maxIdleTime"))
117            .map(Integer::parseInt).map(Duration::ofSeconds)
118            .ifPresent(this::setMaxIdleTime);
119    }
120
121    /**
122     * Start the component.
123     *
124     * @param event the event
125     * @throws MessagingException 
126     */
127    @Handler
128    public void onStart(Start event) throws MessagingException {
129        systemChannel = new SenderChannel(event,
130            channel(), mailProps, password());
131    }
132
133    /**
134     * Open a connection for sending mail as specified by the event.
135     * 
136     * Properties configured for the component are used as fallbacks,
137     * so simply sending an event without specific properties opens
138     * another system connection.
139     *
140     * @param event the event
141     * @param channel the channel
142     * @throws MessagingException 
143     */
144    @Handler
145    public void onOpenMailSender(OpenMailSender event, Channel channel)
146            throws MessagingException {
147        Properties sessionProps = new Properties(mailProps);
148        sessionProps.putAll(event.mailProperties());
149        new SenderChannel(event, channel, sessionProps,
150            event.password().or(this::password));
151    }
152
153    /**
154     * Sends the message as specified by the event.
155     *
156     * @param event the event
157     * @throws MessagingException the messaging exception
158     */
159    @Handler
160    public void onMessage(SendMailMessage event, Channel channel)
161            throws MessagingException {
162        if (channel instanceof SenderChannel chan) {
163            chan.sendMessage(event);
164        } else {
165            systemChannel.sendMessage(event);
166        }
167    }
168
169    /**
170     * The specific implementation of the {@link MailChannel}.
171     */
172    protected class SenderChannel extends MailConnectionManager<
173            MailSender.SenderChannel, Event<?>>.AbstractMailChannel {
174
175        private final Session session;
176        private final Transport transport;
177        private Timer idleTimer;
178
179        /**
180         * Instantiates a new monitor channel.
181         *
182         * @param event the event that triggered the creation
183         * @param mainChannel the main channel (of this {@link Subchannel})
184         * @param sessionProps the session properties
185         * @param password the password
186         * @throws MessagingException the messaging exception
187         */
188        public SenderChannel(Event<?> event, Channel mainChannel,
189                Properties sessionProps, Optional<Password> password)
190                throws MessagingException {
191            super(event, mainChannel);
192            var passwd = password.map(Password::password).map(String::new)
193                .orElse(null);
194            session = Session.getInstance(sessionProps, new Authenticator() {
195                // Workaround for class loading problem in OSGi with j.m. 2.1.
196                // Authenticator's classpath allows accessing provider's
197                // service. See https://github.com/eclipse-ee4j/mail/issues/631
198                @Override
199                protected PasswordAuthentication
200                        getPasswordAuthentication() {
201                    return new PasswordAuthentication(
202                        sessionProps.getProperty("mail.user"), passwd);
203                }
204            });
205            transport = session.getTransport();
206            transport.connect(sessionProps.getProperty("mail.user"), passwd);
207            idleTimer
208                = Components.schedule(timer -> closeConnection(), maxIdleTime);
209        }
210
211        /**
212         * Send the message provided by the event.
213         *
214         * @param event the event
215         * @throws MessagingException 
216         */
217        protected void sendMessage(SendMailMessage event)
218                throws MessagingException {
219            synchronized (transport) {
220                if (idleTimer != null) {
221                    idleTimer.cancel();
222                    idleTimer = null;
223                }
224            }
225            Message msg = new MimeMessage(session);
226            if (event.from() != null) {
227                msg.setFrom(event.from());
228            } else {
229                msg.setFrom();
230            }
231            msg.setRecipients(Message.RecipientType.TO, event.to());
232            msg.setRecipients(Message.RecipientType.CC, event.cc());
233            msg.setRecipients(Message.RecipientType.BCC, event.bcc());
234            msg.setSentDate(new Date());
235            for (var header : event.headers().entrySet()) {
236                msg.setHeader(header.getKey(), header.getValue());
237            }
238            msg.setSubject(event.subject());
239            msg.setContent(event.content());
240
241            synchronized (transport) {
242                if (!transport.isConnected()) {
243                    transport.connect();
244                }
245                idleTimer
246                    = Components.schedule(timer -> closeConnection(),
247                        maxIdleTime);
248            }
249            msg.saveChanges();
250            transport.sendMessage(msg, msg.getAllRecipients());
251        }
252
253        @Override
254        public void close() {
255            closeConnection();
256            super.close();
257        }
258
259        /**
260         * Close the connection (not the channel). May be reopened
261         * later if closed due to idle time over.
262         */
263        @SuppressWarnings("PMD.GuardLogStatement")
264        protected void closeConnection() {
265            synchronized (transport) {
266                if (idleTimer != null) {
267                    idleTimer.cancel();
268                    idleTimer = null;
269                }
270                if (transport.isConnected()) {
271                    try {
272                        transport.close();
273                    } catch (MessagingException e) {
274                        logger.log(Level.WARNING,
275                            "Cannot close connection: " + e.getMessage(), e);
276                    }
277                }
278            }
279        }
280
281    }
282
283}