diff --git a/pkg/routes/api/v2/task_unread_status.go b/pkg/routes/api/v2/task_unread_status.go
new file mode 100644
index 000000000..f7a944092
--- /dev/null
+++ b/pkg/routes/api/v2/task_unread_status.go
@@ -0,0 +1,73 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// taskReadBody confirms the mark-read action: the underlying model carries no
+// JSON-exposed fields, so it returns a status message rather than a resource.
+type taskReadBody struct {
+ Body struct {
+ Message string `json:"message" readOnly:"true" doc:"A confirmation message."`
+ }
+}
+
+// RegisterTaskUnreadStatusRoutes wires the mark-task-as-read action onto the Huma API.
+//
+// Marking a task read clears the caller's unread entry for it, which is what
+// drives the per-task "unread" dot shown for mentions and other notifications.
+// The model's Update deletes that entry, so the action is idempotent — PUT, not
+// POST. It is also unconditional: there is no read entry to clear for a task the
+// caller cannot see, so it succeeds as a no-op rather than refusing.
+func RegisterTaskUnreadStatusRoutes(api huma.API) {
+ tags := []string{"tasks"}
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-mark-read",
+ Summary: "Mark a task as read",
+ Description: "Clears the authenticated user's unread status for a task, dismissing the unread indicator raised by mentions and other task notifications. Idempotent: marking an already-read or inaccessible task succeeds as a no-op.",
+ Method: http.MethodPut,
+ Path: "/tasks/{projecttask}/read",
+ Tags: tags,
+ }, tasksMarkRead)
+}
+
+func init() { AddRouteRegistrar(RegisterTaskUnreadStatusRoutes) }
+
+func tasksMarkRead(ctx context.Context, in *struct {
+ TaskID int64 `path:"projecttask" doc:"The numeric id of the task to mark as read."`
+}) (*taskReadBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ t := &models.TaskUnreadStatus{TaskID: in.TaskID}
+ if err := handler.DoUpdate(ctx, t, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ out := &taskReadBody{}
+ out.Body.Message = "success"
+ return out, nil
+}
diff --git a/pkg/webtests/huma_task_unread_status_test.go b/pkg/webtests/huma_task_unread_status_test.go
new file mode 100644
index 000000000..9ea27544d
--- /dev/null
+++ b/pkg/webtests/huma_task_unread_status_test.go
@@ -0,0 +1,88 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package webtests
+
+import (
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHumaTaskUnreadStatus ports v1's POST /tasks/:projecttask/read (no v1
+// webtest exists). The action deletes the caller's unread entry for the task;
+// there is no fixture file for task_unread_statuses, so the table starts empty
+// and the test seeds the row it expects to clear.
+//
+// Note on the permission model: the v1 handler enforces nothing — CanUpdate is
+// a hardcoded true and Update is an unconditional DELETE on (task_id, user_id).
+// A task the caller can't see (or doesn't exist) therefore has no row to clear
+// and the call succeeds as a no-op. The only thing actually gated is auth, so
+// that is what the negative case covers.
+func TestHumaTaskUnreadStatus(t *testing.T) {
+ t.Run("Normal - clears the caller's unread entry", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ s := db.NewSession()
+ _, err = s.Insert(&models.TaskUnreadStatus{TaskID: 1, UserID: testuser1.ID})
+ require.NoError(t, err)
+ require.NoError(t, s.Commit())
+ require.NoError(t, s.Close())
+
+ token := humaTokenFor(t, &testuser1)
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"message":"success"`)
+
+ db.AssertMissing(t, "task_unread_statuses", map[string]interface{}{
+ "task_id": 1,
+ "user_id": testuser1.ID,
+ })
+ })
+
+ t.Run("No-op - already read, no entry to clear", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ token := humaTokenFor(t, &testuser1)
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"message":"success"`)
+ })
+
+ t.Run("No-op - nonexistent task (unenforced, mirrors v1)", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ token := humaTokenFor(t, &testuser1)
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/99999/read", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Anonymous request is rejected with 401", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "anonymous must get 401; body: %s", rec.Body.String())
+ })
+}