packages feed

notifications-tray-icon-0.2.0.0: src/StatusNotifier/Item/Notifications/Gmail.hs

{-# LANGUAGE DisambiguateRecordFields #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}

module StatusNotifier.Item.Notifications.Gmail
  ( GmailConfig(..)
  , defaultGmailConfig
  , setupGmailEnv
  , gmailUpdaterNew
  ) where

import           Control.Concurrent
import           Control.Concurrent.Async (race)
import           Control.Concurrent.MVar as MV
import           Control.Exception (SomeException, bracket, try)
import           Control.Monad
import           Control.Monad.IO.Class
import qualified Data.ByteString.Char8 as BS8
import           Data.Either (isRight)
import           Data.List (sort, (\\))
import           Data.Maybe (fromMaybe, mapMaybe)
import           Data.Proxy (Proxy(..))
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import           GI.Dbusmenu
import           Gogol
                   ( Env
                   , LogLevel(..)
                   , newEnvWith
                   , newLogger
                   , send
                   , runResourceT
                   )
import           Gogol.Auth
                   ( OAuthClient(..)
                   , OAuthCode(..)
                   , ClientId(..)
                   , GSecret(..)
                   , saveAuthorizedUser
                   , fromFilePath
                   )
import           Gogol.Auth.Scope (KnownScopes(..), queryEncodeScopes)
import           Gogol.Internal.Auth
                   ( AuthorizedUser(..)
                   , OAuthToken(..)
                   , accountsURL
                   , refreshRequest
                   , textBody
                   , tokenRequest
                   )
import qualified Gogol.Internal.Logger as GLogger
import           Gogol.Prelude (toQueryParam)
import           Gogol.Gmail
                   ( GmailUsersMessagesList(..)
                   , GmailUsersMessagesGet(..)
                   , GmailUsersMessagesModify(..)
                   , ListMessagesResponse(..)
                   , Message(..)
                   , MessagePart(..)
                   , MessagePartHeader(..)
                   , ModifyMessageRequest(..)
                   , UsersMessagesGetFormat(..)
                   , Gmail'Modify
                   , newGmailUsersMessagesList
                   , newGmailUsersMessagesGet
                   , newGmailUsersMessagesModify
                   , newModifyMessageRequest
                   )
import           Network.HTTP.Conduit (newManager, tlsManagerSettings)
import qualified Network.HTTP.Client as Client
import           Network.Socket
import           Network.Socket.ByteString (recv, sendAll)
import           StatusNotifier.Item.Notifications.Util
import           System.Directory (createDirectoryIfMissing, doesFileExist, getXdgDirectory, XdgDirectory(..))
import           System.FilePath ((</>))
import           System.IO (hFlush, stdout)
import           System.Log.Logger
import           Text.Printf

-- | Configuration for the Gmail notifier.
data GmailConfig = GmailConfig
  { gmailClientId     :: T.Text
  , gmailClientSecret :: T.Text
  , gmailTokenFile    :: Maybe FilePath
  , gmailRefreshSeconds :: Rational
  }

-- | Create a default config. You must provide client ID and secret.
defaultGmailConfig :: T.Text -> T.Text -> GmailConfig
defaultGmailConfig cid csecret = GmailConfig
  { gmailClientId       = cid
  , gmailClientSecret   = csecret
  , gmailTokenFile      = Nothing
  , gmailRefreshSeconds = 30
  }

gmailLog :: Priority -> String -> IO ()
gmailLog = logM "StatusNotifier.Item.Notifications.Gmail"

-- | Extract the message ID from a Message, avoiding conflict with Prelude.id.
messageId :: Message -> Maybe T.Text
messageId Message{id = mid} = mid

-- | A summary of a Gmail message for display.
data MessageSummary = MessageSummary
  { msId      :: T.Text
  , msSender  :: T.Text
  , msSubject :: T.Text
  , msSnippet :: T.Text
  } deriving (Show, Eq)

-- | Determine the token file path, defaulting to XDG config directory.
getTokenFilePath :: GmailConfig -> IO FilePath
getTokenFilePath GmailConfig{..} =
  case gmailTokenFile of
    Just fp -> return fp
    Nothing -> do
      dir <- getXdgDirectory XdgConfig "notifications-tray-icon"
      createDirectoryIfMissing True dir
      return $ dir </> "gmail-token.json"

-- | Build an OAuth2 authorization URL with a loopback redirect URI.
loopbackAuthURL :: OAuthClient -> Int -> T.Text
loopbackAuthURL client port =
  accountsURL
  <> "?response_type=code"
  <> "&client_id=" <> toQueryParam (_clientId client)
  <> "&redirect_uri=" <> loopbackRedirectURI port
  <> "&scope=" <> T.decodeUtf8 (queryEncodeScopes (scopeVals (Proxy :: Proxy '[Gmail'Modify])))
  <> "&access_type=offline"

