-
Notifications
You must be signed in to change notification settings - Fork 11
feat(backend): send release confirmation emails #6905
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
theosanderson-agent
wants to merge
11
commits into
main
Choose a base branch
from
agent/release-confirmation-emails
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,303
−1
Draft
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
15180d9
feat(backend): send release confirmation emails
theosanderson 264f3ed
chore(backend): regenerate database schema
theosanderson 595cd09
Update schema documentation based on migration changes
actions-user ac359f7
chore(backend): rerun CI after schema generation
theosanderson e12515b
refactor(backend): queue pending release notifications instead of log…
theosanderson 63e5d77
Update schema documentation based on migration changes
actions-user 9730306
refactor(backend): denormalize pending release notifications queue
theosanderson d1fcc44
refactor(backend): drop vestigial group partitioning from release path
theosanderson 4971f8d
refactor(backend): render release email from pre-sorted summary
theosanderson a7976f2
feat(backend): distinguish new, revised, and revoked in release emails
theosanderson bb92430
refactor(backend): trim email-sending plumbing
theosanderson File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
20 changes: 20 additions & 0 deletions
20
.../main/kotlin/org/loculus/backend/service/notification/PendingReleaseNotificationsTable.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| } |
143 changes: 143 additions & 0 deletions
143
...c/main/kotlin/org/loculus/backend/service/notification/ReleaseConfirmationEmailService.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| 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 | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎.