/* * Password Management Servlets (PWM) * http://www.pwm-project.org * * Copyright (c) 2006-2009 Novell, Inc. * Copyright (c) 2009-2017 The PWM Project * * 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 2 of the License, or * (at your option) any later version. * * 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. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA */ package password.pwm.util.queue; import password.pwm.AppProperty; import password.pwm.PwmApplication; import password.pwm.PwmApplicationMode; import password.pwm.PwmConstants; import password.pwm.bean.EmailItemBean; import password.pwm.bean.UserInfoBean; import password.pwm.config.Configuration; import password.pwm.config.PwmSetting; import password.pwm.config.option.DataStorageMethod; import password.pwm.error.ErrorInformation; import password.pwm.error.PwmError; import password.pwm.error.PwmException; import password.pwm.error.PwmOperationalException; import password.pwm.health.HealthMessage; import password.pwm.health.HealthRecord; import password.pwm.svc.PwmService; import password.pwm.svc.stats.Statistic; import password.pwm.svc.stats.StatisticsManager; import password.pwm.util.PasswordData; import password.pwm.util.java.TimeDuration; import password.pwm.util.localdb.WorkQueueProcessor; import password.pwm.util.java.JavaHelper; import password.pwm.util.java.JsonUtil; import password.pwm.util.java.StringUtil; import password.pwm.util.localdb.LocalDB; import password.pwm.util.localdb.LocalDBStoredQueue; import password.pwm.util.logging.PwmLogger; import password.pwm.util.macro.MacroMachine; import javax.mail.Message; import javax.mail.MessagingException; import javax.mail.Transport; import javax.mail.internet.AddressException; import javax.mail.internet.InternetAddress; import javax.mail.internet.MimeBodyPart; import javax.mail.internet.MimeMessage; import javax.mail.internet.MimeMultipart; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Properties; /** * @author Jason D. Rivard */ public class EmailQueueManager implements PwmService { private static final PwmLogger LOGGER = PwmLogger.forClass(EmailQueueManager.class); private PwmApplication pwmApplication; private Properties javaMailProps = new Properties(); private WorkQueueProcessor<EmailItemBean> workQueueProcessor; private PwmService.STATUS status = STATUS.NEW; private ErrorInformation lastError; public void init(final PwmApplication pwmApplication) throws PwmException { status = STATUS.OPENING; this.pwmApplication = pwmApplication; javaMailProps = makeJavaMailProps(pwmApplication.getConfig()); if (pwmApplication.getLocalDB() == null || pwmApplication.getLocalDB().status() != LocalDB.Status.OPEN) { LOGGER.warn("localdb is not open, EmailQueueManager will remain closed"); status = STATUS.CLOSED; return; } final WorkQueueProcessor.Settings settings = new WorkQueueProcessor.Settings(); settings.setMaxEvents(Integer.parseInt(pwmApplication.getConfig().readAppProperty(AppProperty.QUEUE_EMAIL_MAX_COUNT))); settings.setRetryDiscardAge(new TimeDuration(Long.parseLong(pwmApplication.getConfig().readAppProperty(AppProperty.QUEUE_EMAIL_MAX_AGE_MS)))); settings.setRetryInterval(new TimeDuration(Long.parseLong(pwmApplication.getConfig().readAppProperty(AppProperty.QUEUE_EMAIL_RETRY_TIMEOUT_MS)))); final LocalDBStoredQueue localDBStoredQueue = LocalDBStoredQueue.createLocalDBStoredQueue(pwmApplication, pwmApplication.getLocalDB(), LocalDB.DB.EMAIL_QUEUE); workQueueProcessor = new WorkQueueProcessor<>(pwmApplication, localDBStoredQueue, settings, new EmailItemProcessor(), this.getClass()); status = STATUS.OPEN; } public void close() { status = STATUS.CLOSED; workQueueProcessor.close(); } @Override public STATUS status() { return status; } public List<HealthRecord> healthCheck() { if (pwmApplication.getLocalDB() == null || pwmApplication.getLocalDB().status() != LocalDB.Status.OPEN) { return Collections.singletonList(HealthRecord.forMessage(HealthMessage.ServiceClosed_LocalDBUnavail, this.getClass().getSimpleName())); } if (pwmApplication.getApplicationMode() == PwmApplicationMode.READ_ONLY) { return Collections.singletonList(HealthRecord.forMessage(HealthMessage.ServiceClosed_AppReadOnly, this.getClass().getSimpleName())); } if (lastError != null) { return Collections.singletonList(HealthRecord.forMessage(HealthMessage.Email_SendFailure, lastError.toDebugStr())); } return Collections.emptyList(); } @Override public ServiceInfo serviceInfo() { if (status() == STATUS.OPEN) { return new ServiceInfo(Collections.singletonList(DataStorageMethod.LOCALDB)); } else { return new ServiceInfo(Collections.emptyList()); } } public int queueSize() { return workQueueProcessor == null ? 0 : workQueueProcessor.queueSize(); } public Instant eldestItem() { return workQueueProcessor.eldestItem(); } private class EmailItemProcessor implements WorkQueueProcessor.ItemProcessor<EmailItemBean> { @Override public WorkQueueProcessor.ProcessResult process(final EmailItemBean workItem) { return sendItem(workItem); } public String convertToDebugString(final EmailItemBean emailItemBean) { return emailItemToDebugString(emailItemBean); } } private static String emailItemToDebugString(final EmailItemBean emailItemBean) { final Map<String,Object> debugOutputMap = new LinkedHashMap<>(); debugOutputMap.put("to", emailItemBean.getTo()); debugOutputMap.put("from", emailItemBean.getFrom()); debugOutputMap.put("subject", emailItemBean.getSubject()); return JsonUtil.serializeMap(debugOutputMap); } private boolean determineIfItemCanBeDelivered(final EmailItemBean emailItem) { final String serverAddress = pwmApplication.getConfig().readSettingAsString(PwmSetting.EMAIL_SERVER_ADDRESS); if (serverAddress == null || serverAddress.length() < 1) { LOGGER.debug("discarding email send event (no SMTP server address configured) " + emailItem.toString()); return false; } if (emailItem.getFrom() == null || emailItem.getFrom().length() < 1) { LOGGER.error("discarding email event (no from address): " + emailItem.toString()); return false; } if (emailItem.getTo() == null || emailItem.getTo().length() < 1) { LOGGER.error("discarding email event (no to address): " + emailItem.toString()); return false; } if (emailItem.getSubject() == null || emailItem.getSubject().length() < 1) { LOGGER.error("discarding email event (no subject): " + emailItem.toString()); return false; } if ((emailItem.getBodyPlain() == null || emailItem.getBodyPlain().length() < 1) && (emailItem.getBodyHtml() == null || emailItem.getBodyHtml().length() < 1)) { LOGGER.error("discarding email event (no body): " + emailItem.toString()); return false; } return true; } public void submitEmail( final EmailItemBean emailItem, final UserInfoBean uiBean, final MacroMachine macroMachine ) { if (emailItem == null) { return; } EmailItemBean workingItemBean = emailItem; if ((emailItem.getTo() == null || emailItem.getTo().isEmpty()) && uiBean != null) { final String toAddress = uiBean.getUserEmailAddress(); workingItemBean = newEmailToAddress(workingItemBean, toAddress); } if (macroMachine != null) { workingItemBean = applyMacrosToEmail(workingItemBean, macroMachine); } if (workingItemBean.getTo() == null || workingItemBean.getTo().length() < 1) { LOGGER.error("no destination address available for email, skipping; email: " + emailItem.toString()); } if (!determineIfItemCanBeDelivered(emailItem)) { return; } try { workQueueProcessor.submit(workingItemBean); } catch (PwmOperationalException e) { LOGGER.warn("unable to add email to queue: " + e.getMessage()); } } private WorkQueueProcessor.ProcessResult sendItem(final EmailItemBean emailItemBean) { // create a new MimeMessage object (using the Session created above) try { final List<Message> messages = convertEmailItemToMessages(emailItemBean, this.pwmApplication.getConfig()); final String mailUser = this.pwmApplication.getConfig().readSettingAsString(PwmSetting.EMAIL_USERNAME); final PasswordData mailPassword = this.pwmApplication.getConfig().readSettingAsPassword(PwmSetting.EMAIL_PASSWORD); // Login to SMTP server first if both username and password is given final String logText; if (mailUser == null || mailUser.length() < 1 || mailPassword == null) { logText = "plaintext"; for (final Message message : messages) { Transport.send(message); } } else { // create a new Session object for the message final javax.mail.Session session = javax.mail.Session.getInstance(javaMailProps, null); final String mailhost = this.pwmApplication.getConfig().readSettingAsString(PwmSetting.EMAIL_SERVER_ADDRESS); final int mailport = (int)this.pwmApplication.getConfig().readSettingAsLong(PwmSetting.EMAIL_SERVER_PORT); final Transport tr = session.getTransport("smtp"); tr.connect(mailhost, mailport, mailUser, mailPassword.getStringValue()); for (final Message message : messages) { message.saveChanges(); tr.sendMessage(message, message.getAllRecipients()); } tr.close(); logText = "authenticated "; lastError = null; } LOGGER.debug("successfully sent " + logText + "email: " + emailItemBean.toString()); StatisticsManager.incrementStat(pwmApplication, Statistic.EMAIL_SEND_SUCCESSES); return WorkQueueProcessor.ProcessResult.SUCCESS; } catch (Exception e) { final ErrorInformation errorInformation; if (e instanceof PwmException) { errorInformation = ((PwmException) e).getErrorInformation(); } else { final String errorMsg = "error sending email: " + e.getMessage(); errorInformation = new ErrorInformation( PwmError.ERROR_EMAIL_SEND_FAILURE, errorMsg, new String[]{ emailItemToDebugString(emailItemBean), JavaHelper.readHostileExceptionMessage(e)} ); } lastError = errorInformation; LOGGER.error(errorInformation); if (sendIsRetryable(e)) { LOGGER.error("error sending email (" + e.getMessage() + ") " + emailItemBean.toString() + ", will retry"); StatisticsManager.incrementStat(pwmApplication, Statistic.EMAIL_SEND_FAILURES); return WorkQueueProcessor.ProcessResult.RETRY; } else { LOGGER.error( "error sending email (" + e.getMessage() + ") " + emailItemBean.toString() + ", permanent failure, discarding message"); StatisticsManager.incrementStat(pwmApplication, Statistic.EMAIL_SEND_DISCARDS); return WorkQueueProcessor.ProcessResult.FAILED; } } } List<Message> convertEmailItemToMessages(final EmailItemBean emailItemBean, final Configuration config) throws MessagingException { final List<Message> messages = new ArrayList<>(); final boolean hasPlainText = emailItemBean.getBodyPlain() != null && emailItemBean.getBodyPlain().length() > 0; final boolean hasHtml = emailItemBean.getBodyHtml() != null && emailItemBean.getBodyHtml().length() > 0; final String subjectEncodingCharset = config.readAppProperty(AppProperty.SMTP_SUBJECT_ENCODING_CHARSET); // create a new Session object for the messagejavamail final javax.mail.Session session = javax.mail.Session.getInstance(javaMailProps, null); final String emailTo = emailItemBean.getTo(); if (emailTo != null) { final InternetAddress[] recipients = InternetAddress.parse(emailTo); for (final InternetAddress recipient : recipients) { final MimeMessage message = new MimeMessage(session); message.setFrom(makeInternetAddress(emailItemBean.getFrom())); message.setRecipient(Message.RecipientType.TO, recipient); { if (subjectEncodingCharset != null && !subjectEncodingCharset.isEmpty()) { message.setSubject(emailItemBean.getSubject(), subjectEncodingCharset); } else { message.setSubject(emailItemBean.getSubject()); } } message.setSentDate(new Date()); if (hasPlainText && hasHtml) { final MimeMultipart content = new MimeMultipart("alternative"); final MimeBodyPart text = new MimeBodyPart(); final MimeBodyPart html = new MimeBodyPart(); text.setContent(emailItemBean.getBodyPlain(), PwmConstants.ContentTypeValue.plain.getHeaderValue()); html.setContent(emailItemBean.getBodyHtml(), PwmConstants.ContentTypeValue.html.getHeaderValue()); content.addBodyPart(text); content.addBodyPart(html); message.setContent(content); } else if (hasPlainText) { message.setContent(emailItemBean.getBodyPlain(), PwmConstants.ContentTypeValue.plain.getHeaderValue()); } else if (hasHtml) { message.setContent(emailItemBean.getBodyHtml(), PwmConstants.ContentTypeValue.html.getHeaderValue()); } messages.add(message); } } return messages; } private static Properties makeJavaMailProps(final Configuration config) { //Create a properties item to start setting up the mail final Properties props = new Properties(); //Specify the desired SMTP server props.put("mail.smtp.host", config.readSettingAsString(PwmSetting.EMAIL_SERVER_ADDRESS)); //Specify SMTP server port props.put("mail.smtp.port",(int)config.readSettingAsLong(PwmSetting.EMAIL_SERVER_PORT)); //Specify configured advanced settings. final Map<String, String> advancedSettingValues = StringUtil.convertStringListToNameValuePair(config.readSettingAsStringArray(PwmSetting.EMAIL_ADVANCED_SETTINGS), "="); for (final String key : advancedSettingValues.keySet()) { props.put(key, advancedSettingValues.get(key)); } return props; } private static InternetAddress makeInternetAddress(final String input) throws AddressException { if (input == null) { return null; } if (input.matches("^.*<.*>$")) { // check for format like: John Doe <jdoe@example.com> final String[] splitString = input.split("<|>"); if (splitString.length < 2) { return new InternetAddress(input); } final InternetAddress address = new InternetAddress(); address.setAddress(splitString[1].trim()); try { address.setPersonal(splitString[0].trim(), PwmConstants.DEFAULT_CHARSET.toString()); } catch (UnsupportedEncodingException e) { LOGGER.error("unsupported encoding error while parsing internet address '" + input + "', error: " + e.getMessage()); } return address; } return new InternetAddress(input); } private static EmailItemBean applyMacrosToEmail(final EmailItemBean emailItem, final MacroMachine macroMachine) { final EmailItemBean expandedEmailItem; expandedEmailItem = new EmailItemBean( macroMachine.expandMacros(emailItem.getTo()), macroMachine.expandMacros(emailItem.getFrom()), macroMachine.expandMacros(emailItem.getSubject()), macroMachine.expandMacros(emailItem.getBodyPlain()), macroMachine.expandMacros(emailItem.getBodyHtml()) ); return expandedEmailItem; } private static EmailItemBean newEmailToAddress(final EmailItemBean emailItem, final String toAddress) { final EmailItemBean expandedEmailItem; expandedEmailItem = new EmailItemBean( toAddress, emailItem.getFrom(), emailItem.getSubject(), emailItem.getBodyPlain(), emailItem.getBodyHtml() ); return expandedEmailItem; } private static boolean sendIsRetryable(final Exception e) { if (e != null) { final Throwable cause = e.getCause(); if (cause instanceof IOException) { LOGGER.trace("message send failure cause is due to an IOException: " + e.getMessage()); return true; } } return false; } }