loopbackRedirectURI :: Int -> T.Text
loopbackRedirectURI port = "http://localhost:" <> T.pack (show port)

-- | Exchange an authorization code for a token using the loopback redirect URI.
exchangeCodeLoopback
  :: OAuthClient -> OAuthCode s -> Int
  -> GLogger.Logger
  -> Client.Manager -> IO (OAuthToken s)
exchangeCodeLoopback client code port =
  refreshRequest $
    tokenRequest
      { Client.requestBody = textBody $
          "grant_type=authorization_code"
          <> "&client_id=" <> toQueryParam (_clientId client)
          <> "&client_secret=" <> toQueryParam (_clientSecret client)
          <> "&code=" <> toQueryParam code
          <> "&redirect_uri=" <> loopbackRedirectURI port
      }

-- | Start a temporary HTTP server, wait for the OAuth redirect, extract the code.
waitForOAuthRedirect :: Int -> IO T.Text
waitForOAuthRedirect port = do
  let hints = defaultHints { addrSocketType = Stream, addrFlags = [AI_PASSIVE] }
  addr:_ <- getAddrInfo (Just hints) (Just "127.0.0.1") (Just $ show port)
  bracket (openSocket addr) close $ \sock -> do
    setSocketOption sock ReuseAddr 1
    bind sock (addrAddress addr)
    listen sock 1
    (conn, _) <- accept sock
    request <- recv conn 4096
    let code = extractCodeFromRequest request
        responseBody = case code of
              Just _ -> "Authorization successful! You can close this window."
              Nothing -> "Authorization failed. No code received."
        response = BS8.unlines
              [ "HTTP/1.1 200 OK"
              , "Content-Type: text/plain"
              , "Connection: close"
              , ""
              , responseBody
              ]
    sendAll conn response
    close conn
    case code of
      Just c  -> return c
      Nothing -> fail "No authorization code received in OAuth redirect"

-- | Extract the 'code' query parameter from an HTTP GET request.
extractCodeFromRequest :: BS8.ByteString -> Maybe T.Text
extractCodeFromRequest req = do
  firstLine <- case BS8.lines req of
    (l:_) -> Just l
    []    -> Nothing
  -- Parse "GET /path?code=xxx&... HTTP/1.1"
  path <- case BS8.words firstLine of
    (_:p:_) -> Just p
    _       -> Nothing
  let query = BS8.dropWhile (/= '?') path
      params = parseQueryParams (BS8.drop 1 query)  -- drop the '?'
  lookup "code" params

-- | Parse query parameters from a query string like "code=xxx&scope=yyy"
parseQueryParams :: BS8.ByteString -> [(BS8.ByteString, T.Text)]
parseQueryParams qs =
  [ (key, T.decodeUtf8 val)
  | part <- BS8.split '&' qs
  , let (key, rest) = BS8.break (== '=') part
        val = BS8.drop 1 rest  -- drop the '='
  , not (BS8.null key)
  ]

