feat(mail): archive all sent mail & better verp

This commit is contained in:
Gregor Kleen 2020-11-04 15:27:06 +01:00
parent 4451ceedf7
commit 1666081fea
21 changed files with 349 additions and 78 deletions

View File

@ -18,10 +18,10 @@ mail-from:
mail-object-domain: "_env:MAILOBJECT_DOMAIN:localhost" mail-object-domain: "_env:MAILOBJECT_DOMAIN:localhost"
mail-verp: mail-verp:
separator: "_env:VERP_SEPARATOR:+" separator: "_env:VERP_SEPARATOR:+"
at-replacement: "_env:VERP_AT_REPLACEMENT:="
mail-support: mail-support:
name: "_env:MAILSUPPORT_NAME:" name: "_env:MAILSUPPORT_NAME:"
email: "_env:MAILSUPPORT:uni2work@ifi.lmu.de" email: "_env:MAILSUPPORT:uni2work@ifi.lmu.de"
mail-retain-sent: 31470547
job-workers: "_env:JOB_WORKERS:10" job-workers: "_env:JOB_WORKERS:10"
job-flush-interval: "_env:JOB_FLUSH:30" job-flush-interval: "_env:JOB_FLUSH:30"

View File

@ -17,14 +17,6 @@ CronLastExec
instance InstanceId -- Which uni2work-instance did the work instance InstanceId -- Which uni2work-instance did the work
UniqueCronLastExec job UniqueCronLastExec job
SentNotification
content Value
user UserId
time UTCTime
instance InstanceId
TokenBucket TokenBucket
ident TokenBucketIdent ident TokenBucketIdent
lastValue Int64 lastValue Int64

13
models/mail.model Normal file
View File

@ -0,0 +1,13 @@
SentMail
sentAt UTCTime
sentBy InstanceId
objectId MailObjectId Maybe
bounceSecret BounceSecret Maybe
recipient UserId Maybe
headers MailHeaders
contentRef SentMailContentId
SentMailContent
hash MailContentReference
content MailContent
Primary hash

View File

