Allow notification templates to fail

Templates were hardcoded. To make them dynamic, errors must be handled.
This commit is contained in:
eikek 2022-01-11 21:49:39 +01:00
parent 42d631876d
commit dd9937740a
14 changed files with 165 additions and 116 deletions

View File

@ -31,30 +31,33 @@ trait EventContext {
"content" -> content
)
def defaultTitle: String
def defaultTitleHtml: String
def defaultTitle: Either[String, String]
def defaultTitleHtml: Either[String, String]
def defaultBody: String
def defaultBodyHtml: String
def defaultBody: Either[String, String]
def defaultBodyHtml: Either[String, String]
def defaultBoth: String
def defaultBothHtml: String
def defaultBoth: Either[String, String]
def defaultBothHtml: Either[String, String]
lazy val asJsonWithMessage: Json = {
val data = asJson
val msg = Json.obj(
"message" -> Json.obj(
"title" -> defaultTitle.asJson,
"body" -> defaultBody.asJson
),
"messageHtml" -> Json.obj(
"title" -> defaultTitleHtml.asJson,
"body" -> defaultBodyHtml.asJson
lazy val asJsonWithMessage: Either[String, Json] =
for {
tt1 <- defaultTitle
tb1 <- defaultBody
tt2 <- defaultTitleHtml
tb2 <- defaultBodyHtml
data = asJson
msg = Json.obj(
"message" -> Json.obj(
"title" -> tt1.asJson,
"body" -> tb1.asJson
),
"messageHtml" -> Json.obj(
"title" -> tt2.asJson,
"body" -> tb2.asJson
)
)
)
data.withObject(o1 => msg.withObject(o2 => o1.deepMerge(o2).asJson))
}
} yield data.withObject(o1 => msg.withObject(o2 => o1.deepMerge(o2).asJson))
}
object EventContext {
@ -62,12 +65,12 @@ object EventContext {
new EventContext {
val event = ev
def content = Json.obj()
def defaultTitle = ""
def defaultTitleHtml = ""
def defaultBody = ""
def defaultBodyHtml = ""
def defaultBoth: String = ""
def defaultBothHtml: String = ""
def defaultTitle = Right("")
def defaultTitleHtml = Right("")
def defaultBody = Right("")
def defaultBodyHtml = Right("")
def defaultBoth = Right("")
def defaultBothHtml = Right("")
}
/** For an event, the context can be created that is usually amended with more

View File

@ -14,9 +14,9 @@ import yamusca.imports._
abstract class AbstractEventContext extends EventContext {
def titleTemplate: Template
def titleTemplate: Either[String, Template]
def bodyTemplate: Template
def bodyTemplate: Either[String, Template]
def render(template: Template): String =
asJson.render(template).trim()
@ -24,33 +24,39 @@ abstract class AbstractEventContext extends EventContext {
def renderHtml(template: Template): String =
Markdown.toHtml(render(template))
lazy val defaultTitle: String =
render(titleTemplate)
lazy val defaultTitle: Either[String, String] =
titleTemplate.map(render)
lazy val defaultTitleHtml: String =
renderHtml(titleTemplate)
lazy val defaultTitleHtml: Either[String, String] =
titleTemplate.map(renderHtml)
lazy val defaultBody: String =
render(bodyTemplate)
lazy val defaultBody: Either[String, String] =
bodyTemplate.map(render)
lazy val defaultBodyHtml: String =
renderHtml(bodyTemplate)
lazy val defaultBodyHtml: Either[String, String] =
bodyTemplate.map(renderHtml)
lazy val defaultBoth: String =
render(
lazy val defaultBoth: Either[String, String] =
for {
tt <- titleTemplate
tb <- bodyTemplate
} yield render(
AbstractEventContext.concat(
titleTemplate,
tt,
AbstractEventContext.sepTemplate,
bodyTemplate
tb
)
)
lazy val defaultBothHtml: String =
renderHtml(
lazy val defaultBothHtml: Either[String, String] =
for {
tt <- titleTemplate
tb <- bodyTemplate
} yield renderHtml(
AbstractEventContext.concat(
titleTemplate,
tt,
AbstractEventContext.sepTemplate,
bodyTemplate
tb
)
)
}

View File

@ -19,22 +19,24 @@ final class EmailBackend[F[_]: Sync](
channel: NotificationChannel.Email,
mailService: Emil[F],
logger: Logger[F]
) extends NotificationBackend[F] {
) extends NotificationBackend[F]
with EventContextSyntax {
import emil.builder._
def send(event: EventContext): F[Unit] = {
val mail =
MailBuilder.build(
From(channel.from),
Tos(channel.recipients.toList),
Subject(event.defaultTitle),
MarkdownBody[F](event.defaultBody)
)
def send(event: EventContext): F[Unit] =
event.withDefault(logger) { (title, body) =>
val mail =
MailBuilder.build(
From(channel.from),
Tos(channel.recipients.toList),
Subject(title),
MarkdownBody[F](body)
)
logger.debug(s"Attempting to send notification mail: $channel") *>
mailService(channel.config)
.send(mail)
.flatMap(msgId => logger.info(s"Send notification mail ${msgId.head}"))
}
logger.debug(s"Attempting to send notification mail: $channel") *>
mailService(channel.config)
.send(mail)
.flatMap(msgId => logger.info(s"Send notification mail ${msgId.head}"))
}
}

View File

@ -0,0 +1,30 @@
package docspell.notification.impl
import docspell.notification.api.EventContext
import docspell.common.Logger
import io.circe.Json
trait EventContextSyntax {
private def logError[F[_]](logger: Logger[F])(reason: String): F[Unit] =
logger.error(s"Unable to send notification, the template failed to render: $reason")
implicit final class EventContextOps(self: EventContext) {
def withDefault[F[_]](logger: Logger[F])(f: (String, String) => F[Unit]): F[Unit] =
(for {
tt <- self.defaultTitle
tb <- self.defaultBody
} yield f(tt, tb)).fold(logError(logger), identity)
def withJsonMessage[F[_]](logger: Logger[F])(f: Json => F[Unit]): F[Unit] =
self.asJsonWithMessage match {
case Right(m) => f(m)
case Left(err) => logError(logger)(err)
}
def withDefaultBoth[F[_]](logger: Logger[F])(f: (String, String) => F[Unit]): F[Unit] =
(for {
md <- self.defaultBoth
html <- self.defaultBothHtml
} yield f(md, html)).fold(logError(logger), identity)
}
}

View File

@ -23,27 +23,29 @@ final class GotifyBackend[F[_]: Async](
channel: NotificationChannel.Gotify,
client: Client[F],
logger: Logger[F]
) extends NotificationBackend[F] {
) extends NotificationBackend[F]
with EventContextSyntax {
val dsl = new Http4sDsl[F] with Http4sClientDsl[F] {}
import dsl._
def send(event: EventContext): F[Unit] = {
val url = Uri.unsafeFromString((channel.url / "message").asString)
val req = POST(
Json.obj(
"title" -> Json.fromString(event.defaultTitle),
"message" -> Json.fromString(event.defaultBody),
"extras" -> Json.obj(
"client::display" -> Json.obj(
"contentType" -> Json.fromString("text/markdown")
def send(event: EventContext): F[Unit] =
event.withDefault(logger) { (title, body) =>
val url = Uri.unsafeFromString((channel.url / "message").asString)
val req = POST(
Json.obj(
"title" -> Json.fromString(title),
"message" -> Json.fromString(body),
"extras" -> Json.obj(
"client::display" -> Json.obj(
"contentType" -> Json.fromString("text/markdown")
)
)
)
),
url
)
.putHeaders("X-Gotify-Key" -> channel.appKey.pass)
logger.debug(s"Seding request: $req") *>
HttpSend.sendRequest(client, req, channel, logger)
}
),
url
)
.putHeaders("X-Gotify-Key" -> channel.appKey.pass)
logger.debug(s"Seding request: $req") *>
HttpSend.sendRequest(client, req, channel, logger)
}
}

View File

@ -21,16 +21,18 @@ final class HttpPostBackend[F[_]: Async](
channel: NotificationChannel.HttpPost,
client: Client[F],
logger: Logger[F]
) extends NotificationBackend[F] {
) extends NotificationBackend[F]
with EventContextSyntax {
val dsl = new Http4sDsl[F] with Http4sClientDsl[F] {}
import dsl._
import org.http4s.circe.CirceEntityCodec._
def send(event: EventContext): F[Unit] = {
val url = Uri.unsafeFromString(channel.url.asString)
val req = POST(event.asJsonWithMessage, url).putHeaders(channel.headers.toList)
logger.debug(s"$channel sending request: $req") *>
HttpSend.sendRequest(client, req, channel, logger)
}
def send(event: EventContext): F[Unit] =
event.withJsonMessage(logger) { json =>
val url = Uri.unsafeFromString(channel.url.asString)
val req = POST(json, url).putHeaders(channel.headers.toList)
logger.debug(s"$channel sending request: $req") *>
HttpSend.sendRequest(client, req, channel, logger)
}
}

View File

@ -20,26 +20,28 @@ final class MatrixBackend[F[_]: Async](
channel: NotificationChannel.Matrix,
client: Client[F],
logger: Logger[F]
) extends NotificationBackend[F] {
) extends NotificationBackend[F]
with EventContextSyntax {
val dsl = new Http4sDsl[F] with Http4sClientDsl[F] {}
import dsl._
import org.http4s.circe.CirceEntityCodec._
def send(event: EventContext): F[Unit] = {
val url =
(channel.homeServer / "_matrix" / "client" / "r0" / "rooms" / channel.roomId / "send" / "m.room.message")
.withQuery("access_token", channel.accessToken.pass)
val uri = Uri.unsafeFromString(url.asString)
val req = POST(
Map(
"msgtype" -> channel.messageType,
"format" -> "org.matrix.custom.html",
"formatted_body" -> event.defaultBothHtml,
"body" -> event.defaultBoth
),
uri
)
HttpSend.sendRequest(client, req, channel, logger)
}
def send(event: EventContext): F[Unit] =
event.withDefaultBoth(logger) { (md, html) =>
val url =
(channel.homeServer / "_matrix" / "client" / "r0" / "rooms" / channel.roomId / "send" / "m.room.message")
.withQuery("access_token", channel.accessToken.pass)
val uri = Uri.unsafeFromString(url.asString)
val req = POST(
Map(
"msgtype" -> channel.messageType,
"format" -> "org.matrix.custom.html",
"formatted_body" -> html,
"body" -> md
),
uri
)
HttpSend.sendRequest(client, req, channel, logger)
}
}

View File

@ -30,9 +30,9 @@ final case class DeleteFieldValueCtx(
val content = data.asJson
val titleTemplate = mustache"{{eventType}} (by *{{account.user}}*)"
val titleTemplate = Right(mustache"{{eventType}} (by *{{account.user}}*)")
val bodyTemplate =
mustache"""{{#content}}{{#field.label}}*{{field.label}}* {{/field.label}}{{^field.label}}*{{field.name}}* {{/field.label}} was removed from {{#items}}{{^-first}}, {{/-first}}{{#itemUrl}}[`{{name}}`]({{{itemUrl}}}/{{{id}}}){{/itemUrl}}{{^itemUrl}}`{{name}}`{{/itemUrl}}{{/items}}.{{/content}}"""
Right(mustache"""{{#content}}{{#field.label}}*{{field.label}}* {{/field.label}}{{^field.label}}*{{field.name}}* {{/field.label}} was removed from {{#items}}{{^-first}}, {{/-first}}{{#itemUrl}}[`{{name}}`]({{{itemUrl}}}/{{{id}}}){{/itemUrl}}{{^itemUrl}}`{{name}}`{{/itemUrl}}{{/items}}.{{/content}}""")
}

View File

@ -25,8 +25,8 @@ final case class ItemSelectionCtx(event: Event.ItemSelection, data: ItemSelectio
val content = data.asJson
val titleTemplate = mustache"Your items"
val bodyTemplate = mustache"""
val titleTemplate = Right(mustache"Your items")
val bodyTemplate = Right(mustache"""
Hello {{{ content.username }}},
this is Docspell informing you about your next items.
@ -51,7 +51,7 @@ this is Docspell informing you about your next items.
Sincerely yours,
Docspell
"""
""")
}
object ItemSelectionCtx {
@ -113,5 +113,4 @@ object ItemSelectionCtx {
account.user.id
)
}
}

View File

@ -22,8 +22,8 @@ final case class JobDoneCtx(event: Event.JobDone, data: JobDoneCtx.Data)
val content = data.asJson
val titleTemplate = mustache"{{eventType}} (by *{{account.user}}*)"
val bodyTemplate = mustache"""{{#content}}_'{{subject}}'_ finished {{/content}}"""
val titleTemplate = Right(mustache"{{eventType}} (by *{{account.user}}*)")
val bodyTemplate = Right(mustache"""{{#content}}_'{{subject}}'_ finished {{/content}}""")
}
object JobDoneCtx {

View File

@ -22,9 +22,9 @@ final case class JobSubmittedCtx(event: Event.JobSubmitted, data: JobSubmittedCt
val content = data.asJson
val titleTemplate = mustache"{{eventType}} (by *{{account.user}}*)"
val titleTemplate = Right(mustache"{{eventType}} (by *{{account.user}}*)")
val bodyTemplate =
mustache"""{{#content}}_'{{subject}}'_ submitted by {{submitter}} {{/content}}"""
Right(mustache"""{{#content}}_'{{subject}}'_ submitted by {{submitter}} {{/content}}""")
}
object JobSubmittedCtx {

View File

@ -28,9 +28,9 @@ final case class SetFieldValueCtx(event: Event.SetFieldValue, data: SetFieldValu
val content = data.asJson
val titleTemplate = mustache"{{eventType}} (by *{{account.user}}*)"
val titleTemplate = Right(mustache"{{eventType}} (by *{{account.user}}*)")
val bodyTemplate =
mustache"""{{#content}}{{#field.label}}*{{field.label}}* {{/field.label}}{{^field.label}}*{{field.name}}* {{/field.label}} was set to '{{value}}' on {{#items}}{{^-first}}, {{/-first}}{{#itemUrl}}[`{{name}}`]({{{itemUrl}}}/{{{id}}}){{/itemUrl}}{{^itemUrl}}`{{name}}`{{/itemUrl}}{{/items}}.{{/content}}"""
Right(mustache"""{{#content}}{{#field.label}}*{{field.label}}* {{/field.label}}{{^field.label}}*{{field.name}}* {{/field.label}} was set to '{{value}}' on {{#items}}{{^-first}}, {{/-first}}{{#itemUrl}}[`{{name}}`]({{{itemUrl}}}/{{{id}}}){{/itemUrl}}{{^itemUrl}}`{{name}}`{{/itemUrl}}{{/items}}.{{/content}}""")
}

View File

@ -26,10 +26,9 @@ final case class TagsChangedCtx(event: Event.TagsChanged, data: TagsChangedCtx.D
val content = data.asJson
val titleTemplate = mustache"{{eventType}} (by *{{account.user}}*)"
val titleTemplate = Right(mustache"{{eventType}} (by *{{account.user}}*)")
val bodyTemplate =
mustache"""{{#content}}{{#added}}{{#-first}}Adding {{/-first}}{{^-first}}, {{/-first}}*{{name}}*{{/added}}{{#removed}}{{#added}}{{#-first}};{{/-first}}{{/added}}{{#-first}} Removing {{/-first}}{{^-first}}, {{/-first}}*{{name}}*{{/removed}} on {{#items}}{{^-first}}, {{/-first}}{{#itemUrl}}[`{{name}}`]({{{itemUrl}}}/{{{id}}}){{/itemUrl}}{{^itemUrl}}`{{name}}`{{/itemUrl}}{{/items}}.{{/content}}"""
Right(mustache"""{{#content}}{{#added}}{{#-first}}Adding {{/-first}}{{^-first}}, {{/-first}}*{{name}}*{{/added}}{{#removed}}{{#added}}{{#-first}};{{/-first}}{{/added}}{{#-first}} Removing {{/-first}}{{^-first}}, {{/-first}}*{{name}}*{{/removed}} on {{#items}}{{^-first}}, {{/-first}}{{#itemUrl}}[`{{name}}`]({{{itemUrl}}}/{{{id}}}){{/itemUrl}}{{^itemUrl}}`{{name}}`{{/itemUrl}}{{/items}}.{{/content}}""")
}
object TagsChangedCtx {

View File

@ -162,7 +162,11 @@ object NotificationRoutes {
user.account,
baseUrl.some
)
resp <- Ok(data.asJsonWithMessage)
resp <- data.asJsonWithMessage match {
case Right(m) => Ok(m)
case Left(err) =>
BadRequest(BasicResult(false, s"Unable to render message: $err"))
}
} yield resp
}
}