diff --git a/build.sbt b/build.sbt index 69f8dd57..ddc6288b 100644 --- a/build.sbt +++ b/build.sbt @@ -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), diff --git a/modules/common/src/main/scala/docspell/common/Duration.scala b/modules/common/src/main/scala/docspell/common/Duration.scala index 2e4efb85..9c5cdd20 100644 --- a/modules/common/src/main/scala/docspell/common/Duration.scala +++ b/modules/common/src/main/scala/docspell/common/Duration.scala @@ -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) diff --git a/modules/common/src/main/scala/docspell/common/ItemState.scala b/modules/common/src/main/scala/docspell/common/ItemState.scala index ed4e5511..506e803d 100644 --- a/modules/common/src/main/scala/docspell/common/ItemState.scala +++ b/modules/common/src/main/scala/docspell/common/ItemState.scala @@ -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) diff --git a/modules/common/src/main/scala/docspell/common/Timestamp.scala b/modules/common/src/main/scala/docspell/common/Timestamp.scala index f09fad20..bac4bb8f 100644 --- a/modules/common/src/main/scala/docspell/common/Timestamp.scala +++ b/modules/common/src/main/scala/docspell/common/Timestamp.scala @@ -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) } diff --git a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala index 5a922e95..9ab7e46e 100644 --- a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala +++ b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala @@ -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] ) ) diff --git a/modules/joex/src/main/scala/docspell/joex/notify/MailContext.scala b/modules/joex/src/main/scala/docspell/joex/notify/MailContext.scala new file mode 100644 index 00000000..bf6f352d --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/notify/MailContext.scala @@ -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] + +} diff --git a/modules/joex/src/main/scala/docspell/joex/notify/MailTemplate.scala b/modules/joex/src/main/scala/docspell/joex/notify/MailTemplate.scala new file mode 100644 index 00000000..bb8cbe6e --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/notify/MailTemplate.scala @@ -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) +} diff --git a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala index 9a3ef09e..8ad7364c 100644 --- a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala @@ -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) + ) + } } diff --git a/modules/joex/src/main/scala/docspell/joex/notify/YamuscaConverter.scala b/modules/joex/src/main/scala/docspell/joex/notify/YamuscaConverter.scala new file mode 100644 index 00000000..361a4cd7 --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/notify/YamuscaConverter.scala @@ -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 diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index d28f633f..f479912e 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -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, diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index 6bcc714c..a992e1b5 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -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