mirror of
				https://github.com/TheAnachronism/docspell.git
				synced 2025-10-31 17:50:11 +00:00 
			
		
		
		
	Send mails for items
This commit is contained in:
		| @@ -9,6 +9,7 @@ import docspell.store.ops.ONode | |||||||
| import docspell.store.queue.JobQueue | import docspell.store.queue.JobQueue | ||||||
|  |  | ||||||
| import scala.concurrent.ExecutionContext | import scala.concurrent.ExecutionContext | ||||||
|  | import emil.javamail.JavaMailEmil | ||||||
|  |  | ||||||
| trait BackendApp[F[_]] { | trait BackendApp[F[_]] { | ||||||
|  |  | ||||||
| @@ -28,10 +29,11 @@ trait BackendApp[F[_]] { | |||||||
|  |  | ||||||
| object BackendApp { | object BackendApp { | ||||||
|  |  | ||||||
|   def create[F[_]: ConcurrentEffect]( |   def create[F[_]: ConcurrentEffect: ContextShift]( | ||||||
|       cfg: Config, |       cfg: Config, | ||||||
|       store: Store[F], |       store: Store[F], | ||||||
|       httpClientEc: ExecutionContext |     httpClientEc: ExecutionContext, | ||||||
|  |     blocker: Blocker | ||||||
|   ): Resource[F, BackendApp[F]] = |   ): Resource[F, BackendApp[F]] = | ||||||
|     for { |     for { | ||||||
|       queue      <- JobQueue(store) |       queue      <- JobQueue(store) | ||||||
| @@ -46,7 +48,7 @@ object BackendApp { | |||||||
|       nodeImpl   <- ONode(store) |       nodeImpl   <- ONode(store) | ||||||
|       jobImpl    <- OJob(store, httpClientEc) |       jobImpl    <- OJob(store, httpClientEc) | ||||||
|       itemImpl   <- OItem(store) |       itemImpl   <- OItem(store) | ||||||
|       mailImpl   <- OMail(store) |       mailImpl   <- OMail(store, JavaMailEmil(blocker)) | ||||||
|     } yield new BackendApp[F] { |     } yield new BackendApp[F] { | ||||||
|       val login: Login[F]            = loginImpl |       val login: Login[F]            = loginImpl | ||||||
|       val signup: OSignup[F]         = signupImpl |       val signup: OSignup[F]         = signupImpl | ||||||
| @@ -70,6 +72,6 @@ object BackendApp { | |||||||
|   ): Resource[F, BackendApp[F]] = |   ): Resource[F, BackendApp[F]] = | ||||||
|     for { |     for { | ||||||
|       store   <- Store.create(cfg.jdbc, connectEC, blocker) |       store   <- Store.create(cfg.jdbc, connectEC, blocker) | ||||||
|       backend <- create(cfg, store, httpClientEc) |       backend <- create(cfg, store, httpClientEc, blocker) | ||||||
|     } yield backend |     } yield backend | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,14 +1,22 @@ | |||||||
| package docspell.backend.ops | package docspell.backend.ops | ||||||
|  |  | ||||||
|  | import fs2.Stream | ||||||
| import cats.effect._ | import cats.effect._ | ||||||
| import cats.implicits._ | import cats.implicits._ | ||||||
| import cats.data.OptionT | import cats.data.OptionT | ||||||
| import emil.{MailAddress, SSLType} | import emil._ | ||||||
|  | import emil.javamail.syntax._ | ||||||
|  |  | ||||||
| import docspell.common._ | import docspell.common._ | ||||||
| import docspell.store._ | import docspell.store._ | ||||||
| import docspell.store.records.RUserEmail | import docspell.store.records.RUserEmail | ||||||
| import OMail.{ItemMail, SmtpSettings} | import OMail.{ItemMail, SmtpSettings} | ||||||
|  | import docspell.store.records.RAttachment | ||||||
|  | import bitpeace.FileMeta | ||||||
|  | import bitpeace.RangeDef | ||||||
|  | import docspell.store.records.RItem | ||||||
|  | import docspell.store.records.RSentMail | ||||||
|  | import docspell.store.records.RSentMailItem | ||||||
|  |  | ||||||
| trait OMail[F[_]] { | trait OMail[F[_]] { | ||||||
|  |  | ||||||
| @@ -35,10 +43,19 @@ object OMail { | |||||||
|       attach: AttachSelection |       attach: AttachSelection | ||||||
|   ) |   ) | ||||||
|  |  | ||||||
|   sealed trait AttachSelection |   sealed trait AttachSelection { | ||||||
|  |     def filter(v: Vector[(RAttachment, FileMeta)]): Vector[(RAttachment, FileMeta)] | ||||||
|  |   } | ||||||
|   object AttachSelection { |   object AttachSelection { | ||||||
|     case object All                       extends AttachSelection |     case object All extends AttachSelection { | ||||||
|     case class Selected(ids: List[Ident]) extends AttachSelection |       def filter(v: Vector[(RAttachment, FileMeta)]): Vector[(RAttachment, FileMeta)] = v | ||||||
|  |     } | ||||||
|  |     case class Selected(ids: List[Ident]) extends AttachSelection { | ||||||
|  |       def filter(v: Vector[(RAttachment, FileMeta)]): Vector[(RAttachment, FileMeta)] = { | ||||||
|  |         val set = ids.toSet | ||||||
|  |         v.filter(set contains _._1.id) | ||||||
|  |       } | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   case class SmtpSettings( |   case class SmtpSettings( | ||||||
| @@ -68,7 +85,7 @@ object OMail { | |||||||
|       ) |       ) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   def apply[F[_]: Effect](store: Store[F]): Resource[F, OMail[F]] = |   def apply[F[_]: Effect](store: Store[F], emil: Emil[F]): Resource[F, OMail[F]] = | ||||||
|     Resource.pure(new OMail[F] { |     Resource.pure(new OMail[F] { | ||||||
|       def getSettings(accId: AccountId, nameQ: Option[String]): F[Vector[RUserEmail]] = |       def getSettings(accId: AccountId, nameQ: Option[String]): F[Vector[RUserEmail]] = | ||||||
|         store.transact(RUserEmail.findByAccount(accId, nameQ)) |         store.transact(RUserEmail.findByAccount(accId, nameQ)) | ||||||
| @@ -97,7 +114,71 @@ object OMail { | |||||||
|       def deleteSettings(accId: AccountId, name: Ident): F[Int] = |       def deleteSettings(accId: AccountId, name: Ident): F[Int] = | ||||||
|         store.transact(RUserEmail.delete(accId, name)) |         store.transact(RUserEmail.delete(accId, name)) | ||||||
|  |  | ||||||
|       def sendMail(accId: AccountId, name: Ident, m: ItemMail): F[SendResult] = |       def sendMail(accId: AccountId, name: Ident, m: ItemMail): F[SendResult] = { | ||||||
|         Effect[F].pure(SendResult.Failure(new Exception("not implemented"))) |  | ||||||
|  |         val getSettings: OptionT[F, RUserEmail] = | ||||||
|  |           OptionT(store.transact(RUserEmail.getByName(accId, name))) | ||||||
|  |  | ||||||
|  |         def createMail(sett: RUserEmail): OptionT[F, Mail[F]] = { | ||||||
|  |           import _root_.emil.builder._ | ||||||
|  |  | ||||||
|  |           for { | ||||||
|  |             _ <- OptionT.liftF(store.transact(RItem.existsById(m.item))).filter(identity) | ||||||
|  |             ras <- OptionT.liftF( | ||||||
|  |               store.transact(RAttachment.findByItemAndCollectiveWithMeta(m.item, accId.collective)) | ||||||
|  |             ) | ||||||
|  |           } yield { | ||||||
|  |             val addAttach = m.attach.filter(ras).map { a => | ||||||
|  |               Attach[F](Stream.emit(a._2).through(store.bitpeace.fetchData2(RangeDef.all))) | ||||||
|  |                 .withFilename(a._1.name) | ||||||
|  |                 .withLength(a._2.length) | ||||||
|  |                 .withMimeType(_root_.emil.MimeType.parse(a._2.mimetype.asString).toOption) | ||||||
|  |             } | ||||||
|  |             val fields: Seq[Trans[F]] = Seq( | ||||||
|  |               From(sett.mailFrom), | ||||||
|  |               Tos(m.recipients), | ||||||
|  |               Subject(m.subject), | ||||||
|  |               TextBody[F](m.body) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             MailBuilder.fromSeq[F](fields).addAll(addAttach).build | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         def sendMail(cfg: MailConfig, mail: Mail[F]): F[Either[SendResult, String]] = | ||||||
|  |           emil(cfg).send(mail).map(_.head).attempt.map(_.left.map(SendResult.SendFailure)) | ||||||
|  |  | ||||||
|  |         def storeMail(msgId: String, cfg: RUserEmail): F[Either[SendResult, Ident]] = { | ||||||
|  |           val save = for { | ||||||
|  |             data <- RSentMail.forItem( | ||||||
|  |               m.item, | ||||||
|  |               accId, | ||||||
|  |               msgId, | ||||||
|  |               cfg.mailFrom, | ||||||
|  |               m.subject, | ||||||
|  |               m.recipients, | ||||||
|  |               m.body | ||||||
|  |             ) | ||||||
|  |             _ <- OptionT.liftF(RSentMail.insert(data._1)) | ||||||
|  |             _ <- OptionT.liftF(RSentMailItem.insert(data._2)) | ||||||
|  |           } yield data._1.id | ||||||
|  |  | ||||||
|  |           store.transact(save.value).attempt.map { | ||||||
|  |             case Right(Some(id)) => Right(id) | ||||||
|  |             case Right(None) => | ||||||
|  |               Left(SendResult.StoreFailure(new Exception(s"Could not find user to save mail."))) | ||||||
|  |             case Left(ex) => Left(SendResult.StoreFailure(ex)) | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         (for { | ||||||
|  |           mailCfg <- getSettings | ||||||
|  |           mail    <- createMail(mailCfg) | ||||||
|  |           mid     <- OptionT.liftF(sendMail(mailCfg.toMailConfig, mail)) | ||||||
|  |           res     <- mid.traverse(id => OptionT.liftF(storeMail(id, mailCfg))) | ||||||
|  |           conv = res.fold(identity, _.fold(identity, id => SendResult.Success(id))) | ||||||
|  |         } yield conv).getOrElse(SendResult.NotFound) | ||||||
|  |       } | ||||||
|  |  | ||||||
|     }) |     }) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -6,7 +6,21 @@ sealed trait SendResult | |||||||
|  |  | ||||||
| object SendResult { | object SendResult { | ||||||
|  |  | ||||||
|  |   /** Mail was successfully sent and stored to db. | ||||||
|  |     */ | ||||||
|   case class Success(id: Ident) extends SendResult |   case class Success(id: Ident) extends SendResult | ||||||
|  |  | ||||||
|   case class Failure(ex: Throwable) extends SendResult |   /** There was a failure sending the mail. The mail is then not saved | ||||||
|  |     * to db. | ||||||
|  |     */ | ||||||
|  |   case class SendFailure(ex: Throwable) extends SendResult | ||||||
|  |  | ||||||
|  |   /** The mail was successfully sent, but storing to db failed. | ||||||
|  |     */ | ||||||
|  |   case class StoreFailure(ex: Throwable) extends SendResult | ||||||
|  |  | ||||||
|  |   /** Something could not be found required for sending (mail configs, | ||||||
|  |     * items etc). | ||||||
|  |     */ | ||||||
|  |   case object NotFound extends SendResult | ||||||
| } | } | ||||||
|   | |||||||
| @@ -8,6 +8,8 @@ | |||||||
|   </appender> |   </appender> | ||||||
|  |  | ||||||
|   <logger name="docspell" level="debug" /> |   <logger name="docspell" level="debug" /> | ||||||
|  |   <logger name="emil" level="debug"/> | ||||||
|  |  | ||||||
|   <root level="INFO"> |   <root level="INFO"> | ||||||
|     <appender-ref ref="STDOUT" /> |     <appender-ref ref="STDOUT" /> | ||||||
|   </root> |   </root> | ||||||
|   | |||||||
| @@ -46,7 +46,11 @@ object MailSendRoutes { | |||||||
|     res match { |     res match { | ||||||
|       case SendResult.Success(_) => |       case SendResult.Success(_) => | ||||||
|         BasicResult(true, "Mail sent.") |         BasicResult(true, "Mail sent.") | ||||||
|       case SendResult.Failure(ex) => |       case SendResult.SendFailure(ex) => | ||||||
|         BasicResult(false, s"Mail sending failed: ${ex.getMessage}") |         BasicResult(false, s"Mail sending failed: ${ex.getMessage}") | ||||||
|  |       case SendResult.StoreFailure(ex) => | ||||||
|  |         BasicResult(false, s"Mail was sent, but could not be store to database: ${ex.getMessage}") | ||||||
|  |       case SendResult.NotFound => | ||||||
|  |         BasicResult(false, s"There was no mail-connection or item found.") | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -95,7 +95,9 @@ trait DoobieMeta { | |||||||
|     Meta[String].imap(EmilUtil.unsafeReadMailAddress)(EmilUtil.mailAddressString) |     Meta[String].imap(EmilUtil.unsafeReadMailAddress)(EmilUtil.mailAddressString) | ||||||
|  |  | ||||||
|   implicit def mailAddressList: Meta[List[MailAddress]] = |   implicit def mailAddressList: Meta[List[MailAddress]] = | ||||||
|     ??? |     Meta[String].imap(str => str.split(',').toList.map(_.trim).map(EmilUtil.unsafeReadMailAddress))( | ||||||
|  |       lma => lma.map(EmilUtil.mailAddressString).mkString(",") | ||||||
|  |     ) | ||||||
| } | } | ||||||
|  |  | ||||||
| object DoobieMeta extends DoobieMeta { | object DoobieMeta extends DoobieMeta { | ||||||
|   | |||||||
| @@ -64,6 +64,26 @@ object RAttachment { | |||||||
|     q.query[RAttachment].to[Vector] |     q.query[RAttachment].to[Vector] | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   def findByItemAndCollectiveWithMeta( | ||||||
|  |       id: Ident, | ||||||
|  |       coll: Ident | ||||||
|  |   ): ConnectionIO[Vector[(RAttachment, FileMeta)]] = { | ||||||
|  |     import bitpeace.sql._ | ||||||
|  |  | ||||||
|  |     val cols      = all.map(_.prefix("a")) ++ RFileMeta.Columns.all.map(_.prefix("m")) | ||||||
|  |     val afileMeta = fileId.prefix("a") | ||||||
|  |     val aItem     = itemId.prefix("a") | ||||||
|  |     val mId       = RFileMeta.Columns.id.prefix("m") | ||||||
|  |     val iId       = RItem.Columns.id.prefix("i") | ||||||
|  |     val iColl     = RItem.Columns.cid.prefix("i") | ||||||
|  |  | ||||||
|  |     val from = table ++ fr"a INNER JOIN" ++ RFileMeta.table ++ fr"m ON" ++ afileMeta.is(mId) ++ | ||||||
|  |       fr"INNER JOIN" ++ RItem.table ++ fr"i ON" ++ aItem.is(iId) | ||||||
|  |     val cond = Seq(aItem.is(id), iColl.is(coll)) | ||||||
|  |  | ||||||
|  |     selectSimple(cols, from, and(cond)).query[(RAttachment, FileMeta)].to[Vector] | ||||||
|  |   } | ||||||
|  |  | ||||||
|   def findByItemWithMeta(id: Ident): ConnectionIO[Vector[(RAttachment, FileMeta)]] = { |   def findByItemWithMeta(id: Ident): ConnectionIO[Vector[(RAttachment, FileMeta)]] = { | ||||||
|     import bitpeace.sql._ |     import bitpeace.sql._ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -0,0 +1,22 @@ | |||||||
|  | package docspell.store.records | ||||||
|  |  | ||||||
|  | import doobie.implicits._ | ||||||
|  | import docspell.store.impl._ | ||||||
|  |  | ||||||
|  | object RFileMeta { | ||||||
|  |  | ||||||
|  |   val table = fr"filemeta" | ||||||
|  |  | ||||||
|  |   object Columns { | ||||||
|  |     val id = Column("id") | ||||||
|  |     val timestamp = Column("timestamp") | ||||||
|  |     val mimetype = Column("mimetype") | ||||||
|  |     val length = Column("length") | ||||||
|  |     val checksum = Column("checksum") | ||||||
|  |     val chunks = Column("chunks") | ||||||
|  |     val chunksize = Column("chunksize") | ||||||
|  |  | ||||||
|  |     val all = List(id, timestamp, mimetype, length, checksum, chunks, chunksize) | ||||||
|  |  | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -262,4 +262,7 @@ object RItem { | |||||||
|  |  | ||||||
|   def deleteByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Int] = |   def deleteByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Int] = | ||||||
|     deleteFrom(table, and(id.is(itemId), cid.is(coll))).update.run |     deleteFrom(table, and(id.is(itemId), cid.is(coll))).update.run | ||||||
|  |  | ||||||
|  |   def existsById(itemId: Ident): ConnectionIO[Boolean] = | ||||||
|  |     selectCount(id, table, id.is(itemId)).query[Int].unique.map(_ > 0) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,12 +1,15 @@ | |||||||
| package docspell.store.records | package docspell.store.records | ||||||
|  |  | ||||||
| import fs2.Stream | import fs2.Stream | ||||||
|  | import cats.effect._ | ||||||
|  | import cats.implicits._ | ||||||
| import doobie._ | import doobie._ | ||||||
| import doobie.implicits._ | import doobie.implicits._ | ||||||
| import docspell.common._ | import docspell.common._ | ||||||
| import docspell.store.impl.Column | import docspell.store.impl.Column | ||||||
| import docspell.store.impl.Implicits._ | import docspell.store.impl.Implicits._ | ||||||
| import emil.MailAddress | import emil.MailAddress | ||||||
|  | import cats.data.OptionT | ||||||
|  |  | ||||||
| case class RSentMail( | case class RSentMail( | ||||||
|     id: Ident, |     id: Ident, | ||||||
| @@ -21,17 +24,46 @@ case class RSentMail( | |||||||
|  |  | ||||||
| object RSentMail { | object RSentMail { | ||||||
|  |  | ||||||
|  |   def apply[F[_]: Sync]( | ||||||
|  |       uid: Ident, | ||||||
|  |       messageId: String, | ||||||
|  |       sender: MailAddress, | ||||||
|  |       subject: String, | ||||||
|  |       recipients: List[MailAddress], | ||||||
|  |       body: String | ||||||
|  |   ): F[RSentMail] = | ||||||
|  |     for { | ||||||
|  |       id  <- Ident.randomId[F] | ||||||
|  |       now <- Timestamp.current[F] | ||||||
|  |     } yield RSentMail(id, uid, messageId, sender, subject, recipients, body, now) | ||||||
|  |  | ||||||
|  |   def forItem( | ||||||
|  |       itemId: Ident, | ||||||
|  |       accId: AccountId, | ||||||
|  |       messageId: String, | ||||||
|  |       sender: MailAddress, | ||||||
|  |       subject: String, | ||||||
|  |       recipients: List[MailAddress], | ||||||
|  |       body: String | ||||||
|  |   ): OptionT[ConnectionIO, (RSentMail, RSentMailItem)] = | ||||||
|  |     for { | ||||||
|  |       user <- OptionT(RUser.findByAccount(accId)) | ||||||
|  |       sm <- OptionT.liftF(RSentMail[ConnectionIO](user.uid, messageId, sender, subject, recipients, body)) | ||||||
|  |       si <- OptionT.liftF(RSentMailItem[ConnectionIO](itemId, sm.id, Some(sm.created))) | ||||||
|  |     } yield (sm, si) | ||||||
|  |  | ||||||
|  |  | ||||||
|   val table = fr"sentmail" |   val table = fr"sentmail" | ||||||
|  |  | ||||||
|   object Columns { |   object Columns { | ||||||
|     val id            = Column("id") |     val id         = Column("id") | ||||||
|     val uid           = Column("uid") |     val uid        = Column("uid") | ||||||
|     val messageId      = Column("message_id") |     val messageId  = Column("message_id") | ||||||
|     val sender      = Column("sender") |     val sender     = Column("sender") | ||||||
|     val subject      = Column("subject") |     val subject    = Column("subject") | ||||||
|     val recipients      = Column("recipients") |     val recipients = Column("recipients") | ||||||
|     val body       = Column("body") |     val body       = Column("body") | ||||||
|     val created       = Column("created") |     val created    = Column("created") | ||||||
|  |  | ||||||
|     val all = List( |     val all = List( | ||||||
|       id, |       id, | ||||||
|   | |||||||
| @@ -17,13 +17,16 @@ case class RSentMailItem( | |||||||
|  |  | ||||||
| object RSentMailItem { | object RSentMailItem { | ||||||
|  |  | ||||||
|   def create[F[_]: Sync](itemId: Ident, sentmailId: Ident, created: Option[Timestamp] = None): F[RSentMailItem] = |   def apply[F[_]: Sync]( | ||||||
|  |       itemId: Ident, | ||||||
|  |       sentmailId: Ident, | ||||||
|  |       created: Option[Timestamp] = None | ||||||
|  |   ): F[RSentMailItem] = | ||||||
|     for { |     for { | ||||||
|       id <- Ident.randomId[F] |       id  <- Ident.randomId[F] | ||||||
|       now <- created.map(_.pure[F]).getOrElse(Timestamp.current[F]) |       now <- created.map(_.pure[F]).getOrElse(Timestamp.current[F]) | ||||||
|     } yield RSentMailItem(id, itemId, sentmailId, now) |     } yield RSentMailItem(id, itemId, sentmailId, now) | ||||||
|  |  | ||||||
|  |  | ||||||
|   val table = fr"sentmailitem" |   val table = fr"sentmailitem" | ||||||
|  |  | ||||||
|   object Columns { |   object Columns { | ||||||
|   | |||||||
| @@ -8,7 +8,7 @@ import cats.data.OptionT | |||||||
| import docspell.common._ | import docspell.common._ | ||||||
| import docspell.store.impl.Column | import docspell.store.impl.Column | ||||||
| import docspell.store.impl.Implicits._ | import docspell.store.impl.Implicits._ | ||||||
| import emil.{MailAddress, SSLType} | import emil.{MailAddress, MailConfig, SSLType} | ||||||
|  |  | ||||||
| case class RUserEmail( | case class RUserEmail( | ||||||
|     id: Ident, |     id: Ident, | ||||||
| @@ -23,7 +23,19 @@ case class RUserEmail( | |||||||
|     mailFrom: MailAddress, |     mailFrom: MailAddress, | ||||||
|     mailReplyTo: Option[MailAddress], |     mailReplyTo: Option[MailAddress], | ||||||
|     created: Timestamp |     created: Timestamp | ||||||
| ) {} | ) { | ||||||
|  |  | ||||||
|  |   def toMailConfig: MailConfig = { | ||||||
|  |     val port = smtpPort.map(p => s":$p").getOrElse("") | ||||||
|  |     MailConfig( | ||||||
|  |       s"smtp://$smtpHost$port", | ||||||
|  |       smtpUser.getOrElse(""), | ||||||
|  |       smtpPassword.map(_.pass).getOrElse(""), | ||||||
|  |       smtpSsl, | ||||||
|  |       !smtpCertCheck | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| object RUserEmail { | object RUserEmail { | ||||||
|  |  | ||||||
|   | |||||||
| @@ -9,7 +9,7 @@ object Dependencies { | |||||||
|   val BitpeaceVersion = "0.4.2" |   val BitpeaceVersion = "0.4.2" | ||||||
|   val CirceVersion = "0.12.3" |   val CirceVersion = "0.12.3" | ||||||
|   val DoobieVersion = "0.8.8" |   val DoobieVersion = "0.8.8" | ||||||
|   val EmilVersion = "0.1.1" |   val EmilVersion = "0.1.2-SNAPSHOT" | ||||||
|   val FastparseVersion = "2.1.3" |   val FastparseVersion = "2.1.3" | ||||||
|   val FlywayVersion = "6.1.3" |   val FlywayVersion = "6.1.3" | ||||||
|   val Fs2Version = "2.1.0" |   val Fs2Version = "2.1.0" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user