package server

import (
	"context"
	"errors"
	"net/http"

	"galaxy/backend/internal/diplomail"
	"galaxy/backend/internal/lobby"
	"galaxy/backend/internal/server/clientip"
	"galaxy/backend/internal/server/handlers"
	"galaxy/backend/internal/server/httperr"
	"galaxy/backend/internal/server/middleware/userid"
	"galaxy/backend/internal/telemetry"
	"galaxy/backend/internal/user"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"go.uber.org/zap"
)

// UserMailHandlers groups the diplomatic-mail handlers under
// `/api/v1/user/games/{game_id}/mail/*` and the lobby-side
// `/api/v1/user/lobby/mail/unread-counts`. Stage A wires the
// personal subset; Stage B adds the owner-only admin send path,
// which needs `*lobby.Service` to confirm ownership and `*user.Service`
// to resolve the owner's `user_name` for the `sender_username` column.
type UserMailHandlers struct {
	svc    *diplomail.Service
	lobby  *lobby.Service
	users  *user.Service
	logger *zap.Logger
}

// NewUserMailHandlers constructs the handler set. svc may be nil — in
// that case every handler returns 501 not_implemented. lobby and
// users are optional: when either is nil the admin-send handler
// degrades to 501 (the personal-send and read paths stay functional).
func NewUserMailHandlers(svc *diplomail.Service, lobbySvc *lobby.Service, users *user.Service, logger *zap.Logger) *UserMailHandlers {
	if logger == nil {
		logger = zap.NewNop()
	}
	return &UserMailHandlers{
		svc:    svc,
		lobby:  lobbySvc,
		users:  users,
		logger: logger.Named("http.user.mail"),
	}
}

// preferredLanguage looks up the caller's `accounts.preferred_language`
// so the per-message read can attach the cached translation when
// available. Failures are logged at debug level and the function
// returns an empty string — translation is best-effort and the
// caller still receives the original body.
func (h *UserMailHandlers) preferredLanguage(ctx context.Context, userID uuid.UUID) string {
	if h.users == nil {
		return ""
	}
	account, err := h.users.GetAccount(ctx, userID)
	if err != nil {
		h.logger.Debug("resolve preferred_language failed",
			zap.String("user_id", userID.String()),
			zap.Error(err))
		return ""
	}
	return account.PreferredLanguage
}

// SendPersonal handles POST /api/v1/user/games/{game_id}/mail/messages.
func (h *UserMailHandlers) SendPersonal() gin.HandlerFunc {
	if h.svc == nil {
		return handlers.NotImplemented("userMailSendPersonal")
	}
	return func(c *gin.Context) {
		userID, ok := userid.FromContext(c.Request.Context())
		if !ok {
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
			return
		}
		gameID, ok := parseGameIDParam(c)
		if !ok {
			return
		}
		var req userMailSendRequestWire
		if err := c.ShouldBindJSON(&req); err != nil {
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
			return
		}
		recipientID, err := uuid.Parse(req.RecipientUserID)
		if err != nil {
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
			return
		}
		ctx := c.Request.Context()
		msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{
			GameID:          gameID,
			SenderUserID:    userID,
			RecipientUserID: recipientID,
			Subject:         req.Subject,
			Body:            req.Body,
			SenderIP:        clientip.ExtractSourceIP(c),
		})
		if err != nil {
			respondDiplomailError(c, h.logger, "user mail send personal", ctx, err)
			return
		}
		c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
	}
}

// Get handles GET /api/v1/user/games/{game_id}/mail/messages/{message_id}.
func (h *UserMailHandlers) Get() gin.HandlerFunc {
	if h.svc == nil {
		return handlers.NotImplemented("userMailGet")
	}
	return func(c *gin.Context) {
		userID, ok := userid.FromContext(c.Request.Context())
		if !ok {
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
			return
		}
		if _, ok := parseGameIDParam(c); !ok {
			return
		}
		messageID, ok := parseMessageIDParam(c)
		if !ok {
			return
		}
		ctx := c.Request.Context()
		targetLang := h.preferredLanguage(ctx, userID)
		entry, err := h.svc.GetMessage(ctx, userID, messageID, targetLang)
		if err != nil {
			respondDiplomailError(c, h.logger, "user mail get", ctx, err)
			return
		}
		c.JSON(http.StatusOK, mailMessageDetailToWire(entry, false))
	}
}