@ -182,7 +182,7 @@ makeFoundation appSettings'@AppSettings{..} = do
-- logging function. To get out of this loop, we initially create a -- logging function. To get out of this loop, we initially create a
-- temporary foundation without a real connection pool, get a log function -- temporary foundation without a real connection pool, get a log function
-- from there, and then create the real foundation. -- from there, and then create the real foundation.
let mkFoundation appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached appUploadCache = UniWorX {..} let mkFoundation appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached appUploadCache appVerpSecret = UniWorX {..}
-- The UniWorX {..} syntax is an example of record wild cards. For more -- The UniWorX {..} syntax is an example of record wild cards. For more
-- information, see: -- information, see:
-- https://ocharles.org.uk/blog/posts/2014-12-04-record-wildcards.html -- https://ocharles.org.uk/blog/posts/2014-12-04-record-wildcards.html
@ -198,6 +198,7 @@ makeFoundation appSettings'@AppSettings{..} = do
(error "ClusterID forced in tempFoundation") (error "ClusterID forced in tempFoundation")
(error "memcached forced in tempFoundation") (error "memcached forced in tempFoundation")
(error "MinioConn forced in tempFoundation") (error "MinioConn forced in tempFoundation")
(error "VerpSecret forced in tempFoundation")
runAppLoggingT tempFoundation $ do runAppLoggingT tempFoundation $ do
$logInfoS "InstanceID" $ UUID.toText appInstanceID $logInfoS "InstanceID" $ UUID.toText appInstanceID
@ -241,6 +242,7 @@ makeFoundation appSettings'@AppSettings{..} = do
appSecretBoxKey <- clusterSetting (Proxy :: Proxy 'ClusterSecretBoxKey) `runSqlPool` sqlPool appSecretBoxKey <- clusterSetting (Proxy :: Proxy 'ClusterSecretBoxKey) `runSqlPool` sqlPool
appJSONWebKeySet <- clusterSetting (Proxy :: Proxy 'ClusterJSONWebKeySet) `runSqlPool` sqlPool appJSONWebKeySet <- clusterSetting (Proxy :: Proxy 'ClusterJSONWebKeySet) `runSqlPool` sqlPool
appClusterID <- clusterSetting (Proxy :: Proxy 'ClusterId) `runSqlPool` sqlPool appClusterID <- clusterSetting (Proxy :: Proxy 'ClusterId) `runSqlPool` sqlPool
appVerpSecret <- clusterSetting (Proxy :: Proxy 'ClusterVerpSecret) `runSqlPool` sqlPool
appMemcached <- for appMemcachedConf $ \memcachedConf -> do appMemcached <- for appMemcachedConf $ \memcachedConf -> do
$logDebugS "setup" "Memcached" $logDebugS "setup" "Memcached"
@ -258,7 +260,7 @@ makeFoundation appSettings'@AppSettings{..} = do
handleIf isBucketExists (const $ return ()) $ Minio.makeBucket appUploadCacheBucket Nothing handleIf isBucketExists (const $ return ()) $ Minio.makeBucket appUploadCacheBucket Nothing
return conn return conn
let foundation = mkFoundation sqlPool smtpPool ldapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached appUploadCache let foundation = mkFoundation sqlPool smtpPool ldapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached appUploadCache appVerpSecret
-- Return the foundation -- Return the foundation
$logDebugS "setup" "Done" $logDebugS "setup" "Done"

View File

@ -39,6 +39,16 @@ import Network.Wai.Parse (lbsBackEnd)
import UnliftIO.Pool (withResource) import UnliftIO.Pool (withResource)
import qualified Control.Monad.State.Class as State
import qualified Crypto.Hash as Crypto
import qualified Crypto.MAC.KMAC as Crypto
import qualified Data.Binary as Binary
import qualified Data.CaseInsensitive as CI
import qualified Database.Esqueleto as E
data instance ButtonClass UniWorX data instance ButtonClass UniWorX
= BCIsButton = BCIsButton
@ -178,22 +188,97 @@ instance YesodAuthPersist UniWorX where
instance YesodMail UniWorX where instance YesodMail UniWorX where
defaultFromAddress = getsYesod $ view _appMailFrom defaultFromAddress = getsYesod $ view _appMailFrom
mailObjectIdDomain = getsYesod $ view _appMailObjectDomain mailObjectIdDomain = getsYesod $ view _appMailObjectDomain
mailVerp = getsYesod $ view _appMailVerp
mailDateTZ = return appTZ mailDateTZ = return appTZ
mailSmtp act = do mailSmtp act = do
pool <- maybe (throwM MailNotAvailable) return =<< getsYesod appSmtpPool pool <- maybe (throwM MailNotAvailable) return =<< getsYesod appSmtpPool
withResource pool act withResource pool act
mailT ctx mail = defMailT ctx $ do mailT ctx mail = do
void setMailObjectIdRandom mailRecord <- newEmptyTMVarIO
setDateCurrent mailProcess <- allocateLinkedAsync $ do
replaceMailHeader "Sender" . Just =<< getsYesod (view $ _appMailFrom . _addressEmail) defMailT ctx $ do
void setMailObjectIdRandom
sentMailSentAt <- liftIO getCurrentTime
setDate sentMailSentAt
replaceMailHeader "Sender" . Just =<< getsYesod (view $ _appMailFrom . _addressEmail)
(mRes, smtpData) <- listen mail (mRes, smtpData) <- listen mail
unless (view _MailSmtpDataSet smtpData)
setMailSmtpData
return mRes sentMailObjectId <- getMailObjectId
mContent <- State.get
smtpData' <- if | smtpData ^. _MailSmtpDataSet -> return smtpData
| otherwise -> getMailSmtpData
verpMode <- getsYesod $ view _appMailVerp
(smtpData'', sentMailBounceSecret) <- if
| Verp{..} <- verpMode
, [_] <- smtpData' ^.. _smtpRecipients . folded
, Just [l, d] <- previews (_smtpEnvelopeFrom . _Wrapped . _Just) (Text.splitOn "@") smtpData'
-> do
verpSecret <- getsYesod appVerpSecret
let bounceSecret = BounceSecret . Crypto.kmacGetDigest $ kmaclazy ("bounce" :: ByteString) verpSecret $ Binary.encode mContent
verpAddr = l <> Text.singleton verpSeparator <> toPathPiece bounceSecret <> "@" <> d
return ( smtpData' <> mempty { smtpEnvelopeFrom = Last $ Just verpAddr }
, Just bounceSecret
)
| otherwise -> return (smtpData', Nothing)
tell smtpData''
sentMailSentBy <- getsYesod appInstanceID
let sentMailRecipient = Nothing -- Fill in later
sentMailHeaders = MailHeaders $ mconcat
[ renderAddressHeader "From" [mailFrom mContent]
, fromMaybe [] $ do
toAddrs <- assertM' (not . null) $ mailTo mContent
return $ renderAddressHeader "To" toAddrs
, fromMaybe [] $ do
ccAddrs <- assertM' (not . null) $ mailCc mContent
return $ renderAddressHeader "Cc" ccAddrs
, fromMaybe [] $ do
bccAddrs <- assertM' (not . null) $ mailBcc mContent
return $ renderAddressHeader "Bcc" bccAddrs
, mailHeaders mContent
]
where
renderAddressHeader :: ByteString -> [Address] -> [(ByteString, Text)]
renderAddressHeader lbl = pure . (lbl, ) . Text.intercalate ", " . map renderAddress
sentMailContent = MailContent $ mailParts mContent
sentMailContentRef = SentMailContentKey . MailContentReference . Crypto.hashlazy $ Binary.encode sentMailContent
atomically $ putTMVar mailRecord
( smtpData'' ^.. _smtpRecipients . folded
, sentMailContent
, SentMail{..}
)
atomically . guardM $ isEmptyTMVar mailRecord
return mRes
(smtpRecipients, sentMailContentContent, sentMail) <- atomically $ takeTMVar mailRecord
liftHandler . runDB . setSerializable $ do
sentMailRecipient <- if
| [Address _ (CI.mk -> recipAddr)] <- smtpRecipients -> do
recipUsers <- E.select . E.from $ \user -> do
E.where_ $ user E.^. UserDisplayEmail E.==. E.val recipAddr
E.||. user E.^. UserEmail E.==. E.val recipAddr
E.||. user E.^. UserIdent E.==. E.val recipAddr
return user
let recipUserCompare = mconcat
[ comparing $ Down . (== recipAddr) . userIdent . entityVal
, comparing $ Down . (== recipAddr) . userEmail . entityVal
, comparing $ Down . (== recipAddr) . userDisplayEmail . entityVal
]
return $ if
| ( bU : us ) <- sortBy recipUserCompare recipUsers
, maybe True (\u -> recipUserCompare bU u == LT) $ listToMaybe us
-> Just $ entityKey bU
| otherwise -> Nothing
| otherwise -> return Nothing
void $ insertUnique SentMailContent{ sentMailContentHash = unSentMailContentKey $ sentMailContentRef sentMail
, sentMailContentContent
}
insert_ sentMail{ sentMailRecipient }
wait mailProcess
instance (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX) => MonadCrypto m where instance (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX) => MonadCrypto m where
type MonadCryptoKey m = CryptoIDKey type MonadCryptoKey m = CryptoIDKey

View File

@ -57,6 +57,7 @@ data UniWorX = UniWorX
, appHealthReport :: TVar (Set (UTCTime, HealthReport)) , appHealthReport :: TVar (Set (UTCTime, HealthReport))
, appMemcached :: Maybe (AEAD.Key, Memcached.Connection) , appMemcached :: Maybe (AEAD.Key, Memcached.Connection)
, appUploadCache :: Maybe MinioConn , appUploadCache :: Maybe MinioConn
, appVerpSecret :: VerpSecret
} }
makeLenses_ ''UniWorX makeLenses_ ''UniWorX

