Implement notify-due-items task

This commit is contained in:
Eike Kettner 2020-04-21 21:43:05 +02:00
parent e7b81c701f
commit 2723d6b43b
11 changed files with 215 additions and 10 deletions

View File

@ -303,6 +303,7 @@ val joex = project.in(file("modules/joex")).
Dependencies.emilMarkdown ++
Dependencies.emilJsoup ++
Dependencies.jsoup ++
Dependencies.yamusca ++
Dependencies.loggingApi ++
Dependencies.logging.map(_ % Runtime),
addCompilerPlugin(Dependencies.kindProjectorPlugin),

View File

@ -43,6 +43,9 @@ object Duration {
def hours(n: Long): Duration =
apply(JDur.ofHours(n))
def days(n: Long): Duration =
apply(JDur.ofDays(n))
def nanos(n: Long): Duration =
Duration(n)

View File

@ -24,6 +24,9 @@ object ItemState {
case _ => Left(s"Invalid item state: $str")
}
val validStates: Seq[ItemState] =
Seq(Created, Confirmed)
def unsafe(str: String): ItemState =
fromString(str).fold(sys.error, identity)

View File

@ -19,6 +19,12 @@ case class Timestamp(value: Instant) {
def -(d: Duration): Timestamp =
minus(d)
def +(d: Duration): Timestamp =
plus(d)
def plus(d: Duration): Timestamp =
Timestamp(value.plusNanos(d.nanos))
def minusHours(n: Long): Timestamp =
Timestamp(value.minusSeconds(n * 60 * 60))
@ -59,4 +65,6 @@ object Timestamp {
implicit val decodeTimestamp: Decoder[Timestamp] =
BaseJsonCodecs.decodeInstantEpoch.map(Timestamp(_))
implicit val ordering: Ordering[Timestamp] =
Ordering.by(_.value)
}

View File

@ -2,6 +2,7 @@ package docspell.joex
import cats.implicits._
import cats.effect._
import emil.javamail._
import docspell.common._
import docspell.joex.hk._
import docspell.joex.process.ItemHandler
@ -12,7 +13,6 @@ import docspell.store.queue._
import docspell.store.ops.ONode
import docspell.store.records.RJobLog
import fs2.concurrent.SignallingRef
import scala.concurrent.ExecutionContext
final class JoexAppImpl[F[_]: ConcurrentEffect: ContextShift: Timer](
@ -78,7 +78,7 @@ object JoexAppImpl {
.withTask(
JobTask.json(
NotifyDueItemsArgs.taskName,
NotifyDueItemsTask[F],
NotifyDueItemsTask[F](JavaMailEmil(blocker)),
NotifyDueItemsTask.onCancel[F]
)
)

View File

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

View File

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

View File

@ -2,22 +2,100 @@ package docspell.joex.hk
import cats.implicits._
import cats.effect._
import emil._
import emil.builder._
import emil.markdown._
import emil.javamail.syntax._
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 {
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 =>
for {
now <- Timestamp.current[F]
_ <- ctx.logger.info(s" $now")
_ <- ctx.logger.info(s"Removed $ctx")
} yield ()
for {
_ <- ctx.logger.info("Getting mail configuration")
mailCfg <- getMailSettings(ctx)
_ <- ctx.logger.info(
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] =
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)
)
}
}

View File

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

View File

@ -109,7 +109,7 @@ trait Conversions {
coll,
m.name,
if (m.inbox) Seq(ItemState.Created)
else Seq(ItemState.Created, ItemState.Confirmed),
else ItemState.validStates,
m.direction,
m.corrPerson,
m.corrOrg,

View File

@ -125,6 +125,26 @@ object QItem {
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] = {
val IC = RItem.Columns
val AC = RAttachment.Columns