// Inbox handles GET /api/v1/user/games/{game_id}/mail/inbox.
func (h *UserMailHandlers) Inbox() gin.HandlerFunc {
	if h.svc == nil {
		return handlers.NotImplemented("userMailInbox")
	}
	return func(c *gin.Context) {
		userID, ok := userid.FromContext(c.Request.Context())
		if !ok {
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
			return
		}
		gameID, ok := parseGameIDParam(c)
		if !ok {
			return
		}
		ctx := c.Request.Context()
		targetLang := h.preferredLanguage(ctx, userID)
		items, err := h.svc.ListInbox(ctx, gameID, userID, targetLang)
		if err != nil {
			respondDiplomailError(c, h.logger, "user mail inbox", ctx, err)
			return
		}
		out := userMailInboxListWire{Items: make([]userMailMessageDetailWire, 0, len(items))}
		for _, e := range items {
			out.Items = append(out.Items, mailMessageDetailToWire(e, false))
		}
		c.JSON(http.StatusOK, out)
	}
}

// Sent handles GET /api/v1/user/games/{game_id}/mail/sent.
func (h *UserMailHandlers) Sent() gin.HandlerFunc {
	if h.svc == nil {
		return handlers.NotImplemented("userMailSent")
	}
	return func(c *gin.Context) {
		userID, ok := userid.FromContext(c.Request.Context())
		if !ok {
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
			return
		}
		gameID, ok := parseGameIDParam(c)
		if !ok {
			return
		}
		ctx := c.Request.Context()
		items, err := h.svc.ListSent(ctx, gameID, userID)
		if err != nil {
			respondDiplomailError(c, h.logger, "user mail sent", ctx, err)
			return
		}
		out := userMailSentListWire{Items: make([]userMailSentSummaryWire, 0, len(items))}
		for _, m := range items {
			out.Items = append(out.Items, mailMessageSummaryToWire(m))
		}
		c.JSON(http.StatusOK, out)
	}
}

// MarkRead handles POST /api/v1/user/games/{game_id}/mail/messages/{message_id}/read.
func (h *UserMailHandlers) MarkRead() gin.HandlerFunc {
	if h.svc == nil {
		return handlers.NotImplemented("userMailMarkRead")
	}
	return func(c *gin.Context) {
		userID, ok := userid.FromContext(c.Request.Context())
		if !ok {
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
			return
		}
		if _, ok := parseGameIDParam(c); !ok {
			return
		}
		messageID, ok := parseMessageIDParam(c)
		if !ok {
			return
		}
		ctx := c.Request.Context()
		rcpt, err := h.svc.MarkRead(ctx, userID, messageID)
		if err != nil {
			respondDiplomailError(c, h.logger, "user mail mark read", ctx, err)
			return
		}
		c.JSON(http.StatusOK, mailRecipientStateToWire(rcpt))
	}
}

// Delete handles DELETE /api/v1/user/games/{game_id}/mail/messages/{message_id}.
func (h *UserMailHandlers) Delete() gin.HandlerFunc {
	if h.svc == nil {
		return handlers.NotImplemented("userMailDelete")
	}
	return func(c *gin.Context) {
		userID, ok := userid.FromContext(c.Request.Context())
		if !ok {
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
			return
		}
		if _, ok := parseGameIDParam(c); !ok {
			return
		}
		messageID, ok := parseMessageIDParam(c)
		if !ok {
			return
		}
		ctx := c.Request.Context()
		rcpt, err := h.svc.DeleteMessage(ctx, userID, messageID)
		if err != nil {
			respondDiplomailError(c, h.logger, "user mail delete", ctx, err)
			return
		}
		c.JSON(http.StatusOK, mailRecipientStateToWire(rcpt))
	}
}

// SendBroadcast handles POST /api/v1/user/games/{game_id}/mail/broadcast.
//
// The endpoint is the paid-tier player broadcast: any player on a
// non-`free` entitlement tier may send one personal message that
// fans out to every other active member of the game. The result
// rows carry `kind="personal"`, `sender_kind="player"`,
// `broadcast_scope="game_broadcast"`. Free-tier callers see a 403.
func (h *UserMailHandlers) SendBroadcast() gin.HandlerFunc {
	if h.svc == nil {
		return handlers.NotImplemented("userMailSendBroadcast")
	}
	return func(c *gin.Context) {
		userID, ok := userid.FromContext(c.Request.Context())
		if !ok {
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
			return
		}
		gameID, ok := parseGameIDParam(c)
		if !ok {
			return
		}
		var req userMailSendBroadcastRequestWire
		if err := c.ShouldBindJSON(&req); err != nil {
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
			return
		}
		ctx := c.Request.Context()
		msg, recipients, err := h.svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{
			GameID:       gameID,
			SenderUserID: userID,
			Subject:      req.Subject,
			Body:         req.Body,
			SenderIP:     clientip.ExtractSourceIP(c),
		})
		if err != nil {
			respondDiplomailError(c, h.logger, "user mail send broadcast", ctx, err)
			return
		}
		c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
	}
}

