Merge pull request #1274 from eikek/feature/email-content

Feature/email content
This commit is contained in:
mergify[bot]
2022-01-11 22:06:20 +00:00
committed by GitHub
31 changed files with 291 additions and 422 deletions

View File

@ -170,13 +170,6 @@ object JoexAppImpl extends MailAddressCodec {
ReProcessItem.onCancel[F] ReProcessItem.onCancel[F]
) )
) )
.withTask(
JobTask.json(
NotifyDueItemsArgs.taskName,
NotifyDueItemsTask[F](cfg.sendMail, javaEmil),
NotifyDueItemsTask.onCancel[F]
)
)
.withTask( .withTask(
JobTask.json( JobTask.json(
ScanMailboxArgs.taskName, ScanMailboxArgs.taskName,

View File

@ -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]
}

View File

@ -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)
}

View File

@ -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; }")
)
)
}
}

View File

@ -106,6 +106,7 @@ object PeriodicDueItemsTask {
ctx.args.account, ctx.args.account,
ctx.args.baseUrl, ctx.args.baseUrl,
items, items,
None,
limit, limit,
now now
)(cont) )(cont)

View File

@ -143,6 +143,7 @@ object PeriodicQueryTask {
ctx.args.account, ctx.args.account,
ctx.args.baseUrl, ctx.args.baseUrl,
items, items,
ctx.args.contentStart,
limit, limit,
now now
)(cont) )(cont)

View File

@ -45,6 +45,7 @@ trait TaskOperations {
account: AccountId, account: AccountId,
baseUrl: Option[LenientUri], baseUrl: Option[LenientUri],
items: Vector[ListItem], items: Vector[ListItem],
contentStart: Option[String],
limit: Int, limit: Int,
now: Timestamp now: Timestamp
)(cont: EventContext => F[Unit]): F[Unit] = )(cont: EventContext => F[Unit]): F[Unit] =
@ -52,7 +53,7 @@ trait TaskOperations {
case Some(nel) => case Some(nel) =>
val more = items.size >= limit val more = items.size >= limit
val eventCtx = ItemSelectionCtx( val eventCtx = ItemSelectionCtx(
Event.ItemSelection(account, nel.map(_.id), more, baseUrl), Event.ItemSelection(account, nel.map(_.id), more, baseUrl, contentStart),
ItemSelectionCtx.Data ItemSelectionCtx.Data
.create(account, items, baseUrl, more, now) .create(account, items, baseUrl, more, now)
) )

View File

@ -148,7 +148,8 @@ object Event {
account: AccountId, account: AccountId,
items: Nel[Ident], items: Nel[Ident],
more: Boolean, more: Boolean,
baseUrl: Option[LenientUri] baseUrl: Option[LenientUri],
contentStart: Option[String]
) extends Event { ) extends Event {
val eventType = ItemSelection val eventType = ItemSelection
} }
@ -161,7 +162,7 @@ object Event {
for { for {
id1 <- Ident.randomId[F] id1 <- Ident.randomId[F]
id2 <- 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 */ /** Event when a new job is added to the queue */

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

@ -12,6 +12,13 @@ import emil.MailAddress
import io.circe.generic.semiauto import io.circe.generic.semiauto
import io.circe.{Decoder, Encoder} 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( final case class PeriodicDueItemsArgs(
account: AccountId, account: AccountId,
channel: ChannelOrRef, channel: ChannelOrRef,

View File

@ -17,7 +17,8 @@ final case class PeriodicQueryArgs(
channel: ChannelOrRef, channel: ChannelOrRef,
query: Option[ItemQueryString], query: Option[ItemQueryString],
bookmark: Option[String], bookmark: Option[String],
baseUrl: Option[LenientUri] baseUrl: Option[LenientUri],
contentStart: Option[String]
) )
object PeriodicQueryArgs { object PeriodicQueryArgs {

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,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)
}
}

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,11 @@ 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 =
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}}""" 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

@ -19,39 +19,29 @@ import doobie._
import io.circe.Encoder import io.circe.Encoder
import io.circe.syntax._ import io.circe.syntax._
import yamusca.implicits._ import yamusca.implicits._
import yamusca.imports._
final case class ItemSelectionCtx(event: Event.ItemSelection, data: ItemSelectionCtx.Data) final case class ItemSelectionCtx(event: Event.ItemSelection, data: ItemSelectionCtx.Data)
extends AbstractEventContext { extends AbstractEventContext {
val content = data.asJson val content = data.asJson
val titleTemplate = mustache"Your items" val titleTemplate = Right(mustache"Your items")
val bodyTemplate = mustache""" val bodyTemplate = event.contentStart match {
Hello {{{ content.username }}}, 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}} implicit final class TemplateOps(self: Template) {
{{#itemUrl}} def ++(next: Template) = Template(self.els ++ next.els)
{{#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
"""
} }
object ItemSelectionCtx { 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}}"""
} }

View File

@ -22,8 +22,10 @@ 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,11 @@ 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 =
Right(
mustache"""{{#content}}_'{{subject}}'_ submitted by {{submitter}} {{/content}}""" mustache"""{{#content}}_'{{subject}}'_ submitted by {{submitter}} {{/content}}"""
)
} }
object JobSubmittedCtx { object JobSubmittedCtx {

View File

@ -28,9 +28,11 @@ 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 =
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}}""" 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,11 @@ 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 =
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}}""" 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

@ -46,9 +46,9 @@ class TagsChangedCtxTest extends FunSuite {
TagsChangedCtx.Data(account, List(item), List(tag), Nil, url.some.map(_.asString)) 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( assertEquals(
ctx.defaultBody, ctx.defaultBody.toOption.get,
"Adding *tag-red* on [`Report 2`](http://test/item-1)." "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( assertEquals(
ctx.defaultBody, ctx.defaultBody.toOption.get,
"Adding *tag-red*; Removing *tag-blue* on [`Report 2`](http://test/item-1)." "Adding *tag-red*; Removing *tag-blue* on [`Report 2`](http://test/item-1)."
) )
} }

