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    @SuppressWarnings("PMD.CompareObjectsWithEquals")
161    public void onMessage(SendMailMessage event, Channel channel)
162            throws MessagingException {
163        if (channel instanceof SenderChannel chan
164            && chan.mailSender() == this) {
165            chan.sendMessage(event);
166        } else {
167            systemChannel.sendMessage(event);
168        }
169    }
170
171    /**
172     * The specific implementation of the {@link MailChannel}.
173     */
174    protected class SenderChannel extends MailConnectionManager<
175            MailSender.SenderChannel, Event<?>>.AbstractMailChannel {
176
177        private final Session session;
178        private final Transport transport;
179        private Timer idleTimer;
180
181        /**
182         * Instantiates a new monitor channel.
183         *
184         * @param event the event that triggered the creation
185         * @param mainChannel the main channel (of this {@link Subchannel})
186         * @param sessionProps the session properties
187         * @param password the password
188         * @throws MessagingException the messaging exception
189         */
190        public SenderChannel(Event<?> event, Channel mainChannel,
191                Properties sessionProps, Optional<Password> password)
192                throws MessagingException {
193            super(event, mainChannel);
194            var passwd = password.map(Password::password).map(String::new)
195                .orElse(null);
196            session = Session.getInstance(sessionProps, new Authenticator() {
197                // Workaround for class loading problem in OSGi with j.m. 2.1.
198                // Authenticator's classpath allows accessing provider's
199                // service. See https://github.com/eclipse-ee4j/mail/issues/631
200                @Override
201                protected PasswordAuthentication
202                        getPasswordAuthentication() {
203                    return new PasswordAuthentication(
204                        sessionProps.getProperty("mail.user"), passwd);
205                }
206            });
207            transport = session.getTransport();
208            transport.connect(sessionProps.getProperty("mail.user"), passwd);
209            idleTimer
210                = Components.schedule(timer -> closeConnection(), maxIdleTime);
211        }
212
213        private MailSender mailSender() {
214            return MailSender.this;
215        }
216
217        /**
218         * Send the message provided by the event.
219         *
220         * @param event the event
221         * @throws MessagingException 
222         */
223        protected void sendMessage(SendMailMessage event)
224                throws MessagingException {
225            synchronized (transport) {
226                if (idleTimer != null) {
227                    idleTimer.cancel();
228                    idleTimer = null;
229                }
230            }
231            Message msg = new MimeMessage(session);
232            if (event.from() != null) {
233                msg.setFrom(event.from());
234            } else {
235                msg.setFrom();
236            }
237            msg.setRecipients(Message.RecipientType.TO, event.to());
238            msg.setRecipients(Message.RecipientType.CC, event.cc());
239            msg.setRecipients(Message.RecipientType.BCC, event.bcc());
240            msg.setSentDate(new Date());
241            for (var header : event.headers().entrySet()) {
242                msg.setHeader(header.getKey(), header.getValue());
243            }
244            msg.setSubject(event.subject());
245            msg.setContent(event.content());
246
247            synchronized (transport) {
248                if (!transport.isConnected()) {
249                    transport.connect();
250                }
251                idleTimer
252                    = Components.schedule(timer -> closeConnection(),
253                        maxIdleTime);
254            }
255            msg.saveChanges();
256            transport.sendMessage(msg, msg.getAllRecipients());
257        }
258
259        @Override
260        public void close() {
261            closeConnection();
262            super.close();
263        }
264
265        /**
266         * Close the connection (not the channel). May be reopened
267         * later if closed due to idle time over.
268         */
269        @SuppressWarnings("PMD.GuardLogStatement")
270        protected void closeConnection() {
271            synchronized (transport) {
272                if (idleTimer != null) {
273                    idleTimer.cancel();
274                    idleTimer = null;
275                }
276                if (transport.isConnected()) {
277                    try {
278                        transport.close();
279                    } catch (MessagingException e) {
280                        logger.log(Level.WARNING,
281                            "Cannot close connection: " + e.getMessage(), e);
282                    }
283                }
284            }
285        }
286
287    }
288
289}