// SendAdmin handles POST /api/v1/user/games/{game_id}/mail/admin.
//
// Owner-only: the caller must be the owner of the private game. The
// handler resolves the owner's `user_name` so the
// `sender_username` column carries a useful identity, then routes to
// SendAdminPersonal (for `target="user"`) or SendAdminBroadcast (for
// `target="all"`). Site administrators use the separate admin route
// in `handlers_admin_mail_send.go`.
func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
	if h.svc == nil || h.lobby == nil || h.users == nil {
		return handlers.NotImplemented("userMailSendAdmin")
	}
	return func(c *gin.Context) {
		userID, ok := userid.FromContext(c.Request.Context())
		if !ok {
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
			return
		}
		gameID, ok := parseGameIDParam(c)
		if !ok {
			return
		}
		var req userMailSendAdminRequestWire
		if err := c.ShouldBindJSON(&req); err != nil {
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
			return
		}
		ctx := c.Request.Context()

		game, err := h.lobby.GetGame(ctx, gameID)
		if err != nil {
			respondLobbyError(c, h.logger, "user mail send admin: load game", ctx, err)
			return
		}
		if game.OwnerUserID == nil || *game.OwnerUserID != userID {
			httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "caller is not the owner of this game")
			return
		}
		account, err := h.users.GetAccount(ctx, userID)
		if err != nil {
			respondAccountError(c, h.logger, "user mail send admin: resolve user_name", ctx, err)
			return
		}

		switch req.Target {
		case "", "user":
			recipientID, parseErr := uuid.Parse(req.RecipientUserID)
			if parseErr != nil {
				httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
				return
			}
			callerUserID := userID
			msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
				GameID:          gameID,
				CallerKind:      diplomail.CallerKindOwner,
				CallerUserID:    &callerUserID,
				CallerUsername:  account.UserName,
				RecipientUserID: recipientID,
				Subject:         req.Subject,
				Body:            req.Body,
				SenderIP:        clientip.ExtractSourceIP(c),
			})
			if sendErr != nil {
				respondDiplomailError(c, h.logger, "user mail send admin personal", ctx, sendErr)
				return
			}
			c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
		case "all":
			callerUserID := userID
			msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
				GameID:         gameID,
				CallerKind:     diplomail.CallerKindOwner,
				CallerUserID:   &callerUserID,
				CallerUsername: account.UserName,
				RecipientScope: req.Recipients,
				Subject:        req.Subject,
				Body:           req.Body,
				SenderIP:       clientip.ExtractSourceIP(c),
			})
			if sendErr != nil {
				respondDiplomailError(c, h.logger, "user mail send admin broadcast", ctx, sendErr)
				return
			}
			c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
		default:
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'")
		}
	}
}

// UnreadCounts handles GET /api/v1/user/lobby/mail/unread-counts.
func (h *UserMailHandlers) UnreadCounts() gin.HandlerFunc {
	if h.svc == nil {
		return handlers.NotImplemented("userMailUnreadCounts")
	}
	return func(c *gin.Context) {
		userID, ok := userid.FromContext(c.Request.Context())
		if !ok {
			httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
			return
		}
		ctx := c.Request.Context()
		items, err := h.svc.UnreadCountsForUser(ctx, userID)
		if err != nil {
			respondDiplomailError(c, h.logger, "user mail unread counts", ctx, err)
			return
		}
		out := userMailUnreadCountsResponseWire{Items: make([]userMailUnreadCountWire, 0, len(items))}
		total := 0
		for _, u := range items {
			out.Items = append(out.Items, userMailUnreadCountWire{
				GameID:   u.GameID.String(),
				GameName: u.GameName,
				Unread:   u.Unread,
			})
			total += u.Unread
		}
		out.Total = total
		c.JSON(http.StatusOK, out)
	}
}

// respondDiplomailError maps diplomail-package sentinels to the
// standard JSON error envelope. Unknown errors land on a 500.
func respondDiplomailError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
	switch {
	case errors.Is(err, diplomail.ErrInvalidInput):
		httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
	case errors.Is(err, diplomail.ErrNotFound):
		httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found")
	case errors.Is(err, diplomail.ErrForbidden):
		httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, err.Error())
	case errors.Is(err, diplomail.ErrConflict):
		httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
	default:
		logger.Error(op+" failed",
			append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
		)
		httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
	}
}