View File

@ -651,7 +651,7 @@ assimilateUser newUserId oldUserId = mapReaderT execWriterT $ do
lift $ update jId [ QueuedJobContent =. toJSON uContent' ] lift $ update jId [ QueuedJobContent =. toJSON uContent' ]
in runConduit $ getQueuedJobs .| C.mapM_ updateQueuedJob in runConduit $ getQueuedJobs .| C.mapM_ updateQueuedJob
updateWhere [ SentNotificationUser ==. oldUserId ] [ SentNotificationUser =. newUserId ] updateWhere [ SentMailRecipient ==. Just oldUserId ] [ SentMailRecipient =. Just newUserId ]
updateWhere [ SheetEditUser ==. oldUserId] [ SheetEditUser =. newUserId ] updateWhere [ SheetEditUser ==. oldUserId] [ SheetEditUser =. newUserId ]

View File

@ -146,7 +146,7 @@ import Data.Time.Clock.Instances as Import ()
import Data.Time.LocalTime.Instances as Import () import Data.Time.LocalTime.Instances as Import ()
import Data.Time.Calendar.Instances as Import () import Data.Time.Calendar.Instances as Import ()
import Data.Time.Format.Instances as Import () import Data.Time.Format.Instances as Import ()
import Network.Mail.Mime.Instances as Import () import Network.Mail.Mime.Instances as Import
import Yesod.Core.Instances as Import () import Yesod.Core.Instances as Import ()
import Data.Aeson.Types.Instances as Import () import Data.Aeson.Types.Instances as Import ()
import Database.Esqueleto.Instances as Import () import Database.Esqueleto.Instances as Import ()

View File

@ -60,6 +60,7 @@ import Jobs.Handler.PruneInvitations
import Jobs.Handler.ChangeUserDisplayEmail import Jobs.Handler.ChangeUserDisplayEmail
import Jobs.Handler.Files import Jobs.Handler.Files
import Jobs.Handler.PersonalisedSheetFiles import Jobs.Handler.PersonalisedSheetFiles
import Jobs.Handler.PruneOldSentMails
import Jobs.HealthReport import Jobs.HealthReport

View File

@ -78,6 +78,16 @@ determineCrontab = execWriterT $ do
, cronNotAfter = Right CronNotScheduled , cronNotAfter = Right CronNotScheduled
} }
oldestSentMail <- lift $ preview (_head . _entityVal . _sentMailSentAt) <$> selectList [] [Asc SentMailSentAt, LimitTo 1]
whenIsJust ((,) <$> appMailRetainSent <*> oldestSentMail) $ \(retain, oldest) -> tell $ HashMap.singleton
(JobCtlQueue JobPruneOldSentMails)
Cron
{ cronInitial = CronTimestamp . utcToLocalTime $ addUTCTime retain oldest
, cronRepeat = CronRepeatOnChange
, cronRateLimit = retain / 2
, cronNotAfter = Right CronNotScheduled
}
whenIsJust (appInjectFiles <* appUploadCacheConf) $ \iInterval -> whenIsJust (appInjectFiles <* appUploadCacheConf) $ \iInterval ->
tell $ HashMap.singleton tell $ HashMap.singleton

View File

