Send a weekly digest email every Monday
Java · Spring Boot · intermediate · modification
Adds the weekly digest email. `WeeklyDigestJob` runs on Mondays at 08:00 Berlin time, works out the week's Monday from the clock and asks `UserRepository` for the opted-in users not yet recorded for that week. For each of them, `WeeklyDigestService.sendTo` loads last week's completed tasks and this week's open due tasks with two queries, sends one `SimpleMailMessage`, and then records a `digest_sends` row (unique per user and week). Each user is handled in its own try/catch, so one failure is logged and the run moves on; `sendWeek` returns a summary of sent, skipped and failed users. Includes the Flyway migration for `digest_sends` and a `@SpringBootTest` with a fixed clock.
Taskboard is the company's internal task tracker (every user is an employee working in Germany, so Berlin time is everyone's local time), on Spring Boot 3.3.5 (Java 21), Spring Data JPA (Hibernate 6.5) on PostgreSQL 16 with Flyway. It runs as a single instance. Existing and unchanged: `TaskboardApplication` with `@EnableScheduling`; the scheduler pool is set to 4 threads and is shared with two nightly jobs, and Spring schedules a cron task's next execution only after the current one returns. The `Clock` bean is `Clock.systemUTC()`. `User` has `id`, a unique `email` validated at signup, `displayName`, `active` and `weeklyDigest` (toggled on the settings page); users are deactivated, never deleted. `Task` has a nullable `assigneeId`, a single-line `title` of at most 200 characters, `status` (`TODO`, `IN_PROGRESS`, `DONE`), a nullable `dueDate` (`LocalDate`) and `completedAt` (`Instant`), which `complete()` sets when a task moves to `DONE` and `reopen()` clears. Up to 5,000 users opt in, and a user has at most a few hundred open tasks. Mail goes through the company SMTP relay via `spring-boot-starter-mail` (`JavaMailSender` from `spring.mail.*`, connection and read timeouts of 10 seconds; the relay usually accepts a message in a fraction of a second); `taskboard.mail.from` is an existing property. An operator runs a week again through the existing internal ops runner, which calls `WeeklyDigestJob.sendWeek` with that week's Monday, runs one job at a time, and is used only after that week's scheduled run has finished (out of scope); there is no automatic catch-up when the app is down at 08:00 on a Monday. Tests run as `@SpringBootTest` on embedded H2 with Flyway off and `ddl-auto=create-drop` (existing `src/test/resources/application.properties`).
Requirements
- Every Monday at 08:00 Berlin time, each active user who has turned on the weekly digest (the existing `weeklyDigest` setting) gets one plain-text email listing (a) the tasks assigned to them that were completed last week, from Monday 00:00 to the next Monday 00:00 Berlin time, and (b) their open tasks (status other than `DONE`) due this week, Monday to Sunday inclusive, ordered by due date. A user with nothing in either list gets no email.
- A failure for one user (the mail server rejects the address, a database error while building that user's email) skips only that user: the others still get theirs, the failure is logged with the user id, and the run finishes.
- A user gets at most one digest per week. A run for a week skips the users whose digest for that week has been recorded, so an operator can run a week again (after an outage, or after a rejected address is fixed) and only the users who were missed get one. If the email was handed to the mail server but recording it then fails (the process dies, the insert fails, or the server's reply is lost), that one user may get it a second time on a re-run; this is accepted.
- A user who turns the digest off, or is deactivated, before their digest is started in a run gets no email in that run.
- Times come from the application's existing `Clock` bean.
Files touched
- src/main/java/com/acme/taskboard/user/UserRepository.java
- src/main/java/com/acme/taskboard/task/TaskRepository.java
- src/main/java/com/acme/taskboard/digest/DigestSend.java
- src/main/java/com/acme/taskboard/digest/DigestSendRepository.java
- src/main/java/com/acme/taskboard/digest/WeeklyDigestService.java
- src/main/java/com/acme/taskboard/digest/WeeklyDigestJob.java
- src/main/resources/db/migration/V23__digest_sends.sql
- src/test/java/com/acme/taskboard/digest/WeeklyDigestJobTest.java
--- src/main/java/com/acme/taskboard/user/UserRepository.java
package com.acme.taskboard.user;
+import java.time.LocalDate;
+import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
+
+ @Query("""
+ select u.id from User u
+ where u.active = true and u.weeklyDigest = true
+ and not exists (
+ select d.id from DigestSend d
+ where d.userId = u.id and d.weekStart = :weekStart)
+ order by u.id""")
+ List<Long> findDigestRecipientIds(@Param("weekStart") LocalDate weekStart);
}
--- src/main/java/com/acme/taskboard/task/TaskRepository.java
package com.acme.taskboard.task;
+import java.time.Instant;
+import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;