// parseMessageIDParam reads `message_id` from the path. Writes a 400
// envelope on invalid input and returns false in that case.
func parseMessageIDParam(c *gin.Context) (uuid.UUID, bool) {
	parsed, err := uuid.Parse(c.Param("message_id"))
	if err != nil {
		httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "message_id must be a valid UUID")
		return uuid.Nil, false
	}
	return parsed, true
}

// userMailSendRequestWire mirrors the request body for SendPersonal.
type userMailSendRequestWire struct {
	RecipientUserID string `json:"recipient_user_id"`
	Subject         string `json:"subject,omitempty"`
	Body            string `json:"body"`
}

// userMailSendBroadcastRequestWire mirrors the request body for the
// paid-tier player broadcast. There is no `target` discriminator —
// the recipient set is always "every other active member".
type userMailSendBroadcastRequestWire struct {
	Subject string `json:"subject,omitempty"`
	Body    string `json:"body"`
}

// userMailSendAdminRequestWire mirrors the request body for the
// owner-only admin send. `target="user"` requires
// `recipient_user_id`; `target="all"` accepts the optional
// `recipients` scope (default `active`).
type userMailSendAdminRequestWire struct {
	Target          string `json:"target"`
	RecipientUserID string `json:"recipient_user_id,omitempty"`
	Recipients      string `json:"recipients,omitempty"`
	Subject         string `json:"subject,omitempty"`
	Body            string `json:"body"`
}

// userMailBroadcastReceiptWire is the response shape returned after a
// successful broadcast. It carries the canonical message metadata
// together with the count of materialised recipient rows so the
// caller (UI, admin tool) can confirm the fan-out happened.
type userMailBroadcastReceiptWire struct {
	MessageID      string `json:"message_id"`
	GameID         string `json:"game_id"`
	GameName       string `json:"game_name,omitempty"`
	Kind           string `json:"kind"`
	SenderKind     string `json:"sender_kind"`
	Subject        string `json:"subject,omitempty"`
	Body           string `json:"body"`
	BodyLang       string `json:"body_lang"`
	BroadcastScope string `json:"broadcast_scope"`
	CreatedAt      string `json:"created_at"`
	RecipientCount int    `json:"recipient_count"`
}

func mailBroadcastReceiptToWire(m diplomail.Message, recipients []diplomail.Recipient) userMailBroadcastReceiptWire {
	return userMailBroadcastReceiptWire{
		MessageID:      m.MessageID.String(),
		GameID:         m.GameID.String(),
		GameName:       m.GameName,
		Kind:           m.Kind,
		SenderKind:     m.SenderKind,
		Subject:        m.Subject,
		Body:           m.Body,
		BodyLang:       m.BodyLang,
		BroadcastScope: m.BroadcastScope,
		CreatedAt:      m.CreatedAt.UTC().Format(timestampLayout),
		RecipientCount: len(recipients),
	}
}

// userMailMessageDetailWire mirrors the unified response shape for
// inbox listings and per-message reads. Sender identifiers are
// optional: system messages carry neither user id nor username.
// Translation fields are populated when a cached rendering exists
// for the caller's `preferred_language`; the UI renders
// `body_translated` and surfaces the original through a
// "show original" toggle.
type userMailMessageDetailWire struct {
	MessageID         string  `json:"message_id"`
	GameID            string  `json:"game_id"`
	GameName          string  `json:"game_name,omitempty"`
	Kind              string  `json:"kind"`
	SenderKind        string  `json:"sender_kind"`
	SenderUserID      *string `json:"sender_user_id,omitempty"`
	SenderUsername    *string `json:"sender_username,omitempty"`
	Subject           string  `json:"subject,omitempty"`
	Body              string  `json:"body"`
	BodyLang          string  `json:"body_lang"`
	BroadcastScope    string  `json:"broadcast_scope"`
	CreatedAt         string  `json:"created_at"`
	RecipientUserID   string  `json:"recipient_user_id"`
	RecipientUserName string  `json:"recipient_user_name,omitempty"`
	RecipientRaceName *string `json:"recipient_race_name,omitempty"`
	ReadAt            *string `json:"read_at,omitempty"`
	DeletedAt         *string `json:"deleted_at,omitempty"`
	TranslatedSubject *string `json:"translated_subject,omitempty"`
	TranslatedBody    *string `json:"translated_body,omitempty"`
	TranslationLang   *string `json:"translation_lang,omitempty"`
	Translator        *string `json:"translator,omitempty"`
}

