Liitä mediatiedostot viesteihin
HTML-sähköpostit voivat linkata liitteisiin, jolloin viesti on itsenäinen. Viestin sisäisiin kuviin linkkaaminen toimii yleensä sähköpostiohjelmissa ulkoisia linkkejä paremmin, koska ulkoisten linkkien seuraaminen on yksityisyysriski.
This commit is contained in:
parent
373e34a9e4
commit
eddbceba67
@ -26,6 +26,8 @@
|
|||||||
#:select? vcs-file?))
|
#:select? vcs-file?))
|
||||||
(build-system haskell-build-system)
|
(build-system haskell-build-system)
|
||||||
(inputs (list ghc-acid-state
|
(inputs (list ghc-acid-state
|
||||||
|
ghc-attoparsec
|
||||||
|
ghc-base64
|
||||||
ghc-cryptonite
|
ghc-cryptonite
|
||||||
ghc-case-insensitive
|
ghc-case-insensitive
|
||||||
ghc-glob
|
ghc-glob
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||||
{-# LANGUAGE NamedFieldPuns #-}
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
{-# LANGUAGE PackageImports #-}
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
|
||||||
module TiedoteMD.Read where
|
module TiedoteMD.Read where
|
||||||
@ -16,10 +17,13 @@ import Data.Acid (AcidState, update)
|
|||||||
import Data.Bifunctor (first, second)
|
import Data.Bifunctor (first, second)
|
||||||
import Data.ByteArray (convert)
|
import Data.ByteArray (convert)
|
||||||
import Data.ByteString (ByteString)
|
import Data.ByteString (ByteString)
|
||||||
|
import "base64" Data.ByteString.Base64.URL
|
||||||
import Data.Default (def)
|
import Data.Default (def)
|
||||||
import Data.Either (rights, lefts)
|
import Data.Either (rights, lefts, fromRight)
|
||||||
import Data.FileStore (FileStore(..), Revision(..), FileStoreError(..), gitFileStore)
|
import Data.FileStore (FileStore(..), Revision(..), FileStoreError(..), gitFileStore)
|
||||||
import Data.List (singleton, isSuffixOf)
|
import Data.List (singleton, isSuffixOf)
|
||||||
|
import Data.MIME (ContentID, makeContentID, renderContentID)
|
||||||
|
import Data.Maybe (fromMaybe)
|
||||||
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
|
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
|
||||||
import Data.Time (UTCTime, zonedTimeToUTC)
|
import Data.Time (UTCTime, zonedTimeToUTC)
|
||||||
import Data.Time (getCurrentTime)
|
import Data.Time (getCurrentTime)
|
||||||
@ -29,12 +33,15 @@ import System.FilePath.Glob (match, compile)
|
|||||||
import System.IO (hClose)
|
import System.IO (hClose)
|
||||||
import System.Process (createProcess, proc, waitForProcess, CreateProcess(..), StdStream(..))
|
import System.Process (createProcess, proc, waitForProcess, CreateProcess(..), StdStream(..))
|
||||||
import Text.Pandoc (Pandoc(..), PandocMonad, PandocIO, PandocError)
|
import Text.Pandoc (Pandoc(..), PandocMonad, PandocIO, PandocError)
|
||||||
|
import Text.Pandoc.MediaBag (MediaItem(..), lookupMedia, mediaItems)
|
||||||
import Text.Pandoc.Readers (readMarkdown)
|
import Text.Pandoc.Readers (readMarkdown)
|
||||||
|
import Text.Pandoc.Walk (walkM)
|
||||||
import Text.Pandoc.Writers (writePlain, writeMarkdown, writeHtml5String)
|
import Text.Pandoc.Writers (writePlain, writeMarkdown, writeHtml5String)
|
||||||
|
|
||||||
import qualified Data.ByteString as BS
|
import qualified Data.ByteString as BS
|
||||||
import qualified Data.ByteString.Lazy as LBS
|
import qualified Data.ByteString.Lazy as LBS
|
||||||
import qualified Data.Map as Map
|
import qualified Data.Map as Map
|
||||||
|
import qualified Data.Set as Set
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import qualified Data.Text.IO as T
|
import qualified Data.Text.IO as T
|
||||||
import qualified Text.Pandoc as Pandoc
|
import qualified Text.Pandoc as Pandoc
|
||||||
@ -101,10 +108,40 @@ readMessageFile store = flip (retrieve store) Nothing >=>
|
|||||||
uncurry (parseMessageFile store) . addHash . T.filter (/= '\r') . decodeUtf8 . BS.toStrict
|
uncurry (parseMessageFile store) . addHash . T.filter (/= '\r') . decodeUtf8 . BS.toStrict
|
||||||
where addHash text = (convert $ hashWith SHA256 $ encodeUtf8 text, text)
|
where addHash text = (convert $ hashWith SHA256 $ encodeUtf8 text, text)
|
||||||
|
|
||||||
|
cidOf :: MediaItem -> ContentID
|
||||||
|
cidOf MediaItem {mediaContents, mediaMimeType} =
|
||||||
|
fromRight (error "makeContentID failed with valid input!") $
|
||||||
|
makeContentID $ "<" <> encodedHash <> "@tiedote.md.sha256>"
|
||||||
|
where encodedHash = encodeBase64' $ convert $ hashWith SHA256 $
|
||||||
|
LBS.toStrict mediaContents <> encodeUtf8 mediaMimeType
|
||||||
|
|
||||||
|
cidUrl :: ContentID -> T.Text
|
||||||
|
cidUrl = mappend "cid:" . stripAngleBrackets . decodeUtf8 . renderContentID
|
||||||
|
where stripAngleBrackets = T.init . T.tail
|
||||||
|
-- The string is always surrounded by < > so this is safe
|
||||||
|
|
||||||
|
makeMediaPart :: MediaItem -> MediaPart
|
||||||
|
makeMediaPart mediaItem@MediaItem {..} = MediaPart
|
||||||
|
{ mediaPartMimeType = encodeUtf8 mediaMimeType
|
||||||
|
, mediaPartContentID = renderContentID $ cidOf mediaItem
|
||||||
|
, mediaPartContents = LBS.toStrict mediaContents
|
||||||
|
}
|
||||||
|
|
||||||
|
reconstructMediaItem :: (FilePath, T.Text, LBS.ByteString) -> MediaItem
|
||||||
|
reconstructMediaItem (mediaPath, mediaMimeType, mediaContents) = MediaItem {..}
|
||||||
|
|
||||||
|
replaceImagesWithCid :: PandocMonad m => Pandoc -> m Pandoc
|
||||||
|
replaceImagesWithCid = Pandoc.fillMediaBag >=> walkM handleImage
|
||||||
|
where handleImage (Pandoc.Image attr lab (src, title)) = do
|
||||||
|
mediaItem <- (\media -> fromMaybe (error $ "fillMediaBag left an image uncollected! impossible!")
|
||||||
|
$ lookupMedia (T.unpack src) media) <$> Pandoc.getMediaBag
|
||||||
|
pure $ Pandoc.Image attr lab (cidUrl $ cidOf mediaItem, title)
|
||||||
|
handleImage x = pure x
|
||||||
|
|
||||||
parseMessageFile :: FileStore -> ByteString -> T.Text -> IO (Either Error Message)
|
parseMessageFile :: FileStore -> ByteString -> T.Text -> IO (Either Error Message)
|
||||||
parseMessageFile store hash text = fmap (join . first PandocError) . runReadM store $ do
|
parseMessageFile store hash text = fmap (join . first PandocError) . runReadM store $ do
|
||||||
pandoc@(Pandoc meta _) <- flip readMarkdown text
|
pandoc@(Pandoc meta _) <- flip readMarkdown text
|
||||||
def {Pandoc.readerStandalone = True, Pandoc.readerExtensions = Pandoc.pandocExtensions}
|
def {Pandoc.readerStandalone = True, Pandoc.readerExtensions = Pandoc.pandocExtensions} >>= replaceImagesWithCid
|
||||||
let tiedoteMeta = do
|
let tiedoteMeta = do
|
||||||
previewTo <- lookupMeta' "tarkistaja" meta >>= metaToEmails
|
previewTo <- lookupMeta' "tarkistaja" meta >>= metaToEmails
|
||||||
previewTime <- lookupMeta' "deadline" meta >>= metaToTime
|
previewTime <- lookupMeta' "deadline" meta >>= metaToTime
|
||||||
@ -120,6 +157,7 @@ parseMessageFile store hash text = fmap (join . first PandocError) . runReadM st
|
|||||||
(Pandoc.Meta $ Map.insertWith (flip const) "pagetitle" (Pandoc.MetaString subject) $ Pandoc.unMeta meta')
|
(Pandoc.Meta $ Map.insertWith (flip const) "pagetitle" (Pandoc.MetaString subject) $ Pandoc.unMeta meta')
|
||||||
blocks'
|
blocks'
|
||||||
htmlMessage <- liftIO . inlineCSS =<< renderHelper writeHtml5String htmlTemplate htmlPandoc
|
htmlMessage <- liftIO . inlineCSS =<< renderHelper writeHtml5String htmlTemplate htmlPandoc
|
||||||
|
mediaParts <- Set.fromList . map (makeMediaPart . reconstructMediaItem) . mediaItems <$> Pandoc.getMediaBag
|
||||||
pure $ pure $ Message
|
pure $ pure $ Message
|
||||||
{ recipients = []
|
{ recipients = []
|
||||||
, messageHash = hash
|
, messageHash = hash
|
||||||
@ -133,7 +171,6 @@ parseMessageFile store hash text = fmap (join . first PandocError) . runReadM st
|
|||||||
, Pandoc.writerTableOfContents = True
|
, Pandoc.writerTableOfContents = True
|
||||||
, Pandoc.writerSectionDivs = True
|
, Pandoc.writerSectionDivs = True
|
||||||
}
|
}
|
||||||
-- TODO: Store the media somewhere
|
|
||||||
|
|
||||||
inlineCSS :: T.Text -> IO T.Text
|
inlineCSS :: T.Text -> IO T.Text
|
||||||
inlineCSS html = do
|
inlineCSS html = do
|
||||||
|
@ -54,7 +54,8 @@ managePreviews acid sender sendmailPath = forever $ do
|
|||||||
Just msg@(Message {..}) -> do
|
Just msg@(Message {..}) -> do
|
||||||
mailID <- uniqueMailID sender
|
mailID <- uniqueMailID sender
|
||||||
boundary <- getStdRandom uniform
|
boundary <- getStdRandom uniform
|
||||||
let mail = renderMessage' "Esikatselu: " (Just mailID) msg sender boundary
|
boundary' <- getStdRandom uniform
|
||||||
|
let mail = renderMessage' "Esikatselu: " (Just mailID) msg sender boundary boundary'
|
||||||
sendmail sendmailPath $ toLazyByteString $ buildMessage $
|
sendmail sendmailPath $ toLazyByteString $ buildMessage $
|
||||||
set (headerTo defaultCharsets) (map (Single . emailToMailbox) previewTo) mail
|
set (headerTo defaultCharsets) (map (Single . emailToMailbox) previewTo) mail
|
||||||
update acid $ SetPreviewID messageHash mailID
|
update acid $ SetPreviewID messageHash mailID
|
||||||
|
@ -5,12 +5,14 @@ module TiedoteMD.Send where
|
|||||||
|
|
||||||
import Control.Concurrent (threadDelay)
|
import Control.Concurrent (threadDelay)
|
||||||
import Control.Exception (throwIO)
|
import Control.Exception (throwIO)
|
||||||
import Control.Lens (set)
|
import Control.Lens (set, _Just)
|
||||||
import Control.Monad (forever, unless)
|
import Control.Monad (forever, unless)
|
||||||
import Data.Acid (AcidState, query, update)
|
import Data.Acid (AcidState, query, update)
|
||||||
|
import Data.Attoparsec.ByteString (endOfInput, parseOnly)
|
||||||
import Data.Binary.Builder (toLazyByteString)
|
import Data.Binary.Builder (toLazyByteString)
|
||||||
import Data.MIME (Address(..), Mailbox, Boundary, MIMEMessage, MIME(..), Headers(..), MultipartSubtype(..), buildMessage, headerTo, headerSubject, headerFrom, headerMessageID, header, createTextPlainMessage, contentType)
|
import Data.MIME (Address(..), Mailbox, Boundary, MIMEMessage, MIME(..), Headers(..), MultipartSubtype(..), ContentTypeWith(..), DispositionType(..), buildMessage, headerTo, headerSubject, headerFrom, headerMessageID, headerContentID, header, createTextPlainMessage, contentType, contentDisposition, dispositionType, createAttachment, parseContentType, makeContentID)
|
||||||
import Data.MIME.Charset (defaultCharsets)
|
import Data.MIME.Charset (defaultCharsets)
|
||||||
|
import Data.Set (Set)
|
||||||
import Data.Time (getCurrentTime)
|
import Data.Time (getCurrentTime)
|
||||||
import System.Exit (ExitCode(..))
|
import System.Exit (ExitCode(..))
|
||||||
import System.Exit.Codes (codeTempFail)
|
import System.Exit.Codes (codeTempFail)
|
||||||
@ -21,6 +23,7 @@ import System.Random (getStdRandom, uniform)
|
|||||||
import qualified Data.ByteString.Lazy as LBS
|
import qualified Data.ByteString.Lazy as LBS
|
||||||
import qualified Data.IMF as IMF
|
import qualified Data.IMF as IMF
|
||||||
import qualified Data.List.NonEmpty as NE
|
import qualified Data.List.NonEmpty as NE
|
||||||
|
import qualified Data.Set as Set
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
|
||||||
import TiedoteMD.State
|
import TiedoteMD.State
|
||||||
@ -42,7 +45,8 @@ manageQueue acid sender sendmailPath = forever $ do
|
|||||||
Nothing -> pure ()
|
Nothing -> pure ()
|
||||||
Just (address, message) -> do
|
Just (address, message) -> do
|
||||||
boundary <- getStdRandom uniform
|
boundary <- getStdRandom uniform
|
||||||
let mail = renderMessage message sender boundary
|
boundary' <- getStdRandom uniform
|
||||||
|
let mail = renderMessage message sender boundary boundary'
|
||||||
sendmail sendmailPath $ toLazyByteString $ buildMessage $
|
sendmail sendmailPath $ toLazyByteString $ buildMessage $
|
||||||
set (headerTo defaultCharsets) [Single $ emailToMailbox address] mail
|
set (headerTo defaultCharsets) [Single $ emailToMailbox address] mail
|
||||||
update acid MarkMessageAsSent
|
update acid MarkMessageAsSent
|
||||||
@ -58,11 +62,11 @@ manageQueueingMessages acid = forever $ do
|
|||||||
queueMessages :: AcidState State -> IO ()
|
queueMessages :: AcidState State -> IO ()
|
||||||
queueMessages acid = getCurrentTime >>= update acid . MoveToSendQueue
|
queueMessages acid = getCurrentTime >>= update acid . MoveToSendQueue
|
||||||
|
|
||||||
renderMessage :: Message -> Mailbox -> Boundary -> MIMEMessage
|
renderMessage :: Message -> Mailbox -> Boundary -> Boundary -> MIMEMessage
|
||||||
renderMessage = renderMessage' "" Nothing
|
renderMessage = renderMessage' "" Nothing
|
||||||
|
|
||||||
renderMessage' :: T.Text -> Maybe MailID -> Message -> Mailbox -> Boundary -> MIMEMessage
|
renderMessage' :: T.Text -> Maybe MailID -> Message -> Mailbox -> Boundary -> Boundary -> MIMEMessage
|
||||||
renderMessage' subjectPrefix maybeMailID (Message {messageContent = MessageContent {..},..}) sender boundary =
|
renderMessage' subjectPrefix maybeMailID (Message {messageContent = MessageContent {..},..}) sender boundary boundary' =
|
||||||
maybe id (set headerMessageID . Just . mailIDToMessageID) maybeMailID $
|
maybe id (set headerMessageID . Just . mailIDToMessageID) maybeMailID $
|
||||||
set (headerSubject defaultCharsets) (Just $ subjectPrefix <> subject) $
|
set (headerSubject defaultCharsets) (Just $ subjectPrefix <> subject) $
|
||||||
set (header "Precedence") "Bulk" $
|
set (header "Precedence") "Bulk" $
|
||||||
@ -71,12 +75,25 @@ renderMessage' subjectPrefix maybeMailID (Message {messageContent = MessageConte
|
|||||||
IMF.Message (Headers []) $ Multipart Alternative boundary $ NE.fromList
|
IMF.Message (Headers []) $ Multipart Alternative boundary $ NE.fromList
|
||||||
[ createTextPlainMessage plainTextMessage
|
[ createTextPlainMessage plainTextMessage
|
||||||
, createTextMarkdownMessage markdownMessage
|
, createTextMarkdownMessage markdownMessage
|
||||||
, createTextHtmlMessage htmlMessage
|
, createTextHtmlMessage boundary' mediaParts htmlMessage
|
||||||
]
|
]
|
||||||
|
|
||||||
createTextMarkdownMessage :: T.Text -> MIMEMessage
|
createTextMarkdownMessage :: T.Text -> MIMEMessage
|
||||||
createTextMarkdownMessage = set contentType "text/markdown; charset=utf-8; variant=pandoc" . createTextPlainMessage
|
createTextMarkdownMessage = set contentType "text/markdown; charset=utf-8; variant=pandoc" . createTextPlainMessage
|
||||||
|
|
||||||
createTextHtmlMessage :: T.Text -> MIMEMessage
|
createTextHtmlMessage :: Boundary -> Set MediaPart -> T.Text -> MIMEMessage
|
||||||
createTextHtmlMessage = set contentType "text/html; charset=utf-8" . createTextPlainMessage
|
createTextHtmlMessage boundary mediaParts html = IMF.Message (Headers []) $ multipartRelated $
|
||||||
-- TODO: multipart/related with media
|
(set contentType "text/html; charset=utf-8" $ createTextPlainMessage html) NE.:| mediaAttachments
|
||||||
|
where multipartRelated = Multipart
|
||||||
|
(Related (Just $ ContentType "text" "html" ()) Nothing Nothing) boundary
|
||||||
|
mediaAttachments = mediaPartToAttachment <$> Set.toList mediaParts
|
||||||
|
|
||||||
|
mediaPartToAttachment :: MediaPart -> MIMEMessage
|
||||||
|
mediaPartToAttachment MediaPart {..} =
|
||||||
|
set (contentDisposition . _Just . dispositionType) Inline $
|
||||||
|
set headerContentID (Just contentID) $
|
||||||
|
createAttachment mimeType Nothing mediaPartContents
|
||||||
|
where mimeType = either (error "purebred-email couldn't parse pandoc's mime type!") id $
|
||||||
|
parseOnly (parseContentType <* endOfInput) mediaPartMimeType
|
||||||
|
contentID = either (error "purebred-email couldn't parse it's own contentID!") id $
|
||||||
|
makeContentID mediaPartContentID
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
{-# LANGUAGE TemplateHaskell #-}
|
{-# LANGUAGE TemplateHaskell #-}
|
||||||
|
{-# LANGUAGE DuplicateRecordFields #-}
|
||||||
|
{-# LANGUAGE TypeFamilies #-}
|
||||||
|
|
||||||
module TiedoteMD.Types
|
module TiedoteMD.Types
|
||||||
( Email(..)
|
( Email(..)
|
||||||
, Error(..)
|
, Error(..)
|
||||||
, MailID
|
, MailID
|
||||||
|
, MediaPart(..)
|
||||||
, Message(..)
|
, Message(..)
|
||||||
, MessageContent(..)
|
, MessageContent(..)
|
||||||
, SendJob(..)
|
, SendJob(..)
|
||||||
@ -28,8 +31,9 @@ import Data.CaseInsensitive (original, mk)
|
|||||||
import Data.IMF (MessageID, Mailbox(..), AddrSpec(..), Domain(..), mailbox, parseMessageID, parse, renderMessageID, renderMailbox)
|
import Data.IMF (MessageID, Mailbox(..), AddrSpec(..), Domain(..), mailbox, parseMessageID, parse, renderMessageID, renderMailbox)
|
||||||
import Data.List.NonEmpty (NonEmpty)
|
import Data.List.NonEmpty (NonEmpty)
|
||||||
import Data.MIME.Charset (defaultCharsets)
|
import Data.MIME.Charset (defaultCharsets)
|
||||||
import Data.SafeCopy (base, deriveSafeCopy)
|
import Data.SafeCopy (Migrate(..), base, deriveSafeCopy, extension)
|
||||||
import Data.Semigroup (sconcat)
|
import Data.Semigroup (sconcat)
|
||||||
|
import Data.Set (Set)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
|
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
|
||||||
import Data.Time (UTCTime, getCurrentTime, defaultTimeLocale, formatTime)
|
import Data.Time (UTCTime, getCurrentTime, defaultTimeLocale, formatTime)
|
||||||
@ -88,6 +92,23 @@ data Message = Message
|
|||||||
} deriving (Show, Eq, Typeable)
|
} deriving (Show, Eq, Typeable)
|
||||||
|
|
||||||
data MessageContent = MessageContent
|
data MessageContent = MessageContent
|
||||||
|
{ plainTextMessage :: Text
|
||||||
|
, markdownMessage :: Text
|
||||||
|
, htmlMessage :: Text
|
||||||
|
, mediaParts :: Set MediaPart
|
||||||
|
} deriving (Show, Eq, Typeable)
|
||||||
|
|
||||||
|
data MediaPart = MediaPart
|
||||||
|
-- XXX Ideally purebred-email would implement safecopy and we could use proper types
|
||||||
|
{ mediaPartMimeType :: ByteString -- ContentTypeWith Parameters
|
||||||
|
, mediaPartContentID :: ByteString -- ContentID
|
||||||
|
, mediaPartContents :: ByteString
|
||||||
|
} deriving (Show, Typeable, Ord)
|
||||||
|
|
||||||
|
instance Eq MediaPart where
|
||||||
|
MediaPart {mediaPartContentID = a} == MediaPart {mediaPartContentID = b} = a == b
|
||||||
|
|
||||||
|
data MessageContent_v0 = MessageContent_v0
|
||||||
{ plainTextMessage :: Text
|
{ plainTextMessage :: Text
|
||||||
, markdownMessage :: Text
|
, markdownMessage :: Text
|
||||||
, htmlMessage :: Text
|
, htmlMessage :: Text
|
||||||
@ -148,7 +169,14 @@ renderError (FileNotFoundError path) = T.pack path <> " not found"
|
|||||||
|
|
||||||
deriveSafeCopy 0 'base ''Domain'
|
deriveSafeCopy 0 'base ''Domain'
|
||||||
deriveSafeCopy 0 'base ''Email
|
deriveSafeCopy 0 'base ''Email
|
||||||
deriveSafeCopy 0 'base ''MessageContent
|
deriveSafeCopy 0 'base ''MessageContent_v0
|
||||||
|
deriveSafeCopy 0 'base ''MediaPart
|
||||||
|
|
||||||
|
instance Migrate MessageContent where
|
||||||
|
type MigrateFrom MessageContent = MessageContent_v0
|
||||||
|
migrate MessageContent_v0 {..} = MessageContent {mediaParts = mempty, ..}
|
||||||
|
|
||||||
|
deriveSafeCopy 1 'extension ''MessageContent
|
||||||
deriveSafeCopy 0 'base ''MailID
|
deriveSafeCopy 0 'base ''MailID
|
||||||
deriveSafeCopy 0 'base ''Message
|
deriveSafeCopy 0 'base ''Message
|
||||||
deriveSafeCopy 0 'base ''SendJob
|
deriveSafeCopy 0 'base ''SendJob
|
||||||
|
@ -23,7 +23,9 @@ source-repository head
|
|||||||
executable tiedote.md
|
executable tiedote.md
|
||||||
build-depends:
|
build-depends:
|
||||||
acid-state,
|
acid-state,
|
||||||
|
attoparsec,
|
||||||
base,
|
base,
|
||||||
|
base64,
|
||||||
binary,
|
binary,
|
||||||
bytestring,
|
bytestring,
|
||||||
case-insensitive,
|
case-insensitive,
|
||||||
@ -35,6 +37,7 @@ executable tiedote.md
|
|||||||
doctemplates,
|
doctemplates,
|
||||||
exit-codes,
|
exit-codes,
|
||||||
file-embed,
|
file-embed,
|
||||||
|
filepath,
|
||||||
filestore,
|
filestore,
|
||||||
Glob,
|
Glob,
|
||||||
hostname,
|
hostname,
|
||||||
@ -44,6 +47,7 @@ executable tiedote.md
|
|||||||
network,
|
network,
|
||||||
optparse-applicative,
|
optparse-applicative,
|
||||||
pandoc,
|
pandoc,
|
||||||
|
pandoc-types,
|
||||||
process,
|
process,
|
||||||
purebred-email,
|
purebred-email,
|
||||||
random,
|
random,
|
||||||
|
Loading…
Reference in New Issue
Block a user