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?))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-acid-state
|
||||
ghc-attoparsec
|
||||
ghc-base64
|
||||
ghc-cryptonite
|
||||
ghc-case-insensitive
|
||||
ghc-glob
|
||||
|
@ -1,6 +1,7 @@
|
||||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE PackageImports #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
|
||||
module TiedoteMD.Read where
|
||||
@ -16,10 +17,13 @@ import Data.Acid (AcidState, update)
|
||||
import Data.Bifunctor (first, second)
|
||||
import Data.ByteArray (convert)
|
||||
import Data.ByteString (ByteString)
|
||||
import "base64" Data.ByteString.Base64.URL
|
||||
import Data.Default (def)
|
||||
import Data.Either (rights, lefts)
|
||||
import Data.Either (rights, lefts, fromRight)
|
||||
import Data.FileStore (FileStore(..), Revision(..), FileStoreError(..), gitFileStore)
|
||||
import Data.List (singleton, isSuffixOf)
|
||||
import Data.MIME (ContentID, makeContentID, renderContentID)
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
|
||||
import Data.Time (UTCTime, zonedTimeToUTC)
|
||||
import Data.Time (getCurrentTime)
|
||||
@ -29,12 +33,15 @@ import System.FilePath.Glob (match, compile)
|
||||
import System.IO (hClose)
|
||||
import System.Process (createProcess, proc, waitForProcess, CreateProcess(..), StdStream(..))
|
||||
import Text.Pandoc (Pandoc(..), PandocMonad, PandocIO, PandocError)
|
||||
import Text.Pandoc.MediaBag (MediaItem(..), lookupMedia, mediaItems)
|
||||
import Text.Pandoc.Readers (readMarkdown)
|
||||
import Text.Pandoc.Walk (walkM)
|
||||
import Text.Pandoc.Writers (writePlain, writeMarkdown, writeHtml5String)
|
||||
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Lazy as LBS
|
||||
import qualified Data.Map as Map
|
||||
import qualified Data.Set as Set
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.IO as T
|
||||
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
|
||||
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 store hash text = fmap (join . first PandocError) . runReadM store $ do
|
||||
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
|
||||
previewTo <- lookupMeta' "tarkistaja" meta >>= metaToEmails
|
||||
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')
|
||||
blocks'
|
||||
htmlMessage <- liftIO . inlineCSS =<< renderHelper writeHtml5String htmlTemplate htmlPandoc
|
||||
mediaParts <- Set.fromList . map (makeMediaPart . reconstructMediaItem) . mediaItems <$> Pandoc.getMediaBag
|
||||
pure $ pure $ Message
|
||||
{ recipients = []
|
||||
, messageHash = hash
|
||||
@ -133,7 +171,6 @@ parseMessageFile store hash text = fmap (join . first PandocError) . runReadM st
|
||||
, Pandoc.writerTableOfContents = True
|
||||
, Pandoc.writerSectionDivs = True
|
||||
}
|
||||
-- TODO: Store the media somewhere
|
||||
|
||||
inlineCSS :: T.Text -> IO T.Text
|
||||
inlineCSS html = do
|
||||
|
@ -54,7 +54,8 @@ managePreviews acid sender sendmailPath = forever $ do
|
||||
Just msg@(Message {..}) -> do
|
||||
mailID <- uniqueMailID sender
|
||||
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 $
|
||||
set (headerTo defaultCharsets) (map (Single . emailToMailbox) previewTo) mail
|
||||
update acid $ SetPreviewID messageHash mailID
|
||||
|
@ -5,12 +5,14 @@ module TiedoteMD.Send where
|
||||
|
||||
import Control.Concurrent (threadDelay)
|
||||
import Control.Exception (throwIO)
|
||||
import Control.Lens (set)
|
||||
import Control.Lens (set, _Just)
|
||||
import Control.Monad (forever, unless)
|
||||
import Data.Acid (AcidState, query, update)
|
||||
import Data.Attoparsec.ByteString (endOfInput, parseOnly)
|
||||
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.Set (Set)
|
||||
import Data.Time (getCurrentTime)
|
||||
import System.Exit (ExitCode(..))
|
||||
import System.Exit.Codes (codeTempFail)
|
||||
@ -21,6 +23,7 @@ import System.Random (getStdRandom, uniform)
|
||||
import qualified Data.ByteString.Lazy as LBS
|
||||
import qualified Data.IMF as IMF
|
||||
import qualified Data.List.NonEmpty as NE
|
||||
import qualified Data.Set as Set
|
||||
import qualified Data.Text as T
|
||||
|
||||
import TiedoteMD.State
|
||||
@ -42,7 +45,8 @@ manageQueue acid sender sendmailPath = forever $ do
|
||||
Nothing -> pure ()
|
||||
Just (address, message) -> do
|
||||
boundary <- getStdRandom uniform
|
||||
let mail = renderMessage message sender boundary
|
||||
boundary' <- getStdRandom uniform
|
||||
let mail = renderMessage message sender boundary boundary'
|
||||
sendmail sendmailPath $ toLazyByteString $ buildMessage $
|
||||
set (headerTo defaultCharsets) [Single $ emailToMailbox address] mail
|
||||
update acid MarkMessageAsSent
|
||||
@ -58,11 +62,11 @@ manageQueueingMessages acid = forever $ do
|
||||
queueMessages :: AcidState State -> IO ()
|
||||
queueMessages acid = getCurrentTime >>= update acid . MoveToSendQueue
|
||||
|
||||
renderMessage :: Message -> Mailbox -> Boundary -> MIMEMessage
|
||||
renderMessage :: Message -> Mailbox -> Boundary -> Boundary -> MIMEMessage
|
||||
renderMessage = renderMessage' "" Nothing
|
||||
|
||||
renderMessage' :: T.Text -> Maybe MailID -> Message -> Mailbox -> Boundary -> MIMEMessage
|
||||
renderMessage' subjectPrefix maybeMailID (Message {messageContent = MessageContent {..},..}) sender boundary =
|
||||
renderMessage' :: T.Text -> Maybe MailID -> Message -> Mailbox -> Boundary -> Boundary -> MIMEMessage
|
||||
renderMessage' subjectPrefix maybeMailID (Message {messageContent = MessageContent {..},..}) sender boundary boundary' =
|
||||
maybe id (set headerMessageID . Just . mailIDToMessageID) maybeMailID $
|
||||
set (headerSubject defaultCharsets) (Just $ subjectPrefix <> subject) $
|
||||
set (header "Precedence") "Bulk" $
|
||||
@ -71,12 +75,25 @@ renderMessage' subjectPrefix maybeMailID (Message {messageContent = MessageConte
|
||||
IMF.Message (Headers []) $ Multipart Alternative boundary $ NE.fromList
|
||||
[ createTextPlainMessage plainTextMessage
|
||||
, createTextMarkdownMessage markdownMessage
|
||||
, createTextHtmlMessage htmlMessage
|
||||
, createTextHtmlMessage boundary' mediaParts htmlMessage
|
||||
]
|
||||
|
||||
createTextMarkdownMessage :: T.Text -> MIMEMessage
|
||||
createTextMarkdownMessage = set contentType "text/markdown; charset=utf-8; variant=pandoc" . createTextPlainMessage
|
||||
|
||||
createTextHtmlMessage :: T.Text -> MIMEMessage
|
||||
createTextHtmlMessage = set contentType "text/html; charset=utf-8" . createTextPlainMessage
|
||||
-- TODO: multipart/related with media
|
||||
createTextHtmlMessage :: Boundary -> Set MediaPart -> T.Text -> MIMEMessage
|
||||
createTextHtmlMessage boundary mediaParts html = IMF.Message (Headers []) $ multipartRelated $
|
||||
(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 RecordWildCards #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
|
||||
module TiedoteMD.Types
|
||||
( Email(..)
|
||||
, Error(..)
|
||||
, MailID
|
||||
, MediaPart(..)
|
||||
, Message(..)
|
||||
, MessageContent(..)
|
||||
, SendJob(..)
|
||||
@ -28,8 +31,9 @@ import Data.CaseInsensitive (original, mk)
|
||||
import Data.IMF (MessageID, Mailbox(..), AddrSpec(..), Domain(..), mailbox, parseMessageID, parse, renderMessageID, renderMailbox)
|
||||
import Data.List.NonEmpty (NonEmpty)
|
||||
import Data.MIME.Charset (defaultCharsets)
|
||||
import Data.SafeCopy (base, deriveSafeCopy)
|
||||
import Data.SafeCopy (Migrate(..), base, deriveSafeCopy, extension)
|
||||
import Data.Semigroup (sconcat)
|
||||
import Data.Set (Set)
|
||||
import Data.Text (Text)
|
||||
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
|
||||
import Data.Time (UTCTime, getCurrentTime, defaultTimeLocale, formatTime)
|
||||
@ -88,6 +92,23 @@ data Message = Message
|
||||
} deriving (Show, Eq, Typeable)
|
||||
|
||||
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
|
||||
, markdownMessage :: Text
|
||||
, htmlMessage :: Text
|
||||
@ -148,7 +169,14 @@ renderError (FileNotFoundError path) = T.pack path <> " not found"
|
||||
|
||||
deriveSafeCopy 0 'base ''Domain'
|
||||
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 ''Message
|
||||
deriveSafeCopy 0 'base ''SendJob
|
||||
|
@ -23,7 +23,9 @@ source-repository head
|
||||
executable tiedote.md
|
||||
build-depends:
|
||||
acid-state,
|
||||
attoparsec,
|
||||
base,
|
||||
base64,
|
||||
binary,
|
||||
bytestring,
|
||||
case-insensitive,
|
||||
@ -35,6 +37,7 @@ executable tiedote.md
|
||||
doctemplates,
|
||||
exit-codes,
|
||||
file-embed,
|
||||
filepath,
|
||||
filestore,
|
||||
Glob,
|
||||
hostname,
|
||||
@ -44,6 +47,7 @@ executable tiedote.md
|
||||
network,
|
||||
optparse-applicative,
|
||||
pandoc,
|
||||
pandoc-types,
|
||||
process,
|
||||
purebred-email,
|
||||
random,
|
||||
|
Loading…
Reference in New Issue
Block a user