@ -0,0 +1,21 @@
module Jobs.Handler.PruneOldSentMails
( dispatchJobPruneOldSentMails
) where
import Import
import qualified Database.Esqueleto as E
import Database.Persist.Sql (deleteWhereCount)
dispatchJobPruneOldSentMails :: JobHandler UniWorX
dispatchJobPruneOldSentMails = JobHandlerAtomic $ do
retain' <- getsYesod $ view _appMailRetainSent
whenIsJust retain' $ \retain -> do
now <- liftIO getCurrentTime
del <- deleteWhereCount [SentMailSentAt <. addUTCTime (-retain) now]
$logInfoS "JobPruneOldSentMails" [st|Deleted #{del} old sent mails|]
del <- E.deleteCount . E.from $ \sentMailContent ->
E.where_ . E.not_ . E.exists . E.from $ \sentMail ->
E.where_ $ sentMail E.^. SentMailContentRef E.==. sentMailContent E.^. SentMailContentId
$logInfoS "JobPruneOldSentMails" [st|Deleted #{del} old sent mail bodies|]

View File

@ -23,10 +23,5 @@ import Jobs.Handler.SendNotification.SubmissionEdited
dispatchJobSendNotification :: UserId -> Notification -> JobHandler UniWorX dispatchJobSendNotification :: UserId -> Notification -> JobHandler UniWorX
dispatchJobSendNotification jRecipient jNotification = JobHandlerException $ do dispatchJobSendNotification jRecipient jNotification = JobHandlerException $
$(dispatchTH ''Notification) jNotification jRecipient $(dispatchTH ''Notification) jNotification jRecipient
instanceID' <- getsYesod $ view instanceID
now <- liftIO getCurrentTime
runDB . insert_ $ SentNotification (toJSON jNotification) jRecipient now instanceID'

View File

@ -63,6 +63,10 @@ dispatchHealthCheckMatchingClusterConfig
ourSetting <- getsYesod $ fmap fst . appMemcached ourSetting <- getsYesod $ fmap fst . appMemcached
dbSetting <- clusterSetting @'ClusterMemcachedKey dbSetting <- clusterSetting @'ClusterMemcachedKey
return $ maybe True ((== dbSetting) . Just) ourSetting return $ maybe True ((== dbSetting) . Just) ourSetting
clusterSettingMatches ClusterVerpSecret = do
ourSetting <- getsYesod appVerpSecret
dbSetting <- clusterSetting @'ClusterVerpSecret
return $ Just ourSetting == dbSetting
clusterSetting :: forall key. clusterSetting :: forall key.

View File

@ -96,6 +96,7 @@ data Job
| JobPruneFallbackPersonalisedSheetFilesKeys | JobPruneFallbackPersonalisedSheetFilesKeys
| JobRechunkFiles | JobRechunkFiles
| JobDetectMissingFiles | JobDetectMissingFiles
| JobPruneOldSentMails
deriving (Eq, Ord, Show, Read, Generic, Typeable) deriving (Eq, Ord, Show, Read, Generic, Typeable)
data Notification data Notification
= NotificationSubmissionRated { nSubmission :: SubmissionId } = NotificationSubmissionRated { nSubmission :: SubmissionId }

View File

@ -8,13 +8,12 @@ module Mail
module Network.Mail.Mime module Network.Mail.Mime
-- * MailT -- * MailT
, MailT, defMailT , MailT, defMailT
, MailSmtpData(..) , MailSmtpData(..), _smtpEnvelopeFrom, _smtpRecipients
, _MailSmtpDataSet , _MailSmtpDataSet
, MailContext(..) , MailContext(..)
, MonadMail(..) , MonadMail(..)
, getMailMessageRender, getMailMsgRenderer , getMailMessageRender, getMailMsgRenderer
-- * YesodMail -- * YesodMail
, VerpMode(..)
, YesodMail(..) , YesodMail(..)
, MailException(..) , MailException(..)
-- * Monadically constructing Mail -- * Monadically constructing Mail
@ -25,12 +24,13 @@ module Mail
, MonadHeader(..) , MonadHeader(..)
, MailHeader , MailHeader
, MailObjectId , MailObjectId
, replaceMailHeader, addMailHeader, removeMailHeader , replaceMailHeader, addMailHeader, removeMailHeader, getMailHeaders, lookupMailHeader
, replaceMailHeaderI, addMailHeaderI , replaceMailHeaderI, addMailHeaderI
, setSubjectI , setSubjectI
, setMailObjectUUID, setMailObjectIdRandom, setMailObjectIdCrypto, setMailObjectIdPseudorandom , setMailObjectUUID, setMailObjectIdRandom, setMailObjectIdCrypto, setMailObjectIdPseudorandom
, getMailObjectId
, setDate, setDateCurrent , setDate, setDateCurrent
, setMailSmtpData , getMailSmtpData
, _addressName, _addressEmail , _addressName, _addressEmail
, _mailFrom, _mailTo, _mailCc, _mailBcc, _mailHeaders, _mailHeader, _mailParts , _mailFrom, _mailTo, _mailCc, _mailBcc, _mailHeaders, _mailHeader, _mailParts
, _partType, _partEncoding, _partDisposition, _partFilename, _partHeaders, _partContent , _partType, _partEncoding, _partDisposition, _partFilename, _partHeaders, _partContent
@ -62,10 +62,9 @@ import qualified Data.Sequence as Seq
import qualified Data.Set as Set import qualified Data.Set as Set
import qualified Data.Text as Text
import qualified Data.Foldable as Foldable import qualified Data.Foldable as Foldable
import qualified Data.Text as Text
import qualified Data.Text.Lazy as LT import qualified Data.Text.Lazy as LT
import qualified Data.Text.Lazy.Builder as LTB import qualified Data.Text.Lazy.Builder as LTB
import qualified Data.ByteString.Lazy as LBS import qualified Data.ByteString.Lazy as LBS
@ -123,6 +122,10 @@ import Crypto.Hash.Algorithms (SHAKE128)
import Language.Haskell.TH (nameBase) import Language.Haskell.TH (nameBase)
import Network.Mail.Mime.Instances()
import Control.Monad.Trans.Writer (execWriterT)
makeLenses_ ''Address makeLenses_ ''Address
makeLenses_ ''Mail makeLenses_ ''Mail
@ -152,7 +155,7 @@ instance {-# OVERLAPPING #-} (MonadCrypto m, MonadCryptoKey m ~ CryptoIDKey) =>
data MailSmtpData = MailSmtpData data MailSmtpData = MailSmtpData
{ smtpEnvelopeFrom :: Last Text { smtpEnvelopeFrom :: Last Text
, smtpRecipients :: Set Text , smtpRecipients :: Set Address
} deriving (Eq, Ord, Show, Read, Generic) } deriving (Eq, Ord, Show, Read, Generic)
instance Semigroup MailSmtpData where instance Semigroup MailSmtpData where
@ -188,6 +191,8 @@ instance Default MailContext where
makeLenses_ ''MailContext makeLenses_ ''MailContext
makeLenses_ ''MailSmtpData
class (MonadHandler m, MonadState Mail m) => MonadMail m where class (MonadHandler m, MonadState Mail m) => MonadMail m where
askMailLanguages :: m Languages askMailLanguages :: m Languages
askMailDateTimeFormat :: SelDateTimeFormat -> m DateTimeFormat askMailDateTimeFormat :: SelDateTimeFormat -> m DateTimeFormat
@ -198,16 +203,6 @@ instance MonadHandler m => MonadMail (MailT m) where
askMailDateTimeFormat = (view _mcDateTimeFormat ??) askMailDateTimeFormat = (view _mcDateTimeFormat ??)
tellMailSmtpData = tell tellMailSmtpData = tell
data VerpMode = VerpNone
| Verp { verpSeparator, verpAtReplacement :: Char }
deriving (Eq, Show, Read, Generic)
deriveJSON defaultOptions
{ constructorTagModifier = intercalate "-" . map toLower . drop 1 . splitCamel
, fieldLabelModifier = intercalate "-" . map toLower . drop 1 . splitCamel
, sumEncoding = UntaggedValue
} ''VerpMode
getMailMessageRender :: ( MonadMail m getMailMessageRender :: ( MonadMail m
, HandlerSite m ~ site , HandlerSite m ~ site
, RenderMessage site msg , RenderMessage site msg
@ -248,11 +243,6 @@ class Yesod site => YesodMail site where
) => (SMTPConnection -> m a) -> m a ) => (SMTPConnection -> m a) -> m a
mailSmtp _ = throwM MailNotAvailable mailSmtp _ = throwM MailNotAvailable
mailVerp :: ( MonadHandler m
, HandlerSite m ~ site
) => m VerpMode
mailVerp = return VerpNone
mailT :: ( MonadHandler m mailT :: ( MonadHandler m
, HandlerSite m ~ site , HandlerSite m ~ site
, MonadUnliftIO m , MonadUnliftIO m
@ -304,7 +294,7 @@ defMailT ls (MailT mailC) = do
MailSmtpData{ smtpRecipients } MailSmtpData{ smtpRecipients }
| Set.null smtpRecipients -> throwM MailNoRecipientsSpecified | Set.null smtpRecipients -> throwM MailNoRecipientsSpecified
MailSmtpData{ smtpEnvelopeFrom = Last (Just (unpack -> returnPath)) MailSmtpData{ smtpEnvelopeFrom = Last (Just (unpack -> returnPath))
, smtpRecipients = (map unpack . toList -> recipients) , smtpRecipients = (map (unpack . addressEmail) . toList -> recipients)
} -> mailSmtp $ \conn -> do } -> mailSmtp $ \conn -> do
$logInfoS "Mail" $ "Submitting email: " <> tshow smtpData $logInfoS "Mail" $ "Submitting email: " <> tshow smtpData
liftIO $ SMTP.sendMail liftIO $ SMTP.sendMail
@ -434,15 +424,17 @@ partIsAttachment (repack -> fName) = modifyPart $ _partDisposition .= Attachment
class MonadHandler m => MonadHeader m where class MonadHandler m => MonadHeader m where
stateHeaders :: forall a. (Headers -> (a, Headers)) -> m a
modifyHeaders :: (Headers -> Headers) -> m () modifyHeaders :: (Headers -> Headers) -> m ()
modifyHeaders f = stateHeaders $ ((), ) . f
objectIdHeader :: m MailHeader objectIdHeader :: m MailHeader
instance MonadHandler m => MonadHeader (MailT m) where instance MonadHandler m => MonadHeader (MailT m) where
modifyHeaders f = MailT . modify $ over _mailHeaders f stateHeaders = MailT . zoom _mailHeaders . state
objectIdHeader = return "Message-ID" objectIdHeader = return "Message-ID"
instance MonadHandler m => MonadHeader (StateT Part m) where instance MonadHandler m => MonadHeader (StateT Part m) where
modifyHeaders f = _partHeaders %= f stateHeaders = zoom _partHeaders . state
objectIdHeader = return "Content-ID" objectIdHeader = return "Content-ID"
@ -459,6 +451,12 @@ addMailHeader header c = modifyHeaders $ \mailHeaders -> mailHeaders `snoc` (hea
removeMailHeader :: MonadHeader m => MailHeader -> m () removeMailHeader :: MonadHeader m => MailHeader -> m ()
removeMailHeader header = modifyHeaders $ \mailHeaders -> filter ((/= header) . fst) mailHeaders removeMailHeader header = modifyHeaders $ \mailHeaders -> filter ((/= header) . fst) mailHeaders
getMailHeaders :: MonadHeader m => MailHeader -> m [Text]
getMailHeaders header = stateHeaders $ \hdrs -> (, hdrs) . map (view _2) $ filter (views _1 (== header)) hdrs
lookupMailHeader :: MonadHeader m => MailHeader -> m (Maybe Text)
lookupMailHeader = fmap listToMaybe . getMailHeaders
replaceMailHeaderI :: ( RenderMessage site msg replaceMailHeaderI :: ( RenderMessage site msg
, MonadMail m , MonadMail m
@ -512,6 +510,11 @@ setMailObjectIdPseudorandom obj = do
seed = KMAC.finalize . KMAC.updates (KMAC.initialize (BS.pack . encodeUtf8 $ nameBase 'setMailObjectIdPseudorandom) $ Saltine.encode sbKey) . LBS.toChunks $ Binary.encode obj seed = KMAC.finalize . KMAC.updates (KMAC.initialize (BS.pack . encodeUtf8 $ nameBase 'setMailObjectIdPseudorandom) $ Saltine.encode sbKey) . LBS.toChunks $ Binary.encode obj
setMailObjectUUID . fromMaybe (error "Could not convert hash to UUID") . UUID.fromByteString $ fromStrict (ByteArray.convert seed :: ByteString) setMailObjectUUID . fromMaybe (error "Could not convert hash to UUID") . UUID.fromByteString $ fromStrict (ByteArray.convert seed :: ByteString)
getMailObjectId :: ( MonadHeader m, YesodMail (HandlerSite m) ) => m (Maybe MailObjectId)
getMailObjectId = fmap (fmap stripBrackets) . lookupMailHeader =<< objectIdHeader
where stripBrackets val = fromMaybe val $
Text.stripSuffix ">" =<< Text.stripPrefix "<" val
setDateCurrent :: (MonadHandler m, YesodMail (HandlerSite m)) => MailT m () setDateCurrent :: (MonadHandler m, YesodMail (HandlerSite m)) => MailT m ()
setDateCurrent = setDate =<< liftIO getCurrentTime setDateCurrent = setDate =<< liftIO getCurrentTime
@ -539,27 +542,15 @@ setDate time = do
] ]
setMailSmtpData :: (MonadHandler m, YesodMail (HandlerSite m), MonadThrow m) => MailT m () getMailSmtpData :: (MonadHandler m, YesodMail (HandlerSite m), MonadThrow m) => MailT m MailSmtpData
setMailSmtpData = do getMailSmtpData = execWriterT $ do
Address _ from <- maybeT (throwM MailNoSenderSpecified) $ asum Address _ from <- lift . maybeT (throwM MailNoSenderSpecified) $ asum
[ MaybeT . preuses (_mailHeader "Sender") $ fromString . unpack [ MaybeT . preuses (_mailHeader "Sender") $ fromString . unpack
, use _mailFrom , use _mailFrom
] ]
recps <- Set.fromList . map addressEmail . concat <$> forM [_mailTo, _mailCc, _mailBcc] use recps <- lift $ Set.fromList . concat <$> forM [_mailTo, _mailCc, _mailBcc] use
tell $ mempty { smtpRecipients = recps } tell $ mempty
{ smtpRecipients = recps
verpMode <- mailVerp , smtpEnvelopeFrom = Last $ Just from
if }
| Verp{..} <- verpMode
, [recp] <- Set.toList recps
-> let (user, domain) = Text.breakOn "@" from
verp = mconcat
[ user
, Text.singleton verpSeparator
, Text.replace "@" (Text.singleton verpAtReplacement) recp
, domain
]
in tell $ mempty { smtpEnvelopeFrom = Last $ Just verp }
| otherwise
-> tell $ mempty { smtpEnvelopeFrom = Last $ Just from }

View File

@ -176,6 +176,8 @@ migrateManual = do
, ("submission_edit_submission", "CREATE INDEX submission_edit_submission ON submission_edit (submission)" ) , ("submission_edit_submission", "CREATE INDEX submission_edit_submission ON submission_edit (submission)" )
, ("user_ldap_primary_key", "CREATE INDEX user_ldap_primary_key ON \"user\" (ldap_primary_key)" ) , ("user_ldap_primary_key", "CREATE INDEX user_ldap_primary_key ON \"user\" (ldap_primary_key)" )
, ("file_content_entry_chunk_hash", "CREATE INDEX file_content_entry_chunk_hash ON \"file_content_entry\" (chunk_hash)" ) , ("file_content_entry_chunk_hash", "CREATE INDEX file_content_entry_chunk_hash ON \"file_content_entry\" (chunk_hash)" )
, ("sent_mail_bounce_secret", "CREATE INDEX sent_mail_bounce_secret ON \"sent_mail\" (bounce_secret) WHERE bounce_secret IS NOT NULL")
, ("sent_mail_recipient", "CREATE INDEX sent_mail_recipient ON \"sent_mail\" (recipient) WHERE recipient IS NOT NULL")
] ]
where where
addIndex :: Text -> Sql -> Migration addIndex :: Text -> Sql -> Migration

View File

@ -1,5 +1,6 @@
{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE UndecidableInstances #-} {-# LANGUAGE UndecidableInstances #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-| {-|
Module: Model.Types.Mail Module: Model.Types.Mail
@ -17,6 +18,18 @@ import qualified Data.Aeson.Types as Aeson
import qualified Data.HashSet as HashSet import qualified Data.HashSet as HashSet
import qualified Data.HashMap.Strict as HashMap import qualified Data.HashMap.Strict as HashMap
import Crypto.Hash (digestFromByteString, SHAKE256)
import Database.Persist.Sql (PersistFieldSql)
import Data.ByteArray (ByteArrayAccess)
import qualified Data.ByteArray as BA
import Web.HttpApiData (ToHttpApiData, FromHttpApiData)
import Data.ByteString.Base32
import qualified Data.CaseInsensitive as CI
-- ^ `NotificationSettings` is for now a series of boolean checkboxes, i.e. a mapping @NotificationTrigger -> Bool@ -- ^ `NotificationSettings` is for now a series of boolean checkboxes, i.e. a mapping @NotificationTrigger -> Bool@
-- --
@ -85,3 +98,32 @@ instance FromJSON NotificationSettings where
return . NotificationSettings $ \n -> fromMaybe (notificationAllowed def n) $ HashMap.lookup n o' return . NotificationSettings $ \n -> fromMaybe (notificationAllowed def n) $ HashMap.lookup n o'
derivePersistFieldJSON ''NotificationSettings derivePersistFieldJSON ''NotificationSettings
newtype BounceSecret = BounceSecret (Digest (SHAKE256 128))
deriving (Eq, Ord, Read, Show, Lift, Generic, Typeable)
deriving newtype ( PersistField, PersistFieldSql
, Hashable, NFData
, ByteArrayAccess
)
instance PathPiece BounceSecret where
toPathPiece = CI.foldCase . encodeBase32Unpadded . BA.convert
fromPathPiece = fmap BounceSecret . digestFromByteString <=< either (const Nothing) Just . decodeBase32Unpadded . encodeUtf8
newtype MailContent = MailContent [Alternatives]
deriving (Eq, Show, Generic, Typeable)
deriving newtype (ToJSON, FromJSON)
deriving anyclass (Binary)
derivePersistFieldJSON ''MailContent
newtype MailContentReference = MailContentReference (Digest SHA3_512)
deriving (Eq, Ord, Read, Show, Lift, Generic, Typeable)
deriving newtype ( PersistField, PersistFieldSql
, PathPiece, ToHttpApiData, FromHttpApiData, ToJSON, FromJSON
, Hashable, NFData
, ByteArrayAccess
)
derivePersistFieldJSON ''MailHeaders

View File

@ -1,20 +1,31 @@
{-# OPTIONS_GHC -fno-warn-orphans #-} {-# OPTIONS_GHC -fno-warn-orphans #-}
module Network.Mail.Mime.Instances module Network.Mail.Mime.Instances
( ( MailHeaders(..)
) where ) where
import ClassyPrelude import ClassyPrelude
import Network.Mail.Mime import Network.Mail.Mime
import Data.Aeson import Data.Aeson (FromJSON(..), ToJSON(..))
import qualified Data.Aeson as Aeson
import Data.Aeson.TH import Data.Aeson.TH
import Control.Monad.Fail (MonadFail(..))
import Utils.PathPiece import Utils.PathPiece
import Utils (assertM) import Utils (assertM)
import qualified Data.Csv as Csv import qualified Data.Csv as Csv
import Data.Binary (Binary)
import qualified Data.ByteString.Base64 as Base64
import qualified Data.ByteString as BS
import Data.Text.Encoding (decodeUtf8')
import Control.Lens
deriving instance Read Address deriving instance Read Address
@ -29,9 +40,9 @@ deriveToJSON defaultOptions
} ''Address } ''Address
instance FromJSON Address where instance FromJSON Address where
parseJSON = withObject "Address" $ \obj -> do parseJSON = Aeson.withObject "Address" $ \obj -> do
addressName <- assertM (not . null) <$> (obj .:? "name") addressName <- assertM (not . null) <$> (obj Aeson..:? "name")
addressEmail <- obj .: "email" addressEmail <- obj Aeson..: "email"
return Address{..} return Address{..}
@ -43,3 +54,68 @@ instance Csv.ToNamedRecord Address where
instance Csv.DefaultOrdered Address where instance Csv.DefaultOrdered Address where
headerOrder _ = Csv.header [ "name", "email" ] headerOrder _ = Csv.header [ "name", "email" ]
newtype MailHeaders = MailHeaders Headers
deriving (Eq, Ord, Read, Show, Generic, Typeable)
instance ToJSON MailHeaders where
toJSON (MailHeaders hs) = toJSON $ over (traverse . _1) decodeUtf8 hs
instance FromJSON MailHeaders where
parseJSON = fmap (MailHeaders . over (traverse . _1) encodeUtf8) . parseJSON
deriving instance Generic Encoding
deriving instance Generic Disposition
deriving instance Generic PartContent
deriving instance Generic Part
deriving instance Generic Mail
instance Binary Encoding
instance Binary Disposition
instance Binary PartContent
instance Binary Part
instance Binary Address
instance Binary Mail
deriveJSON defaultOptions
{ constructorTagModifier = camelToPathPiece
} ''Encoding
deriveJSON defaultOptions
{ constructorTagModifier = camelToPathPiece . dropSuffix "Disposition"
} ''Disposition
instance ToJSON PartContent where
toJSON (PartContent (toStrict -> content))
| BS.all (< 0x80) content
, Right content' <- decodeUtf8' content = Aeson.String content'
toJSON (PartContent content) = Aeson.object
[ "encoding" Aeson..= ("base64" :: String)
, "content" Aeson..= decodeUtf8 (Base64.encode $ toStrict content)
]
toJSON (NestedParts ps) = toJSON ps
instance FromJSON PartContent where
parseJSON (Aeson.String t) = return . PartContent . fromStrict $ encodeUtf8 t
parseJSON (Aeson.Object o) = do
encoding <- o Aeson..: "encoding"
content <- o Aeson..: "content"
if | encoding == "base64" -> either fail (return . PartContent . fromStrict) . Base64.decode $ encodeUtf8 content
| otherwise -> fail $ "Unknown encoding: “" <> encoding <> ""
parseJSON v = NestedParts <$> parseJSON v
instance ToJSON Part where
toJSON Part{..} = Aeson.object
[ "type" Aeson..= partType
, "encoding" Aeson..= partEncoding
, "disposition" Aeson..= partDisposition
, "headers" Aeson..= MailHeaders partHeaders
, "content" Aeson..= partContent
]
instance FromJSON Part where
parseJSON = Aeson.withObject "Part" $ \o -> do
partType <- o Aeson..: "type"
partEncoding <- o Aeson..: "encoding"
partDisposition <- o Aeson..: "disposition"
MailHeaders partHeaders <- o Aeson..: "headers"
partContent <- o Aeson..: "content"
return Part{..}

View File

@ -114,6 +114,7 @@ data AppSettings = AppSettings
, appMailFrom :: Address , appMailFrom :: Address
, appMailObjectDomain :: Text , appMailObjectDomain :: Text
, appMailVerp :: VerpMode , appMailVerp :: VerpMode
, appMailRetainSent :: Maybe NominalDiffTime
, appMailSupport :: Address , appMailSupport :: Address
, appJobWorkers :: Natural , appJobWorkers :: Natural
, appJobFlushInterval :: Maybe NominalDiffTime , appJobFlushInterval :: Maybe NominalDiffTime
@ -301,6 +302,16 @@ data TokenBucketConf = TokenBucketConf
, tokenBucketInitialValue :: Int64 , tokenBucketInitialValue :: Int64
} deriving (Eq, Ord, Show, Generic, Typeable) } deriving (Eq, Ord, Show, Generic, Typeable)
data VerpMode = VerpNone
| Verp { verpSeparator :: Char }
deriving (Eq, Show, Read, Generic)
deriveJSON defaultOptions
{ constructorTagModifier = camelToPathPiece' 1
, fieldLabelModifier = camelToPathPiece' 1
, sumEncoding = UntaggedValue
} ''VerpMode
deriveJSON defaultOptions deriveJSON defaultOptions
{ fieldLabelModifier = camelToPathPiece' 2 { fieldLabelModifier = camelToPathPiece' 2
} ''TokenBucketConf } ''TokenBucketConf
@ -451,6 +462,7 @@ instance FromJSON AppSettings where
appMailFrom <- o .: "mail-from" appMailFrom <- o .: "mail-from"
appMailObjectDomain <- o .: "mail-object-domain" appMailObjectDomain <- o .: "mail-object-domain"
appMailVerp <- fromMaybe VerpNone . join <$> (o .:? "mail-verp" <|> pure Nothing) appMailVerp <- fromMaybe VerpNone . join <$> (o .:? "mail-verp" <|> pure Nothing)
appMailRetainSent <- o .: "mail-retain-sent"
appMailSupport <- o .: "mail-support" appMailSupport <- o .: "mail-support"
appJobWorkers <- o .: "job-workers" appJobWorkers <- o .: "job-workers"

View File

@ -3,6 +3,7 @@
module Settings.Cluster module Settings.Cluster
( ClusterSettingsKey(..) ( ClusterSettingsKey(..)
, ClusterSetting(..) , ClusterSetting(..)
, VerpSecret(..)
) where ) where
import ClassyPrelude.Yesod import ClassyPrelude.Yesod
@ -36,6 +37,9 @@ import Control.Monad.Fail
import Model.Types.TH.PathPiece import Model.Types.TH.PathPiece
import Data.ByteArray (ByteArray, ByteArrayAccess)
import qualified Crypto.Random as Crypto
data ClusterSettingsKey data ClusterSettingsKey
= ClusterCryptoIDKey = ClusterCryptoIDKey
@ -44,6 +48,7 @@ data ClusterSettingsKey
| ClusterJSONWebKeySet | ClusterJSONWebKeySet
| ClusterId | ClusterId
| ClusterMemcachedKey | ClusterMemcachedKey
| ClusterVerpSecret
deriving (Eq, Ord, Enum, Bounded, Show, Read) deriving (Eq, Ord, Enum, Bounded, Show, Read)
instance Universe ClusterSettingsKey instance Universe ClusterSettingsKey
@ -131,3 +136,19 @@ instance ClusterSetting 'ClusterMemcachedKey where
type ClusterSettingValue 'ClusterMemcachedKey = AEAD.Key type ClusterSettingValue 'ClusterMemcachedKey = AEAD.Key
initClusterSetting _ = liftIO AEAD.newKey initClusterSetting _ = liftIO AEAD.newKey
knownClusterSetting _ = ClusterMemcachedKey knownClusterSetting _ = ClusterMemcachedKey
newtype VerpSecret = VerpSecret ByteString
deriving newtype (Eq, Ord, Monoid, Semigroup, ByteArray, ByteArrayAccess)
instance ToJSON VerpSecret where
toJSON (VerpSecret vSecret) = Aeson.String . decodeUtf8 $ Base64.encode vSecret
instance FromJSON VerpSecret where
parseJSON = Aeson.withText "VerpSecret" $ \t -> do
bytes <- either fail return . Base64.decode $ encodeUtf8 t
return $ VerpSecret bytes
instance ClusterSetting 'ClusterVerpSecret where
type ClusterSettingValue 'ClusterVerpSecret = VerpSecret
initClusterSetting _ = liftIO $ Crypto.getRandomBytes 32
knownClusterSetting _ = ClusterVerpSecret

View File

@ -233,6 +233,8 @@ makeLenses_ ''Rating'
makeLenses_ ''FallbackPersonalisedSheetFilesKey makeLenses_ ''FallbackPersonalisedSheetFilesKey
makeLenses_ ''SentMail
makePrisms ''AllocationPriority makePrisms ''AllocationPriority
-- makeClassy_ ''Load -- makeClassy_ ''Load