-- | Set up a gogol Env for Gmail with Gmail'Modify scope.
--
-- If a saved token file exists, loads credentials from it.
-- Otherwise, runs the OAuth2 loopback flow: opens the browser,
-- captures the redirect on localhost, exchanges the code, and saves the token.
setupGmailEnv :: GmailConfig -> IO (Env '[Gmail'Modify])
setupGmailEnv config@GmailConfig{..} = do
  tokenPath <- getTokenFilePath config
  mgr <- newManager tlsManagerSettings
  lgr <- newLogger Error stdout

  let client = OAuthClient
        { _clientId     = ClientId gmailClientId
        , _clientSecret = GSecret gmailClientSecret
        }

  exists <- doesFileExist tokenPath
  if exists
    then do
      gmailLog INFO $ printf "Loading saved token from %s" tokenPath
      creds <- fromFilePath tokenPath
      newEnvWith creds lgr mgr
    else do
      let port = 8914
          authUrl = loopbackAuthURL client port
      gmailLog INFO "Starting OAuth2 loopback flow"
      putStrLn "Opening browser for Gmail authorization..."
      void $ xdgOpen [T.unpack authUrl]
      putStrLn $ "Waiting for authorization redirect on port " ++ show port ++ "..."
      code <- waitForOAuthRedirect port
      gmailLog INFO "Authorization code received"

      -- Exchange the code ourselves (with matching loopback redirect_uri)
      token <- exchangeCodeLoopback client
                 (OAuthCode code :: OAuthCode '[Gmail'Modify])
                 port lgr mgr

      -- Build an AuthorizedUser from the exchange result and save it
      let authorizedUser = AuthorizedUser
            { _userId = _clientId client
            , _userRefresh = case _tokenRefresh token of
                Just r  -> r
                Nothing -> error "OAuth token exchange did not return a refresh token"
            , _userSecret = _clientSecret client
            }
      saveAuthorizedUser tokenPath True authorizedUser
      gmailLog INFO $ printf "Token saved to %s" tokenPath

      -- Now load from the saved file to create the Env
      creds <- fromFilePath tokenPath
      newEnvWith creds lgr mgr

-- | Extract a header value by name from a Message's payload headers.
getHeader :: T.Text -> Message -> Maybe T.Text
getHeader headerName msg = do
  part <- msg.payload
  hdrs <- part.headers
  let matching = filter (\h -> h.name == Just headerName) hdrs
  case matching of
    (h:_) -> h.value
    []    -> Nothing

-- | Build a MessageSummary from a full Message response.
getMessageSummary :: Message -> Maybe MessageSummary
getMessageSummary msg = do
  msgId <- messageId msg
  let sender  = fromMaybe "(unknown)" $ getHeader "From" msg
      subject = fromMaybe "(no subject)" $ getHeader "Subject" msg
      snip    = fromMaybe "" msg.snippet
  return MessageSummary
    { msId      = msgId
    , msSender  = sender
    , msSubject = subject
    , msSnippet = snip
    }

-- | Shorten a sender string for display. Extracts just the name part
-- from "Name <email>" format, or returns as-is.
shortenSender :: T.Text -> T.Text
shortenSender s
  | T.null before = s
  | otherwise     = T.strip before
  where
    before = T.takeWhile (/= '<') s

-- | Format a notification summary string for display.
formatSummary :: MessageSummary -> String
formatSummary MessageSummary{..} =
  printf "%s - %s" (T.unpack $ shortenSender msSender) (T.unpack msSubject)

-- | Create a menu item for a single Gmail message.
makeGmailMenuItem :: Env '[Gmail'Modify] -> IO () -> MessageSummary -> IO Menuitem
makeGmailMenuItem env onMarkedRead summary@MessageSummary{..} = do
  menuItem <- menuitemNew
  let label = T.pack $ formatSummary summary
  textVariant <- liftIO $ toGVariant label
  menuitemPropertySetVariant menuItem "label" textVariant

  -- Sub-items: mark as read & open in browser
  markReadItem <- makeMenuItemWithLabel "Mark as read"
  onMenuitemItemActivated markReadItem $ const $ void $ forkIO $ do
    let modReq = newModifyMessageRequest
          { removeLabelIds = Just ["UNREAD"]
          }
    result <- try $ runResourceT $ send env (newGmailUsersMessagesModify msId modReq)
    case (result :: Either SomeException Message) of
      Right _  -> do
        gmailLog DEBUG $ printf "Marked message %s as read" (T.unpack msId)
        onMarkedRead
      Left err -> gmailLog ERROR $ printf "Failed to mark %s as read: %s" (T.unpack msId) (show err)
  menuitemChildAppend menuItem markReadItem

  openItem <- makeMenuItemWithLabel "Open in Gmail"
  onMenuitemItemActivated openItem $ const $
    void $ openURL $ "https://mail.google.com/mail/u/0/#inbox/" ++ T.unpack msId
  menuitemChildAppend menuItem openItem

  return menuItem

