diff --git a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala index 3bc993f9..5c4a01a1 100644 --- a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala +++ b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala @@ -170,13 +170,6 @@ object JoexAppImpl extends MailAddressCodec { ReProcessItem.onCancel[F] ) ) - .withTask( - JobTask.json( - NotifyDueItemsArgs.taskName, - NotifyDueItemsTask[F](cfg.sendMail, javaEmil), - NotifyDueItemsTask.onCancel[F] - ) - ) .withTask( JobTask.json( ScanMailboxArgs.taskName, diff --git a/modules/joex/src/main/scala/docspell/joex/notify/MailContext.scala b/modules/joex/src/main/scala/docspell/joex/notify/MailContext.scala deleted file mode 100644 index 55a8b991..00000000 --- a/modules/joex/src/main/scala/docspell/joex/notify/MailContext.scala +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2020 Eike K. & Contributors - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package docspell.joex.notify - -import docspell.common._ -import docspell.joex.notify.YamuscaConverter._ -import docspell.store.queries.ListItem - -import yamusca.implicits._ -import yamusca.imports._ - -/** The context for rendering the e-mail template. */ -case class MailContext( - items: List[MailContext.ItemData], - more: Boolean, - account: AccountId, - username: String, - itemUri: Option[LenientUri] -) - -object MailContext { - - def from( - items: Vector[ListItem], - max: Int, - account: AccountId, - itemBaseUri: Option[LenientUri], - now: Timestamp - ): MailContext = - MailContext( - items.take(max - 1).map(ItemData(now)).toList.sortBy(_.dueDate), - items.sizeCompare(max) >= 0, - account, - account.user.id.capitalize, - itemBaseUri - ) - - case class ItemData( - id: Ident, - name: String, - date: Timestamp, - dueDate: Option[Timestamp], - source: String, - overDue: Boolean, - dueIn: Option[String], - corrOrg: Option[String] - ) - - object ItemData { - - def apply(now: Timestamp)(i: ListItem): ItemData = { - val dueIn = i.dueDate.map(dt => Timestamp.daysBetween(now, dt)) - val dueInLabel = dueIn.map { - case 0 => "**today**" - case 1 => "**tomorrow**" - case -1 => s"**yesterday**" - case n if n > 0 => s"in $n days" - case n => s"${n * -1} days ago" - } - ItemData( - i.id, - i.name, - i.date, - i.dueDate, - i.source, - dueIn.exists(_ < 0), - dueInLabel, - i.corrOrg.map(_.name) - ) - } - - implicit def yamusca: ValueConverter[ItemData] = - ValueConverter.deriveConverter[ItemData] - } - - implicit val yamusca: ValueConverter[MailContext] = - ValueConverter.deriveConverter[MailContext] - -} diff --git a/modules/joex/src/main/scala/docspell/joex/notify/MailTemplate.scala b/modules/joex/src/main/scala/docspell/joex/notify/MailTemplate.scala deleted file mode 100644 index edd3f4e9..00000000 --- a/modules/joex/src/main/scala/docspell/joex/notify/MailTemplate.scala +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2020 Eike K. & Contributors - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package docspell.joex.notify - -import yamusca.implicits._ - -object MailTemplate { - - val text = mustache""" -Hello {{{ username }}}, - -this is Docspell informing you about your next due items coming up. - -{{#itemUri}} -{{#items}} -- {{#overDue}}**(OVERDUE)** {{/overDue}}[{{name}}]({{itemUri}}/{{id}}), - {{#overDue}}was {{/overDue}}due {{dueIn}} on *{{dueDate}}*; {{#corrOrg}}from {{corrOrg}}{{/corrOrg}} - received on {{date}} via {{source}} -{{/items}} -{{/itemUri}} -{{^itemUri}} -{{#items}} -- {{#overDue}}**(OVERDUE)** {{/overDue}}*{{name}}*, - {{#overDue}}was {{/overDue}}due {{dueIn}} on *{{dueDate}}*; {{#corrOrg}}from {{corrOrg}}{{/corrOrg}} - received on {{date}} via {{source}} -{{/items}} -{{/itemUri}} -{{#more}} -- … more have been left out for brevity -{{/more}} - - -Sincerely yours, - -Docspell -""" - - def render(mc: MailContext): String = - mc.render(text) -} diff --git a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala deleted file mode 100644 index 508e40cc..00000000 --- a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright 2020 Eike K. & Contributors - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package docspell.joex.notify - -import cats.data.{NonEmptyList => Nel, OptionT} -import cats.effect._ -import cats.implicits._ - -import docspell.backend.ops.OItemSearch.{Batch, ListItem, Query} -import docspell.common._ -import docspell.joex.mail.EmilHeader -import docspell.joex.scheduler.{Context, Task} -import docspell.query.Date -import docspell.query.ItemQuery._ -import docspell.query.ItemQueryDsl._ -import docspell.store.queries.QItem -import docspell.store.records._ - -import emil._ -import emil.builder._ -import emil.javamail.syntax._ -import emil.markdown._ - -object NotifyDueItemsTask { - val maxItems: Int = 7 - type Args = NotifyDueItemsArgs - - def apply[F[_]: Sync](cfg: MailSendConfig, emil: Emil[F]): Task[F, Args, Unit] = - Task { ctx => - for { - _ <- ctx.logger.info("Getting mail configuration") - mailCfg <- getMailSettings(ctx) - _ <- ctx.logger.info( - s"Searching for items due in ${ctx.args.remindDays} days…." - ) - _ <- createMail(cfg, mailCfg, ctx) - .semiflatMap { mail => - for { - _ <- ctx.logger.info(s"Sending notification mail to ${ctx.args.recipients}") - res <- emil(mailCfg.toMailConfig).send(mail).map(_.head) - _ <- ctx.logger.info(s"Sent mail with id: $res") - } yield () - } - .getOrElseF(ctx.logger.info("No items found")) - } yield () - } - - def onCancel[F[_]]: Task[F, NotifyDueItemsArgs, Unit] = - Task.log(_.warn("Cancelling notify-due-items task")) - - def getMailSettings[F[_]: Sync](ctx: Context[F, Args]): F[RUserEmail] = - ctx.store - .transact(RUserEmail.getByName(ctx.args.account, ctx.args.smtpConnection)) - .flatMap { - case Some(c) => c.pure[F] - case None => - Sync[F].raiseError( - new Exception( - s"No smtp configuration found for: ${ctx.args.smtpConnection.id}" - ) - ) - } - - def createMail[F[_]: Sync]( - sendCfg: MailSendConfig, - cfg: RUserEmail, - ctx: Context[F, Args] - ): OptionT[F, Mail[F]] = - for { - items <- OptionT.liftF(findItems(ctx)).filter(_.nonEmpty) - mail <- OptionT.liftF(makeMail(sendCfg, cfg, ctx.args, items)) - } yield mail - - def findItems[F[_]: Sync](ctx: Context[F, Args]): F[Vector[ListItem]] = - for { - now <- Timestamp.current[F] - rightDate = Date((now + Duration.days(ctx.args.remindDays.toLong)).toMillis) - q = - Query - .all(ctx.args.account) - .withOrder(orderAsc = _.dueDate) - .withFix(_.copy(query = Expr.ValidItemStates.some)) - .withCond(_ => - Query.QueryExpr( - Attr.DueDate <= rightDate &&? - ctx.args.daysBack.map(back => - Attr.DueDate >= Date((now - Duration.days(back.toLong)).toMillis) - ) &&? - Nel - .fromList(ctx.args.tagsInclude) - .map(ids => Q.tagIdsEq(ids.map(_.id))) &&? - Nel - .fromList(ctx.args.tagsExclude) - .map(ids => Q.tagIdsIn(ids.map(_.id)).negate) - ) - ) - res <- - ctx.store - .transact( - QItem - .findItems(q, now.toUtcDate, 0, Batch.limit(maxItems)) - .take(maxItems.toLong) - ) - .compile - .toVector - } yield res - - def makeMail[F[_]: Sync]( - sendCfg: MailSendConfig, - cfg: RUserEmail, - args: Args, - items: Vector[ListItem] - ): F[Mail[F]] = - Timestamp.current[F].map { now => - val templateCtx = - MailContext.from(items, maxItems.toInt, args.account, args.itemDetailUrl, now) - val md = MailTemplate.render(templateCtx) - val recp = args.recipients - .map(MailAddress.parse) - .map { - case Right(ma) => ma - case Left(err) => - throw new Exception(s"Unable to parse recipient address: $err") - } - MailBuilder.build( - From(cfg.mailFrom), - Tos(recp), - XMailer.emil, - Subject("[Docspell] Next due items"), - EmilHeader.listId(sendCfg.listId), - MarkdownBody[F](md).withConfig( - MarkdownConfig("body { font-size: 10pt; font-family: sans-serif; }") - ) - ) - } -} diff --git a/modules/joex/src/main/scala/docspell/joex/notify/PeriodicDueItemsTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/PeriodicDueItemsTask.scala index 9446478d..ebaa60b5 100644 --- a/modules/joex/src/main/scala/docspell/joex/notify/PeriodicDueItemsTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/notify/PeriodicDueItemsTask.scala @@ -106,6 +106,7 @@ object PeriodicDueItemsTask { ctx.args.account, ctx.args.baseUrl, items, + None, limit, now )(cont) diff --git a/modules/joex/src/main/scala/docspell/joex/notify/PeriodicQueryTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/PeriodicQueryTask.scala index c24e47e8..ad16f2b9 100644 --- a/modules/joex/src/main/scala/docspell/joex/notify/PeriodicQueryTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/notify/PeriodicQueryTask.scala @@ -143,6 +143,7 @@ object PeriodicQueryTask { ctx.args.account, ctx.args.baseUrl, items, + ctx.args.contentStart, limit, now )(cont) diff --git a/modules/joex/src/main/scala/docspell/joex/notify/TaskOperations.scala b/modules/joex/src/main/scala/docspell/joex/notify/TaskOperations.scala index 788f3131..a1152c13 100644 --- a/modules/joex/src/main/scala/docspell/joex/notify/TaskOperations.scala +++ b/modules/joex/src/main/scala/docspell/joex/notify/TaskOperations.scala @@ -45,6 +45,7 @@ trait TaskOperations { account: AccountId, baseUrl: Option[LenientUri], items: Vector[ListItem], + contentStart: Option[String], limit: Int, now: Timestamp )(cont: EventContext => F[Unit]): F[Unit] = @@ -52,7 +53,7 @@ trait TaskOperations { case Some(nel) => val more = items.size >= limit val eventCtx = ItemSelectionCtx( - Event.ItemSelection(account, nel.map(_.id), more, baseUrl), + Event.ItemSelection(account, nel.map(_.id), more, baseUrl, contentStart), ItemSelectionCtx.Data .create(account, items, baseUrl, more, now) ) diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/Event.scala b/modules/notification/api/src/main/scala/docspell/notification/api/Event.scala index 371d4ebe..18ff790a 100644 --- a/modules/notification/api/src/main/scala/docspell/notification/api/Event.scala +++ b/modules/notification/api/src/main/scala/docspell/notification/api/Event.scala @@ -148,7 +148,8 @@ object Event { account: AccountId, items: Nel[Ident], more: Boolean, - baseUrl: Option[LenientUri] + baseUrl: Option[LenientUri], + contentStart: Option[String] ) extends Event { val eventType = ItemSelection } @@ -161,7 +162,7 @@ object Event { for { id1 <- Ident.randomId[F] id2 <- Ident.randomId[F] - } yield ItemSelection(account, Nel.of(id1, id2), true, baseUrl) + } yield ItemSelection(account, Nel.of(id1, id2), true, baseUrl, None) } /** Event when a new job is added to the queue */ diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/EventContext.scala b/modules/notification/api/src/main/scala/docspell/notification/api/EventContext.scala index 075d25eb..fc3f2984 100644 --- a/modules/notification/api/src/main/scala/docspell/notification/api/EventContext.scala +++ b/modules/notification/api/src/main/scala/docspell/notification/api/EventContext.scala @@ -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 diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicDueItemsArgs.scala b/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicDueItemsArgs.scala index 2a34ec26..fc10767f 100644 --- a/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicDueItemsArgs.scala +++ b/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicDueItemsArgs.scala @@ -12,6 +12,13 @@ import emil.MailAddress import io.circe.generic.semiauto import io.circe.{Decoder, Encoder} +/** Arguments to the notification task. + * + * This tasks queries items with a due date and informs the user via mail. + * + * If the structure changes, there must be some database migration to update or remove + * the json data of the corresponding task. + */ final case class PeriodicDueItemsArgs( account: AccountId, channel: ChannelOrRef, diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicQueryArgs.scala b/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicQueryArgs.scala index e8ffd089..cf1c16a1 100644 --- a/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicQueryArgs.scala +++ b/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicQueryArgs.scala @@ -17,7 +17,8 @@ final case class PeriodicQueryArgs( channel: ChannelOrRef, query: Option[ItemQueryString], bookmark: Option[String], - baseUrl: Option[LenientUri] + baseUrl: Option[LenientUri], + contentStart: Option[String] ) object PeriodicQueryArgs { diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/AbstractEventContext.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/AbstractEventContext.scala index 0d42e43c..04dc3990 100644 --- a/modules/notification/impl/src/main/scala/docspell/notification/impl/AbstractEventContext.scala +++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/AbstractEventContext.scala @@ -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 ) ) } diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/EmailBackend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/EmailBackend.scala index f93202c8..81a016c2 100644 --- a/modules/notification/impl/src/main/scala/docspell/notification/impl/EmailBackend.scala +++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/EmailBackend.scala @@ -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}")) + } } diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/EventContextSyntax.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/EventContextSyntax.scala new file mode 100644 index 00000000..79fa66a6 --- /dev/null +++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/EventContextSyntax.scala @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.notification.impl + +import docspell.common.Logger +import docspell.notification.api.EventContext + +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) + } +} diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/GotifyBackend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/GotifyBackend.scala index 2d955a8a..02415726 100644 --- a/modules/notification/impl/src/main/scala/docspell/notification/impl/GotifyBackend.scala +++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/GotifyBackend.scala @@ -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) + } } diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpPostBackend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpPostBackend.scala index 226f5522..0a5acb21 100644 --- a/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpPostBackend.scala +++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpPostBackend.scala @@ -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) + } } diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/MatrixBackend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/MatrixBackend.scala index 4222b004..41f0cb9b 100644 --- a/modules/notification/impl/src/main/scala/docspell/notification/impl/MatrixBackend.scala +++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/MatrixBackend.scala @@ -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) + } } diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/DeleteFieldValueCtx.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/DeleteFieldValueCtx.scala index cbee4f07..b204f823 100644 --- a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/DeleteFieldValueCtx.scala +++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/DeleteFieldValueCtx.scala @@ -30,9 +30,11 @@ 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}}""" + ) } diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/ItemSelectionCtx.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/ItemSelectionCtx.scala index 9c3a700d..994ab1b9 100644 --- a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/ItemSelectionCtx.scala +++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/ItemSelectionCtx.scala @@ -19,39 +19,29 @@ import doobie._ import io.circe.Encoder import io.circe.syntax._ import yamusca.implicits._ +import yamusca.imports._ final case class ItemSelectionCtx(event: Event.ItemSelection, data: ItemSelectionCtx.Data) extends AbstractEventContext { - val content = data.asJson - val titleTemplate = mustache"Your items" - val bodyTemplate = mustache""" -Hello {{{ content.username }}}, + val titleTemplate = Right(mustache"Your items") + val bodyTemplate = event.contentStart match { + case Some(cnt) => + mustache + .parse(cnt) + .leftMap { case (in, err) => + s"Error parsing template: $err! Near ${in.pos}: ${in.raw}." + } + .map(start => start ++ ItemSelectionCtx.basicBody) -this is Docspell informing you about your next items. + case None => + Right(ItemSelectionCtx.basicBodyStart ++ ItemSelectionCtx.basicBody) + } -{{#content}} -{{#itemUrl}} -{{#items}} -- {{#overDue}}**(OVERDUE)** {{/overDue}}[{{name}}]({{itemUrl}}/{{id}}){{#dueDate}}, {{#overDue}}was {{/overDue}}due {{dueIn}} on *{{dueDate}}*{{/dueDate}}; {{#corrOrg}}from {{corrOrg}}{{/corrOrg}} received on {{date}} via {{source}} -{{/items}} -{{/itemUrl}} -{{^itemUrl}} -{{#items}} -- {{#overDue}}**(OVERDUE)** {{/overDue}}*{{name}}*{{#dueDate}}, {{#overDue}}was {{/overDue}}due {{dueIn}} on *{{dueDate}}*{{/dueDate}}; {{#corrOrg}}from {{corrOrg}}{{/corrOrg}} received on {{date}} via {{source}} -{{/items}} -{{/itemUrl}} -{{#more}} -- … more have been left out for brevity -{{/more}} -{{/content}} - - -Sincerely yours, - -Docspell -""" + implicit final class TemplateOps(self: Template) { + def ++(next: Template) = Template(self.els ++ next.els) + } } object ItemSelectionCtx { @@ -114,4 +104,25 @@ object ItemSelectionCtx { ) } + private val basicBodyStart = mustache""" +Hello {{{ content.username }}}, + +this is Docspell informing you about your next items.""" + + private val basicBody = mustache""" +{{#content}} +{{#itemUrl}} +{{#items}} +- {{#overDue}}**(OVERDUE)** {{/overDue}}[{{name}}]({{itemUrl}}/{{id}}){{#dueDate}}, {{#overDue}}was {{/overDue}}due {{dueIn}} on *{{dueDate}}*{{/dueDate}}; {{#corrOrg}}from {{corrOrg}}{{/corrOrg}} received on {{date}} via {{source}} +{{/items}} +{{/itemUrl}} +{{^itemUrl}} +{{#items}} +- {{#overDue}}**(OVERDUE)** {{/overDue}}*{{name}}*{{#dueDate}}, {{#overDue}}was {{/overDue}}due {{dueIn}} on *{{dueDate}}*{{/dueDate}}; {{#corrOrg}}from {{corrOrg}}{{/corrOrg}} received on {{date}} via {{source}} +{{/items}} +{{/itemUrl}} +{{#more}} +- … more have been left out for brevity +{{/more}} +{{/content}}""" } diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobDoneCtx.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobDoneCtx.scala index 48009186..2b5c9b24 100644 --- a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobDoneCtx.scala +++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobDoneCtx.scala @@ -22,8 +22,10 @@ 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 { diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobSubmittedCtx.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobSubmittedCtx.scala index 3fb0bf62..c996fd90 100644 --- a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobSubmittedCtx.scala +++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobSubmittedCtx.scala @@ -22,9 +22,11 @@ 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 { diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/SetFieldValueCtx.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/SetFieldValueCtx.scala index fd67c714..ebb8dc70 100644 --- a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/SetFieldValueCtx.scala +++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/SetFieldValueCtx.scala @@ -28,9 +28,11 @@ 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}}""" + ) } diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/TagsChangedCtx.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/TagsChangedCtx.scala index ee536482..bd7aa8d4 100644 --- a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/TagsChangedCtx.scala +++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/TagsChangedCtx.scala @@ -26,10 +26,11 @@ 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 { diff --git a/modules/notification/impl/src/test/scala/docspell/notification/impl/context/TagsChangedCtxTest.scala b/modules/notification/impl/src/test/scala/docspell/notification/impl/context/TagsChangedCtxTest.scala index 4aded5c5..094ae368 100644 --- a/modules/notification/impl/src/test/scala/docspell/notification/impl/context/TagsChangedCtxTest.scala +++ b/modules/notification/impl/src/test/scala/docspell/notification/impl/context/TagsChangedCtxTest.scala @@ -46,9 +46,9 @@ class TagsChangedCtxTest extends FunSuite { TagsChangedCtx.Data(account, List(item), List(tag), Nil, url.some.map(_.asString)) ) - assertEquals(ctx.defaultTitle, "TagsChanged (by *user2*)") + assertEquals(ctx.defaultTitle.toOption.get, "TagsChanged (by *user2*)") assertEquals( - ctx.defaultBody, + ctx.defaultBody.toOption.get, "Adding *tag-red* on [`Report 2`](http://test/item-1)." ) } @@ -65,9 +65,9 @@ class TagsChangedCtxTest extends FunSuite { ) ) - assertEquals(ctx.defaultTitle, "TagsChanged (by *user2*)") + assertEquals(ctx.defaultTitle.toOption.get, "TagsChanged (by *user2*)") assertEquals( - ctx.defaultBody, + ctx.defaultBody.toOption.get, "Adding *tag-red*; Removing *tag-blue* on [`Report 2`](http://test/item-1)." ) } diff --git a/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicQuerySettings.scala b/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicQuerySettings.scala index 964ad125..a3cc89ab 100644 --- a/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicQuerySettings.scala +++ b/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicQuerySettings.scala @@ -23,6 +23,7 @@ final case class PeriodicQuerySettings( channel: NotificationChannel, query: Option[ItemQuery], bookmark: Option[String], + contentStart: Option[String], schedule: CalEvent ) {} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/NotificationRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/NotificationRoutes.scala index c462009e..22d6ec87 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/NotificationRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/NotificationRoutes.scala @@ -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 } } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/PeriodicQueryRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/PeriodicQueryRoutes.scala index 93b90b6e..32e767b8 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/PeriodicQueryRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/PeriodicQueryRoutes.scala @@ -144,7 +144,8 @@ object PeriodicQueryRoutes extends MailAddressCodec { Right(channel), qstr, settings.bookmark, - Some(baseUrl / "app" / "item") + Some(baseUrl / "app" / "item"), + settings.contentStart ) ) } @@ -167,6 +168,7 @@ object PeriodicQueryRoutes extends MailAddressCodec { ch, task.args.query.map(_.query).map(ItemQueryParser.parseUnsafe), task.args.bookmark, + task.args.contentStart, task.timer ) } diff --git a/modules/common/src/main/scala/docspell/common/NotifyDueItemsArgs.scala b/modules/store/src/main/scala/db/migration/NotifyDueItemsArgs.scala similarity index 87% rename from modules/common/src/main/scala/docspell/common/NotifyDueItemsArgs.scala rename to modules/store/src/main/scala/db/migration/NotifyDueItemsArgs.scala index ff3ff9b1..e664b58f 100644 --- a/modules/common/src/main/scala/docspell/common/NotifyDueItemsArgs.scala +++ b/modules/store/src/main/scala/db/migration/NotifyDueItemsArgs.scala @@ -17,6 +17,10 @@ import io.circe.generic.semiauto._ * * If the structure changes, there must be some database migration to update or remove * the json data of the corresponding task. + * + * @deprecated + * note: This has been removed and copied to this place to be able to migrate away from + * this structure. Replaced by PeriodicDueItemsArgs */ case class NotifyDueItemsArgs( account: AccountId, diff --git a/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskForm.elm b/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskForm.elm index 38ef5da6..154c6ab5 100644 --- a/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskForm.elm +++ b/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskForm.elm @@ -46,6 +46,7 @@ type alias Model = , queryModel : Comp.PowerSearchInput.Model , channelModel : Comp.ChannelForm.Model , bookmarkDropdown : Comp.BookmarkDropdown.Model + , contentStart : Maybe String , formState : FormState , loading : Int , deleteRequested : Bool @@ -79,6 +80,7 @@ type Msg | QueryMsg Comp.PowerSearchInput.Msg | ChannelMsg Comp.ChannelForm.Msg | BookmarkMsg Comp.BookmarkDropdown.Msg + | SetContentStart String | StartOnce | Cancel | RequestDelete @@ -115,6 +117,7 @@ initWith flags s = , queryModel = res.model , channelModel = cfm , bookmarkDropdown = bm + , contentStart = Nothing , formState = FormStateInitial , loading = 0 , summary = s.summary @@ -151,6 +154,7 @@ init flags ct = , queryModel = Comp.PowerSearchInput.init , channelModel = cfm , bookmarkDropdown = bm + , contentStart = Nothing , formState = FormStateInitial , loading = 0 , summary = Nothing @@ -218,6 +222,7 @@ makeSettings model = , channel = channel , query = Tuple.first q , bookmark = Tuple.second q + , contentStart = model.contentStart } in Result.map3 make @@ -299,6 +304,13 @@ update flags msg model = , sub = Sub.none } + SetContentStart str -> + { model = { model | contentStart = Util.Maybe.fromString str } + , action = NoAction + , cmd = Cmd.none + , sub = Sub.none + } + ToggleEnabled -> { model = { model @@ -545,6 +557,22 @@ view texts extraClasses settings model = , queryInput ] ] + , div [ class "mb-4" ] + [ formHeader texts.messageContentTitle False + , label [ class S.inputLabel ] + [ text texts.messageContentLabel + ] + , textarea + [ onInput SetContentStart + , Maybe.withDefault "" model.contentStart |> value + , placeholder texts.messageContentPlaceholder + , class S.textAreaInput + ] + [] + , span [ class "text-sm opacity-75" ] + [ text texts.messageContentInfo + ] + ] , div [ class "mb-4" ] [ formHeader texts.schedule False , label [ class S.inputLabel ] diff --git a/modules/webapp/src/main/elm/Data/PeriodicQuerySettings.elm b/modules/webapp/src/main/elm/Data/PeriodicQuerySettings.elm index 6a3a5b1e..73603fc1 100644 --- a/modules/webapp/src/main/elm/Data/PeriodicQuerySettings.elm +++ b/modules/webapp/src/main/elm/Data/PeriodicQuerySettings.elm @@ -20,6 +20,7 @@ type alias PeriodicQuerySettings = , channel : NotificationChannel , query : Maybe String , bookmark : Maybe String + , contentStart : Maybe String , schedule : String } @@ -32,19 +33,21 @@ empty ct = , channel = Data.NotificationChannel.empty ct , query = Nothing , bookmark = Nothing + , contentStart = Nothing , schedule = "" } decoder : D.Decoder PeriodicQuerySettings decoder = - D.map7 PeriodicQuerySettings + D.map8 PeriodicQuerySettings (D.field "id" D.string) (D.field "enabled" D.bool) (D.maybe (D.field "summary" D.string)) (D.field "channel" Data.NotificationChannel.decoder) (D.maybe (D.field "query" D.string)) (D.maybe (D.field "bookmark" D.string)) + (D.maybe (D.field "contentStart" D.string)) (D.field "schedule" D.string) @@ -57,5 +60,6 @@ encode s = , ( "channel", Data.NotificationChannel.encode s.channel ) , ( "query", Maybe.map E.string s.query |> Maybe.withDefault E.null ) , ( "bookmark", Maybe.map E.string s.bookmark |> Maybe.withDefault E.null ) + , ( "contentStart", Maybe.map E.string s.contentStart |> Maybe.withDefault E.null ) , ( "schedule", E.string s.schedule ) ] diff --git a/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskForm.elm b/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskForm.elm index e988ce72..49e717a7 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskForm.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskForm.elm @@ -42,6 +42,10 @@ type alias Texts = , channelRequired : String , queryStringRequired : String , channelHeader : ChannelType -> String + , messageContentTitle : String + , messageContentLabel : String + , messageContentInfo : String + , messageContentPlaceholder : String } @@ -71,6 +75,10 @@ gb = , channelRequired = "A valid channel must be given." , queryStringRequired = "A query string and/or bookmark must be supplied" , channelHeader = \ct -> "Connection details for " ++ Messages.Data.ChannelType.gb ct + , messageContentTitle = "Customize message" + , messageContentLabel = "Beginning of message" + , messageContentInfo = "Insert text that is prependend to the generated message." + , messageContentPlaceholder = "Hello, this is Docspell informing you about new items …" } @@ -100,4 +108,8 @@ de = , channelRequired = "Ein Versandkanal muss angegeben werden." , queryStringRequired = "Eine Suchabfrage und/oder ein Bookmark muss angegeben werden." , channelHeader = \ct -> "Details für " ++ Messages.Data.ChannelType.de ct + , messageContentTitle = "Nachricht anpassen" + , messageContentLabel = "Anfang der Nachricht" + , messageContentInfo = "Dieser Text wird an den Anfang der generierten Nachricht angefügt." + , messageContentPlaceholder = "Hallo, hier ist Docspell mit den nächsten Themen …" }