Home / Side Quests / Email Alerts

Email Alerts

Estimated time: 4–5 hours | Difficulty: Intermediate

Side Quest

What You Will Learn

  • Create a saved searches table and build CRUD endpoints to manage them
  • Use Spring's @Scheduled annotation to run background jobs automatically
  • Understand cron expressions and how they control scheduling
  • Send HTML emails using Spring Boot's JavaMail integration
  • Connect scheduled tasks to your API request budget so automated searches do not exhaust your monthly limit
  • Test and monitor scheduled jobs with logging and a simple dashboard

1. Automated Background Jobs

Up until now, everything in your Resumator application happens because a user clicks something. They click "Search" and results appear. They click "Save" and a job lands in their favorites. Every action requires a human sitting at a keyboard, making a conscious decision, and triggering a request. The application is entirely reactive — it waits for you, and it does nothing on its own.

But think about the applications you use every day. Gmail does not wait for you to click "Check for new mail" — it checks continuously and notifies you when something arrives. Your bank does not wait for you to log in to tell you about a suspicious charge — it sends you a text message at 2 AM. Price tracking websites do not need you sitting in front of a screen — they watch prices in the background and alert you when something drops below your threshold.

These are all examples of background jobs — tasks that run automatically on a schedule, without any user interaction. The application wakes up, does its work, records the results, and goes back to sleep. The user might not even be at their computer. They might be sleeping. The software does not care. It has a job to do, and it does it on time, every time.

In professional software, background jobs are everywhere:

In this side quest, you are going to add this exact capability to your Resumator. Users will be able to save a search — say, "Java Developer in Lansing" — and your application will automatically run that search every day at 7 AM, check for new results, and send the user an email with any new matches. No clicking required. No browser open. The application works while you sleep.

This is one of those features that transforms a project from "something I built for class" to "something that actually feels like a real product." Let us build it.

Here is a high-level overview of what you will build in this side quest:

Each part builds on the previous one. By the end, all the pieces will connect into a single, cohesive feature that demonstrates real professional-grade engineering.

2. The Saved Searches Table

Before you can run a search automatically, you need to remember what the user wants to search for. Right now, search queries live only in the moment — the user types something, results appear, and the query is forgotten. To run that same search tomorrow morning without the user being there, you need to store it in the database.

This means you need a new table. The saved_searches table will hold everything needed to replay a search: the keywords, the location, the user's email address (so you know where to send results), and some metadata about when the search was created and when it last ran.

Start by creating the table directly in your database. Open MySQL Workbench and run this SQL:

CREATE TABLE saved_searches (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    keywords VARCHAR(255) NOT NULL,
    location VARCHAR(255),
    email VARCHAR(255) NOT NULL,
    is_active BOOLEAN DEFAULT TRUE,
    created_at DATETIME NOT NULL,
    last_run_at DATETIME,
    last_result_count INT DEFAULT 0,
    run_count INT DEFAULT 0
);

Each column serves a specific purpose. The keywords column stores what the user searched for — "Java Developer", "Spring Boot", "Frontend Engineer". The location stores the city or region. The email column is where alert emails will be sent. The is_active flag lets users pause their alerts without deleting them entirely. And the metadata columns — created_at, last_run_at, last_result_count, run_count — help you track what the system is doing behind the scenes.

Now create the corresponding Java entity. Add a new file called SavedSearch.java in your model package:

package com.example.resumator.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "saved_searches")
public class SavedSearch {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String keywords;

    @Column
    private String location;

    @Column(nullable = false)
    private String email;

    @Column(name = "is_active")
    private Boolean isActive = true;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    @Column(name = "last_run_at")
    private LocalDateTime lastRunAt;

    @Column(name = "last_result_count")
    private Integer lastResultCount = 0;

