ADR 0008: Weekly Digest Email — Cross-Service Communication & Design¶
- Status: Proposed
- Date: 2026-06-15
- Deciders: Principal Architect (jobhub-architect); David R H
- Affects: notification-service, application-service, auth-service, api-contracts, db/init
Context¶
Story #80 (epic #96) adds a weekly digest email to notification-service. Every Monday morning
a scheduled job finds users who have weekly_digest_email = true, determines what jobs to
recommend, and sends an HTML email. This is the first feature in notification-service that
requires outbound REST calls to other services and email dispatch, neither of which
exist in the service today.
The design must resolve several cross-cutting concerns:
-
Service-to-service authentication. notification-service runs a scheduler, not a user-initiated request. It does not possess user JWTs and cannot impersonate users. The internal endpoints it calls must accept a different credential.
-
Interest profile extraction. application-service stores each user's application history with frozen job snapshots (title, company, location). To recommend jobs, we need to aggregate the titles, locations, and companies the user has applied to. application- service's existing
GET /applicationsendpoint is per-user/JWT-scoped and not callable by notification-service. -
User email lookup. auth-service stores user emails but has no endpoint to look them up by user ID. notification-service needs the email address for each opted-in user.
-
Email sending. Quarkus Mailer with Qute templates is the Quarkus-standard approach.
-
Digest tracking. We need to know when the last digest was sent per user (to avoid double-sends on retries, and to scope "recent jobs" relative to the last digest).
JobHub conventions in scope: Hexagonal architecture for notification-service (ADR 0007),
contract-first api-contracts, schema-per-service, DB owned by db/init/ SQL.
Decision¶
1. Service-to-service authentication: shared secret header¶
We will use a pre-shared API key exchanged via config/environment variable. Each internal
endpoint checks for a header X-Service-Key whose value must match the deployment secret
(JOBHUB_INTERNAL_SERVICE_KEY env var). This is simple, adequate for services on the same
Podman network, and requires no token-minting infrastructure.
- Internal endpoints are tagged
Internalin OpenAPI and document theX-Service-Keyheader requirement. - The key is a single shared value across all services (one
.envvariable). Any service that exposes or calls internal endpoints reads it from config. - Internal endpoints return 401 if the header is missing or wrong.
- This is NOT a user-identity mechanism. Internal endpoints that need a user context receive the user ID as a path or query parameter, not from a JWT.
2. New internal endpoints¶
auth-service -- GET /internal/users/emails (batch):
- Accepts
userIdsas a repeated query parameter (list of UUIDs). - Returns
{ emails: [ { userId, email } ] }. Users not found are silently omitted. - Protected by
X-Service-Key. No JWT required. - No schema change needed (reads existing
auth.usertable).
application-service -- GET /internal/users/{userId}/interest-profile:
- Returns an aggregated interest profile for one user, derived from their application
history: the top locations, top companies, and top title keywords extracted from
applications.job_post_snapshotandapplications.user_job_postjoined throughapplications.application. - Response:
{ userId, locations: [string], companies: [string], keywords: [string] }. Each array is at most 5 entries, ordered by frequency. - Returns 200 with empty arrays if the user has no application history (the caller decides whether to send a generic digest or skip).
- Protected by
X-Service-Key. No JWT required. - No schema change needed (reads existing tables via a GROUP BY/COUNT query).
3. Interest profile to job search mapping¶
notification-service builds a job-service GET /jobs query from the interest profile:
keyword= top 3 title keywords joined by spaces (OR semantics in full-text search).location= all profile locations (repeated query param).postedWithin=week(only jobs seen in the last 7 days).sort=newest,size= 10.
For users with no application history (empty interest profile): send a generic "top jobs
this week" digest using GET /jobs?postedWithin=week&sort=newest&size=10 with no keyword or
location filter. Users with no history still receive the digest if they opted in -- we do not
skip them.
4. Email sending: Quarkus Mailer + Qute templates¶
notification-service adds quarkus-mailer and quarkus-qute dependencies. The digest email
is rendered from a Qute template (templates/digest-email.html) containing job cards with
title, company, location, and a link. SMTP config is via standard Quarkus Mailer properties
(quarkus.mailer.*), with Mailtrap/MailHog for dev.
5. Digest run tracking: notification.digest_run table¶
A new table notification.digest_run records each digest execution:
notification.digest_run (
id UUID PK,
user_id UUID NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
job_count INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ('sent', 'failed', 'skipped')),
error_message TEXT
)
This enables:
- Preventing double-sends on retries (check if a sent row exists for this user in the
current week).
- Scoping "recent" to "since last successful digest" if we want to in the future.
- Observability (how many digests sent, failure rate).
Migration file: db/init/042-notification-digest-run.sql (041 was taken by story #79).
6. Scheduler design¶
A @Scheduled method in adapter/in/scheduler/WeeklyDigestScheduler fires every Monday at
08:00 UTC (cron = "0 0 8 ? * MON"). Config key notification.digest.enabled (default
true) gates execution so it can be disabled without redeployment.
The scheduler:
1. Queries all users with weekly_digest_email = true from the local
notification_preferences table.
2. Batch-fetches emails from auth-service (GET /internal/users/emails).
3. For each user, fetches interest profile from application-service, builds a job-service
query, fetches matching jobs.
4. Renders the Qute email template and sends via Quarkus Mailer.
5. Records the result in digest_run.
Processing is sequential per user (simplicity first). If a per-user step fails, log the error,
record a failed digest_run row, and continue to the next user. The batch-email fetch in step
2 is the only batched call; steps 3-5 are per-user.
7. notification-service hexagonal structure additions¶
domain/
model/
InterestProfile -- locations, companies, keywords (from app-service)
DigestRun -- id, userId, sentAt, jobCount, status, errorMessage
UserEmail -- userId, email (from auth-service)
port/
in/
SendWeeklyDigestUseCase -- triggered by scheduler
out/
DigestRunRepository -- persist/query digest runs
InterestProfileGateway -- fetch interest profile from application-service
UserEmailGateway -- fetch user emails from auth-service
JobSearchGateway -- search jobs from job-service
DigestMailer -- send the rendered email
service/
WeeklyDigestService -- implements SendWeeklyDigestUseCase, orchestrates the flow
exception/
DigestSendException -- wraps mailer failures
adapter/
in/
scheduler/
WeeklyDigestScheduler -- @Scheduled, calls SendWeeklyDigestUseCase
out/
client/
auth/
AuthInternalRestClient -- @RegisterRestClient, X-Service-Key header
UserEmailGatewayAdapter -- implements UserEmailGateway
application/
AppInternalRestClient -- @RegisterRestClient, X-Service-Key header
InterestProfileGatewayAdapter -- implements InterestProfileGateway
job/
JobServiceRestClient -- @RegisterRestClient (public, no auth needed)
JobSearchGatewayAdapter -- implements JobSearchGateway
persistence/
entity/DigestRunEntity
mapper/DigestRunMapper
DigestRunPanacheRepository
mail/
QuteDigestMailer -- implements DigestMailer port
8. Migration-number ranges¶
| Ticket | Service | File | Purpose |
|---|---|---|---|
| #99 | notification-service | db/init/042-notification-digest-run.sql |
digest_run table |
| #100 | auth-service | (none) | No schema change; new REST endpoint over existing auth.user |
| #101 | application-service | (none) | No schema change; new REST endpoint with GROUP BY query |
9. Config keys (notification-service)¶
| Key | Default | Purpose |
|---|---|---|
notification.digest.enabled |
true |
Kill switch for the scheduler |
notification.digest.cron |
0 0 8 ? * MON |
Cron expression (overridable) |
notification.digest.max-jobs |
10 |
Max jobs per digest email |
notification.internal.service-key |
(no default, required in prod) | Shared secret for internal endpoints |
quarkus.rest-client.auth-internal.url |
http://localhost:8082 |
auth-service base URL |
quarkus.rest-client.app-internal.url |
http://localhost:8083 |
application-service base URL |
quarkus.rest-client.job-service.url |
http://localhost:8081 |
job-service base URL |
auth-service and application-service each read the same key for validation:
jobhub.internal.service-key (from JOBHUB_INTERNAL_SERVICE_KEY env var).
10. Test shape¶
| Layer | What | How |
|---|---|---|
Unit: WeeklyDigestService |
Orchestration logic: builds queries, handles empty profiles, records runs | Mockito, mock all outbound ports |
Unit: InterestProfileGatewayAdapter |
Maps REST response to domain model | Mockito, mock RestClient |
Unit: UserEmailGatewayAdapter |
Maps batch response | Mockito |
Unit: DigestRunMapper |
Entity/domain mapping | Plain JUnit |
Component: WeeklyDigestScheduler |
End-to-end with WireMock for auth/app/job-service, DevServices DB, mock Mailer | @QuarkusTest + WireMock |
| Component (auth-service): internal endpoint | X-Service-Key validation, batch lookup | @QuarkusTest + DevServices |
| Component (app-service): internal endpoint | X-Service-Key validation, profile aggregation | @QuarkusTest + DevServices |
notification-service will need WireMock for the first time (three outbound HTTP clients).
Consequences¶
- Positive: The internal-endpoint pattern with
X-Service-Keyis simple, explicit, and reusable for future service-to-service calls (e.g. ghosted-alert checking). No new infrastructure (no service mesh, no mTLS, no token-exchange). - Positive: Interest profile extraction stays in application-service (its data, its query), keeping schema boundaries intact.
- Positive: Users with no history still get a digest (generic "top jobs"), maximising engagement for new users who opted in.
- Negative / cost: The shared secret is a single point of compromise. Acceptable for a same-network deployment; a future ADR can upgrade to mTLS or service-to-service JWT if the threat model changes.
- Negative / cost: notification-service now has three outbound REST clients and needs WireMock, increasing test complexity. Acceptable -- application-service already follows this pattern.
- Follow-ups:
- DevOps: add
JOBHUB_INTERNAL_SERVICE_KEYto.env.exampleand the Podman compose env for auth-service, application-service, and notification-service. - DevOps: add
quarkus-mailerSMTP config to notification-service's Podman env. - Future: the
X-Service-Keypattern should be extracted to a shared JAX-RSContainerRequestFilterin a common library if more than two services expose internal endpoints.
Alternatives considered¶
- Service-to-service JWT (notification-service mints its own token) -- rejected because it requires a signing key in notification-service and a new issuer trust chain in auth- service. Over-engineered for same-network services with no internet exposure.
- Direct database cross-schema reads -- rejected because it violates schema-per-service
isolation (a core project constraint). notification-service must not read from
authorapplicationsschemas. - Skip users with no application history -- rejected because new users who opt in to the digest should still receive value. A generic "top jobs" digest is low-cost and high-engagement.
- Event-driven (Kafka/AMQP) instead of REST for interest profiles -- rejected as over-engineered for a weekly batch of a few hundred users at most. REST with a simple aggregation query is adequate.
- Batch all interest profile fetches into one call -- considered but rejected for v1. The per-user call is simpler and the user count is small. If scale demands it, a batch endpoint can be added later without changing the port interface contract.