EMailServiceImpl.java

/*
 * Copyright 2020 Global Crop Diversity Trust
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.gringlobal.service.impl;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.Period;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import javax.mail.Session;
import javax.mail.internet.MimeMessage;

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.collect.Lists;
import com.querydsl.jpa.impl.JPAQueryFactory;

import lombok.extern.slf4j.Slf4j;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;

import org.apache.commons.lang3.StringUtils;
import org.gringlobal.model.Email;
import org.gringlobal.model.QEmail;
import org.gringlobal.persistence.EmailRepository;
import org.gringlobal.service.AppResourceService;
import org.gringlobal.service.EMailService;
import org.gringlobal.service.TemplatingService;
import org.gringlobal.service.UserService;
import org.gringlobal.spring.TransactionHelper;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.mail.javamail.MimeMessagePreparator;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Slf4j
public class EMailServiceImpl implements EMailService, InitializingBean {

	@Autowired(required = false)
	private JavaMailSender mailSender;

	@Autowired
	private ThreadPoolTaskExecutor executor;

	@Autowired
	private TemplatingService templatingService;

	@Autowired
	private AppResourceService appResourceService;

	@Autowired
	private UserService userService;
	
	@Autowired
	private EmailRepository emailRepository;

	@Autowired
	private JPAQueryFactory jpaQueryFactory;

	@Value("${mail.async}")
	private boolean async;

	@Value("${mail.user.from}")
	private String emailFrom;

	@Value("${base.url}")
	private String baseUrl;

	@Value("${frontend.url}")
	private String frontendUrl;

	@Value("${auditlog.retentionPeriod:3M}") // week
	private String emailLogRetention;

	private Period emailLogRetentionPeriod;

	private final Cache<String, String> templateCache = CacheBuilder.newBuilder().maximumSize(2).expireAfterAccess(1, TimeUnit.MINUTES).build();

	@Override
	public void afterPropertiesSet() throws Exception {
		emailLogRetention = emailLogRetention.replaceAll(" ", "");
		emailLogRetentionPeriod = Period.parse("P".concat(emailLogRetention));
	}

	@Transactional
	@Scheduled(initialDelayString = "PT2H", fixedDelayString = "PT8H")
	@SchedulerLock(name = "org.gringlobal.service.impl.EmailServiceImpl")
	public void cleanExpiredEmailLogs() {
		Instant expiredInstant = ZonedDateTime.now().minus(emailLogRetentionPeriod).toInstant();
		log.trace("Searching for the email logs created before: {}", expiredInstant);
		
		var removed = jpaQueryFactory.delete(QEmail.email).where(QEmail.email.createdDate.before(expiredInstant)).execute();
		log.warn("Removed {} email log entries older than {}. Retention period is {}.", removed, expiredInstant, emailLogRetentionPeriod);
	}
	
	@Override
	public String[] toEmails(String emailAddresses) {
		if (StringUtils.isBlank(emailAddresses)) {
			return null;
		}
		String[] res = emailAddresses.split(",|;");
		for (int i = 0; i < res.length; i++) {
			res[i] = StringUtils.trim(res[i]);
		}
		return res;
	}

	@Override
	public void sendMail(String mailSubject, String mailBody, String... emailTo) {
		sendMail(mailSubject, mailBody, null, emailTo);
	}

	@Override
	public void sendMail(String mailSubject, String mailBody, String[] emailCc, String... emailTo) {
		sendSimpleEmail(mailSubject, mailBody, emailFrom, emailCc, emailTo);
	}

	public void sendSimpleEmail(final String subject, final String text, final String emailFrom, final String[] emailCc, final String... emailTo) {

		if (mailSender == null) {
			log.warn("SMTP not enabled, email was not sent.");
			return;
		}
		
		AtomicReference<String> messageText = new AtomicReference<>(text);
		String emailTemplate;
		try {
			emailTemplate = templateCache.get("email.template", () -> {
				log.info("Loading email.template");
				var resource = appResourceService.getResource(AppResourceService.APP_NAME_GGCE, "email/template.html", LocaleContextHolder.getLocale());
				var template = "<DOCTYPE html>\n\n<html><head>{{extraStyles}}<body>{{messageBody}}</body></html>";
				if (resource != null) {
					template = StringUtils.defaultIfBlank(resource.getDisplayMember(), template);
				} else {
					try {
						log.warn("AppResource 'email/template.html' not found, using bundled template");
						template = Files.readString(Path.of(getClass().getResource("/email/template.html").getPath()));
					} catch (IOException e) {
						log.warn("Could not load email template from resources: {}", e.getMessage());
					}
				}
				return template;
			});
		} catch (ExecutionException e) {
			throw new RuntimeException(e);
		}

		final Map<String, Object> scopes = new HashMap<>();
		scopes.put("messageBody", text);
		scopes.put("subject", subject);
		scopes.put("frontendUrl", frontendUrl);
		scopes.put("baseUrl", baseUrl);
		scopes.put("serverName", "GGCE");

		// Extra styles
		List<String> extraStyles = new LinkedList<>();
		var textWithoutStyles = Pattern.compile("<style>(.(?!<\\/style>))*.?<\\/style>", Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(text).replaceAll(match -> {
			log.trace("Adding to extraStyes:\n{}", match.group());
			if (!extraStyles.contains(match.group())) extraStyles.add(match.group()); // Add if not yet added, keep order
			return "";
		});
		scopes.put("extraStyles", extraStyles.stream().collect(Collectors.joining("\n")));
		scopes.put("messageBody", textWithoutStyles);

		messageText.set(templatingService.fillTemplate(emailTemplate, scopes));

		final MimeMessagePreparator preparator = mimeMessage -> {
			final MimeMessageHelper message = new MimeMessageHelper(mimeMessage, "UTF-8");

			message.setFrom(emailFrom);
			message.setTo(emailTo);
			if (emailCc != null && emailCc.length > 0) {
				message.setCc(emailCc);
			}
			message.setSubject(subject);

			message.setText(messageText.get(), true);
		};

		if (log.isInfoEnabled()) {
			try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
				var message = new MimeMessage((Session)null);
				preparator.prepare(message);
				message.writeTo(baos);
				log.info("Outgoing message:\n{}\n<EOF>", baos);
			} catch (Exception e) {
				log.info(e.getMessage(), e);
			}
		}

		JavaMailSenderImpl javaMailSender = (JavaMailSenderImpl) mailSender;

		if (async) {
			// execute sender in separate thread
			log.info("Sending email message asynchroniously");
			executor.submit(() -> {
				try {
					log.warn("Sending email now via {}:{}", javaMailSender.getHost(), javaMailSender.getPort());
					mailSender.send(preparator);
					log.warn("Email delivered to {}:{}", javaMailSender.getHost(), javaMailSender.getPort());
					saveEmailLog(subject, messageText.get(), emailFrom, emailCc, emailTo);
				} catch (final Throwable e) {
					log.error(e.getMessage(), e);
				}
			});
		} else {
			log.warn("Sending email now via {}:{}", javaMailSender.getHost(), javaMailSender.getPort());
			mailSender.send(preparator);
			saveEmailLog(subject, messageText.get(), emailFrom, emailCc, emailTo);
		}
	}

	private void saveEmailLog(final String subject, final String text, final String emailFrom, final String[] emailCc, final String[] emailTo) {
		try {
			asAdmin(() -> TransactionHelper.executeInTransaction(false, () -> {
				Email email = new Email();
				email.setSubject(subject);
				email.setBody(text);
				email.setEmailFrom(emailFrom);
				email.setEmailCc(emailCc == null ? null : String.join(";", emailCc));
				email.setEmailTo(String.join(";", emailTo));
				email.setSentDate(new Date());
				email.setToBeSentDate(new Date());
				email.setIsHtml("Y");
				emailRepository.save(email);
				return true;
			}));
		} catch (Exception e) {
			log.error("Unable to log email", e);
		}
	}

	private <T> void asAdmin(Callable<T> callable) throws Exception {
		UserDetails administrator = userService.loadUserByUsername("administrator");
		List<GrantedAuthority> authorities = Lists.newArrayList(new SimpleGrantedAuthority("ROLE_ADMINISTRATOR"));
		authorities.addAll(administrator.getAuthorities());
		Authentication authentication = new UsernamePasswordAuthenticationToken(administrator, null, authorities);
		TransactionHelper.asUser(authentication, callable);
	}
}