// userMailSentSummaryWire mirrors the response shape for the
// sender-side listing. Recipient state is intentionally omitted (one
// author may have N recipients per broadcast in later stages).
type userMailSentSummaryWire struct {
	MessageID      string `json:"message_id"`
	GameID         string `json:"game_id"`
	GameName       string `json:"game_name,omitempty"`
	Kind           string `json:"kind"`
	Subject        string `json:"subject,omitempty"`
	Body           string `json:"body"`
	BodyLang       string `json:"body_lang"`
	BroadcastScope string `json:"broadcast_scope"`
	CreatedAt      string `json:"created_at"`
}

type userMailInboxListWire struct {
	Items []userMailMessageDetailWire `json:"items"`
}

type userMailSentListWire struct {
	Items []userMailSentSummaryWire `json:"items"`
}

type userMailUnreadCountWire struct {
	GameID   string `json:"game_id"`
	GameName string `json:"game_name,omitempty"`
	Unread   int    `json:"unread"`
}

type userMailUnreadCountsResponseWire struct {
	Total int                       `json:"total"`
	Items []userMailUnreadCountWire `json:"items"`
}

func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userMailMessageDetailWire {
	out := userMailMessageDetailWire{
		MessageID:         entry.MessageID.String(),
		GameID:            entry.GameID.String(),
		GameName:          entry.GameName,
		Kind:              entry.Kind,
		SenderKind:        entry.SenderKind,
		Subject:           entry.Subject,
		Body:              entry.Body,
		BodyLang:          entry.BodyLang,
		BroadcastScope:    entry.BroadcastScope,
		CreatedAt:         entry.CreatedAt.UTC().Format(timestampLayout),
		RecipientUserID:   entry.Recipient.UserID.String(),
		RecipientUserName: entry.Recipient.RecipientUserName,
	}
	if entry.SenderUserID != nil {
		s := entry.SenderUserID.String()
		out.SenderUserID = &s
	}
	if entry.SenderUsername != nil {
		s := *entry.SenderUsername
		out.SenderUsername = &s
	}
	if entry.Recipient.RecipientRaceName != nil {
		s := *entry.Recipient.RecipientRaceName
		out.RecipientRaceName = &s
	}
	if entry.Recipient.ReadAt != nil {
		s := entry.Recipient.ReadAt.UTC().Format(timestampLayout)
		out.ReadAt = &s
	}
	if entry.Recipient.DeletedAt != nil {
		s := entry.Recipient.DeletedAt.UTC().Format(timestampLayout)
		out.DeletedAt = &s
	}
	if entry.Translation != nil {
		tr := entry.Translation
		subj := tr.TranslatedSubject
		body := tr.TranslatedBody
		lang := tr.TargetLang
		engine := tr.Translator
		out.TranslatedSubject = &subj
		out.TranslatedBody = &body
		out.TranslationLang = &lang
		out.Translator = &engine
	}
	_ = justCreated
	return out
}

func mailMessageSummaryToWire(m diplomail.Message) userMailSentSummaryWire {
	return userMailSentSummaryWire{
		MessageID:      m.MessageID.String(),
		GameID:         m.GameID.String(),
		GameName:       m.GameName,
		Kind:           m.Kind,
		Subject:        m.Subject,
		Body:           m.Body,
		BodyLang:       m.BodyLang,
		BroadcastScope: m.BroadcastScope,
		CreatedAt:      m.CreatedAt.UTC().Format(timestampLayout),
	}
}

// mailRecipientStateToWire renders the recipient row after a
// mark-read or soft-delete call. The caller only needs the per-user
// state, not the full message body again.
func mailRecipientStateToWire(r diplomail.Recipient) userMailRecipientStateWire {
	out := userMailRecipientStateWire{
		MessageID: r.MessageID.String(),
	}
	if r.ReadAt != nil {
		s := r.ReadAt.UTC().Format(timestampLayout)
		out.ReadAt = &s
	}
	if r.DeletedAt != nil {
		s := r.DeletedAt.UTC().Format(timestampLayout)
		out.DeletedAt = &s
	}
	return out
}

type userMailRecipientStateWire struct {
	MessageID string  `json:"message_id"`
	ReadAt    *string `json:"read_at,omitempty"`
	DeletedAt *string `json:"deleted_at,omitempty"`
}
