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