packages feed

shomei-server-0.3.0.0: src/Shomei/Notify.hs

-- | Notification interpreters for the standalone server.
--
-- The core defines the 'Notifier' effect and emits a 'Notification' (recipient, one-time
-- link/token, expiry); turning that into a delivered message is this module's job. It provides
-- three built-in interpreters, selected by 'runNotifierFromConfig' from the operator's
-- 'Shomei.Config.NotifierTransport':
--
-- * 'runNotifierLog' writes the link to the server log (the default; development / log-scraping).
--
-- * 'runNotifierSmtp' delivers a plain-text email through a __provider relay__ (SES, SendGrid,
--   Resend, Postmark) over implicit-TLS / STARTTLS / plaintext-lab modes. Not a mail server.
--
-- * 'runNotifierWebhook' POSTs the notification as HMAC-signed JSON to a configured URL.
--
-- Both delivering interpreters are __fire-and-forget and hardened__: every exception is caught
-- inside the interpreter, a failed delivery logs one redacted line and publishes a
-- 'Shomei.Audit.Event.Domain.NotificationDeliveryFailed' audit event, and the triggering HTTP request
-- still succeeds. Their operational log lines never contain the one-time token. Operators who
-- want a provider Shōmei does not ship supply their own 'Notifier' interpreter.
module Shomei.Notify
  ( SmtpPassword (..),
    WebhookSecret (..),
    NotifierSecrets (..),
    noNotifierSecrets,
    smtpPasswordText,
    webhookSecretBytes,
    DeliveryReason (..),
    SmtpStage (..),
    reasonText,
    classifySmtpFailure,
    classifyWebhookFailure,
    redactDeliveryText,
    runNotifierEnqueue,
    runNotifierFromConfig,
    runNotifierLog,
    runNotifierSmtp,
    runNotifierWebhook,
    renderNotification,
    notificationTypeText,
    transportChannel,
    deliverNotification,
    webhookSignature,
  )
where

import Control.Concurrent (threadDelay)
import Control.Exception (IOException, SomeException, fromException, try)
import Crypto.Hash.Algorithms (SHA256)
import Crypto.MAC.HMAC (HMAC, hmac, hmacGetDigest)
import Data.Aeson (encode)
import Data.ByteArray.Encoding (Base (Base16), convertToBase)
import Data.ByteString (ByteString)
import Data.ByteString.Lazy qualified as BSL
import Data.Char (isSpace)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TE
import Data.Text.Lazy qualified as TL
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
import Data.Time.Format.ISO8601 (iso8601Show)
import Effectful (Eff, IOE, (:>))
import Effectful.Dispatch.Dynamic (interpret_)
import Network.HTTP.Client
  ( HttpException (..),
    HttpExceptionContent (..),
    Manager,
    RequestBody (RequestBodyBS),
    httpLbs,
    method,
    parseRequest,
    requestBody,
    requestHeaders,
    responseStatus,
    responseTimeout,
    responseTimeoutMicro,
  )
