mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-22 10:28:27 +00:00
Merge pull request #1274 from eikek/feature/email-content
Feature/email content
This commit is contained in:
@ -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,
|
||||||
|
@ -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]
|
|
||||||
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
@ -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; }")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
)
|
)
|
||||||
|
@ -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 */
|
||||||
|
@ -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
|
||||||
"message" -> Json.obj(
|
tb1 <- defaultBody
|
||||||
"title" -> defaultTitle.asJson,
|
tt2 <- defaultTitleHtml
|
||||||
"body" -> defaultBody.asJson
|
tb2 <- defaultBodyHtml
|
||||||
),
|
data = asJson
|
||||||
"messageHtml" -> Json.obj(
|
msg = Json.obj(
|
||||||
"title" -> defaultTitleHtml.asJson,
|
"message" -> Json.obj(
|
||||||
"body" -> defaultBodyHtml.asJson
|
"title" -> tt1.asJson,
|
||||||
|
"body" -> tb1.asJson
|
||||||
|
),
|
||||||
|
"messageHtml" -> Json.obj(
|
||||||
|
"title" -> tt2.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
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -19,22 +19,24 @@ 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] =
|
||||||
val mail =
|
event.withDefault(logger) { (title, body) =>
|
||||||
MailBuilder.build(
|
val mail =
|
||||||
From(channel.from),
|
MailBuilder.build(
|
||||||
Tos(channel.recipients.toList),
|
From(channel.from),
|
||||||
Subject(event.defaultTitle),
|
Tos(channel.recipients.toList),
|
||||||
MarkdownBody[F](event.defaultBody)
|
Subject(title),
|
||||||
)
|
MarkdownBody[F](body)
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(s"Attempting to send notification mail: $channel") *>
|
logger.debug(s"Attempting to send notification mail: $channel") *>
|
||||||
mailService(channel.config)
|
mailService(channel.config)
|
||||||
.send(mail)
|
.send(mail)
|
||||||
.flatMap(msgId => logger.info(s"Send notification mail ${msgId.head}"))
|
.flatMap(msgId => logger.info(s"Send notification mail ${msgId.head}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -23,27 +23,29 @@ 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] =
|
||||||
val url = Uri.unsafeFromString((channel.url / "message").asString)
|
event.withDefault(logger) { (title, body) =>
|
||||||
val req = POST(
|
val url = Uri.unsafeFromString((channel.url / "message").asString)
|
||||||
Json.obj(
|
val req = POST(
|
||||||
"title" -> Json.fromString(event.defaultTitle),
|
Json.obj(
|
||||||
"message" -> Json.fromString(event.defaultBody),
|
"title" -> Json.fromString(title),
|
||||||
"extras" -> Json.obj(
|
"message" -> Json.fromString(body),
|
||||||
"client::display" -> Json.obj(
|
"extras" -> Json.obj(
|
||||||
"contentType" -> Json.fromString("text/markdown")
|
"client::display" -> Json.obj(
|
||||||
|
"contentType" -> Json.fromString("text/markdown")
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
),
|
||||||
),
|
url
|
||||||
url
|
)
|
||||||
)
|
.putHeaders("X-Gotify-Key" -> channel.appKey.pass)
|
||||||
.putHeaders("X-Gotify-Key" -> channel.appKey.pass)
|
logger.debug(s"Seding request: $req") *>
|
||||||
logger.debug(s"Seding request: $req") *>
|
HttpSend.sendRequest(client, req, channel, logger)
|
||||||
HttpSend.sendRequest(client, req, channel, logger)
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -21,16 +21,18 @@ 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] =
|
||||||
val url = Uri.unsafeFromString(channel.url.asString)
|
event.withJsonMessage(logger) { json =>
|
||||||
val req = POST(event.asJsonWithMessage, url).putHeaders(channel.headers.toList)
|
val url = Uri.unsafeFromString(channel.url.asString)
|
||||||
logger.debug(s"$channel sending request: $req") *>
|
val req = POST(json, url).putHeaders(channel.headers.toList)
|
||||||
HttpSend.sendRequest(client, req, channel, logger)
|
logger.debug(s"$channel sending request: $req") *>
|
||||||
}
|
HttpSend.sendRequest(client, req, channel, logger)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,26 +20,28 @@ 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] =
|
||||||
val url =
|
event.withDefaultBoth(logger) { (md, html) =>
|
||||||
(channel.homeServer / "_matrix" / "client" / "r0" / "rooms" / channel.roomId / "send" / "m.room.message")
|
val url =
|
||||||
.withQuery("access_token", channel.accessToken.pass)
|
(channel.homeServer / "_matrix" / "client" / "r0" / "rooms" / channel.roomId / "send" / "m.room.message")
|
||||||
val uri = Uri.unsafeFromString(url.asString)
|
.withQuery("access_token", channel.accessToken.pass)
|
||||||
val req = POST(
|
val uri = Uri.unsafeFromString(url.asString)
|
||||||
Map(
|
val req = POST(
|
||||||
"msgtype" -> channel.messageType,
|
Map(
|
||||||
"format" -> "org.matrix.custom.html",
|
"msgtype" -> channel.messageType,
|
||||||
"formatted_body" -> event.defaultBothHtml,
|
"format" -> "org.matrix.custom.html",
|
||||||
"body" -> event.defaultBoth
|
"formatted_body" -> html,
|
||||||
),
|
"body" -> md
|
||||||
uri
|
),
|
||||||
)
|
uri
|
||||||
HttpSend.sendRequest(client, req, channel, logger)
|
)
|
||||||
}
|
HttpSend.sendRequest(client, req, channel, logger)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 =
|
||||||
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}}"""
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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}}"""
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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 =
|
||||||
mustache"""{{#content}}_'{{subject}}'_ submitted by {{submitter}} {{/content}}"""
|
Right(
|
||||||
|
mustache"""{{#content}}_'{{subject}}'_ submitted by {{submitter}} {{/content}}"""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
object JobSubmittedCtx {
|
object JobSubmittedCtx {
|
||||||
|
@ -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 =
|
||||||
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}}"""
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 =
|
||||||
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 {
|
||||||
|
@ -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)."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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,
|
@ -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 ]
|
||||||
|
@ -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 )
|
||||||
]
|
]
|
||||||
|
@ -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 …"
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user