-- | The main updater function. Polls Gmail for unread inbox messages,
-- builds a tray menu, and sends desktop notifications for new arrivals.
--
-- Signature matches what OverlayIcon expects:
-- @GmailConfig -> (Int -> Menuitem -> IO ()) -> IO ()@
gmailUpdaterNew :: GmailConfig
                -> (Int -> Menuitem -> IO ())
                -> IO ()
gmailUpdaterNew config update = do
  env <- setupGmailEnv config

  summariesVar   <- MV.newMVar []
  errorVar       <- MV.newMVar (Nothing :: Maybe String)
  forceRefreshVar <- MV.newEmptyMVar

  let forceRefresh    = void $ MV.tryPutMVar forceRefreshVar ()
      delayedRefresh  = void $ forkIO $ threadDelay 1000000 >> forceRefresh
      refreshSeconds  = gmailRefreshSeconds config

      -- Fetch the list of unread inbox messages and retrieve their metadata.
      fetchMessages :: IO (Either String [MessageSummary])
      fetchMessages = do
        result <- try $ runResourceT $ do
          let listReq = newGmailUsersMessagesList
                { q = Just "is:unread in:inbox"
                , maxResults = 50
                }
          listResp <- send env listReq
          let msgStubs = fromMaybe [] listResp.messages
              msgIds   = mapMaybe (\m -> messageId m) msgStubs
          forM msgIds $ \mid' -> do
            let getReq = (newGmailUsersMessagesGet mid')
                  { format = UsersMessagesGetFormat_Metadata
                  , metadataHeaders = Just ["From", "Subject"]
                  }
            send env getReq
        case (result :: Either SomeException [Message]) of
          Left err -> return $ Left $ show err
          Right msgs -> return $ Right $ mapMaybe getMessageSummary msgs

      getCurrentSummaries = MV.readMVar summariesVar

      buildMenu = do
        sums <- getCurrentSummaries
        root <- menuitemNew
        mapM_ (\s -> makeGmailMenuItem env delayedRefresh s >>= menuitemChildAppend root) sums

        separatorItem <- menuitemNew
        menuitemPropertySet separatorItem MENUITEM_PROP_TYPE CLIENT_TYPES_SEPARATOR
        menuitemChildAppend root separatorItem

        openInboxItem <- makeMenuItemWithLabel "Open Gmail"
        onMenuitemItemActivated openInboxItem $ const $
          void $ openURL "https://mail.google.com"
        menuitemChildAppend root openInboxItem

        refreshItem <- makeMenuItemWithLabel "Refresh"
        onMenuitemItemActivated refreshItem $ const forceRefresh
        menuitemChildAppend root refreshItem

        return root

      updateVariables = do
        result <- fetchMessages
        case result of
          Left err -> do
            MV.modifyMVar_ errorVar (const $ return $ Just err)
            gmailLog ERROR $ printf "Error fetching Gmail: %s" err
            return (False, [])
          Right newSummaries -> do
            MV.modifyMVar_ errorVar (const $ return Nothing)
            MV.modifyMVar summariesVar $ \oldSummaries -> do
              let newIds = sort $ map msId newSummaries
                  oldIds = sort $ map msId oldSummaries
              return ( newSummaries
                     , ( newIds /= oldIds
                       , newIds \\ oldIds
                       )
                     )

      doUpdate = do
        newRoot <- buildMenu
        count <- length <$> getCurrentSummaries
        update count newRoot

      sendNotifications newIds = do
        sums <- getCurrentSummaries
        let getById i = filter ((== i) . msId) sums
        forM_ newIds $ \i ->
          case getById i of
            (s:_) -> sendNotification s
            []    -> return ()

      sendNotification summary =
        void $ runCommandFromPath
          [ "notify-send"
          , "--icon=mail-unread"
          , T.unpack $ shortenSender $ msSender summary
          , T.unpack $ msSubject summary
          ]

  -- Initial fetch and menu build
  void updateVariables
  doUpdate

  -- Polling loop
  void $ forkIO $ forever $ do
    forced <-
      isRight <$> race (threadDelay (floor $ refreshSeconds * 1000000))
                       (takeMVar forceRefreshVar)
    gmailLog DEBUG "Refreshing Gmail notifications"
    (menuNeedsRebuild, newIds) <- updateVariables
    sendNotifications newIds
    gmailLog DEBUG $ printf "Gmail rebuild needed: %s, force: %s"
                            (show menuNeedsRebuild) (show forced)
    when (forced || menuNeedsRebuild) doUpdate