import Network.HTTP.Types.Status (statusCode, statusIsSuccessful)
import Network.Mail.Mime (Address (..), Mail, simpleMail')
import Network.Mail.SMTP
  ( sendMail',
    sendMailSTARTTLS',
    sendMailTLS',
    sendMailWithLogin',
    sendMailWithLoginSTARTTLS',
    sendMailWithLoginTLS',
  )
import Shomei.Account.Email.Domain (emailText)
import Shomei.Account.Notification.Domain (Notification (..))
import Shomei.Account.Notification.Store (Notifier (..))
import Shomei.Account.OneTimeToken.Domain (OneTimeToken, oneTimeTokenText)
import Shomei.Account.Password.Hash.Postgres (sha256Hex)
import Shomei.Audit.Event.Domain (AuthEvent (NotificationDeliveryFailed), NotificationDeliveryFailedData (..))
import Shomei.Audit.Publisher.Store (AuthEventPublisher, publishAuthEvent)
import Shomei.Config (NotifierConfig (..), NotifierTransport (..), ShomeiConfig (..), SmtpConfig (..), SmtpTlsMode (..), WebhookConfig (..))
import Shomei.Notify.Queue (NotifierQueue)
import Shomei.Notify.Queue qualified as Queue
import Shomei.Prelude
import Shomei.Time.Store (Clock, now)
import System.IO (hPutStrLn, stderr)
import System.IO.Error (ioeGetErrorString, isUserError)
import System.Timeout (timeout)
import Text.Read (readMaybe)

-- | Stable, secret-free reasons that may be written to logs and persisted audit rows.
-- Third-party exception text is inspected only long enough to select one of these values.
data DeliveryReason
  = ConnectFailed
  | TlsFailed
  | AuthFailed
  | Timeout
  | RejectedAt SmtpStage Int
  | DataRefused
  | InvalidUrl
  | HttpStatus Int
  | RedirectLoop
  | TransportError
  | QueueFull
  | ShuttingDown
  | ExpiredInQueue
  | Unknown
  deriving stock (Eq, Show)

data SmtpStage = AtEhlo | AtStartTls | AtMail | AtRcpt | AtData
  deriving stock (Eq, Show)

-- | SMTP credential material. Deliberately has no 'Show', 'Eq', or JSON instances: placing it
-- in a printable configuration or diagnostic structure is a type error.
newtype SmtpPassword = SmtpPassword Text

-- | Webhook HMAC key. Deliberately has no 'Show', 'Eq', or JSON instances.
newtype WebhookSecret = WebhookSecret Text

-- | Runtime-only notifier credentials, carried beside (never inside) 'ShomeiConfig'.
data NotifierSecrets = NotifierSecrets
  { smtpPassword :: !(Maybe SmtpPassword),
    webhookSecret :: !(Maybe WebhookSecret)
  }

noNotifierSecrets :: NotifierSecrets
noNotifierSecrets = NotifierSecrets {smtpPassword = Nothing, webhookSecret = Nothing}

smtpPasswordText :: SmtpPassword -> Text
smtpPasswordText (SmtpPassword password) = password

webhookSecretBytes :: WebhookSecret -> ByteString
webhookSecretBytes (WebhookSecret secret) = TE.encodeUtf8 secret

reasonText :: DeliveryReason -> Text
reasonText = \case
  ConnectFailed -> "connect_failed"
  TlsFailed -> "tls_failed"
  AuthFailed -> "auth_failed"
  Timeout -> "timeout"
  RejectedAt stage code -> "rejected_at_" <> smtpStageText stage <> ":" <> Text.pack (show code)
  DataRefused -> "data_refused"
  InvalidUrl -> "invalid_url"
  HttpStatus code -> "http_status:" <> Text.pack (show code)
  RedirectLoop -> "redirect_loop"
  TransportError -> "transport_error"
  QueueFull -> "queue_full"
  ShuttingDown -> "shutting_down"
  ExpiredInQueue -> "expired_in_queue"
  Unknown -> "unknown"

smtpStageText :: SmtpStage -> Text
smtpStageText = \case
  AtEhlo -> "ehlo"
  AtStartTls -> "starttls"
  AtMail -> "mail"
  AtRcpt -> "rcpt"
  AtData -> "data"

-- | Collapse smtp-mail failures into a vocabulary that cannot contain the rendered message.
classifySmtpFailure :: SomeException -> DeliveryReason
classifySmtpFailure err =
  case fromException err :: Maybe IOException of
    Just ioe
      | not (isUserError ioe) -> ConnectFailed
      | otherwise -> classifyUserError (Text.pack (ioeGetErrorString ioe))
    Nothing
      | looksLikeTls (Text.pack (show err)) -> TlsFailed
      | looksLikeConnectFailure (Text.pack (show err)) -> ConnectFailed
      | otherwise -> Unknown
  where
    classifyUserError msg
      | "timed out" `Text.isInfixOf` lower = Timeout
      | "authentication failed" `Text.isInfixOf` lower = AuthFailed
      | "cannot connect to the server" `Text.isInfixOf` lower = ConnectFailed
      | "connection refused" `Text.isInfixOf` lower = ConnectFailed
      | "failed to connect" `Text.isInfixOf` lower = ConnectFailed
      | "cannot accept any data" `Text.isInfixOf` lower = DataRefused
      | Just rejected <- parseRejected msg = rejected
      | otherwise = Unknown
      where
        lower = Text.toLower msg

-- | Collapse http-client failures without retaining the request, URL, headers, or response body.
classifyWebhookFailure :: SomeException -> DeliveryReason
classifyWebhookFailure err =
  case fromException err of
    Just (InvalidUrlException _ _) -> InvalidUrl
    Just (HttpExceptionRequest _ content) -> classifyHttpContent content
    Nothing -> TransportError
  where
    classifyHttpContent = \case
      ResponseTimeout -> Timeout
      ConnectionTimeout -> Timeout
      ConnectionFailure _ -> ConnectFailed
      TooManyRedirects _ -> RedirectLoop
      StatusCodeException response _ -> HttpStatus (statusCode (responseStatus response))
      InternalException inner
        | looksLikeTls (Text.pack (show inner)) -> TlsFailed
      _ -> TransportError

looksLikeTls :: Text -> Bool
looksLikeTls rendered =
  any (`Text.isInfixOf` rendered) ["HandshakeFailed", "TLSException", "TlsException"]

looksLikeConnectFailure :: Text -> Bool
looksLikeConnectFailure rendered =
  any
    (`Text.isInfixOf` rendered)
    ["HostCannotConnect", "Network.Socket.connect", "Connection refused"]

parseRejected :: Text -> Maybe DeliveryReason
parseRejected msg = do
  commandAndRest <- Text.stripPrefix "Unexpected reply to: " msg
  stage <- parseStage commandAndRest
  let (_, reply) = Text.breakOn "Got this instead: " commandAndRest
  guard (not (Text.null reply))
  code <- readMaybe (Text.unpack (Text.take 3 (Text.drop (Text.length "Got this instead: ") reply)))
  pure (RejectedAt stage code)
  where
    parseStage command
      | "EHLO" `Text.isPrefixOf` command || "HELO" `Text.isPrefixOf` command = Just AtEhlo
      | "STARTTLS" `Text.isPrefixOf` command = Just AtStartTls
      | "MAIL" `Text.isPrefixOf` command = Just AtMail
      | "RCPT" `Text.isPrefixOf` command = Just AtRcpt
      | "DATA" `Text.isPrefixOf` command = Just AtData
      | otherwise = Nothing

-- | Select the notifier interpreter from configuration and run it, reusing the server's shared
-- TLS 'Manager' for the webhook transport. A single dispatching handler both implements the
-- @alsoLogNotifications@ tee (log first, then deliver — no double delivery) and guards against a
-- selected transport whose sub-config is somehow absent by falling back to the log sender with a
-- one-line warning (boot validation makes that unreachable in the standalone server).
--
-- This is written as one 'interpret_' rather than the plan's @interpose@-based tee: forwarding to
-- an underlying handler by re-'send'ing inside an 'interpose' handler would re-enter the tee
-- handler and loop. Dispatching per notification is unambiguous and runs each delivery once.
runNotifierFromConfig ::
  (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>
  Manager ->
  NotifierSecrets ->
  ShomeiConfig ->
  Eff (Notifier : es) a ->
  Eff es a
runNotifierFromConfig mgr secrets cfg = interpret_ \case
  SendNotification notification -> deliverNotification mgr secrets cfg notification

-- | The standalone request-path interpreter. Enqueueing is one bounded STM transaction and
-- never waits for a relay. Overflow and shutdown are still observable through the audit port.
runNotifierEnqueue ::
  (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>
  NotifierQueue ->
  Text ->
  Eff (Notifier : es) a ->
  Eff es a
runNotifierEnqueue notifierQueue channel = interpret_ \case
  SendNotification notification -> do
    outcome <- liftIO (Queue.enqueueNotification notifierQueue notification)
    case outcome of
      Queue.Enqueued -> pure ()
      Queue.QueueFull -> publishDeliveryFailed channel notification QueueFull
      Queue.QueueClosed -> publishDeliveryFailed channel notification ShuttingDown

-- | Deliver one dequeued notification. Expired work is audited and skipped before any network
-- operation; otherwise this is exactly the synchronous interpreter's former dispatch path.
deliverNotification ::
  (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>
  Manager ->
  NotifierSecrets ->
  ShomeiConfig ->
  Notification ->
  Eff es ()
deliverNotification mgr secrets cfg notification = do
  currentTime <- now
  if notificationExpiresAt notification <= currentTime
    then publishDeliveryFailed (transportChannel nc.notifierTransport) notification ExpiredInQueue
    else do
      when tee (logNotification nc notification)
      deliver notification
  where
    nc = cfg.notifierConfig
    tee = nc.alsoLogNotifications && nc.notifierTransport /= LogNotifier
    deliver = case nc.notifierTransport of
      LogNotifier -> logNotification nc
      SmtpNotifier -> maybe (logFallback "smtp" nc) (\sc -> deliverSmtp nc sc secrets.smtpPassword) nc.smtpConfig
      WebhookNotifier -> case (nc.webhookConfig, secrets.webhookSecret) of
        (Just wc, Just secret) -> deliverWebhook mgr wc secret
        _ -> \undeliverable -> publishDeliveryFailed "webhook" undeliverable AuthFailed

transportChannel :: NotifierTransport -> Text
transportChannel = \case
  LogNotifier -> "log"
  SmtpNotifier -> "smtp"
  WebhookNotifier -> "webhook"

notificationExpiresAt :: Notification -> UTCTime
notificationExpiresAt = \case
  EmailVerificationRequested _ _ expires -> expires
  PasswordResetRequested _ _ expires -> expires

-- | Fallback used only if a transport is selected with no sub-config (boot validation prevents
-- this): warn once and log the notification rather than silently dropping it.
logFallback :: (IOE :> es) => Text -> NotifierConfig -> Notification -> Eff es ()
logFallback which nc n = do
  liftIO (hPutStrLn stderr ("[shomei:" <> Text.unpack which <> "] no configuration; falling back to the log sender"))
  logNotification nc n

runNotifierLog :: (IOE :> es) => NotifierConfig -> Eff (Notifier : es) a -> Eff es a
runNotifierLog cfg = interpret_ \case
  SendNotification n -> logNotification cfg n

-- | Write one notification to stderr through 'renderNotification' (token redacted unless
-- 'NotifierConfig.logRawTokens'). The per-notification primitive shared by the log sender and the
-- @alsoLogNotifications@ tee.
logNotification :: (IOE :> es) => NotifierConfig -> Notification -> Eff es ()
logNotification cfg n = liftIO (hPutStrLn stderr (renderNotification cfg n))

-- | Render a notification as one log line.
--
-- By default the one-time token is __redacted__: the line carries only the first 8 hex
-- characters of its SHA-256, which is enough to correlate a log line with the token's
-- stored hash trail but useless for taking the account over (the token itself is 32 random
-- bytes). No link is printed either — a link without its token is noise.
--
-- Setting 'NotifierConfig.logRawTokens' (env @SHOMEI_NOTIFIER_LOG_SECRETS=true@) restores
-- the full clickable link. That is for local development, where the logged link is how you
-- complete the flow; in any shared environment it hands account takeover to whoever can
-- read the log.
renderNotification :: NotifierConfig -> Notification -> String
renderNotification cfg = \case
  EmailVerificationRequested email token expires ->
    line "email_verification" "/v1/auth/verify-email/confirm" email token expires
  PasswordResetRequested email token expires ->
    line "password_reset" "/v1/auth/password-reset/confirm" email token expires
  where
    line kind path email token expires =
      "[shomei:log] "
        <> kind
        <> " email="
        <> Text.unpack (emailText email)
        <> secretPart path token
        <> " expires_at="
        <> show expires
        <> hint
    secretPart path token
      | cfg.logRawTokens =
          " link="
            <> Text.unpack cfg.publicBaseUrl
            <> path
            <> "?token="
            <> Text.unpack (oneTimeTokenText token)
      | otherwise = " token_sha256=" <> Text.unpack (tokenPrefix token)
    hint
      | cfg.logRawTokens = ""
      | otherwise = " (set SHOMEI_NOTIFIER_LOG_SECRETS=true to log the full link in development)"

-- | The first 8 hex characters of the token's SHA-256 — a correlation handle, not a secret.
-- One-time tokens are stored as SHA-256 too (base64url rather than hex), so this prefix
-- ties a log line to its @token_hash@ row.
tokenPrefix :: OneTimeToken -> Text
tokenPrefix = Text.take 8 . sha256Hex . oneTimeTokenText

-- SMTP interpreter (EP-8) -----------------------------------------------------

-- | Deliver notifications as plain-text email through a __provider relay__ (SES, SendGrid,
-- Resend, Postmark). Fire-and-forget and hardened: every exception (including a timeout) is
-- caught, a failed send logs one redacted line and publishes a 'NotificationDeliveryFailed'
-- audit event, and the triggering workflow still returns success. This interpreter sits above
-- 'AuthEventPublisher'/'Clock' in the server stack, so it may publish and read the clock.
runNotifierSmtp ::
  (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>
  NotifierConfig ->
  SmtpConfig ->
  Maybe SmtpPassword ->
  Eff (Notifier : es) a ->
  Eff es a
runNotifierSmtp nc sc password = interpret_ \case
  SendNotification n -> deliverSmtp nc sc password n

-- | Deliver one notification over SMTP: build the message, send it under a timeout, and on any
-- failure publish the redacted 'NotificationDeliveryFailed' event. The per-notification primitive
-- shared by 'runNotifierSmtp' and the config dispatcher.
deliverSmtp ::
  (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>
  NotifierConfig ->
  SmtpConfig ->
  Maybe SmtpPassword ->
  Notification ->
  Eff es ()
deliverSmtp nc sc password n = do
  let (subject, body) = renderEmail nc n
      recipient = notificationRecipient n
      mail = simpleMail' (Address Nothing recipient) (Address Nothing sc.fromAddress) subject body
  outcome <- liftIO (try @SomeException (sendViaSmtp sc password mail))
  case outcome of
    Right () -> pure ()
    Left err -> publishDeliveryFailed "smtp" n (classifySmtpFailure err)

-- | Run the SMTP dialogue for one message under a timeout, choosing the connection mode from
-- 'SmtpTlsMode' and using the authenticated variant when credentials are present. Boot
-- validation guarantees username and password are both present or both absent. The explicit
-- mismatch branch keeps a hand-built embedding configuration from silently downgrading.
sendViaSmtp :: SmtpConfig -> Maybe SmtpPassword -> Mail -> IO ()
sendViaSmtp sc mp mail = do
  let SmtpConfig {host = h, port = p, tlsMode = tls, username = mu, timeoutSeconds = to} = sc
      host' = Text.unpack h
      port' = fromIntegral p
      creds = (,) <$> mu <*> fmap smtpPasswordText mp
      send = case (mu, mp, tls, creds) of
        (Just _, Nothing, _, _) -> ioError (userError "SMTP credentials are incomplete")
        (Nothing, Just _, _, _) -> ioError (userError "SMTP credentials are incomplete")
        (_, _, SmtpPlain, Just (u, pw)) -> sendMailWithLogin' host' port' (Text.unpack u) (Text.unpack pw) mail
        (_, _, SmtpPlain, Nothing) -> sendMail' host' port' mail
        (_, _, SmtpStartTls, Just (u, pw)) -> sendMailWithLoginSTARTTLS' host' port' (Text.unpack u) (Text.unpack pw) mail
        (_, _, SmtpStartTls, Nothing) -> sendMailSTARTTLS' host' port' mail
        (_, _, SmtpImplicitTls, Just (u, pw)) -> sendMailWithLoginTLS' host' port' (Text.unpack u) (Text.unpack pw) mail
        (_, _, SmtpImplicitTls, Nothing) -> sendMailTLS' host' port' mail
  result <- timeout (max 1 to * 1_000_000) send
  case result of
    Just () -> pure ()
    Nothing -> ioError (userError ("SMTP delivery to " <> host' <> " timed out after " <> show to <> "s"))

-- Shared rendering + failure reporting ----------------------------------------

-- | The event-style type string for a notification, used both in the failure audit event and as
-- the webhook @X-Shomei-Notification-Type@ header (M3). Kept identical to the audit @event_type@
-- vocabulary so a reader correlates the two.
notificationTypeText :: Notification -> Text
notificationTypeText = \case
  EmailVerificationRequested {} -> "email_verification_requested"
  PasswordResetRequested {} -> "password_reset_requested"

-- | The recipient address of a notification.
notificationRecipient :: Notification -> Text
notificationRecipient = \case
  EmailVerificationRequested e _ _ -> emailText e
  PasswordResetRequested e _ _ -> emailText e

-- | The fixed English subject and plain-text body for a notification. These are the only two
-- bodies Shōmei ever emails; there is no templating, i18n, or HTML part (operators who want
-- branded copy take the webhook or a custom interpreter). The confirm links reuse the exact
-- @\/v1@ routes 'renderNotification' logs, so host confirm pages keep working unchanged.
renderEmail :: NotifierConfig -> Notification -> (Text, TL.Text)
renderEmail nc = \case
  EmailVerificationRequested _ token expires ->
    ( "Verify your email address",
      TL.fromStrict
        ( body
            [ "Hello,",
              "",
              "Please confirm your email address by opening this link:",
              "",
              link "/v1/auth/verify-email/confirm" token,
              "",
              "This link expires at " <> isoUtc expires <> " (UTC). If you did not request this,",
              "you can ignore this message."
            ]
        )
    )
  PasswordResetRequested _ token expires ->
    ( "Reset your password",
      TL.fromStrict
        ( body
            [ "Hello,",
              "",
              "A password reset was requested for your account. Open this link to",
              "choose a new password:",
              "",
              link "/v1/auth/password-reset/confirm" token,
              "",
              "This link expires at " <> isoUtc expires <> " (UTC). If you did not request this,",
              "you can ignore this message and your password will remain unchanged."
            ]
        )
    )
  where
    body = Text.intercalate "\n"
    link path token = nc.publicBaseUrl <> path <> "?token=" <> oneTimeTokenText token
    isoUtc = Text.pack . iso8601Show

-- | Log one redacted line and publish a 'NotificationDeliveryFailed' audit event for a delivery
-- that failed after exhausting its attempts. Shared by the SMTP and webhook interpreters. The
-- token appears __nowhere__: only channel, notification type, recipient, and a closed reason
-- code are emitted. The defence-in-depth redactor makes a future free-text reason safe too.
publishDeliveryFailed ::
  (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>
  -- | channel: @"smtp"@ | @"webhook"@
  Text ->
  Notification ->
  DeliveryReason ->
  Eff es ()
publishDeliveryFailed channel n reason = do
  let recipient = notificationRecipient n
      kind = notificationTypeText n
      safeReason = redactDeliveryText n (reasonText reason)
  liftIO
    ( hPutStrLn
        stderr
        ( Text.unpack
            ( "[shomei:"
                <> channel
                <> "] delivery_failed type="
                <> kind
                <> " recipient="
                <> recipient
                <> " reason="
                <> safeReason
            )
        )
    )
  occ <- now
  publishAuthEvent
    ( NotificationDeliveryFailed
        NotificationDeliveryFailedData
          { channel = channel,
            notificationType = kind,
            recipient = recipient,
            errorText = safeReason,
            occurredAt = occ
          }
    )

-- | Defence in depth for text associated with a delivery. The public failure path uses only
-- 'DeliveryReason', but this also removes the notification's token and any @token=@ parameter
-- before bounding the text in case a future transport threads richer context through here.
redactDeliveryText :: Notification -> Text -> Text
redactDeliveryText notification =
  truncateText
    . redactTokenParameters
    . Text.replace (oneTimeTokenText (notificationToken notification)) "<redacted>"

notificationToken :: Notification -> OneTimeToken
notificationToken = \case
  EmailVerificationRequested _ token _ -> token
  PasswordResetRequested _ token _ -> token

redactTokenParameters :: Text -> Text
redactTokenParameters input =
  case Text.breakOn "token=" input of
    (_, rest) | Text.null rest -> input
    (prefix, rest) ->
      let valueAndSuffix = Text.drop (Text.length "token=") rest
          suffix = Text.dropWhile isTokenCharacter valueAndSuffix
       in prefix <> "token=<redacted>" <> redactTokenParameters suffix
  where
    isTokenCharacter c = not (isSpace c || c `elem` ['&', '\'', '"'])

-- | Collapse whitespace (including newlines) to single spaces and cap at 500 characters, so an
-- error string is one safe line for a log and an audit payload.
truncateText :: Text -> Text
truncateText = Text.take 500 . Text.unwords . Text.words

-- Webhook interpreter (EP-8) --------------------------------------------------

-- | Deliver notifications as a signed JSON POST to a configured URL, reusing the server's shared
-- TLS 'Manager'. Same fire-and-forget hardening as 'runNotifierSmtp': all exceptions caught, a
-- non-2xx response counts as a failure, bounded retries with backoff, then one redacted log line
-- plus a 'NotificationDeliveryFailed' audit event. The JSON body is the notification's derived
-- 'ToJSON' (so it carries the raw token — the receiver builds the link), signed over the exact
-- bytes sent with @X-Shomei-Signature: sha256=<hex HMAC-SHA256>@.
runNotifierWebhook ::
  (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>
  Manager ->
  WebhookConfig ->
  WebhookSecret ->
  Eff (Notifier : es) a ->
  Eff es a
runNotifierWebhook mgr wc secret = interpret_ \case
  SendNotification n -> deliverWebhook mgr wc secret n

-- | Deliver one notification over the webhook and, on ultimate failure, publish the redacted
-- 'NotificationDeliveryFailed' event. The per-notification primitive shared by
-- 'runNotifierWebhook' and the config dispatcher.
deliverWebhook ::
  (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>
  Manager ->
  WebhookConfig ->
  WebhookSecret ->
  Notification ->
  Eff es ()
deliverWebhook mgr wc secret n = do
  result <- attemptWebhook mgr wc secret n
  case result of
    Nothing -> pure ()
    Just reason -> publishDeliveryFailed "webhook" n reason

-- | POST the notification, retrying up to 'WebhookConfig.maxAttempts' with @4^(k-1)@-second
-- backoff (1 s, 4 s, …) between attempts, each under the configured per-attempt timeout. Returns
-- 'Nothing' on the first 2xx, or a secret-free reason after the last attempt fails. All exceptions are
-- caught here; nothing escapes to the interpreter. Each retry gets a fresh timestamp and
-- signature so a receiver's replay window measures the actual attempt, not initial enqueue.
attemptWebhook :: (IOE :> es, Clock :> es) => Manager -> WebhookConfig -> WebhookSecret -> Notification -> Eff es (Maybe DeliveryReason)
attemptWebhook mgr wc secret n = do
  let WebhookConfig {url = u, timeoutSeconds = to, maxAttempts = maxA} = wc
      body = BSL.toStrict (encode n)
      kind = notificationTypeText n
      attempts = max 1 maxA
  reqE <- liftIO (try @SomeException (parseRequest (Text.unpack u)))
  case reqE of
    Left _ -> pure (Just InvalidUrl)
    Right req0 -> go req0 body kind attempts to 1
  where
    go req0 body kind attempts to k = do
      attemptTime <- now
      let timestamp = TE.encodeUtf8 (Text.pack (show (floor (utcTimeToPOSIXSeconds attemptTime) :: Integer)))
          sig = webhookSignature (webhookSecretBytes secret) timestamp body
          req =
            req0
              { method = "POST",
                requestBody = RequestBodyBS body,
                requestHeaders =
                  [ ("Content-Type", "application/json"),
                    ("X-Shomei-Signature", sig),
                    ("X-Shomei-Timestamp", timestamp),
                    ("X-Shomei-Notification-Type", TE.encodeUtf8 kind),
                    ("User-Agent", "shomei")
                  ],
                responseTimeout = responseTimeoutMicro (max 1 to * 1_000_000)
              }
      outcome <- liftIO (try @SomeException (httpLbs req mgr))
      let failed reason
            | k >= attempts = pure (Just reason)
            | otherwise = liftIO (threadDelay (4 ^ (k - 1) * 1_000_000)) >> go req0 body kind attempts to (k + 1)
      case outcome of
        Right resp
          | statusIsSuccessful (responseStatus resp) -> pure Nothing
          | otherwise -> failed (HttpStatus (statusCode (responseStatus resp)))
        Left err -> failed (classifyWebhookFailure err)

-- | The @X-Shomei-Signature@ header value: @sha256=@ followed by the lowercase-hex
-- HMAC-SHA256 of @<unix-seconds>.<exact raw body>@ under the shared secret.
webhookSignature :: ByteString -> ByteString -> ByteString -> ByteString
webhookSignature secret timestamp body =
  "sha256="
    <> convertToBase
      Base16
      (hmacGetDigest (hmac secret (timestamp <> "." <> body) :: HMAC SHA256))