diff --git a/modules/common/src/main/scala/docspell/common/config/Implicits.scala b/modules/common/src/main/scala/docspell/common/config/Implicits.scala index c294cb66..e76b1592 100644 --- a/modules/common/src/main/scala/docspell/common/config/Implicits.scala +++ b/modules/common/src/main/scala/docspell/common/config/Implicits.scala @@ -21,6 +21,9 @@ import scodec.bits.ByteVector object Implicits { + implicit val accountIdReader: ConfigReader[AccountId] = + ConfigReader[String].emap(reason(AccountId.parse)) + implicit val pathReader: ConfigReader[Path] = ConfigReader[JPath].map(Path.fromNioPath) diff --git a/modules/joex/src/main/resources/reference.conf b/modules/joex/src/main/resources/reference.conf index 97ecc84c..38c001c6 100644 --- a/modules/joex/src/main/resources/reference.conf +++ b/modules/joex/src/main/resources/reference.conf @@ -180,6 +180,62 @@ docspell.joex { } } + # A periodic task to check for new releases of docspell. It can + # inform about a new release via e-mail. You need to specify an + # account that has SMTP settings to use for sending. + update-check { + # Whether to enable this task + enabled = false + + # Sends the mail without checking the latest release. Can be used + # if you want to see if mail sending works, but don't want to wait + # until a new release is published. + test-run = false + + # When the update check should execute. Default is to run every + # week. + schedule = "Sun *-*-* 00:00:00" + + # An account id in form of `collective/user` (or just `user` if + # collective and user name are the same). This user account must + # have at least one valid SMTP settings which are used to send the + # mail. + sender-account = "" + + # The SMTP connection id that should be used for sending the mail. + smtp-id = "" + + # A list of recipient e-mail addresses. + # Example: `[ "john.doe@gmail.com" ]` + recipients = [] + + # The subject of the mail. It supports the same variables as the + # body. + subject = "Docspell {{ latestVersion }} is available" + + # The body of the mail. Subject and body can contain these + # variables which are replaced: + # + # - `latestVersion` the latest available version of Docspell + # - `currentVersion` the currently running (old) version of Docspell + # - `releasedAt` a date when the release was published + # + # The body is processed as markdown after the variables have been + # replaced. + body = """ +Hello, + +You are currently running Docspell {{ currentVersion }}. Version *{{ latestVersion }}* +is now available, which was released on {{ releasedAt }}. Check the release page at: + + + +Have a nice day! + +Docpell Update Check +""" + } + # Configuration of text extraction extraction { # For PDF files it is first tried to read the text parts of the diff --git a/modules/joex/src/main/scala/docspell/joex/Config.scala b/modules/joex/src/main/scala/docspell/joex/Config.scala index 05326c0d..6f281783 100644 --- a/modules/joex/src/main/scala/docspell/joex/Config.scala +++ b/modules/joex/src/main/scala/docspell/joex/Config.scala @@ -19,6 +19,7 @@ import docspell.ftssolr.SolrConfig import docspell.joex.analysis.RegexNerFile import docspell.joex.hk.HouseKeepingConfig import docspell.joex.scheduler.{PeriodicSchedulerConfig, SchedulerConfig} +import docspell.joex.updatecheck.UpdateCheckConfig import docspell.store.JdbcConfig case class Config( @@ -36,7 +37,8 @@ case class Config( sendMail: MailSendConfig, files: Files, mailDebug: Boolean, - fullTextSearch: Config.FullTextSearch + fullTextSearch: Config.FullTextSearch, + updateCheck: UpdateCheckConfig ) object Config { diff --git a/modules/joex/src/main/scala/docspell/joex/ConfigFile.scala b/modules/joex/src/main/scala/docspell/joex/ConfigFile.scala index 9091cffd..bf777dcc 100644 --- a/modules/joex/src/main/scala/docspell/joex/ConfigFile.scala +++ b/modules/joex/src/main/scala/docspell/joex/ConfigFile.scala @@ -6,21 +6,56 @@ package docspell.joex +import cats.data.Validated +import cats.data.ValidatedNec +import cats.implicits._ + import docspell.common.config.Implicits._ import docspell.joex.scheduler.CountingScheme +import emil.MailAddress +import emil.javamail.syntax._ import pureconfig._ import pureconfig.generic.auto._ +import yamusca.imports._ object ConfigFile { import Implicits._ def loadConfig: Config = - ConfigSource.default.at("docspell.joex").loadOrThrow[Config] + validOrThrow(ConfigSource.default.at("docspell.joex").loadOrThrow[Config]) + + private def validOrThrow(cfg: Config): Config = + validate(cfg).fold(err => sys.error(err.toList.mkString("- ", "\n", "")), identity) object Implicits { implicit val countingSchemeReader: ConfigReader[CountingScheme] = ConfigReader[String].emap(reason(CountingScheme.readString)) + implicit val templateReader: ConfigReader[Template] = + ConfigReader[String].emap(reason(str => mustache.parse(str.trim).left.map(_._2))) + + implicit val mailAddressReader: ConfigReader[MailAddress] = + ConfigReader[String].emap(reason(MailAddress.parse)) } + + def validate(cfg: Config): ValidatedNec[String, Config] = + List( + failWhen( + cfg.updateCheck.enabled && cfg.updateCheck.recipients.isEmpty, + "No recipients given for enabled update check!" + ), + failWhen( + cfg.updateCheck.enabled && cfg.updateCheck.smtpId.isEmpty, + "No recipients given for enabled update check!" + ), + failWhen( + cfg.updateCheck.enabled && cfg.updateCheck.subject.els.isEmpty, + "No subject given for enabled update check!" + ) + ).reduce(_ |+| _).map(_ => cfg) + + def failWhen(cond: Boolean, msg: => String): ValidatedNec[String, Unit] = + Validated.condNec(!cond, (), msg) + } diff --git a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala index 9bde54ec..6de22aa9 100644 --- a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala +++ b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala @@ -32,16 +32,17 @@ import docspell.joex.process.ItemHandler import docspell.joex.process.ReProcessItem import docspell.joex.scanmailbox._ import docspell.joex.scheduler._ +import docspell.joex.updatecheck._ import docspell.joexapi.client.JoexClient import docspell.store.Store import docspell.store.queue._ import docspell.store.records.{REmptyTrashSetting, RJobLog} +import docspell.store.usertask.UserTaskScope +import docspell.store.usertask.UserTaskStore import emil.javamail._ import org.http4s.blaze.client.BlazeClientBuilder import org.http4s.client.Client -import docspell.store.usertask.UserTaskStore -import docspell.store.usertask.UserTaskScope final class JoexAppImpl[F[_]: Async]( cfg: Config, @@ -81,6 +82,9 @@ final class JoexAppImpl[F[_]: Async]( .periodicTask[F](cfg.houseKeeping.schedule) .flatMap(pstore.insert) *> scheduleEmptyTrashTasks *> + UpdateCheckTask + .periodicTask(cfg.updateCheck) + .flatMap(pstore.insert) *> MigrationTask.job.flatMap(queue.insertIfNew) *> AllPreviewsTask .job(MakePreviewArgs.StoreMode.WhenMissing, None) @@ -130,6 +134,7 @@ object JoexAppImpl { itemSearchOps <- OItemSearch(store) analyser <- TextAnalyser.create[F](cfg.textAnalysis.textAnalysisConfig) regexNer <- RegexNerFile(cfg.textAnalysis.regexNerFileConfig, store) + updateCheck <- UpdateCheck.resource(httpClient) javaEmil = JavaMailEmil(Settings.defaultSettings.copy(debug = cfg.mailDebug)) sch <- SchedulerBuilder(cfg.scheduler, store) @@ -239,6 +244,13 @@ object JoexAppImpl { EmptyTrashTask.onCancel[F] ) ) + .withTask( + JobTask.json( + UpdateCheckTask.taskName, + UpdateCheckTask[F](cfg.updateCheck, cfg.sendMail, javaEmil, updateCheck), + UpdateCheckTask.onCancel[F] + ) + ) .resource psch <- PeriodicScheduler.create( cfg.periodicScheduler, diff --git a/modules/joex/src/main/scala/docspell/joex/updatecheck/MakeMail.scala b/modules/joex/src/main/scala/docspell/joex/updatecheck/MakeMail.scala new file mode 100644 index 00000000..f0432da5 --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/updatecheck/MakeMail.scala @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.joex.updatecheck + +import cats.effect._ + +import docspell.common.MailSendConfig +import docspell.joex.mail.EmilHeader +import docspell.store.records.RUserEmail + +import emil._ +import emil.builder._ +import emil.markdown._ +import yamusca.implicits._ +import yamusca.imports._ + +object MakeMail { + + def apply[F[_]: Sync]( + sendCfg: MailSendConfig, + cfg: UpdateCheckConfig, + smtpCfg: RUserEmail, + latestRelease: UpdateCheck.Release + ): Mail[F] = { + + val templateCtx = TemplateCtx(latestRelease) + val md = templateCtx.render(cfg.body) + val subj = templateCtx.render(cfg.subject) + + MailBuilder.build( + From(smtpCfg.mailFrom), + Tos(cfg.recipients), + XMailer.emil, + Subject(subj), + EmilHeader.listId(sendCfg.listId), + MarkdownBody[F](md).withConfig( + MarkdownConfig("body { font-size: 10pt; font-family: sans-serif; }") + ) + ) + } + + final case class TemplateCtx( + currentVersion: String, + latestVersion: String, + releasedAt: String + ) + object TemplateCtx { + def apply(release: UpdateCheck.Release): TemplateCtx = + TemplateCtx(UpdateCheck.currentVersion, release.version, release.published_at) + + implicit val yamuscaConverter: ValueConverter[TemplateCtx] = + ValueConverter.deriveConverter[TemplateCtx] + } +} diff --git a/modules/joex/src/main/scala/docspell/joex/updatecheck/UpdateCheck.scala b/modules/joex/src/main/scala/docspell/joex/updatecheck/UpdateCheck.scala new file mode 100644 index 00000000..cd98c3f5 --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/updatecheck/UpdateCheck.scala @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.joex.updatecheck + +import cats.effect._ + +import docspell.joex.BuildInfo + +import io.circe.Decoder +import io.circe.generic.semiauto._ +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.client.Client +import org.http4s.implicits._ + +trait UpdateCheck[F[_]] { + + def latestRelease: F[UpdateCheck.Release] + +} + +object UpdateCheck { + + val currentVersion: String = + BuildInfo.version + + final case class Release( + html_url: String, + id: Int, + tag_name: String, + name: String, + created_at: String, + published_at: String + ) { + + def version: String = tag_name + + def isCurrent: Boolean = { + val version = BuildInfo.version + version.endsWith("SNAPSHOT") || version == tag_name + } + + } + + object Release { + implicit val jsonDecoder: Decoder[Release] = + deriveDecoder[Release] + } + + def apply[F[_]: Async](client: Client[F]): UpdateCheck[F] = + new UpdateCheck[F] { + def latestRelease: F[UpdateCheck.Release] = + client.expect[Release](latestReleaseUrl) + } + + def resource[F[_]: Async](client: Client[F]): Resource[F, UpdateCheck[F]] = + Resource.pure(UpdateCheck[F](client)) + + private[this] val latestReleaseUrl = + uri"https://api.github.com/repos/eikek/docspell/releases/latest" +} diff --git a/modules/joex/src/main/scala/docspell/joex/updatecheck/UpdateCheckConfig.scala b/modules/joex/src/main/scala/docspell/joex/updatecheck/UpdateCheckConfig.scala new file mode 100644 index 00000000..2040d75a --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/updatecheck/UpdateCheckConfig.scala @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.joex.updatecheck + +import docspell.common._ + +import com.github.eikek.calev.CalEvent +import emil.MailAddress +import yamusca.data.Template + +final case class UpdateCheckConfig( + enabled: Boolean, + testRun: Boolean, + schedule: CalEvent, + senderAccount: AccountId, + smtpId: Ident, + recipients: List[MailAddress], + subject: Template, + body: Template +) diff --git a/modules/joex/src/main/scala/docspell/joex/updatecheck/UpdateCheckTask.scala b/modules/joex/src/main/scala/docspell/joex/updatecheck/UpdateCheckTask.scala new file mode 100644 index 00000000..bf449401 --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/updatecheck/UpdateCheckTask.scala @@ -0,0 +1,98 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.joex.updatecheck + +import cats.data.OptionT +import cats.effect._ +import cats.implicits._ + +import docspell.common._ +import docspell.joex.scheduler.Context +import docspell.joex.scheduler.Task +import docspell.store.records.RPeriodicTask +import docspell.store.records.RUserEmail +import docspell.store.usertask.UserTask +import docspell.store.usertask.UserTaskScope + +import emil._ + +object UpdateCheckTask { + val taskName: Ident = Ident.unsafe("new-release-check") + + type Args = Unit + + def onCancel[F[_]]: Task[F, Args, Unit] = + Task.log(_.warn("Cancelling update-check task")) + + def periodicTask[F[_]: Sync](cfg: UpdateCheckConfig): F[RPeriodicTask] = + UserTask( + Ident.unsafe("docspell-update-check"), + taskName, + cfg.enabled, + cfg.schedule, + None, + () + ).encode.toPeriodicTask( + UserTaskScope(cfg.senderAccount.collective), + "Docspell Update Check".some + ) + + def apply[F[_]: Async]( + cfg: UpdateCheckConfig, + sendCfg: MailSendConfig, + emil: Emil[F], + updateCheck: UpdateCheck[F] + ): Task[F, Args, Unit] = + if (cfg.enabled) + Task { ctx => + for { + _ <- ctx.logger.info( + s"Check for updates. Current version is: ${UpdateCheck.currentVersion}" + ) + _ <- ctx.logger.debug( + s"Get SMTP connection for ${cfg.senderAccount.asString} and ${cfg.smtpId}" + ) + smtpCfg <- findConnection(ctx, cfg) + _ <- ctx.logger.debug("Checking for latest release at GitHub") + latest <- updateCheck.latestRelease + _ <- ctx.logger.debug(s"Got latest release: $latest.") + _ <- + if (cfg.testRun) + ctx.logger.info( + s"This is a test-run as configured. A mail will always be sent!" + ) + else ().pure[F] + _ <- + if (latest.isCurrent && !cfg.testRun) + ctx.logger.info( + s"Latest release is ${latest.version}, which is the current one. Everything uptodate." + ) + else + ctx.logger.info( + s"Sending mail about new release: ${latest.tag_name}" + ) *> emil(smtpCfg.toMailConfig).send( + MakeMail(sendCfg, cfg, smtpCfg, latest) + ) + } yield () + } + else + Task.pure(()) + + def findConnection[F[_]: Sync]( + ctx: Context[F, _], + cfg: UpdateCheckConfig + ): F[RUserEmail] = + OptionT(ctx.store.transact(RUserEmail.getByName(cfg.senderAccount, cfg.smtpId))) + .getOrElseF( + Sync[F].raiseError( + new Exception( + s"No smtp connection found for user ${cfg.senderAccount.asString} and connection '${cfg.smtpId.id}'!" + ) + ) + ) + +} diff --git a/nix/module-joex.nix b/nix/module-joex.nix index 219e67c3..aefd6c4a 100644 --- a/nix/module-joex.nix +++ b/nix/module-joex.nix @@ -63,6 +63,27 @@ let min-not-found = 2; }; }; + update-check = { + enabled = false; + test-run = false; + schedule = "Sun *-*-* 00:00:00"; + sender-account = ""; + smtp-id = ""; + recipients = []; + subject = "Docspell {{ latestVersion }} is available"; + body = '' +Hello, + +You are currently running Docspell {{ currentVersion }}. Version *{{ latestVersion }}* +is now available, which was released on {{ releasedAt }}. Check the release page at: + + + +Have a nice day! + +Docpell Update Check +''; + }; extraction = { pdf = { min-text-len = 500; @@ -571,6 +592,88 @@ in { ''; }; + update-check = mkOption { + type = types.submodule({ + options = { + enabled = mkOption { + type = types.bool; + default = defaults.update-check.enabled; + description = "Whether this task is enabled."; + }; + test-run = mkOption { + type = types.bool; + default = defaults.update-check.test-run; + description = '' + Sends the mail without checking the latest release. Can be used + if you want to see if mail sending works, but don't want to wait + until a new release is published. + ''; + }; + schedule = mkOption { + type = types.str; + default = defaults.update-check.schedule; + description = '' + When the check-update task should execute. Default is to run every + week. + ''; + }; + sender-account = mkOption { + type = types.str; + default = defaults.update-check.sender-account; + description = '' + An account id in form of `collective/user` (or just `user` if + collective and user name are the same). This user account must + have at least one valid SMTP settings which are used to send the + mail. + ''; + }; + smtp-id = mkOption { + type = types.str; + default = defaults.update-check.smtp-id; + description = '' + The SMTP connection id that should be used for sending the mail. + ''; + }; + recipients = mkOption { + type = types.listOf types.str; + default = defaults.update-check.recipients; + example = [ "josh.doe@gmail.com" ]; + description = '' + A list of recipient e-mail addresses. + ''; + }; + subject = mkOption { + type = types.str; + default = defaults.update-check.subject; + description = '' + The subject of the mail. It supports the same variables as the body. + ''; + }; + body = mkOption { + type = types.str; + default = defaults.update-check.body; + description = '' + The body of the mail. Subject and body can contain these + variables which are replaced: + + - `latestVersion` the latest available version of Docspell + - `currentVersion` the currently running (old) version of Docspell + - `releasedAt` a date when the release was published + + The body is processed as markdown after the variables have been + replaced. + ''; + }; + }; + }); + default = defaults.update-check; + description = '' + A periodic task to check for new releases of docspell. It can + inform about a new release via e-mail. You need to specify an + account that has SMTP settings to use for sending. + ''; + }; + extraction = mkOption { type = types.submodule({ options = {