Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/helm-schema-lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ jobs:
helm lint kubernetes/loculus -f kubernetes/loculus/values.yaml
helm lint kubernetes/loculus -f kubernetes/loculus/values.yaml -f kubernetes/loculus/values_e2e_and_dev.yaml
helm lint kubernetes/loculus -f kubernetes/loculus/values.yaml -f kubernetes/loculus/values_preview_server.yaml
helm lint kubernetes/loculus \
-f kubernetes/loculus/values.yaml \
-f kubernetes/loculus/values_preview_server.yaml \
--set releaseConfirmationEmails.enabled=true
1 change: 1 addition & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ repositories {

dependencies {
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-mail"
implementation "org.springframework.boot:spring-boot-starter-aop"
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation "com.fasterxml.jackson.module:jackson-module-kotlin"
Expand Down
36 changes: 36 additions & 0 deletions backend/docs/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,19 @@ CREATE TABLE public.metadata_upload_aux_table (

ALTER TABLE public.metadata_upload_aux_table OWNER TO postgres;

--
-- Name: pending_release_notifications; Type: TABLE; Schema: public; Owner: postgres
--

CREATE TABLE public.pending_release_notifications (
accession text NOT NULL,
version bigint NOT NULL,
enqueued_at timestamp without time zone DEFAULT timezone('UTC'::text, CURRENT_TIMESTAMP) NOT NULL
);


ALTER TABLE public.pending_release_notifications OWNER TO postgres;

--
-- Name: seqset_citation_source; Type: TABLE; Schema: public; Owner: postgres
--
Expand Down Expand Up @@ -867,6 +880,14 @@ ALTER TABLE ONLY public.metadata_upload_aux_table
ADD CONSTRAINT metadata_upload_aux_table_upload_id_accession_key UNIQUE (upload_id, accession);


--
-- Name: pending_release_notifications pending_release_notifications_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--

ALTER TABLE ONLY public.pending_release_notifications
ADD CONSTRAINT pending_release_notifications_pkey PRIMARY KEY (accession, version);


--
-- Name: seqset_citation_source seqset_citation_source_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
Expand Down Expand Up @@ -992,6 +1013,13 @@ CREATE INDEX files_upload_requested_at_idx ON public.files USING btree (upload_r
CREATE INDEX flyway_schema_history_s_idx ON public.flyway_schema_history USING btree (success);


--
-- Name: pending_release_notifications_enqueued_at_idx; Type: INDEX; Schema: public; Owner: postgres
--

CREATE INDEX pending_release_notifications_enqueued_at_idx ON public.pending_release_notifications USING btree (enqueued_at);


--
-- Name: sequence_entries_organism_covering_idx; Type: INDEX; Schema: public; Owner: postgres
--
Expand Down Expand Up @@ -1207,6 +1235,14 @@ ALTER TABLE ONLY public.seqset_to_records
ADD CONSTRAINT foreign_key_seqset_record_id FOREIGN KEY (seqset_record_id) REFERENCES public.seqset_records(seqset_record_id) ON DELETE CASCADE;


--
-- Name: pending_release_notifications pending_release_notifications_accession_version_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--

ALTER TABLE ONLY public.pending_release_notifications
ADD CONSTRAINT pending_release_notifications_accession_version_fkey FOREIGN KEY (accession, version) REFERENCES public.sequence_entries(accession, version) ON DELETE CASCADE;


--
-- Name: sequence_entries sequence_entries_group_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
Expand Down
3 changes: 3 additions & 0 deletions backend/gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClass
org.ec4j.core:ec4j-core:1.1.1=ktlint,ktlintBaselineReporter,ktlintRuleset
org.eclipse.angus:angus-activation:2.0.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.angus:angus-mail:2.0.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.angus:jakarta.mail:2.0.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.microprofile.openapi:microprofile-openapi-api:4.1.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.flywaydb:flyway-core:11.7.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.flywaydb:flyway-database-postgresql:11.7.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
Expand Down Expand Up @@ -269,6 +270,7 @@ org.springframework.boot:spring-boot-starter-aop:3.5.7=compileClasspath,producti
org.springframework.boot:spring-boot-starter-jdbc:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-json:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-mail:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:3.5.7=testCompileClasspath,testRuntimeClasspath
Expand All @@ -288,6 +290,7 @@ org.springframework.security:spring-security-oauth2-resource-server:6.5.6=compil
org.springframework.security:spring-security-web:6.5.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:6.2.12=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:6.2.12=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context-support:6.2.12=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:6.2.12=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:6.2.12=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:6.2.12=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ object BackendSpringProperty {
const val ENABLE_SEQSETS = "loculus.enable-seqsets"
const val SEQSET_CITATIONS_RUN_EVERY_MINUTES = "loculus.seqset-citations.run-every-minutes"
const val CLEAN_UP_AUX_TABLE_RUN_EVERY_HOURS = "loculus.maintenance.clean-up-aux-table.run-every-hours"
const val RELEASE_CONFIRMATION_EMAILS_ENABLED = "loculus.release-confirmation-emails.enabled"
const val RELEASE_CONFIRMATION_EMAILS_RUN_EVERY_SECONDS =
"loculus.release-confirmation-emails.run-every-seconds"
const val RELEASE_CONFIRMATION_EMAILS_FROM = "loculus.release-confirmation-emails.from"
const val RELEASE_CONFIRMATION_EMAILS_REPLY_TO = "loculus.release-confirmation-emails.reply-to"

const val S3_ENABLED = "loculus.s3.enabled"
const val S3_GC_ENABLED = "loculus.s3.gc-enabled"
Expand All @@ -59,6 +64,7 @@ object BackendSpringProperty {

const val DEBUG_MODE_ON_VALUE = "true"
const val ENABLE_SEQSETS_TRUE_VALUE = "true"
const val RELEASE_CONFIRMATION_EMAILS_ENABLED_VALUE = "true"

private val logger = mu.KotlinLogging.logger {}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.loculus.backend.service.notification

import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
import org.loculus.backend.api.AccessionVersionInterface
import org.loculus.backend.api.toPairs

const val PENDING_RELEASE_NOTIFICATIONS_TABLE_NAME = "pending_release_notifications"

object PendingReleaseNotificationsTable : Table(PENDING_RELEASE_NOTIFICATIONS_TABLE_NAME) {
val accessionColumn = text("accession")
val versionColumn = long("version")
val enqueuedAtColumn = datetime("enqueued_at")

override val primaryKey = PrimaryKey(accessionColumn, versionColumn)

fun accessionVersionIsIn(accessionVersions: List<AccessionVersionInterface>) =
Pair(accessionColumn, versionColumn) inList accessionVersions.toPairs()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package org.loculus.backend.service.notification

import jakarta.mail.internet.AddressException
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeMessage
import org.loculus.backend.config.BackendConfig
import org.loculus.backend.config.BackendSpringProperty
import org.loculus.backend.config.RELEASE_CONFIRMATION_EMAILS_ENABLED_VALUE
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.MimeMessageHelper
import org.springframework.stereotype.Service
import java.nio.charset.StandardCharsets

@Service
@ConditionalOnProperty(
BackendSpringProperty.RELEASE_CONFIRMATION_EMAILS_ENABLED,
havingValue = RELEASE_CONFIRMATION_EMAILS_ENABLED_VALUE,
)
class ReleaseConfirmationEmailService(
private val mailSender: JavaMailSender,
private val backendConfig: BackendConfig,
@Value("\${${BackendSpringProperty.RELEASE_CONFIRMATION_EMAILS_FROM}}") private val from: String,
@Value("\${${BackendSpringProperty.RELEASE_CONFIRMATION_EMAILS_REPLY_TO}}") private val replyTo: String,
) {
init {
validateConfiguredAddress("from", from, required = true)
validateConfiguredAddress("reply-to", replyTo, required = false)
}

fun sendReleaseConfirmation(
recipientEmail: String,
ccEmail: String?,
content: ReleaseNotificationContent,
messageId: String,
) {
require(content.totalCount > 0) { "Cannot send an empty release confirmation" }

val message = mailSender.createMimeMessage()
populateMessage(message, recipientEmail, ccEmail, content, messageId)
mailSender.send(message)
}

private fun populateMessage(
message: MimeMessage,
recipientEmail: String,
ccEmail: String?,
content: ReleaseNotificationContent,
messageId: String,
) {
val helper = MimeMessageHelper(message, false, StandardCharsets.UTF_8.name())
helper.setFrom(from)
helper.setTo(recipientEmail)
if (ccEmail != null) {
helper.setCc(ccEmail)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not let an invalid group CC block confirmations

This makes the group contact address a required SMTP recipient for every confirmation. If a group has an invalid or stale contactEmail (the backend stores it as an unconstrained string), setCc/SMTP delivery can fail or partially send, and the surrounding catch leaves the whole approver/group batch pending, so the valid approver either never gets the confirmation or gets duplicate retries. Validate/skip the CC independently or avoid letting CC failures block recording the To delivery.

Useful? React with 👍 / 👎.

}
if (replyTo.isNotBlank()) {
helper.setReplyTo(replyTo)
}
helper.setSubject(buildSubject(content))
helper.setText(buildBody(content, copiedToGroup = ccEmail != null), false)
message.setHeader("Message-ID", messageId)
}

private fun buildSubject(content: ReleaseNotificationContent): String {
val count = content.totalCount
val sequenceWord = if (count == 1L) "sequence" else "sequences"
return "Loculus: $count $sequenceWord released for ${content.groupName}"
}

private fun buildBody(content: ReleaseNotificationContent, copiedToGroup: Boolean): String {
val count = content.totalCount
val sequenceWord = if (count == 1L) "sequence was" else "sequences were"
val lines = mutableListOf(
"Hello ${content.approver},",
"",
"$count $sequenceWord successfully released for ${content.groupName}.",
)
buildKindBreakdown(content.kindCounts)?.let { lines += it }
lines += ""

content.organisms.forEach { organismSummary ->
lines += organismSummary.organism
organismSummary.accessions.forEach { lines += "- ${formatAccession(it)}" }
val omittedCount = organismSummary.count - organismSummary.accessions.size.toLong()
if (omittedCount > 0) {
lines += "- …and $omittedCount more"
}
lines += "${backendConfig.websiteUrl}/${organismSummary.organism}/submission/${content.groupId}/released"
lines += ""
}

lines += if (copiedToGroup) {
"This message was sent to the user who approved the release and copied to the group's contact email."
} else {
"This message was sent to the user who approved the release."
}
return lines.joinToString("\n")
}

private fun formatAccession(released: ReleasedAccessionVersion): String {
val accessionVersion = "${released.accessionVersion.accession}.${released.accessionVersion.version}"
return when (released.kind) {
ReleaseKind.NEW -> accessionVersion
ReleaseKind.REVISION -> "$accessionVersion (revision)"
ReleaseKind.REVOCATION -> "$accessionVersion (revocation)"
}
}

/**
* Summarizes the release by kind, e.g. "This included 2 new, 1 revised.". Returns null when everything is a new
* submission, so the common case stays uncluttered.
*/
private fun buildKindBreakdown(kindCounts: Map<ReleaseKind, Long>): String? {
val newCount = kindCounts[ReleaseKind.NEW] ?: 0
val revisedCount = kindCounts[ReleaseKind.REVISION] ?: 0
val revokedCount = kindCounts[ReleaseKind.REVOCATION] ?: 0
if (revisedCount == 0L && revokedCount == 0L) return null

val parts = buildList {
if (newCount > 0) add("$newCount new")
if (revisedCount > 0) add("$revisedCount revised")
if (revokedCount > 0) add("$revokedCount revoked")
}
return "This included ${parts.joinToString(", ")}."
}

private fun validateConfiguredAddress(propertyName: String, value: String, required: Boolean) {
if (!required && value.isBlank()) return
require(value.isNotBlank()) { "Release-confirmation email $propertyName address must not be blank" }
require(parseSingleInternetAddress(value) != null) {
"Release-confirmation email $propertyName address is invalid"
}
}
}

/** Parses [value] as exactly one valid email address, returning null if it is blank, malformed, or a list. */
internal fun parseSingleInternetAddress(value: String): InternetAddress? = try {
InternetAddress.parse(value, true).singleOrNull()?.also { it.validate() }
} catch (_: AddressException) {
null
}
Loading
Loading