View File

@ -23,6 +23,7 @@ final case class PeriodicQuerySettings(
channel: NotificationChannel, channel: NotificationChannel,
query: Option[ItemQuery], query: Option[ItemQuery],
bookmark: Option[String], bookmark: Option[String],
contentStart: Option[String],
schedule: CalEvent schedule: CalEvent
) {} ) {}

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
} }
} }

View File

@ -144,7 +144,8 @@ object PeriodicQueryRoutes extends MailAddressCodec {
Right(channel), Right(channel),
qstr, qstr,
settings.bookmark, settings.bookmark,
Some(baseUrl / "app" / "item") Some(baseUrl / "app" / "item"),
settings.contentStart
) )
) )
} }
@ -167,6 +168,7 @@ object PeriodicQueryRoutes extends MailAddressCodec {
ch, ch,
task.args.query.map(_.query).map(ItemQueryParser.parseUnsafe), task.args.query.map(_.query).map(ItemQueryParser.parseUnsafe),
task.args.bookmark, task.args.bookmark,
task.args.contentStart,
task.timer task.timer
) )
} }

View File

@ -17,6 +17,10 @@ import io.circe.generic.semiauto._
* *
* If the structure changes, there must be some database migration to update or remove * If the structure changes, there must be some database migration to update or remove
* the json data of the corresponding task. * 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( case class NotifyDueItemsArgs(
account: AccountId, account: AccountId,

View File

@ -46,6 +46,7 @@ type alias Model =
, queryModel : Comp.PowerSearchInput.Model , queryModel : Comp.PowerSearchInput.Model
, channelModel : Comp.ChannelForm.Model , channelModel : Comp.ChannelForm.Model
, bookmarkDropdown : Comp.BookmarkDropdown.Model , bookmarkDropdown : Comp.BookmarkDropdown.Model
, contentStart : Maybe String
, formState : FormState , formState : FormState
, loading : Int , loading : Int
, deleteRequested : Bool , deleteRequested : Bool
@ -79,6 +80,7 @@ type Msg
| QueryMsg Comp.PowerSearchInput.Msg | QueryMsg Comp.PowerSearchInput.Msg
| ChannelMsg Comp.ChannelForm.Msg | ChannelMsg Comp.ChannelForm.Msg
| BookmarkMsg Comp.BookmarkDropdown.Msg | BookmarkMsg Comp.BookmarkDropdown.Msg
| SetContentStart String
| StartOnce | StartOnce
| Cancel | Cancel
| RequestDelete | RequestDelete
@ -115,6 +117,7 @@ initWith flags s =
, queryModel = res.model , queryModel = res.model
, channelModel = cfm , channelModel = cfm
, bookmarkDropdown = bm , bookmarkDropdown = bm
, contentStart = Nothing
, formState = FormStateInitial , formState = FormStateInitial
, loading = 0 , loading = 0
, summary = s.summary , summary = s.summary
@ -151,6 +154,7 @@ init flags ct =
, queryModel = Comp.PowerSearchInput.init , queryModel = Comp.PowerSearchInput.init
, channelModel = cfm , channelModel = cfm
, bookmarkDropdown = bm , bookmarkDropdown = bm
, contentStart = Nothing
, formState = FormStateInitial , formState = FormStateInitial
, loading = 0 , loading = 0
, summary = Nothing , summary = Nothing
@ -218,6 +222,7 @@ makeSettings model =
, channel = channel , channel = channel
, query = Tuple.first q , query = Tuple.first q
, bookmark = Tuple.second q , bookmark = Tuple.second q
, contentStart = model.contentStart
} }
in in
Result.map3 make Result.map3 make
@ -299,6 +304,13 @@ update flags msg model =
, sub = Sub.none , sub = Sub.none
} }
SetContentStart str ->
{ model = { model | contentStart = Util.Maybe.fromString str }
, action = NoAction
, cmd = Cmd.none
, sub = Sub.none
}
ToggleEnabled -> ToggleEnabled ->
{ model = { model =
{ model { model
@ -545,6 +557,22 @@ view texts extraClasses settings model =
, queryInput , 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" ] , div [ class "mb-4" ]
[ formHeader texts.schedule False [ formHeader texts.schedule False
, label [ class S.inputLabel ] , label [ class S.inputLabel ]

View File

@ -20,6 +20,7 @@ type alias PeriodicQuerySettings =
, channel : NotificationChannel , channel : NotificationChannel
, query : Maybe String , query : Maybe String
, bookmark : Maybe String , bookmark : Maybe String
, contentStart : Maybe String
, schedule : String , schedule : String
} }
@ -32,19 +33,21 @@ empty ct =
, channel = Data.NotificationChannel.empty ct , channel = Data.NotificationChannel.empty ct
, query = Nothing , query = Nothing
, bookmark = Nothing , bookmark = Nothing
, contentStart = Nothing
, schedule = "" , schedule = ""
} }
decoder : D.Decoder PeriodicQuerySettings decoder : D.Decoder PeriodicQuerySettings
decoder = decoder =
D.map7 PeriodicQuerySettings D.map8 PeriodicQuerySettings
(D.field "id" D.string) (D.field "id" D.string)
(D.field "enabled" D.bool) (D.field "enabled" D.bool)
(D.maybe (D.field "summary" D.string)) (D.maybe (D.field "summary" D.string))
(D.field "channel" Data.NotificationChannel.decoder) (D.field "channel" Data.NotificationChannel.decoder)
(D.maybe (D.field "query" D.string)) (D.maybe (D.field "query" D.string))
(D.maybe (D.field "bookmark" D.string)) (D.maybe (D.field "bookmark" D.string))
(D.maybe (D.field "contentStart" D.string))
(D.field "schedule" D.string) (D.field "schedule" D.string)
@ -57,5 +60,6 @@ encode s =
, ( "channel", Data.NotificationChannel.encode s.channel ) , ( "channel", Data.NotificationChannel.encode s.channel )
, ( "query", Maybe.map E.string s.query |> Maybe.withDefault E.null ) , ( "query", Maybe.map E.string s.query |> Maybe.withDefault E.null )
, ( "bookmark", Maybe.map E.string s.bookmark |> 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 ) , ( "schedule", E.string s.schedule )
] ]

View File

@ -42,6 +42,10 @@ type alias Texts =
, channelRequired : String , channelRequired : String
, queryStringRequired : String , queryStringRequired : String
, channelHeader : ChannelType -> String , channelHeader : ChannelType -> String
, messageContentTitle : String
, messageContentLabel : String
, messageContentInfo : String
, messageContentPlaceholder : String
} }
@ -71,6 +75,10 @@ gb =
, channelRequired = "A valid channel must be given." , channelRequired = "A valid channel must be given."
, queryStringRequired = "A query string and/or bookmark must be supplied" , queryStringRequired = "A query string and/or bookmark must be supplied"
, channelHeader = \ct -> "Connection details for " ++ Messages.Data.ChannelType.gb ct , 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." , channelRequired = "Ein Versandkanal muss angegeben werden."
, queryStringRequired = "Eine Suchabfrage und/oder ein Bookmark muss angegeben werden." , queryStringRequired = "Eine Suchabfrage und/oder ein Bookmark muss angegeben werden."
, channelHeader = \ct -> "Details für " ++ Messages.Data.ChannelType.de ct , 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 "
} }