    @Column(name = "run_count")
    private Integer runCount = 0;

    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
    }

    public SavedSearch() {}

    // Getters and setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getKeywords() { return keywords; }
    public void setKeywords(String keywords) { this.keywords = keywords; }

    public String getLocation() { return location; }
    public void setLocation(String location) { this.location = location; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public Boolean getIsActive() { return isActive; }
    public void setIsActive(Boolean isActive) { this.isActive = isActive; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

    public LocalDateTime getLastRunAt() { return lastRunAt; }
    public void setLastRunAt(LocalDateTime lastRunAt) { this.lastRunAt = lastRunAt; }

    public Integer getLastResultCount() { return lastResultCount; }
    public void setLastResultCount(Integer lastResultCount) { this.lastResultCount = lastResultCount; }

    public Integer getRunCount() { return runCount; }
    public void setRunCount(Integer runCount) { this.runCount = runCount; }
}

This entity follows the exact same patterns you learned when building the FavoriteJob entity. The @PrePersist callback automatically sets createdAt when a new saved search is first stored. The isActive field defaults to true, so every new saved search starts out enabled.

Next, create the repository. Add a new file called SavedSearchRepository.java:

package com.example.resumator.repository;

import com.example.resumator.model.SavedSearch;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface SavedSearchRepository extends JpaRepository<SavedSearch, Long> {

    List<SavedSearch> findByIsActiveTrue();

    List<SavedSearch> findByEmail(String email);
}

The findByIsActiveTrue() method is the key one. When the scheduled job wakes up every morning, it needs to find only the searches that are currently active. Spring reads the method name and generates the SQL: SELECT * FROM saved_searches WHERE is_active = true. The findByEmail method lets users see all the alerts they have set up.

Now build the controller with full CRUD endpoints. Create SavedSearchController.java:

package com.example.resumator.controller;

import com.example.resumator.model.SavedSearch;
import com.example.resumator.repository.SavedSearchRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/saved-searches")
@CrossOrigin(origins = "*")
public class SavedSearchController {

    private final SavedSearchRepository repository;

    public SavedSearchController(SavedSearchRepository repository) {
        this.repository = repository;
    }

    @PostMapping
    public ResponseEntity<?> createSavedSearch(@RequestBody SavedSearch savedSearch) {
        if (savedSearch.getKeywords() == null || savedSearch.getKeywords().isBlank()) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Keywords are required"));
        }
        if (savedSearch.getEmail() == null || savedSearch.getEmail().isBlank()) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Email is required"));
        }
        SavedSearch saved = repository.save(savedSearch);
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @GetMapping
    public List<SavedSearch> getAllSavedSearches() {
        return repository.findAll();
    }

    @GetMapping("/by-email")
    public List<SavedSearch> getByEmail(@RequestParam String email) {
        return repository.findByEmail(email);
    }

    @PatchMapping("/{id}/toggle")
    public ResponseEntity<?> toggleActive(@PathVariable Long id) {
        return repository.findById(id)
            .map(search -> {
                search.setIsActive(!search.getIsActive());
                repository.save(search);
                return ResponseEntity.ok(search);
            })
            .orElse(ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<?> deleteSavedSearch(@PathVariable Long id) {
        if (!repository.existsById(id)) {
            return ResponseEntity.notFound().build();
        }
        repository.deleteById(id);
        return ResponseEntity.ok(Map.of("message", "Saved search deleted"));
    }
}

Notice the /toggle endpoint. This is a PATCH request that flips the isActive flag. When a user wants to temporarily stop receiving alerts for a search, they hit this endpoint instead of deleting the search entirely. It is a small touch, but it is the kind of thoughtful feature that makes software pleasant to use.

Also pay attention to the validation in the POST endpoint. Before saving a search, the controller checks that both keywords and email are present and not blank. Without this check, a user could accidentally save an empty search, and your scheduled job would run a meaningless query against the API every single day — wasting budget and sending empty emails. Validation at the controller layer is your first line of defense against bad data entering the system.

The GET /by-email endpoint deserves a closer look too. It uses @RequestParam instead of @PathVariable, which means the email is passed as a query parameter: /api/saved-searches/by-email?email=user@example.com. This is the correct pattern for filtering operations. Path variables are for identifying specific resources (/api/saved-searches/42). Query parameters are for filtering collections. This distinction matters in REST API design, and interviewers will notice if you get it right.

The "Save this Search" Button On your search results page, add a "Save this search" button that captures the current keywords and location, prompts the user for their email, and sends a POST request to /api/saved-searches. When the button is clicked, the search is saved to the database and the user starts receiving daily email alerts. Think about where this button should appear in your UI — right next to the search results, above the job listings, is a natural spot. Consider showing a small confirmation message after the search is saved, and perhaps a link to "Manage your alerts" so the user can see all their saved searches in one place.

3. Spring's @Scheduled Annotation

Now for the magic. Spring has a built-in scheduling system that lets you run methods automatically at specific times. No external tools needed. No complex configuration. You annotate a method, and Spring calls it on schedule. It is remarkably simple for how powerful it is.

First, you need to enable scheduling in your application. Open your main application class — the one with @SpringBootApplication — and add the @EnableScheduling annotation:

package com.example.resumator;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class ResumatorApplication {

    public static void main(String[] args) {
        SpringApplication.run(ResumatorApplication.class, args);
    }
}

That single annotation — @EnableScheduling — tells Spring to start its task scheduling infrastructure. Without it, any @Scheduled methods you write would be silently ignored. With it, Spring scans your application for scheduled methods and sets up timers to call them at the right times.

Now create the service that will run on schedule. Add a new file called ScheduledSearchService.java:

package com.example.resumator.service;

import com.example.resumator.model.SavedSearch;
import com.example.resumator.repository.SavedSearchRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.List;

@Service
public class ScheduledSearchService {

    private static final Logger logger = LoggerFactory.getLogger(ScheduledSearchService.class);

    private final SavedSearchRepository savedSearchRepository;
    private final JobSearchService jobSearchService;
    private final EmailService emailService;

    public ScheduledSearchService(
            SavedSearchRepository savedSearchRepository,
            JobSearchService jobSearchService,
            EmailService emailService) {
        this.savedSearchRepository = savedSearchRepository;
        this.jobSearchService = jobSearchService;
        this.emailService = emailService;
    }

    @Scheduled(cron = "0 0 7 * * *")
    public void runDailySavedSearches() {
        logger.info("=== Starting daily saved search run at {} ===", LocalDateTime.now());

        List<SavedSearch> activeSearches = savedSearchRepository.findByIsActiveTrue();
        logger.info("Found {} active saved searches", activeSearches.size());

        int totalJobsFound = 0;
        int successCount = 0;
        int errorCount = 0;

        for (SavedSearch search : activeSearches) {
            try {
                logger.info("Running search: '{}' in '{}'", search.getKeywords(), search.getLocation());

                List<Map<String, Object>> results = jobSearchService.search(
                    search.getKeywords(), search.getLocation()
                );

                int jobCount = results.size();
                totalJobsFound += jobCount;

                if (jobCount > 0) {
                    emailService.sendJobAlertEmail(
                        search.getEmail(),
                        search.getKeywords(),
                        search.getLocation(),
                        results
                    );
                    logger.info("Sent email to {} with {} new jobs", search.getEmail(), jobCount);
                } else {
                    logger.info("No new jobs found for '{}' — skipping email", search.getKeywords());
                }

                // Update search metadata
                search.setLastRunAt(LocalDateTime.now());
                search.setLastResultCount(jobCount);
                search.setRunCount(search.getRunCount() + 1);
                savedSearchRepository.save(search);
                successCount++;

            } catch (Exception e) {
                errorCount++;
                logger.error("Error running search id={}: {}", search.getId(), e.getMessage(), e);
            }
        }

        logger.info("=== Daily run complete: {} searches, {} succeeded, {} failed, {} total jobs found ===",
            activeSearches.size(), successCount, errorCount, totalJobsFound);
    }
}

There is a lot happening in this class, so let us break it down piece by piece.

The @Service annotation tells Spring this is a service class — a component that contains business logic. Spring will create an instance of it automatically and inject the three dependencies it needs: the repository (to find saved searches), the job search service (to call the API), and the email service (to send results). This is constructor injection, the same pattern you have used throughout the Resumator project. All three dependencies are declared as final fields and assigned in the constructor, which ensures they cannot be null and cannot be changed after the object is created.

Look at the structure of the runDailySavedSearches method. It follows a clear pattern: fetch, iterate, process, record. First, it fetches all active saved searches. Then it loops through each one. For each search, it calls the job search API, sends an email if results exist, updates the metadata, and catches any exceptions so a failure on one search does not kill the entire run. The counters at the top (totalJobsFound, successCount, errorCount) accumulate across the loop and are reported in the summary log at the end.

The try-catch inside the loop is critical. Without it, if the third search out of ten throws an exception, searches four through ten would never run. By catching exceptions per-search, you ensure that one bad search does not prevent all the others from executing. This is a resilience pattern that matters in any batch processing system.

The Logger at the top is critically important. When code runs at 7 AM and nobody is watching, logging is the only way you will know what happened. Every step is logged: when the run starts, how many searches were found, what each search returned, whether emails were sent, and a summary at the end. If something goes wrong at 3 AM on a Saturday, the logs will tell you exactly what happened.

The Cron Expression

The @Scheduled(cron = "0 0 7 * * *") annotation is what makes this method run automatically. The string "0 0 7 * * *" is a cron expression — a compact way to define a schedule. It has six fields, read left to right:

 ┌───────────── second (0-59)
 │ ┌───────────── minute (0-59)
 │ │ ┌───────────── hour (0-23)
 │ │ │ ┌───────────── day of month (1-31)
 │ │ │ │ ┌───────────── month (1-12)
 │ │ │ │ │ ┌───────────── day of week (0-7, 0 and 7 = Sunday)
 │ │ │ │ │ │
 0 0 7 * * *     ← Every day at 7:00:00 AM

The asterisk (*) means "every." So 0 0 7 * * * reads as: "At second 0, minute 0, hour 7, every day of the month, every month, every day of the week." In plain English: every day at exactly 7:00 AM.

Here are some other common cron expressions so you can see how flexible they are:

The power of this annotation is stunning. You did not install a separate scheduling system. You did not configure a cron daemon on a server. You did not set up a message queue. You added one annotation to one method, and Spring handles everything else — creating the timer, managing the thread, calling the method on schedule, and handling any exceptions that might occur. This is the kind of productivity that makes Spring Boot such a popular framework for building real applications.

Important: The method runs automatically Once you start the application with @EnableScheduling and a @Scheduled method, that method will run on its schedule. There is no user action needed. There is no button to click. There is no API endpoint to call. The method simply executes when the cron expression says it should. If your application is running at 7:00 AM, this method runs. If your application was restarted at 6:59 AM, the method still runs at 7:00 AM. It is fully automatic. This is both powerful and something to be careful with — you are writing code that will execute without human oversight.

4. Sending Email with JavaMail

Your scheduled job can find new results, but it needs a way to tell the user about them. The most natural channel for this kind of notification is email. Spring Boot makes sending emails straightforward through its spring-boot-starter-mail dependency, which integrates JavaMail into your application with minimal configuration.

First, add the mail dependency to your pom.xml (inside the <dependencies> section):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>

Next, configure your SMTP settings. SMTP (Simple Mail Transfer Protocol) is the standard protocol for sending emails. You need to tell Spring Boot which mail server to use, what port to connect on, and what credentials to authenticate with. Add these properties to your application.properties file:

# Email Configuration (SMTP)
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=${EMAIL_USERNAME}
spring.mail.password=${EMAIL_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.properties.mail.smtp.connectiontimeout=5000
spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.writetimeout=5000

Notice the ${EMAIL_USERNAME} and ${EMAIL_PASSWORD} syntax. These are environment variables. You never put real credentials directly in your properties file — anyone who sees your code (on GitHub, in a code review, in a backup) would have your password. Instead, you reference environment variables that are set on the machine where the application runs. You set these in your terminal before starting the application, or in your IDE's run configuration. This is a fundamental security practice that every professional developer follows.

Using Gmail for SMTP If you use Gmail, you will need to generate an "App Password" in your Google account settings (Security → 2-Step Verification → App Passwords). This gives you a 16-character password specifically for your application, separate from your regular Google password. This is safer because you can revoke it at any time without changing your main password, and it only works for sending mail — it cannot be used to log into your Google account.

Now create the email service. Add a new file called EmailService.java:

package com.example.resumator.service;

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;

@Service
public class EmailService {

    private static final Logger logger = LoggerFactory.getLogger(EmailService.class);

    private final JavaMailSender mailSender;

    @Value("${spring.mail.username}")
    private String fromEmail;

    public EmailService(JavaMailSender mailSender) {
        this.mailSender = mailSender;
    }

    public void sendJobAlertEmail(String toEmail, String keywords,
            String location, List<Map<String, Object>> jobs) {

        try {
            MimeMessage message = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

            helper.setFrom(fromEmail);
            helper.setTo(toEmail);
            helper.setSubject(jobs.size() + " new " + keywords + " jobs in " + location);

            String htmlContent = buildHtmlEmail(keywords, location, jobs);
            helper.setText(htmlContent, true);

            mailSender.send(message);
            logger.info("Alert email sent to {} for '{} in {}'", toEmail, keywords, location);

        } catch (MessagingException e) {
            logger.error("Failed to send email to {}: {}", toEmail, e.getMessage(), e);
            throw new RuntimeException("Failed to send email", e);
        }
    }

    private String buildHtmlEmail(String keywords, String location,
            List<Map<String, Object>> jobs) {

        StringBuilder html = new StringBuilder();
        html.append("<!DOCTYPE html>");
        html.append("<html><head><style>");
        html.append("body { font-family: Arial, sans-serif; margin: 0; padding: 20px; ");
        html.append("background-color: #f5f5f5; }");
        html.append(".container { max-width: 600px; margin: 0 auto; ");
        html.append("background: white; border-radius: 8px; overflow: hidden; }");
        html.append(".header { background: #2563eb; color: white; padding: 24px; }");
        html.append(".header h1 { margin: 0; font-size: 20px; }");
        html.append(".header p { margin: 8px 0 0; opacity: 0.9; }");
        html.append(".jobs { padding: 16px 24px; }");
        html.append(".job-card { border: 1px solid #e5e7eb; border-radius: 6px; ");
        html.append("padding: 16px; margin-bottom: 12px; }");
        html.append(".job-title { font-size: 16px; font-weight: bold; ");
        html.append("color: #1e40af; margin: 0 0 4px; }");
        html.append(".job-company { color: #6b7280; margin: 0 0 8px; }");
        html.append(".job-location { color: #6b7280; font-size: 14px; }");
        html.append(".apply-btn { display: inline-block; background: #2563eb; ");
        html.append("color: white; padding: 8px 16px; border-radius: 4px; ");
        html.append("text-decoration: none; font-size: 14px; margin-top: 8px; }");
        html.append(".footer { padding: 16px 24px; background: #f9fafb; ");
        html.append("border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }");
        html.append(".footer a { color: #6b7280; }");
        html.append("</style></head><body>");

        html.append("<div class='container'>");
        html.append("<div class='header'>");
        html.append("<h1>").append(jobs.size()).append(" new ").append(keywords);
        html.append(" jobs in ").append(location).append("</h1>");
        html.append("<p>Your daily Resumator job alert</p>");
        html.append("</div>");

        html.append("<div class='jobs'>");
        for (Map<String, Object> job : jobs) {
            String title = (String) job.getOrDefault("job_title", "Untitled");
            String company = (String) job.getOrDefault("employer_name", "Unknown");
            String city = (String) job.getOrDefault("job_city", "");
            String state = (String) job.getOrDefault("job_state", "");
            String applyLink = (String) job.getOrDefault("job_apply_link", "#");

            html.append("<div class='job-card'>");
            html.append("<p class='job-title'>").append(title).append("</p>");
            html.append("<p class='job-company'>").append(company).append("</p>");
            html.append("<p class='job-location'>").append(city);
            if (!state.isEmpty()) {
                html.append(", ").append(state);
            }
            html.append("</p>");
            html.append("<a href='").append(applyLink).append("' class='apply-btn'>View Job</a>");
            html.append("</div>");
        }
        html.append("</div>");

        html.append("<div class='footer'>");
        html.append("<p>You received this email because you saved a search for '");
        html.append(keywords).append("' on Resumator.</p>");
        html.append("<p><a href='http://localhost:8080/saved-searches'>Manage your alerts</a>");
        html.append(" | <a href='http://localhost:8080/api/saved-searches/unsubscribe?email=");
        html.append(toEmail).append("'>Unsubscribe from all alerts</a></p>");
        html.append("</div>");

        html.append("</div></body></html>");
        return html.toString();
    }
}

Let us walk through the key parts of this service:

The JavaMailSender is provided by Spring Boot automatically when the mail starter dependency is on your classpath. You do not create it yourself — Spring injects it through the constructor, just like your repositories.

The MimeMessage and MimeMessageHelper let you send rich HTML emails instead of plain text. The true parameter in the MimeMessageHelper constructor enables multipart mode, which supports HTML content and attachments. The "UTF-8" ensures international characters display correctly.

The email subject line is dynamic: "3 new Java Developer jobs in Lansing". It tells the user exactly how many results they have and what the search was, right in their inbox before they even open the email.

The buildHtmlEmail method creates a complete HTML document with inline CSS styling. Why inline CSS? Because most email clients strip out external stylesheets and <style> tags for security reasons. Inline styles are the only reliable way to style emails across Gmail, Outlook, Apple Mail, and mobile clients. The template includes a blue header with the search summary, individual job cards with company and location details, an "apply" button for each job, and a footer with an unsubscribe link.

Always include an unsubscribe link This is not optional. Every automated email must include a way for the user to stop receiving messages. This is a legal requirement in many countries (CAN-SPAM Act in the US, GDPR in Europe) and a basic respect for your users. The unsubscribe link in our footer points to an endpoint that deactivates all saved searches for that email address. You should implement this endpoint in your SavedSearchController.

Notice how the sendJobAlertEmail method wraps the entire email-sending logic in a try-catch block. Email delivery can fail for many reasons: the SMTP server might be down, the user's email address might be invalid, the network might time out. By catching MessagingException, the method logs the error with full details and re-throws it as a RuntimeException. This lets the calling code (the scheduled service) catch it per-search and continue processing the remaining searches.

The @Value("${spring.mail.username}") annotation on the fromEmail field is another Spring feature worth understanding. It reads the value from your application.properties file and injects it directly into the field. This means the "from" address in your emails automatically matches whatever email account you configured for SMTP. You do not hardcode it, and if you change your SMTP account, the from address updates automatically.

One more thing about the buildHtmlEmail method: it uses a StringBuilder instead of string concatenation. When you write String result = a + b + c + d, Java creates a new string object for each + operation. For a small number of concatenations, this is fine. But when you are building an entire HTML document with dozens of appends, StringBuilder is significantly more efficient because it maintains a single mutable buffer. This is a performance best practice that professional developers follow instinctively, and it is the kind of detail that shows up in code reviews.

5. Connecting to the API Request Budget

Here is something that is easy to overlook but critical to get right. Every time your scheduled job runs a saved search, it makes an API request to the JSearch API. And you have a limited budget — 200 requests per month on the free tier. If you have 10 saved searches running daily, that is 10 requests per day, 300 per month — well over your limit. Your automated searches would consume your entire budget before the month is half over, leaving no requests for manual searches.

This is a real-world constraint that every professional developer has to manage. APIs cost money. Resources are finite. Smart scheduling means being aware of your budget and making intelligent decisions about when to run and when to skip.

The rules are straightforward:

Create a budget-aware version of the scheduling logic. You may already have an ApiUsageService or similar class that tracks how many API requests you have used. If not, you will need one. Here is how the scheduled service integrates with the budget:

package com.example.resumator.service;

import com.example.resumator.model.SavedSearch;
import com.example.resumator.repository.SavedSearchRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.List;

@Service
public class BudgetAwareScheduledService {

    private static final Logger logger = LoggerFactory.getLogger(BudgetAwareScheduledService.class);

    private static final int MONTHLY_BUDGET = 200;
    private static final int MANUAL_RESERVE = 50;
    private static final int AUTOMATED_BUDGET = MONTHLY_BUDGET - MANUAL_RESERVE;

    private final SavedSearchRepository savedSearchRepository;
    private final JobSearchService jobSearchService;
    private final EmailService emailService;
    private final ApiUsageService apiUsageService;

    public BudgetAwareScheduledService(
            SavedSearchRepository savedSearchRepository,
            JobSearchService jobSearchService,
            EmailService emailService,
            ApiUsageService apiUsageService) {
        this.savedSearchRepository = savedSearchRepository;
        this.jobSearchService = jobSearchService;
        this.emailService = emailService;
        this.apiUsageService = apiUsageService;
    }

    @Scheduled(cron = "0 0 7 * * *")
    public void runDailySavedSearches() {
        logger.info("=== Budget-aware daily run starting at {} ===", LocalDateTime.now());

        int usedThisMonth = apiUsageService.getUsageThisMonth();
        int remaining = MONTHLY_BUDGET - usedThisMonth;
        int automatedRemaining = AUTOMATED_BUDGET - apiUsageService.getAutomatedUsageThisMonth();

        logger.info("Budget status: {}/{} total used, {} automated remaining",
            usedThisMonth, MONTHLY_BUDGET, automatedRemaining);

        if (remaining <= MANUAL_RESERVE) {
            logger.warn("Budget critically low ({} remaining). Skipping automated run "
                + "to preserve manual search capacity.", remaining);
            return;
        }

        if (automatedRemaining <= 0) {
            logger.warn("Automated budget exhausted for this month. Skipping run.");
            return;
        }

        List<SavedSearch> activeSearches = savedSearchRepository.findByIsActiveTrue();
        int searchesToRun = Math.min(activeSearches.size(), automatedRemaining);

        if (searchesToRun < activeSearches.size()) {
            logger.warn("Budget only allows {} of {} searches. "
                + "Running oldest-first.", searchesToRun, activeSearches.size());
        }

        int successCount = 0;

        for (int i = 0; i < searchesToRun; i++) {
            SavedSearch search = activeSearches.get(i);
            try {
                logger.info("[{}/{}] Running: '{}' in '{}'",
                    i + 1, searchesToRun, search.getKeywords(), search.getLocation());

                List results = jobSearchService.search(
                    search.getKeywords(), search.getLocation());

                apiUsageService.recordAutomatedUsage();

                if (!results.isEmpty()) {
                    emailService.sendJobAlertEmail(
                        search.getEmail(), search.getKeywords(),
                        search.getLocation(), results);
                }

                search.setLastRunAt(LocalDateTime.now());
                search.setLastResultCount(results.size());
                search.setRunCount(search.getRunCount() + 1);
                savedSearchRepository.save(search);
                successCount++;

            } catch (Exception e) {
                logger.error("Error on search id={}: {}", search.getId(), e.getMessage());
            }
        }

        logger.info("=== Daily run complete: {}/{} succeeded, budget now at {}/{} ===",
            successCount, searchesToRun,
            apiUsageService.getUsageThisMonth(), MONTHLY_BUDGET);
    }
}

This version of the scheduled service adds several critical safeguards:

Budget partitioning. The total monthly budget of 200 is split into two pools: 150 for automated searches and 50 reserved for manual use. This guarantees that even if every automated request runs, the user can still do at least 50 manual searches during the month. The constants MONTHLY_BUDGET, MANUAL_RESERVE, and AUTOMATED_BUDGET make these decisions visible and easy to adjust.

Pre-run budget check. Before processing any searches, the service checks the overall budget. If the remaining requests have dropped to the manual reserve threshold or below, the entire run is skipped. This is a hard stop — no automated search is worth leaving the user unable to search manually.

Partial runs. If there are 10 active searches but only 5 requests remaining in the automated budget, the service runs only 5. It processes them in order (oldest first), logs a warning about the limitation, and stops cleanly. This is better than either running all 10 (and going over budget) or running none (and wasting the remaining capacity).

Per-request tracking. After each successful API call, the service calls apiUsageService.recordAutomatedUsage() to increment the automated usage counter. This keeps the manual and automated usage counts separate, so you can always see how your budget is being consumed.

Priority: Manual > Automated

This is a design principle, not just a technical detail. When resources are limited, the user's active work always takes priority over background automation. A user sitting at their computer, actively searching for jobs, should never see a "rate limit exceeded" error because a background job ran earlier that morning. The reserve system guarantees this. It is the kind of decision that separates thoughtful software from software that works against its users.

Think about the math for a moment. With 200 requests per month and 50 reserved for manual use, you have 150 automated requests available. If you run one search per day, that is roughly 30 per month — well within budget. If you have 5 saved searches, that is 150 per month — right at the automated limit. If you have 6 or more, the system will start doing partial runs. These numbers will vary depending on your API plan, but the architecture handles it gracefully. When you upgrade to a paid plan with more requests, you only need to change the MONTHLY_BUDGET constant — the rest of the logic adapts automatically.

Also notice that the BudgetAwareScheduledService logs budget status at the start of every run. This is not just for debugging — it creates a historical record. If a user asks "why did I not get my alert yesterday?", you can check the logs and see: "Budget critically low (48 remaining). Skipping automated run." The answer is right there, with no guesswork required.

6. Testing and Monitoring

You have built a system that runs automatically, without anyone watching. That is powerful. It is also risky. If the scheduled job fails silently, you will not know until users complain that they stopped receiving alerts. If a bug causes it to send duplicate emails, users will get annoyed before you even realize something is wrong. Automated systems require monitoring — a way to know what the system is doing, whether it is healthy, and when something goes wrong.

Testing with a Fast Schedule

During development, you do not want to wait until 7 AM to see if your scheduled job works. Change the cron expression to run every minute:

// For testing only — runs every 1 minute
@Scheduled(cron = "0 */1 * * * *")
public void runDailySavedSearches() {
    // ... same code as before
}

Start your application, create a saved search through the API, and watch the console. Within 60 seconds, you should see the log messages from your scheduled method. Check that it finds the saved search, runs the query, and attempts to send the email. Fix any errors you see, restart, and test again. Once everything works reliably with a 1-minute schedule, change it back to the daily cron expression before deploying.

Do not forget to change it back! Running a scheduled job every minute in production would burn through your API budget in hours and flood users with emails. Always use a fast schedule only during testing, and always verify you have restored the production schedule before deploying. A common professional practice is to make the cron expression configurable through application.properties: @Scheduled(cron = "${alerts.cron:0 0 7 * * *}"). This way, you can set a fast schedule in your development properties file and a daily schedule in production, without changing any code.

Comprehensive Logging

Every run of your scheduled job should log at minimum:

Our ScheduledSearchService already logs most of this. The key principle is: if you cannot see it in the logs, it did not happen. Future-you, debugging an issue at 10 PM, will thank present-you for writing thorough log messages.

A Simple Monitoring Dashboard

For a more visual approach, create a monitoring endpoint that returns the status of the scheduled job system. Add this to your controller:

@GetMapping("/api/saved-searches/dashboard")
public Map<String, Object> getDashboard() {
    List<SavedSearch> allSearches = repository.findAll();
    List<SavedSearch> activeSearches = repository.findByIsActiveTrue();

    LocalDateTime lastRun = allSearches.stream()
        .map(SavedSearch::getLastRunAt)
        .filter(Objects::nonNull)
        .max(LocalDateTime::compareTo)
        .orElse(null);

    int totalJobsFoundLastRun = activeSearches.stream()
        .mapToInt(s -> s.getLastResultCount() != null ? s.getLastResultCount() : 0)
        .sum();

    return Map.of(
        "totalSavedSearches", allSearches.size(),
        "activeSearches", activeSearches.size(),
        "lastRunAt", lastRun != null ? lastRun.toString() : "Never",
        "nextRunAt", "Tomorrow at 7:00 AM",
        "jobsFoundLastRun", totalJobsFoundLastRun,
        "budgetUsed", apiUsageService.getUsageThisMonth(),
        "budgetRemaining", 200 - apiUsageService.getUsageThisMonth()
    );
}

Hit http://localhost:8080/api/saved-searches/dashboard in your browser and you will get a JSON response showing the health of your alert system at a glance: how many searches are active, when the last run happened, how many jobs were found, and where your budget stands. During development, check this endpoint after every test run to verify the system is working correctly. In a production application, you could build a simple HTML page that polls this endpoint and displays the data in a dashboard format.

Notice the Java Streams API in action here. The allSearches.stream() call converts the list into a stream, then .map(SavedSearch::getLastRunAt) extracts the lastRunAt field from each search, .filter(Objects::nonNull) removes any searches that have never run, and .max(LocalDateTime::compareTo) finds the most recent run time. This is a clean, readable way to answer the question "when did the last run happen?" without writing a loop. If you find streams confusing, you can always write a traditional for-loop instead — the result is the same.

Testing Checklist

Before you consider this feature complete, walk through each of these scenarios manually:

Each of these tests exercises a different path through your code. It is tempting to test only the happy path — create a search, run it, get an email — but the edge cases are where bugs hide. What happens when there are no active searches? What happens when the email server is unreachable? What happens when the API returns an error for one search but not another? Testing these scenarios now will save you from discovering them in production.

The Monitoring Mindset

Professional developers think about monitoring from the beginning, not as an afterthought. When you build a feature that runs automatically, the very first question should be: "How will I know if this is working?" and the second should be: "How will I know when it breaks?" Logs answer the second question. Dashboards answer the first. Together, they give you confidence that your automated system is doing what it should, even when you are not watching.

Knowledge Check

1. What does the cron expression "0 0 7 * * *" mean in the context of Spring's @Scheduled annotation?

Correct! The six fields in a Spring cron expression are: second, minute, hour, day-of-month, month, day-of-week. So "0 0 7 * * *" means "at second 0, minute 0, hour 7, every day of every month, every day of the week" — which is every day at 7:00 AM. The asterisks mean "every" for that field. This is one of the most common cron patterns for daily scheduled tasks.

2. Why does the budget-aware scheduler reserve 50 API requests for manual use instead of letting automated searches use the entire monthly budget?

Exactly right. The principle is that a user's active work always takes priority over background automation. If automated searches consumed the entire budget, a user who sits down to search for jobs manually could be told "rate limit exceeded" — which would be a terrible experience. The reserve ensures there is always capacity for human-driven searches, even in the worst case. This is a design decision rooted in user experience, not a technical limitation of the API or framework.

3. Why should you avoid putting your SMTP email password directly in application.properties and use environment variables like ${EMAIL_PASSWORD} instead?

Correct! Credentials in source code are one of the most common security vulnerabilities. If your code is pushed to GitHub (even accidentally), anyone who sees it has your password. Environment variables keep secrets out of the codebase entirely. The properties file references the variable name, but the actual value only exists on the machine where the application runs. This is a fundamental security practice — never commit real credentials to version control.

Deliverable

By the end of this side quest, your Resumator application should have a fully working email alerts system with the following capabilities:

This is the kind of feature that makes a portfolio project stand out. Recruiters and hiring managers see hundreds of CRUD applications. A project with background jobs, scheduled email delivery, and intelligent resource management shows that you understand how real software works — not just the happy path, but the operational concerns that matter in production.

New Files You Created

Key Concepts You Practiced

When you talk about this feature in an interview, emphasize the decisions you made, not just the code you wrote. Why did you reserve budget for manual searches? Why did you catch exceptions per-search instead of per-run? Why did you use PATCH for toggling instead of PUT? Why did you build a dashboard endpoint? These decisions demonstrate engineering judgment — the ability to think beyond "does it work?" and consider "does it work well?" That is what employers are looking for.

You have built something that works autonomously. Your Resumator now has a feature that most student projects never attempt: a background process that runs on schedule, interacts with external services, manages a shared resource budget, and communicates results to users through a completely different channel than the web interface. That is real software engineering.

Finished this side quest?

← Back to Side Quests