mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-05 22:55:58 +00:00
Implement notify-due-items task
This commit is contained in:
parent
e7b81c701f
commit
2723d6b43b
@ -303,6 +303,7 @@ val joex = project.in(file("modules/joex")).
|
|||||||
Dependencies.emilMarkdown ++
|
Dependencies.emilMarkdown ++
|
||||||
Dependencies.emilJsoup ++
|
Dependencies.emilJsoup ++
|
||||||
Dependencies.jsoup ++
|
Dependencies.jsoup ++
|
||||||
|
Dependencies.yamusca ++
|
||||||
Dependencies.loggingApi ++
|
Dependencies.loggingApi ++
|
||||||
Dependencies.logging.map(_ % Runtime),
|
Dependencies.logging.map(_ % Runtime),
|
||||||
addCompilerPlugin(Dependencies.kindProjectorPlugin),
|
addCompilerPlugin(Dependencies.kindProjectorPlugin),
|
||||||
|
@ -43,6 +43,9 @@ object Duration {
|
|||||||
def hours(n: Long): Duration =
|
def hours(n: Long): Duration =
|
||||||
apply(JDur.ofHours(n))
|
apply(JDur.ofHours(n))
|
||||||
|
|
||||||
|
def days(n: Long): Duration =
|
||||||
|
apply(JDur.ofDays(n))
|
||||||
|
|
||||||
def nanos(n: Long): Duration =
|
def nanos(n: Long): Duration =
|
||||||
Duration(n)
|
Duration(n)
|
||||||
|
|
||||||
|
@ -24,6 +24,9 @@ object ItemState {
|
|||||||
case _ => Left(s"Invalid item state: $str")
|
case _ => Left(s"Invalid item state: $str")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val validStates: Seq[ItemState] =
|
||||||
|
Seq(Created, Confirmed)
|
||||||
|
|
||||||
def unsafe(str: String): ItemState =
|
def unsafe(str: String): ItemState =
|
||||||
fromString(str).fold(sys.error, identity)
|
fromString(str).fold(sys.error, identity)
|
||||||
|
|
||||||
|
@ -19,6 +19,12 @@ case class Timestamp(value: Instant) {
|
|||||||
def -(d: Duration): Timestamp =
|
def -(d: Duration): Timestamp =
|
||||||
minus(d)
|
minus(d)
|
||||||
|
|
||||||
|
def +(d: Duration): Timestamp =
|
||||||
|
plus(d)
|
||||||
|
|
||||||
|
def plus(d: Duration): Timestamp =
|
||||||
|
Timestamp(value.plusNanos(d.nanos))
|
||||||
|
|
||||||
def minusHours(n: Long): Timestamp =
|
def minusHours(n: Long): Timestamp =
|
||||||
Timestamp(value.minusSeconds(n * 60 * 60))
|
Timestamp(value.minusSeconds(n * 60 * 60))
|
||||||
|
|
||||||
@ -59,4 +65,6 @@ object Timestamp {
|
|||||||
implicit val decodeTimestamp: Decoder[Timestamp] =
|
implicit val decodeTimestamp: Decoder[Timestamp] =
|
||||||
BaseJsonCodecs.decodeInstantEpoch.map(Timestamp(_))
|
BaseJsonCodecs.decodeInstantEpoch.map(Timestamp(_))
|
||||||
|
|
||||||
|
implicit val ordering: Ordering[Timestamp] =
|
||||||
|
Ordering.by(_.value)
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package docspell.joex
|
|||||||
|
|
||||||
import cats.implicits._
|
import cats.implicits._
|
||||||
import cats.effect._
|
import cats.effect._
|
||||||
|
import emil.javamail._
|
||||||
import docspell.common._
|
import docspell.common._
|
||||||
import docspell.joex.hk._
|
import docspell.joex.hk._
|
||||||
import docspell.joex.process.ItemHandler
|
import docspell.joex.process.ItemHandler
|
||||||
@ -12,7 +13,6 @@ import docspell.store.queue._
|
|||||||
import docspell.store.ops.ONode
|
import docspell.store.ops.ONode
|
||||||
import docspell.store.records.RJobLog
|
import docspell.store.records.RJobLog
|
||||||
import fs2.concurrent.SignallingRef
|
import fs2.concurrent.SignallingRef
|
||||||
|
|
||||||
import scala.concurrent.ExecutionContext
|
import scala.concurrent.ExecutionContext
|
||||||
|
|
||||||
final class JoexAppImpl[F[_]: ConcurrentEffect: ContextShift: Timer](
|
final class JoexAppImpl[F[_]: ConcurrentEffect: ContextShift: Timer](
|
||||||
@ -78,7 +78,7 @@ object JoexAppImpl {
|
|||||||
.withTask(
|
.withTask(
|
||||||
JobTask.json(
|
JobTask.json(
|
||||||
NotifyDueItemsArgs.taskName,
|
NotifyDueItemsArgs.taskName,
|
||||||
NotifyDueItemsTask[F],
|
NotifyDueItemsTask[F](JavaMailEmil(blocker)),
|
||||||
NotifyDueItemsTask.onCancel[F]
|
NotifyDueItemsTask.onCancel[F]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -0,0 +1,42 @@
|
|||||||
|
package docspell.joex.notify
|
||||||
|
|
||||||
|
import yamusca.implicits._
|
||||||
|
import yamusca.imports._
|
||||||
|
|
||||||
|
import docspell.common._
|
||||||
|
import docspell.store.queries.QItem
|
||||||
|
import docspell.joex.notify.YamuscaConverter._
|
||||||
|
|
||||||
|
case class MailContext(items: List[MailContext.ItemData], more: Boolean, account: AccountId)
|
||||||
|
|
||||||
|
object MailContext {
|
||||||
|
|
||||||
|
def from(items: Vector[QItem.ListItem], max: Int, account: AccountId): MailContext =
|
||||||
|
MailContext(
|
||||||
|
items.take(max - 1).map(ItemData.apply).toList.sortBy(_.dueDate),
|
||||||
|
items.sizeCompare(max) >= 0,
|
||||||
|
account
|
||||||
|
)
|
||||||
|
|
||||||
|
case class ItemData(
|
||||||
|
id: Ident,
|
||||||
|
name: String,
|
||||||
|
date: Timestamp,
|
||||||
|
dueDate: Option[Timestamp],
|
||||||
|
source: String
|
||||||
|
)
|
||||||
|
|
||||||
|
object ItemData {
|
||||||
|
|
||||||
|
def apply(i: QItem.ListItem): ItemData =
|
||||||
|
ItemData(i.id, i.name, i.date, i.dueDate, i.source)
|
||||||
|
|
||||||
|
implicit def yamusca: ValueConverter[ItemData] =
|
||||||
|
ValueConverter.deriveConverter[ItemData]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
implicit val yamusca: ValueConverter[MailContext] =
|
||||||
|
ValueConverter.deriveConverter[MailContext]
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
package docspell.joex.notify
|
||||||
|
|
||||||
|
import yamusca.implicits._
|
||||||
|
|
||||||
|
object MailTemplate {
|
||||||
|
|
||||||
|
val text = mustache"""
|
||||||
|
## Hello {{{ account.user }}},
|
||||||
|
|
||||||
|
this is Docspell informing you about due items coming up.
|
||||||
|
|
||||||
|
{{#items}}
|
||||||
|
- *{{name}}*, due on *{{dueDate}}*
|
||||||
|
(received on {{date}} via {{source}})
|
||||||
|
{{/items}}
|
||||||
|
{{#more}}
|
||||||
|
- ...
|
||||||
|
{{/more}}
|
||||||
|
|
||||||
|
|
||||||
|
Sincerly,
|
||||||
|
Docspell
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render(mc: MailContext): String =
|
||||||
|
mc.render(text)
|
||||||
|
}
|
@ -2,22 +2,100 @@ package docspell.joex.hk
|
|||||||
|
|
||||||
import cats.implicits._
|
import cats.implicits._
|
||||||
import cats.effect._
|
import cats.effect._
|
||||||
|
import emil._
|
||||||
|
import emil.builder._
|
||||||
|
import emil.markdown._
|
||||||
|
import emil.javamail.syntax._
|
||||||
|
|
||||||
import docspell.common._
|
import docspell.common._
|
||||||
import docspell.joex.scheduler.Task
|
import docspell.store.records._
|
||||||
|
import docspell.store.queries.QItem
|
||||||
|
import docspell.joex.scheduler.{Context, Task}
|
||||||
|
import cats.data.OptionT
|
||||||
|
import docspell.joex.notify.MailContext
|
||||||
|
import docspell.joex.notify.MailTemplate
|
||||||
|
|
||||||
object NotifyDueItemsTask {
|
object NotifyDueItemsTask {
|
||||||
|
val maxItems: Long = 5
|
||||||
|
type Args = NotifyDueItemsArgs
|
||||||
|
|
||||||
def apply[F[_]: Sync](): Task[F, NotifyDueItemsArgs, Unit] =
|
def apply[F[_]: Sync](emil: Emil[F]): Task[F, Args, Unit] =
|
||||||
Task { ctx =>
|
Task { ctx =>
|
||||||
for {
|
for {
|
||||||
now <- Timestamp.current[F]
|
_ <- ctx.logger.info("Getting mail configuration")
|
||||||
_ <- ctx.logger.info(s" $now")
|
mailCfg <- getMailSettings(ctx)
|
||||||
_ <- ctx.logger.info(s"Removed $ctx")
|
_ <- ctx.logger.info(
|
||||||
} yield ()
|
s"Searching for items due in ${ctx.args.remindDays} days…."
|
||||||
|
)
|
||||||
|
_ <- createMail(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[_]: Sync]: Task[F, NotifyDueItemsArgs, Unit] =
|
def onCancel[F[_]: Sync]: Task[F, NotifyDueItemsArgs, Unit] =
|
||||||
Task.log(_.warn("Cancelling notify-due-items task"))
|
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](
|
||||||
|
cfg: RUserEmail,
|
||||||
|
ctx: Context[F, Args]
|
||||||
|
): OptionT[F, Mail[F]] =
|
||||||
|
for {
|
||||||
|
items <- OptionT.liftF(findItems(ctx)).filter(_.nonEmpty)
|
||||||
|
mail <- OptionT.liftF(makeMail(cfg, ctx.args, items))
|
||||||
|
} yield mail
|
||||||
|
|
||||||
|
def findItems[F[_]: Sync](ctx: Context[F, Args]): F[Vector[QItem.ListItem]] =
|
||||||
|
for {
|
||||||
|
now <- Timestamp.current[F]
|
||||||
|
q = QItem.Query
|
||||||
|
.empty(ctx.args.account.collective)
|
||||||
|
.copy(
|
||||||
|
states = ItemState.validStates,
|
||||||
|
tagsInclude = ctx.args.tagsInclude,
|
||||||
|
tagsExclude = ctx.args.tagsExclude,
|
||||||
|
dueDateTo = Some(now + Duration.days(ctx.args.remindDays.toLong))
|
||||||
|
)
|
||||||
|
res <- ctx.store.transact(QItem.findItems(q).take(maxItems)).compile.toVector
|
||||||
|
} yield res
|
||||||
|
|
||||||
|
def makeMail[F[_]: Sync](
|
||||||
|
cfg: RUserEmail,
|
||||||
|
args: Args,
|
||||||
|
items: Vector[QItem.ListItem]
|
||||||
|
): F[Mail[F]] = Sync[F].delay {
|
||||||
|
val templateCtx = MailContext.from(items, maxItems.toInt - 1, args.account)
|
||||||
|
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),
|
||||||
|
Subject("Next due items"),
|
||||||
|
MarkdownBody[F](md)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
package docspell.joex.notify
|
||||||
|
|
||||||
|
import yamusca.imports._
|
||||||
|
import yamusca.implicits._
|
||||||
|
import docspell.common._
|
||||||
|
|
||||||
|
trait YamuscaConverter {
|
||||||
|
|
||||||
|
implicit val uriConverter: ValueConverter[LenientUri] =
|
||||||
|
ValueConverter.of(uri => Value.fromString(uri.asString))
|
||||||
|
|
||||||
|
implicit val timestamp: ValueConverter[Timestamp] =
|
||||||
|
ValueConverter.of(ts => Value.fromString(ts.toUtcDate.toString))
|
||||||
|
|
||||||
|
implicit val ident: ValueConverter[Ident] =
|
||||||
|
ValueConverter.of(id => Value.fromString(id.id))
|
||||||
|
|
||||||
|
implicit val account: ValueConverter[AccountId] =
|
||||||
|
ValueConverter.deriveConverter[AccountId]
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
object YamuscaConverter extends YamuscaConverter
|
@ -109,7 +109,7 @@ trait Conversions {
|
|||||||
coll,
|
coll,
|
||||||
m.name,
|
m.name,
|
||||||
if (m.inbox) Seq(ItemState.Created)
|
if (m.inbox) Seq(ItemState.Created)
|
||||||
else Seq(ItemState.Created, ItemState.Confirmed),
|
else ItemState.validStates,
|
||||||
m.direction,
|
m.direction,
|
||||||
m.corrPerson,
|
m.corrPerson,
|
||||||
m.corrOrg,
|
m.corrOrg,
|
||||||
|
@ -125,6 +125,26 @@ object QItem {
|
|||||||
dueDateTo: Option[Timestamp]
|
dueDateTo: Option[Timestamp]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
object Query {
|
||||||
|
def empty(collective: Ident): Query =
|
||||||
|
Query(
|
||||||
|
collective,
|
||||||
|
None,
|
||||||
|
Seq.empty,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Nil,
|
||||||
|
Nil,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
def findItems(q: Query): Stream[ConnectionIO, ListItem] = {
|
def findItems(q: Query): Stream[ConnectionIO, ListItem] = {
|
||||||
val IC = RItem.Columns
|
val IC = RItem.Columns
|
||||||
val AC = RAttachment.Columns
|
val AC = RAttachment.Columns
|
||||||
|
Loading…
x
Reference in New Issue
Block a user