diff --git a/src/main/java/uk/ac/ngs/TaskConfig.java b/src/main/java/uk/ac/ngs/TaskConfig.java
new file mode 100644
index 0000000..9b41629
--- /dev/null
+++ b/src/main/java/uk/ac/ngs/TaskConfig.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2015 STFC
+ * 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 uk.ac.ngs;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.scheduling.annotation.SchedulingConfigurer;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+import org.springframework.scheduling.config.ScheduledTaskRegistrar;
+
+import uk.ac.ngs.service.CertificateService;
+
+@Configuration
+@EnableScheduling
+public class TaskConfig implements SchedulingConfigurer {
+ public static final Logger log = LoggerFactory.getLogger(TaskConfig.class);
+
+ private CertificateService certificateService;
+
+ public TaskConfig(CertificateService certificateService) {
+ this.certificateService = certificateService;
+ }
+
+
+ @Scheduled(cron = "${cert.expiry.reminder.cron:0 0 7 * * ?}")
+ public void runDailyCertificateExpiryReminderJob() {
+
+ log.info("Starting daily certificate expiry reminder job");
+
+ long startTime = System.currentTimeMillis();
+
+ try {
+ certificateService.sendCertificateExpiryReminders();
+
+ long durationMs = System.currentTimeMillis() - startTime;
+ log.info("Completed daily certificate expiry reminder job successfully in {} ms",
+ durationMs);
+
+ } catch (Exception ex) {
+ log.error("Daily certificate expiry reminder job failed", ex);
+ throw ex;
+ }
+
+ }
+
+ @Override
+ public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
+ taskRegistrar.setScheduler(taskScheduler());
+ }
+
+
+ @Bean
+ public ThreadPoolTaskScheduler taskScheduler() {
+ ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+ scheduler.setPoolSize(5);
+ scheduler.setThreadNamePrefix("cert-expiry-scheduler-");
+ scheduler.setWaitForTasksToCompleteOnShutdown(true);
+ scheduler.setAwaitTerminationSeconds(30);
+ return scheduler;
+ }
+}
diff --git a/src/main/java/uk/ac/ngs/WebConfig.java b/src/main/java/uk/ac/ngs/WebConfig.java
index d9df489..ec142d5 100644
--- a/src/main/java/uk/ac/ngs/WebConfig.java
+++ b/src/main/java/uk/ac/ngs/WebConfig.java
@@ -120,6 +120,7 @@ public EmailService emailService() {
emailService.setEmailOnRaopRoleRequestApprovalTemplate("emailOnRaopRoleRequestApprovalTemplate.html");
emailService.setEmailOnRaopRoleRequestRejectionTemplate("emailOnRaopRoleRequestRejectionTemplate.html");
emailService.setEmailOnRoleChangeToUserTemplate("emailOnRoleChangeToUserTemplate.html");
+ emailService.setEmailUserCertExpiryReminderTemplate("emailUserCertExpiryReminderTemplate.html");
emailService.setBasePortalUrl(basePortalUrl);
return emailService;
}
diff --git a/src/main/java/uk/ac/ngs/dao/JdbcCertificateDao.java b/src/main/java/uk/ac/ngs/dao/JdbcCertificateDao.java
index 90f14c5..af79c5c 100644
--- a/src/main/java/uk/ac/ngs/dao/JdbcCertificateDao.java
+++ b/src/main/java/uk/ac/ngs/dao/JdbcCertificateDao.java
@@ -34,6 +34,9 @@
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -458,6 +461,52 @@ public String updateDataCol_LastActionDateRaop(String data, long raopId) {
}
+ /**
+ * Retrieves all valid certificates that are expiring exactly in the given
+ * number of days.
+ *
+ *
+ * This method is intended for reminder notifications (e.g. 7-day expiry
+ * reminder).
+ * A certificate is considered matching if its {@code notafter} timestamp falls
+ * within the full UTC day that is {@code daysToExpire} days from now.
+ *
+ *
+ * @param daysToExpire number of days from today (e.g. 7 for a 7-day reminder)
+ * @return list of certificates expiring exactly in the specified number of days
+ */
+ public List getValidCertificatesExpiringInDays(LocalDate processingDate, int daysToExpire) {
+
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(ZoneOffset.UTC);
+
+ LocalDate targetDate = processingDate.plusDays(daysToExpire);
+
+ // yyyyMMddHHmmss produces only digits → safe to parse as long
+ long startOfDay = Long.parseLong(
+ formatter.format(targetDate.atStartOfDay(ZoneOffset.UTC)));
+
+ // yyyyMMddHHmmss produces only digits → safe to parse as long
+ long endOfDay = Long.parseLong(
+ formatter.format(targetDate.plusDays(1)
+ .atStartOfDay(ZoneOffset.UTC)));
+
+ Map params = Map.of(
+ "startOfDay", startOfDay,
+ "endOfDay", endOfDay);
+
+ String sql = """
+ SELECT cert_key, 'data' as data, dn, cn, email, status, role, notafter
+ FROM certificate
+ WHERE status = 'VALID'
+ AND notafter >= :startOfDay
+ AND notafter < :endOfDay
+ ORDER BY notafter ASC
+ """;
+
+ return jdbcTemplate.query(sql, params, new CertificateRowMapper());
+ }
+
+
/**
* Build up the query using the given where by parameters in the map
* and return the query and the named parameter map for subsequent parameter-binding/execution.
diff --git a/src/main/java/uk/ac/ngs/dao/JobExecutionDao.java b/src/main/java/uk/ac/ngs/dao/JobExecutionDao.java
new file mode 100644
index 0000000..d5a21fe
--- /dev/null
+++ b/src/main/java/uk/ac/ngs/dao/JobExecutionDao.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2015 STFC
+ * 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 uk.ac.ngs.dao;
+
+import org.springframework.dao.EmptyResultDataAccessException;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+import java.util.Map;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+@Repository
+public class JobExecutionDao {
+ private NamedParameterJdbcTemplate jdbcTemplate;
+ private static final Log log = LogFactory.getLog(JobExecutionDao.class);
+
+ public JobExecutionDao(NamedParameterJdbcTemplate jdbcTemplate) {
+ this.jdbcTemplate = jdbcTemplate;
+ }
+
+ public LocalDate getLastRunDate(String jobName) {
+
+ String sql = """
+ SELECT last_run_date
+ FROM job_execution_tracker
+ WHERE job_name = :jobName
+ """;
+
+ Map params = Map.of("jobName", jobName);
+
+ try {
+ LocalDate result = jdbcTemplate.queryForObject(sql, params,
+ (rs, rowNum) -> rs.getDate("last_run_date").toLocalDate());
+
+ log.info("Last run date for job '" + jobName + "' is " + result);
+ return result;
+
+ } catch (EmptyResultDataAccessException ex) {
+ log.warn("No last run date found for job '" + jobName + "'. Assuming first run.");
+ return null; // first run case
+ }
+ }
+
+ public void updateLastRunDate(String jobName, LocalDate lastRunDate) {
+ // This query inserts a new job record if it doesn’t exist,
+ // or updates its last run date if it already exists, using PostgreSQL’s UPSERT mechanism.
+ String sql = """
+ INSERT INTO job_execution_tracker (job_name, last_run_date)
+ VALUES (:jobName, :lastRunDate)
+ ON CONFLICT (job_name)
+ DO UPDATE SET last_run_date = EXCLUDED.last_run_date
+ """;
+
+ Map params = Map.of(
+ "jobName", jobName,
+ "lastRunDate", java.sql.Date.valueOf(lastRunDate));
+
+ int rows = jdbcTemplate.update(sql, params);
+
+ log.info("Updated last run date for job '" + jobName + "' to " + lastRunDate + " (rows affected: " + rows + ")");
+ }
+
+}
diff --git a/src/main/java/uk/ac/ngs/service/CertificateService.java b/src/main/java/uk/ac/ngs/service/CertificateService.java
index 8dc7f81..dab9ccb 100644
--- a/src/main/java/uk/ac/ngs/service/CertificateService.java
+++ b/src/main/java/uk/ac/ngs/service/CertificateService.java
@@ -19,6 +19,7 @@
import org.springframework.validation.Errors;
import org.springframework.validation.MapBindingResult;
import uk.ac.ngs.common.MutableConfigParams;
+import uk.ac.ngs.dao.JobExecutionDao;
import uk.ac.ngs.dao.JdbcCertificateDao;
import uk.ac.ngs.dao.RoleChangeRequestRepository;
import uk.ac.ngs.domain.CertificateRow;
@@ -29,6 +30,7 @@
import javax.inject.Inject;
import java.io.IOException;
import java.time.LocalDate;
+import java.time.ZoneOffset;
import java.util.Date;
import java.util.EnumMap;
import java.util.HashMap;
@@ -48,6 +50,7 @@
public class CertificateService {
private static final Log log = LogFactory.getLog(CertificateService.class);
private JdbcCertificateDao jdbcCertDao;
+ private JobExecutionDao jobExecutionDao;
private EmailService emailService;
private MutableConfigParams mutableConfigParams;
private RoleChangeRequestRepository roleChangeRequestRepository;
@@ -245,11 +248,86 @@ private void sendEmailNotificationOnRoleChangeRequest(CertificateRow targetCert,
this.emailService.sendUserOnRaopRoleRequest(requesterCN, targetCN, targetEmail);
}
+
+ /**
+ *
+ * Sends certificate expiry reminder emails for 7-day and 30-day intervals,
+ * including catch-up processing for any missed executions.
+ *
+ *
+ * This method is intended to be executed by a scheduled job
+ * once per day.
+ *
+ *
+ *
+ * If the scheduler did not run on previous days (e.g. due to downtime),
+ * this method processes all missed dates from the last successful run up to
+ * today, ensuring no expiry reminders are skipped.
+ *
+ *
+ */
+ public void sendCertificateExpiryReminders() {
+ LocalDate today = LocalDate.now(ZoneOffset.UTC);
+
+ LocalDate lastRun = jobExecutionDao.getLastRunDate("CERT_EXPIRY_REMINDER");
+
+ // First time case
+ if (lastRun == null) {
+ lastRun = today.minusDays(1);
+ }
+
+ LocalDate processingDate = lastRun.plusDays(1);
+
+ while (!processingDate.isAfter(today)) {
+ sendRemindersForDaysToExpire(processingDate, 7);
+ sendRemindersForDaysToExpire(processingDate, 30);
+ processingDate = processingDate.plusDays(1);
+ }
+
+ jobExecutionDao.updateLastRunDate(
+ "CERT_EXPIRY_REMINDER", today);
+ }
+
+ private void sendRemindersForDaysToExpire(LocalDate processingDate, int daysToExpire) {
+
+ List certificates = jdbcCertDao.getValidCertificatesExpiringInDays(processingDate,
+ daysToExpire);
+
+ int totalCertificates = certificates.size();
+
+ if (totalCertificates > 0) {
+ log.info(totalCertificates + " certificate(s) are going to expire after " + daysToExpire + " days.");
+ } else {
+ log.info("No certificates are going to expire after " + daysToExpire + " days.");
+ }
+
+ int successCount = 0;
+ int failureCount = 0;
+
+ for (CertificateRow cert : certificates) {
+ log.info("Sending " + daysToExpire + "-days expiry reminder for cert [" + cert.getCert_key() + "] to "
+ + cert.getEmail());
+ if (this.emailService.sendEmailReminderToUserOnCertExpiry(cert, daysToExpire)) {
+ successCount++;
+ } else {
+ failureCount++;
+ }
+ }
+ log.info("Summary for " + daysToExpire + "-day expiry reminder (processingDate=" + processingDate + "): total="
+ + totalCertificates + ", sent=" + successCount + ", failed=" + failureCount);
+ }
+
+
@Inject
public void setJdbcCertificateDao(JdbcCertificateDao jdbcCertDao) {
this.jdbcCertDao = jdbcCertDao;
}
+ @Inject
+ public void setJobExecutionDao(JobExecutionDao certExpiryReminderJobExecutionDao) {
+ this.jobExecutionDao = certExpiryReminderJobExecutionDao;
+ }
+
@Inject
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
diff --git a/src/main/java/uk/ac/ngs/service/email/EmailService.java b/src/main/java/uk/ac/ngs/service/email/EmailService.java
index 023d1c9..3e9729a 100644
--- a/src/main/java/uk/ac/ngs/service/email/EmailService.java
+++ b/src/main/java/uk/ac/ngs/service/email/EmailService.java
@@ -18,6 +18,7 @@
import org.springframework.mail.SimpleMailMessage;
import uk.ac.ngs.common.CertUtil;
import uk.ac.ngs.domain.CSR_Flags;
+import uk.ac.ngs.domain.CertificateRow;
import javax.inject.Inject;
import java.io.PrintWriter;
@@ -55,6 +56,7 @@ public class EmailService {
private String emailOnRaopRoleRequestApprovalTemplate;
private String emailOnRaopRoleRequestRejectionTemplate;
private String emailOnRoleChangeToUserTemplate;
+ private String emailUserCertExpiryReminderTemplate;
private String basePortalUrl;
@@ -490,6 +492,38 @@ public void sendEmailOnRaopRoleRequestRejection(String recipient, String actorCN
}
}
+
+/**
+ * Sends an email reminder to the user informing them of an upcoming
+ * certificate expiry.
+ *
+ * @param cert certificate
+ * @param daysToExpire number of days remaining before expiry
+ */
+
+ public boolean sendEmailReminderToUserOnCertExpiry(CertificateRow cert, int daysToExpire) {
+ SimpleMailMessage msg = new SimpleMailMessage(this.emailTemplate);
+ msg.setTo(cert.getEmail());
+ msg.setSubject("Your e-Science User Certificate will expire in " + daysToExpire + " days!");
+ Map vars = new HashMap<>();
+
+ vars.put("certKey", cert.getCert_key());
+ vars.put("daysToExpire", daysToExpire);
+ vars.put("cn", cert.getCn());
+ vars.put("dn", cert.getDn());
+ vars.put("basePortalUrl", basePortalUrl);
+
+ try {
+ this.mailSender.send(msg, vars, this.emailUserCertExpiryReminderTemplate);
+ log.debug("Certificate expiry reminder email sent to " + cert.getCn());
+ return true;
+ } catch (MailException ex) {
+ log.error("Error while sending certificate expiry reminder email to " + cert.getCn() + ": "
+ + ex.getMessage());
+ return false;
+ }
+ }
+
/**
* Email on RAOP to user role change.
*
@@ -649,4 +683,11 @@ public void setEmailOnRoleChangeToUserTemplate(String emailOnRoleChangeToUserTem
this.emailOnRoleChangeToUserTemplate = emailOnRoleChangeToUserTemplate;
}
+ /**
+ * @param emailUserCertExpiryReminderTemplate the emailUserCertExpiryReminderTemplate to set
+ */
+ public void setEmailUserCertExpiryReminderTemplate(String emailUserCertExpiryReminderTemplate) {
+ this.emailUserCertExpiryReminderTemplate = emailUserCertExpiryReminderTemplate;
+ }
+
}
diff --git a/src/main/resources/application.propertiesTEMPLATE b/src/main/resources/application.propertiesTEMPLATE
index bff4f4f..928c815 100644
--- a/src/main/resources/application.propertiesTEMPLATE
+++ b/src/main/resources/application.propertiesTEMPLATE
@@ -46,6 +46,9 @@ email.password=password
# The portal can (optionally) send emails, this is used for the from address:
email.from=support@grid-support.ac.uk
+#Schedule to run certificate expiry reminder email
+cert.expiry.reminder.cron=0 0 7 * * ?
+
# From https://blog.swdev.ed.ac.uk/2015/06/24/adding-embedded-tomcat-ajp-support-to-a-spring-boot-application/
tomcat.ajp.port=9090
diff --git a/src/main/webapp/WEB-INF/freemarker/email/emailUserCertExpiryReminderTemplate.html b/src/main/webapp/WEB-INF/freemarker/email/emailUserCertExpiryReminderTemplate.html
new file mode 100644
index 0000000..9d09d43
--- /dev/null
+++ b/src/main/webapp/WEB-INF/freemarker/email/emailUserCertExpiryReminderTemplate.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+ Dear Owner of UK e-Science Certificate number: ${certKey},
+
+
+ You have a certificate which will expire in ${daysToExpire} days. If you do not wish to renew this
+ certificate, or if you have renewed already, please ignore this message.
+
+
+ Your certificate was issued for "${cn}" and has the subject:
+
+
+ [${dn}]
+
+
+ If you prefer a browser interface for your certificate management and you have your current certificate in your
+ browser, please go to the CA Portal ${basePortalUrl} and follow the instructions
+ for renewal.
+
+
+ Other tools are available at https://ca.grid-support.ac.uk
+
+
+ The server will then queue your renewal request for your RA to approve.
+ There is NO need to visit your RA. You will receive an e-mail when the certificate is available.
+
+
+ If you have any questions, concerns or comments, then we shall be pleased to hear them. We are always aiming to
+ improve our service, and so your feedback will be appreciated.
+
+
+ Regards,
+
+
+ The UK eScience CA Support Centre
+
+
+
\ No newline at end of file
diff --git a/src/test/java/uk/ac/ngs/dao/JdbcCertificateDaoTest.java b/src/test/java/uk/ac/ngs/dao/JdbcCertificateDaoTest.java
index 22c3531..2a6bb79 100644
--- a/src/test/java/uk/ac/ngs/dao/JdbcCertificateDaoTest.java
+++ b/src/test/java/uk/ac/ngs/dao/JdbcCertificateDaoTest.java
@@ -12,6 +12,11 @@
*/
package uk.ac.ngs.dao;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -28,6 +33,7 @@
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -157,4 +163,109 @@ public void testFindActiveCAs_withValidInputs_returnsResults() {
assertTrue(capturedQuery.contains("notafter > :current_time"));
}
+
+ @Test
+ public void shouldReturnCertificatesExpiringInSevenDays() {
+ // given
+ int daysToExpire = 7;
+ LocalDate processingDate = LocalDate.now(ZoneOffset.UTC);
+
+ CertificateRow cert1 = new CertificateRow();
+ cert1.setCert_key(201L);
+ cert1.setDn("dn1");
+ cert1.setCn("cn1");
+ cert1.setEmail("a@test.com");
+ CertificateRow cert2 = new CertificateRow();
+ cert1.setCert_key(202L);
+ cert1.setDn("dn2");
+ cert1.setCn("cn2");
+ cert1.setEmail("b@test.com");
+
+ List expectedRows = List.of(cert1, cert2);
+
+ when(jdbcTemplate.query(anyString(), anyMap(), ArgumentMatchers.>any()))
+ .thenReturn(expectedRows);
+
+ // when
+ List result = jdbcCertificateDao.getValidCertificatesExpiringInDays(processingDate, daysToExpire);
+
+ // then
+ assertNotNull(result);
+ assertEquals(2, result.size());
+ assertEquals(expectedRows, result);
+ }
+
+ @Test
+ public void shouldReturnEmptyListWhenNoCertificatesExpiringInGivenDays() {
+ int daysToExpire = 7;
+ LocalDate processingDate = LocalDate.now(ZoneOffset.UTC);
+
+ // given
+ when(jdbcTemplate.query(anyString(), anyMap(), ArgumentMatchers.>any()))
+ .thenReturn(List.of());
+
+ // when
+ List result = jdbcCertificateDao.getValidCertificatesExpiringInDays(processingDate, daysToExpire);
+
+ // then
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void shouldUseCorrectStartAndEndOfDayForExpiryCheck() {
+ int daysToExpire = 7;
+ LocalDate processingDate = LocalDate.now(ZoneOffset.UTC);
+
+ String EXPECTED_SQL = """
+ SELECT cert_key, 'data' as data, dn, cn, email, status, role, notafter
+ FROM certificate
+ WHERE status = 'VALID'
+ AND notafter >= :startOfDay
+ AND notafter < :endOfDay
+ ORDER BY notafter ASC
+ """;
+
+ // given
+ ArgumentCaptor