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 "content" -> content
) )
def defaultTitle: String def defaultTitle: Either[String, String]
def defaultTitleHtml: String def defaultTitleHtml: Either[String, String]
def defaultBody: String def defaultBody: Either[String, String]
def defaultBodyHtml: String def defaultBodyHtml: Either[String, String]
def defaultBoth: String def defaultBoth: Either[String, String]
def defaultBothHtml: String def defaultBothHtml: Either[String, String]
lazy val asJsonWithMessage: Json = { lazy val asJsonWithMessage: Either[String, Json] =
val data = asJson for {
val msg = Json.obj( tt1 <- defaultTitle
tb1 <- defaultBody
tt2 <- defaultTitleHtml
tb2 <- defaultBodyHtml
data = asJson
msg = Json.obj(
"message" -> Json.obj( "message" -> Json.obj(
"title" -> defaultTitle.asJson, "title" -> tt1.asJson,
"body" -> defaultBody.asJson "body" -> tb1.asJson
), ),
"messageHtml" -> Json.obj( "messageHtml" -> Json.obj(
"title" -> defaultTitleHtml.asJson, "title" -> tt2.asJson,
"body" -> defaultBodyHtml.asJson "body" -> tb2.asJson
) )
) )
} yield data.withObject(o1 => msg.withObject(o2 => o1.deepMerge(o2).asJson))
data.withObject(o1 => msg.withObject(o2 => o1.deepMerge(o2).asJson))
}
} }
object EventContext { object EventContext {
@ -62,12 +65,12 @@ object EventContext {
new EventContext { new EventContext {
val event = ev val event = ev
def content = Json.obj() def content = Json.obj()
def defaultTitle = "" def defaultTitle = Right("")
def defaultTitleHtml = "" def defaultTitleHtml = Right("")
def defaultBody = "" def defaultBody = Right("")
def defaultBodyHtml = "" def defaultBodyHtml = Right("")
def defaultBoth: String = "" def defaultBoth = Right("")
def defaultBothHtml: String = "" def defaultBothHtml = Right("")
} }
/** For an event, the context can be created that is usually amended with more /** 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 { 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 = def render(template: Template): String =
asJson.render(template).trim() asJson.render(template).trim()
@ -24,33 +24,39 @@ abstract class AbstractEventContext extends EventContext {
def renderHtml(template: Template): String = def renderHtml(template: Template): String =
Markdown.toHtml(render(template)) Markdown.toHtml(render(template))
lazy val defaultTitle: String = lazy val defaultTitle: Either[String, String] =
render(titleTemplate) titleTemplate.map(render)
lazy val defaultTitleHtml: String = lazy val defaultTitleHtml: Either[String, String] =
renderHtml(titleTemplate) titleTemplate.map(renderHtml)
lazy val defaultBody: String = lazy val defaultBody: Either[String, String] =
render(bodyTemplate) bodyTemplate.map(render)
lazy val defaultBodyHtml: String = lazy val defaultBodyHtml: Either[String, String] =
renderHtml(bodyTemplate) bodyTemplate.map(renderHtml)
lazy val defaultBoth: String = lazy val defaultBoth: Either[String, String] =
render( for {
tt <- titleTemplate
tb <- bodyTemplate
} yield render(
AbstractEventContext.concat( AbstractEventContext.concat(
titleTemplate, tt,
AbstractEventContext.sepTemplate, AbstractEventContext.sepTemplate,
bodyTemplate tb
) )
) )
lazy val defaultBothHtml: String = lazy val defaultBothHtml: Either[String, String] =
renderHtml( for {
tt <- titleTemplate
tb <- bodyTemplate
} yield renderHtml(
AbstractEventContext.concat( AbstractEventContext.concat(
titleTemplate, tt,
AbstractEventContext.sepTemplate, AbstractEventContext.sepTemplate,
bodyTemplate tb
) )
) )
} }

View File

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

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,17 +23,19 @@ final class GotifyBackend[F[_]: Async](
channel: NotificationChannel.Gotify, channel: NotificationChannel.Gotify,
client: Client[F], client: Client[F],
logger: Logger[F] logger: Logger[F]
) extends NotificationBackend[F] { ) extends NotificationBackend[F]
with EventContextSyntax {
val dsl = new Http4sDsl[F] with Http4sClientDsl[F] {} val dsl = new Http4sDsl[F] with Http4sClientDsl[F] {}
import dsl._ import dsl._
def send(event: EventContext): F[Unit] = { def send(event: EventContext): F[Unit] =
event.withDefault(logger) { (title, body) =>
val url = Uri.unsafeFromString((channel.url / "message").asString) val url = Uri.unsafeFromString((channel.url / "message").asString)
val req = POST( val req = POST(
Json.obj( Json.obj(
"title" -> Json.fromString(event.defaultTitle), "title" -> Json.fromString(title),
"message" -> Json.fromString(event.defaultBody), "message" -> Json.fromString(body),
"extras" -> Json.obj( "extras" -> Json.obj(
"client::display" -> Json.obj( "client::display" -> Json.obj(
"contentType" -> Json.fromString("text/markdown") "contentType" -> Json.fromString("text/markdown")

View File

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

View File

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

View File

@ -30,9 +30,9 @@ final case class DeleteFieldValueCtx(
val content = data.asJson val content = data.asJson
val titleTemplate = mustache"{{eventType}} (by *{{account.user}}*)" val titleTemplate = Right(mustache"{{eventType}} (by *{{account.user}}*)")
val bodyTemplate = 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 content = data.asJson
val titleTemplate = mustache"Your items" val titleTemplate = Right(mustache"Your items")
val bodyTemplate = mustache""" val bodyTemplate = Right(mustache"""
Hello {{{ content.username }}}, Hello {{{ content.username }}},
this is Docspell informing you about your next items. this is Docspell informing you about your next items.
@ -51,7 +51,7 @@ this is Docspell informing you about your next items.
Sincerely yours, Sincerely yours,
Docspell Docspell
""" """)
} }
object ItemSelectionCtx { object ItemSelectionCtx {
@ -113,5 +113,4 @@ object ItemSelectionCtx {
account.user.id account.user.id
) )
} }
} }

View File

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

View File

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

View File

@ -28,9 +28,9 @@ final case class SetFieldValueCtx(event: Event.SetFieldValue, data: SetFieldValu
val content = data.asJson val content = data.asJson
val titleTemplate = mustache"{{eventType}} (by *{{account.user}}*)" val titleTemplate = Right(mustache"{{eventType}} (by *{{account.user}}*)")
val bodyTemplate = 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 content = data.asJson
val titleTemplate = mustache"{{eventType}} (by *{{account.user}}*)" val titleTemplate = Right(mustache"{{eventType}} (by *{{account.user}}*)")
val bodyTemplate = 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 { object TagsChangedCtx {

View File

@ -162,7 +162,11 @@ object NotificationRoutes {
user.account, user.account,
baseUrl.some 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 } yield resp
} }
} }