diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index da8958cc..59982761 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -9,6 +9,7 @@ import docspell.store.ops.ONode import docspell.store.queue.JobQueue import scala.concurrent.ExecutionContext +import emil.javamail.JavaMailEmil trait BackendApp[F[_]] { @@ -28,10 +29,11 @@ trait BackendApp[F[_]] { object BackendApp { - def create[F[_]: ConcurrentEffect]( + def create[F[_]: ConcurrentEffect: ContextShift]( cfg: Config, store: Store[F], - httpClientEc: ExecutionContext + httpClientEc: ExecutionContext, + blocker: Blocker ): Resource[F, BackendApp[F]] = for { queue <- JobQueue(store) @@ -46,7 +48,7 @@ object BackendApp { nodeImpl <- ONode(store) jobImpl <- OJob(store, httpClientEc) itemImpl <- OItem(store) - mailImpl <- OMail(store) + mailImpl <- OMail(store, JavaMailEmil(blocker)) } yield new BackendApp[F] { val login: Login[F] = loginImpl val signup: OSignup[F] = signupImpl @@ -70,6 +72,6 @@ object BackendApp { ): Resource[F, BackendApp[F]] = for { store <- Store.create(cfg.jdbc, connectEC, blocker) - backend <- create(cfg, store, httpClientEc) + backend <- create(cfg, store, httpClientEc, blocker) } yield backend } diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala index 6fd9ab32..80875f92 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala @@ -1,14 +1,22 @@ package docspell.backend.ops +import fs2.Stream import cats.effect._ import cats.implicits._ import cats.data.OptionT -import emil.{MailAddress, SSLType} +import emil._ +import emil.javamail.syntax._ import docspell.common._ import docspell.store._ import docspell.store.records.RUserEmail 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[_]] { @@ -35,10 +43,19 @@ object OMail { attach: AttachSelection ) - sealed trait AttachSelection + sealed trait AttachSelection { + def filter(v: Vector[(RAttachment, FileMeta)]): Vector[(RAttachment, FileMeta)] + } object AttachSelection { - case object All extends AttachSelection - case class Selected(ids: List[Ident]) extends AttachSelection + case object All 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( @@ -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] { def getSettings(accId: AccountId, nameQ: Option[String]): F[Vector[RUserEmail]] = store.transact(RUserEmail.findByAccount(accId, nameQ)) @@ -97,7 +114,71 @@ object OMail { def deleteSettings(accId: AccountId, name: Ident): F[Int] = store.transact(RUserEmail.delete(accId, name)) - def sendMail(accId: AccountId, name: Ident, m: ItemMail): F[SendResult] = - Effect[F].pure(SendResult.Failure(new Exception("not implemented"))) + def sendMail(accId: AccountId, name: Ident, m: ItemMail): F[SendResult] = { + + 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) + } + }) } diff --git a/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala b/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala index b3ac6450..f64f48f6 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala @@ -6,7 +6,21 @@ sealed trait SendResult object SendResult { + /** Mail was successfully sent and stored to db. + */ 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 } diff --git a/modules/restserver/src/main/resources/logback.xml b/modules/restserver/src/main/resources/logback.xml index c33ec1f7..f9b2d921 100644 --- a/modules/restserver/src/main/resources/logback.xml +++ b/modules/restserver/src/main/resources/logback.xml @@ -8,6 +8,8 @@ + + diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala index 8c10c33e..3d7a08e3 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala @@ -46,7 +46,11 @@ object MailSendRoutes { res match { case SendResult.Success(_) => BasicResult(true, "Mail sent.") - case SendResult.Failure(ex) => + case SendResult.SendFailure(ex) => 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.") } } diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala index 84762e0b..62f058cd 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -95,7 +95,9 @@ trait DoobieMeta { Meta[String].imap(EmilUtil.unsafeReadMailAddress)(EmilUtil.mailAddressString) 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 { diff --git a/modules/store/src/main/scala/docspell/store/records/RAttachment.scala b/modules/store/src/main/scala/docspell/store/records/RAttachment.scala index 52f71d30..ee193e69 100644 --- a/modules/store/src/main/scala/docspell/store/records/RAttachment.scala +++ b/modules/store/src/main/scala/docspell/store/records/RAttachment.scala @@ -64,6 +64,26 @@ object RAttachment { 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)]] = { import bitpeace.sql._ diff --git a/modules/store/src/main/scala/docspell/store/records/RFileMeta.scala b/modules/store/src/main/scala/docspell/store/records/RFileMeta.scala new file mode 100644 index 00000000..e6f206e5 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RFileMeta.scala @@ -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) + + } +} diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala index 7d9dafda..bf447317 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -262,4 +262,7 @@ object RItem { def deleteByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Int] = 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) } diff --git a/modules/store/src/main/scala/docspell/store/records/RSentMail.scala b/modules/store/src/main/scala/docspell/store/records/RSentMail.scala index 9bde3fcc..8eb1b43d 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSentMail.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSentMail.scala @@ -1,12 +1,15 @@ package docspell.store.records import fs2.Stream +import cats.effect._ +import cats.implicits._ import doobie._ import doobie.implicits._ import docspell.common._ import docspell.store.impl.Column import docspell.store.impl.Implicits._ import emil.MailAddress +import cats.data.OptionT case class RSentMail( id: Ident, @@ -21,17 +24,46 @@ case class 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" object Columns { - val id = Column("id") - val uid = Column("uid") - val messageId = Column("message_id") - val sender = Column("sender") - val subject = Column("subject") - val recipients = Column("recipients") + val id = Column("id") + val uid = Column("uid") + val messageId = Column("message_id") + val sender = Column("sender") + val subject = Column("subject") + val recipients = Column("recipients") val body = Column("body") - val created = Column("created") + val created = Column("created") val all = List( id, diff --git a/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala b/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala index 2a729539..b9b12da2 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala @@ -17,13 +17,16 @@ case class 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 { - id <- Ident.randomId[F] + id <- Ident.randomId[F] now <- created.map(_.pure[F]).getOrElse(Timestamp.current[F]) } yield RSentMailItem(id, itemId, sentmailId, now) - val table = fr"sentmailitem" object Columns { diff --git a/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala index cc67029b..e8fcc0b7 100644 --- a/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala +++ b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala @@ -8,7 +8,7 @@ import cats.data.OptionT import docspell.common._ import docspell.store.impl.Column import docspell.store.impl.Implicits._ -import emil.{MailAddress, SSLType} +import emil.{MailAddress, MailConfig, SSLType} case class RUserEmail( id: Ident, @@ -23,7 +23,19 @@ case class RUserEmail( mailFrom: MailAddress, mailReplyTo: Option[MailAddress], 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 { diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 33dcd904..73bb2890 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -9,7 +9,7 @@ object Dependencies { val BitpeaceVersion = "0.4.2" val CirceVersion = "0.12.3" val DoobieVersion = "0.8.8" - val EmilVersion = "0.1.1" + val EmilVersion = "0.1.2-SNAPSHOT" val FastparseVersion = "2.1.3" val FlywayVersion = "6.1.3" val Fs2Version = "2.1.0"