diff --git a/api/hqbase-mail-api-v1.openapi.json b/api/hqbase-mail-api-v1.openapi.json index 4db4fb62..fdb83946 100644 --- a/api/hqbase-mail-api-v1.openapi.json +++ b/api/hqbase-mail-api-v1.openapi.json @@ -307,6 +307,16 @@ "default": 100 }, "description": "Maximum number of journal entries read for this page." + }, + { + "name": "includeLabels", + "in": "query", + "required": false, + "description": "Include label membership. Does not filter results or change the cursor.", + "schema": { + "type": "boolean", + "default": false + } } ] } @@ -527,6 +537,16 @@ "maxLength": 512 }, "description": "Opaque cursor from the previous page. Read it from the Link header and send it unchanged." + }, + { + "name": "includeLabels", + "in": "query", + "required": false, + "description": "Include label membership. Does not filter results or change the cursor.", + "schema": { + "type": "boolean", + "default": false + } } ] } @@ -616,6 +636,16 @@ "minLength": 1, "maxLength": 100 } + }, + { + "name": "includeLabels", + "in": "query", + "required": false, + "description": "Include label membership. Does not filter results or change the cursor.", + "schema": { + "type": "boolean", + "default": false + } } ] } @@ -708,6 +738,16 @@ "minLength": 1, "maxLength": 100 } + }, + { + "name": "includeLabels", + "in": "query", + "required": false, + "description": "Include label membership. Does not filter results or change the cursor.", + "schema": { + "type": "boolean", + "default": false + } } ] } @@ -1203,6 +1243,16 @@ "restore" ] } + }, + { + "name": "includeLabels", + "in": "query", + "required": false, + "description": "Include label membership in the updated message.", + "schema": { + "type": "boolean", + "default": false + } } ] } @@ -1482,6 +1532,16 @@ "maxLength": 512 }, "description": "Opaque cursor from the previous page." + }, + { + "name": "includeLabels", + "in": "query", + "required": false, + "description": "Include label membership. Does not filter results or change the cursor.", + "schema": { + "type": "boolean", + "default": false + } } ] } @@ -3091,6 +3151,91 @@ } } ] + }, + "post": { + "summary": "Create a signature", + "operationId": "createSignature", + "tags": ["Signatures"], + "security": [ + { + "oauth2": ["signatures:manage"] + }, + { + "cookieSession": [] + } + ], + "responses": { + "201": { + "description": "Create a signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Signature" + } + } + } + }, + "400": { + "description": "Invalid signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or signature management access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Signature or target not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Signature name already exists in this scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSignatureInput" + } + } + } + } } }, "/api/v1/drafts/{id}/labels/{labelId}": { @@ -3230,103 +3375,359 @@ } ] } - } - }, - "components": { - "securitySchemes": { - "oauth2": { - "type": "oauth2", - "description": "Audience-bound OAuth token. Request resource={origin}/api/v1.", - "flows": { - "authorizationCode": { - "authorizationUrl": "/api/auth/oauth2/authorize", - "tokenUrl": "/api/auth/oauth2/token", - "refreshUrl": "/api/auth/oauth2/token", - "scopes": { - "mail:read": "Read accessible mail", - "mail:write": "Organize accessible mail", - "mail:send": "Manage drafts and send mail", - "offline_access": "Request a refresh token" - } - } - } - }, - "cookieSession": { - "type": "apiKey", - "in": "cookie", - "name": "better-auth.session_token", - "description": "Same-origin HQBase web session. Secure deployments may prefix the cookie name." - } }, - "schemas": { - "Error": { - "type": "object", - "required": ["error"], - "properties": { - "error": { - "type": "object", - "required": ["code", "message"], - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - } - } + "/api/v1/signatures/manage": { + "get": { + "summary": "List manageable signatures", + "operationId": "listManageableSignatures", + "tags": ["Signatures"], + "security": [ + { + "oauth2": ["signatures:manage"] + }, + { + "cookieSession": [] } - } - }, - "MailboxAddress": { - "type": "object", - "required": [ - "id", - "mailboxId", - "mailDomainId", - "address", - "displayName", - "receiveEnabled", - "sendEnabled", - "isPrimary" ], - "properties": { - "id": { - "type": "string" - }, - "mailboxId": { - "type": "string" - }, - "mailDomainId": { - "type": "string" + "responses": { + "200": { + "description": "List manageable signatures", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Signature" + } + } + } + } }, - "address": { - "type": "string", - "format": "email" + "400": { + "description": "Invalid signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "displayName": { - "type": "string", - "description": "Shared sender name used with the mailbox address on future outbound messages." + "401": { + "description": "Missing or invalid authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "receiveEnabled": { - "type": "boolean" + "403": { + "description": "Insufficient scope or signature management access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "sendEnabled": { - "type": "boolean" + "404": { + "description": "Signature or target not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "isPrimary": { - "type": "boolean" + "409": { + "description": "Signature name already exists in this scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } - }, - "Mailbox": { - "type": "object", - "required": [ - "id", - "address", - "addresses", - "displayName", - "isActive", - "accessLevel", + } + }, + "/api/v1/signatures/{id}": { + "patch": { + "summary": "Update a signature", + "operationId": "updateSignature", + "tags": ["Signatures"], + "security": [ + { + "oauth2": ["signatures:manage"] + }, + { + "cookieSession": [] + } + ], + "responses": { + "200": { + "description": "Update a signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Signature" + } + } + } + }, + "400": { + "description": "Invalid signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or signature management access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Signature or target not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Signature name already exists in this scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSignatureInput" + } + } + } + } + }, + "delete": { + "summary": "Delete a signature", + "operationId": "deleteSignature", + "tags": ["Signatures"], + "security": [ + { + "oauth2": ["signatures:manage"] + }, + { + "cookieSession": [] + } + ], + "responses": { + "204": { + "description": "Delete a signature" + }, + "400": { + "description": "Invalid signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or signature management access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Signature or target not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Signature name already exists in this scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + } + }, + "components": { + "securitySchemes": { + "oauth2": { + "type": "oauth2", + "description": "Audience-bound OAuth token. Request resource={origin}/api/v1.", + "flows": { + "authorizationCode": { + "authorizationUrl": "/api/auth/oauth2/authorize", + "tokenUrl": "/api/auth/oauth2/token", + "refreshUrl": "/api/auth/oauth2/token", + "scopes": { + "mail:read": "Read accessible mail", + "mail:write": "Organize accessible mail", + "mail:send": "Manage drafts and send mail", + "offline_access": "Request a refresh token", + "signatures:manage": "Manage signatures within the person's current access" + } + } + } + }, + "cookieSession": { + "type": "apiKey", + "in": "cookie", + "name": "better-auth.session_token", + "description": "Same-origin HQBase web session. Secure deployments may prefix the cookie name." + } + }, + "schemas": { + "Error": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + }, + "MailboxAddress": { + "type": "object", + "required": [ + "id", + "mailboxId", + "mailDomainId", + "address", + "displayName", + "receiveEnabled", + "sendEnabled", + "isPrimary" + ], + "properties": { + "id": { + "type": "string" + }, + "mailboxId": { + "type": "string" + }, + "mailDomainId": { + "type": "string" + }, + "address": { + "type": "string", + "format": "email" + }, + "displayName": { + "type": "string", + "description": "Shared sender name used with the mailbox address on future outbound messages." + }, + "receiveEnabled": { + "type": "boolean" + }, + "sendEnabled": { + "type": "boolean" + }, + "isPrimary": { + "type": "boolean" + } + } + }, + "Mailbox": { + "type": "object", + "required": [ + "id", + "address", + "addresses", + "displayName", + "isActive", + "accessLevel", "createdAt", "updatedAt" ], @@ -3566,6 +3967,13 @@ "createdAt": { "type": "string", "format": "date-time" + }, + "labels": { + "type": "array", + "description": "Included only when includeLabels=true.", + "items": { + "$ref": "#/components/schemas/Label" + } } } }, @@ -3940,96 +4348,24 @@ } }, "DraftInput": { - "type": "object", - "required": [ - "mailboxId", - "replyToMessageId", - "forwardOfMessageId", - "from", - "to", - "cc", - "bcc", - "subject", - "text", - "html" - ], - "properties": { - "mailboxId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "replyToMessageId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "allOf": [ + { + "$ref": "#/components/schemas/DraftFields" }, - "forwardOfMessageId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + { + "type": "object", + "properties": { + "signature": { + "$ref": "#/components/schemas/SignatureSelection" } - ] - }, - "from": { - "type": "string" - }, - "to": { - "type": "array", - "items": { - "type": "string" - } - }, - "cc": { - "type": "array", - "items": { - "type": "string" - } - }, - "bcc": { - "type": "array", - "items": { - "type": "string" } - }, - "subject": { - "type": "string", - "maxLength": 200 - }, - "text": { - "type": "string", - "maxLength": 100000 - }, - "html": { - "type": "string", - "maxLength": 200000 - }, - "version": { - "type": "integer", - "minimum": 1 - }, - "signature": { - "$ref": "#/components/schemas/SignatureSelection" } - } + ] }, "Draft": { "allOf": [ { - "$ref": "#/components/schemas/DraftInput" + "$ref": "#/components/schemas/DraftFields" }, { "type": "object", @@ -4372,7 +4708,15 @@ "enum": ["automatic", "selected", "none"] }, "id": { - "type": ["string", "null"] + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Null when no signature was selected or the selected signature was deleted. Saved snapshot content remains available." }, "name": { "type": "string" @@ -4440,7 +4784,14 @@ "required": ["automaticSignatureId", "signatures"], "properties": { "automaticSignatureId": { - "type": ["string", "null"] + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "signatures": { "type": "array", @@ -4449,6 +4800,152 @@ } } } + }, + "CreateSignatureInput": { + "type": "object", + "required": ["name", "html", "scope"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "html": { + "type": "string", + "maxLength": 400000 + }, + "scope": { + "type": "object", + "required": ["type", "id"], + "properties": { + "type": { + "type": "string", + "enum": ["user", "mailbox", "domain"] + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + } + }, + "isDefault": { + "type": "boolean", + "default": false + } + } + }, + "UpdateSignatureInput": { + "type": "object", + "anyOf": [ + { + "required": ["name"] + }, + { + "required": ["html"] + }, + { + "required": ["isDefault"] + } + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "html": { + "type": "string", + "maxLength": 400000 + }, + "isDefault": { + "type": "boolean" + } + } + }, + "DraftFields": { + "type": "object", + "required": [ + "mailboxId", + "replyToMessageId", + "forwardOfMessageId", + "from", + "to", + "cc", + "bcc", + "subject", + "text", + "html" + ], + "properties": { + "mailboxId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "replyToMessageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "forwardOfMessageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "from": { + "type": "string" + }, + "to": { + "type": "array", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string", + "maxLength": 200 + }, + "text": { + "type": "string", + "maxLength": 100000 + }, + "html": { + "type": "string", + "maxLength": 200000 + }, + "version": { + "type": "integer", + "minimum": 1 + } + } } } } diff --git a/api/hqbase-mail-api-v1.postman_collection.json b/api/hqbase-mail-api-v1.postman_collection.json index 344745aa..7c74cc20 100644 --- a/api/hqbase-mail-api-v1.postman_collection.json +++ b/api/hqbase-mail-api-v1.postman_collection.json @@ -188,6 +188,12 @@ "value": "", "disabled": true, "description": "Maximum number of journal entries read for this page." + }, + { + "key": "includeLabels", + "value": "", + "disabled": true, + "description": "Include label membership. Does not filter results or change the cursor." } ] }, @@ -400,6 +406,12 @@ "value": "", "disabled": true, "description": "Opaque cursor from the previous page. Read it from the Link header and send it unchanged." + }, + { + "key": "includeLabels", + "value": "", + "disabled": true, + "description": "Include label membership. Does not filter results or change the cursor." } ] }, @@ -419,6 +431,14 @@ "key": "id", "value": "{{id}}" } + ], + "query": [ + { + "key": "includeLabels", + "value": "", + "disabled": true, + "description": "Include label membership. Does not filter results or change the cursor." + } ] }, "description": "Get a message" @@ -437,6 +457,14 @@ "key": "id", "value": "{{id}}" } + ], + "query": [ + { + "key": "includeLabels", + "value": "", + "disabled": true, + "description": "Include label membership. Does not filter results or change the cursor." + } ] }, "description": "Get a message thread" @@ -543,6 +571,14 @@ "key": "action", "value": "{{action}}" } + ], + "query": [ + { + "key": "includeLabels", + "value": "", + "disabled": true, + "description": "Include label membership in the updated message." + } ] }, "description": "Update message state" @@ -598,6 +634,12 @@ "value": "", "disabled": true, "description": "Opaque cursor from the previous page." + }, + { + "key": "includeLabels", + "value": "", + "disabled": true, + "description": "Include label membership. Does not filter results or change the cursor." } ] }, @@ -1003,6 +1045,96 @@ "description": "Returns only personal, mailbox, and exact-domain signatures usable from the exact address." }, "response": [] + }, + { + "name": "Create a signature", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/signatures", + "variable": [] + }, + "description": "Create a signature", + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "List manageable signatures", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/signatures/manage", + "variable": [] + }, + "description": "List manageable signatures" + }, + "response": [] + }, + { + "name": "Update a signature", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/signatures/:id", + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Update a signature", + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Delete a signature", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/signatures/:id", + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Delete a signature" + }, + "response": [] } ] } diff --git a/api/hqbase-mail-api-v2.openapi.json b/api/hqbase-mail-api-v2.openapi.json index d5b49058..4b4a5fde 100644 --- a/api/hqbase-mail-api-v2.openapi.json +++ b/api/hqbase-mail-api-v2.openapi.json @@ -3211,6 +3211,91 @@ } ], "x-hqbase-agent-capabilities": ["mail:send"] + }, + "post": { + "summary": "Create a signature", + "operationId": "createSignature", + "tags": ["Signatures"], + "security": [ + { + "oauth2": ["signatures:manage"] + }, + { + "cookieSession": [] + } + ], + "responses": { + "201": { + "description": "Create a signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Signature" + } + } + } + }, + "400": { + "description": "Invalid signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or signature management access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Signature or target not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Signature name already exists in this scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSignatureInput" + } + } + } + } } }, "/api/v2/drafts/{id}/labels/{labelId}": { @@ -3332,31 +3417,286 @@ } } }, - "tags": ["Labels"], - "operationId": "removeDraftLabel", + "tags": ["Labels"], + "operationId": "removeDraftLabel", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + { + "in": "path", + "name": "labelId", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + } + ], + "x-hqbase-agent-capabilities": ["mail:send"] + } + }, + "/api/v2/signatures/manage": { + "get": { + "summary": "List manageable signatures", + "operationId": "listManageableSignatures", + "tags": ["Signatures"], + "security": [ + { + "oauth2": ["signatures:manage"] + }, + { + "cookieSession": [] + } + ], + "responses": { + "200": { + "description": "List manageable signatures", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Signature" + } + } + } + } + }, + "400": { + "description": "Invalid signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or signature management access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Signature or target not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Signature name already exists in this scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v2/signatures/{id}": { + "patch": { + "summary": "Update a signature", + "operationId": "updateSignature", + "tags": ["Signatures"], + "security": [ + { + "oauth2": ["signatures:manage"] + }, + { + "cookieSession": [] + } + ], + "responses": { + "200": { + "description": "Update a signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Signature" + } + } + } + }, + "400": { + "description": "Invalid signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or signature management access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Signature or target not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Signature name already exists in this scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSignatureInput" + } + } + } + } + }, + "delete": { + "summary": "Delete a signature", + "operationId": "deleteSignature", + "tags": ["Signatures"], + "security": [ + { + "oauth2": ["signatures:manage"] + }, + { + "cookieSession": [] + } + ], + "responses": { + "204": { + "description": "Delete a signature" + }, + "400": { + "description": "Invalid signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or signature management access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Signature or target not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Signature name already exists in this scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, "parameters": [ { - "in": "path", "name": "id", - "required": true, - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 100 - } - }, - { "in": "path", - "name": "labelId", "required": true, "schema": { - "type": "string", - "minLength": 1, - "maxLength": 100 + "type": "string" } } - ], - "x-hqbase-agent-capabilities": ["mail:send"] + ] } } }, @@ -3374,7 +3714,8 @@ "mail:read": "Read accessible mail", "mail:write": "Organize accessible mail", "mail:send": "Manage drafts and send mail", - "offline_access": "Request a refresh token" + "offline_access": "Request a refresh token", + "signatures:manage": "Manage signatures within the person's current access" } } } @@ -4055,96 +4396,24 @@ } }, "DraftInput": { - "type": "object", - "required": [ - "mailboxId", - "replyToMessageId", - "forwardOfMessageId", - "from", - "to", - "cc", - "bcc", - "subject", - "text", - "html" - ], - "properties": { - "mailboxId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "replyToMessageId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "allOf": [ + { + "$ref": "#/components/schemas/DraftFields" }, - "forwardOfMessageId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + { + "type": "object", + "properties": { + "signature": { + "$ref": "#/components/schemas/SignatureSelection" } - ] - }, - "from": { - "type": "string" - }, - "to": { - "type": "array", - "items": { - "type": "string" - } - }, - "cc": { - "type": "array", - "items": { - "type": "string" - } - }, - "bcc": { - "type": "array", - "items": { - "type": "string" } - }, - "subject": { - "type": "string", - "maxLength": 200 - }, - "text": { - "type": "string", - "maxLength": 100000 - }, - "html": { - "type": "string", - "maxLength": 200000 - }, - "version": { - "type": "integer", - "minimum": 1 - }, - "signature": { - "$ref": "#/components/schemas/SignatureSelection" } - } + ] }, "Draft": { "allOf": [ { - "$ref": "#/components/schemas/DraftInput" + "$ref": "#/components/schemas/DraftFields" }, { "type": "object", @@ -4487,7 +4756,15 @@ "enum": ["automatic", "selected", "none"] }, "id": { - "type": ["string", "null"] + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Null when no signature was selected or the selected signature was deleted. Saved snapshot content remains available." }, "name": { "type": "string" @@ -4555,7 +4832,14 @@ "required": ["automaticSignatureId", "signatures"], "properties": { "automaticSignatureId": { - "type": ["string", "null"] + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "signatures": { "type": "array", @@ -4564,6 +4848,152 @@ } } } + }, + "CreateSignatureInput": { + "type": "object", + "required": ["name", "html", "scope"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "html": { + "type": "string", + "maxLength": 400000 + }, + "scope": { + "type": "object", + "required": ["type", "id"], + "properties": { + "type": { + "type": "string", + "enum": ["user", "mailbox", "domain"] + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + } + }, + "isDefault": { + "type": "boolean", + "default": false + } + } + }, + "UpdateSignatureInput": { + "type": "object", + "anyOf": [ + { + "required": ["name"] + }, + { + "required": ["html"] + }, + { + "required": ["isDefault"] + } + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "html": { + "type": "string", + "maxLength": 400000 + }, + "isDefault": { + "type": "boolean" + } + } + }, + "DraftFields": { + "type": "object", + "required": [ + "mailboxId", + "replyToMessageId", + "forwardOfMessageId", + "from", + "to", + "cc", + "bcc", + "subject", + "text", + "html" + ], + "properties": { + "mailboxId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "replyToMessageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "forwardOfMessageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "from": { + "type": "string" + }, + "to": { + "type": "array", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string", + "maxLength": 200 + }, + "text": { + "type": "string", + "maxLength": 100000 + }, + "html": { + "type": "string", + "maxLength": 200000 + }, + "version": { + "type": "integer", + "minimum": 1 + } + } } } } diff --git a/api/hqbase-mail-api-v2.postman_collection.json b/api/hqbase-mail-api-v2.postman_collection.json index fa627b21..154002cd 100644 --- a/api/hqbase-mail-api-v2.postman_collection.json +++ b/api/hqbase-mail-api-v2.postman_collection.json @@ -1003,6 +1003,96 @@ "description": "Returns only personal, mailbox, and exact-domain signatures usable from the exact address." }, "response": [] + }, + { + "name": "Create a signature", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/api/v2/signatures", + "variable": [] + }, + "description": "Create a signature", + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "List manageable signatures", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v2/signatures/manage", + "variable": [] + }, + "description": "List manageable signatures" + }, + "response": [] + }, + { + "name": "Update a signature", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/api/v2/signatures/:id", + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Update a signature", + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Delete a signature", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}/api/v2/signatures/:id", + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Delete a signature" + }, + "response": [] } ] } diff --git a/app/features/agents/connections-table.tsx b/app/features/agents/connections-table.tsx index 96081ab7..11d45998 100644 --- a/app/features/agents/connections-table.tsx +++ b/app/features/agents/connections-table.tsx @@ -323,9 +323,11 @@ function StatusBadge({ status }: { status: string }): React.ReactElement { } export function oauthAccessLabel(scopes: readonly string[]): string { - return scopes.includes("mail:write") || scopes.includes("mail:send") - ? "Handle mail" - : "Read only"; + const handleMail = scopes.includes("mail:write") || scopes.includes("mail:send"); + if (scopes.includes("signatures:manage")) { + return handleMail ? "Handle mail, manage signatures" : "Manage signatures"; + } + return handleMail ? "Handle mail" : "Read only"; } export function connectionLabel(resources: readonly string[]): string { diff --git a/app/features/auth/device-authorization-view.tsx b/app/features/auth/device-authorization-view.tsx index b6cc4668..e124b1f2 100644 --- a/app/features/auth/device-authorization-view.tsx +++ b/app/features/auth/device-authorization-view.tsx @@ -10,6 +10,8 @@ const scopeDescriptions: Record = { "mail:write": "Change message and conversation state where you have Handle mail access", "mail:send": "Manage drafts and attachments, then send, reply, or forward where you have Handle mail access", + "signatures:manage": + "Manage personal signatures and shared signatures where you have management access", offline_access: "Stay connected until you revoke access" }; diff --git a/app/features/mcp/consent-page.tsx b/app/features/mcp/consent-page.tsx index 1dc99d94..8ed72e06 100644 --- a/app/features/mcp/consent-page.tsx +++ b/app/features/mcp/consent-page.tsx @@ -16,6 +16,8 @@ const scopeDescriptions: Record = { "mail:write": "Change message and conversation state where you have agent access", "mail:send": "Manage drafts and attachments, then send, reply, or forward where you have agent access", + "signatures:manage": + "Manage personal signatures and shared signatures where you have management access", offline_access: "Stay connected until you revoke access" }; diff --git a/package.json b/package.json index 81cde372..124ee967 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "api:generate": "node scripts/generate-mail-api-artifacts.mjs --write && biome format --write api/hqbase-mail-api-v1.openapi.json api/hqbase-mail-api-v1.postman_collection.json api/hqbase-mail-api-v1.postman_environment.json api/hqbase-mail-api-v2.openapi.json api/hqbase-mail-api-v2.postman_collection.json api/hqbase-mail-api-v2.postman_environment.json", "api:check": "node scripts/generate-mail-api-artifacts.mjs --check", "test:pwa": "node scripts/test-pwa.mjs", - "test:e2e:staging": "playwright test --config playwright.config.ts app-shell-smoke.spec.ts event-socket.spec.ts lifecycle.spec.ts", + "test:e2e:staging": "playwright test --config playwright.config.ts app-shell-smoke.spec.ts event-socket.spec.ts lifecycle.spec.ts native-client-api.spec.ts", "test:e2e:staging:event-socket": "playwright test --config playwright.config.ts event-socket.spec.ts", "test:e2e:staging:smoke": "playwright test --config playwright.config.ts app-shell-smoke.spec.ts", "test:e2e:staging:lifecycle": "playwright test --config playwright.config.ts lifecycle.spec.ts", diff --git a/scripts/generate-mail-api-artifacts.mjs b/scripts/generate-mail-api-artifacts.mjs index 160fad64..f4f67af1 100644 --- a/scripts/generate-mail-api-artifacts.mjs +++ b/scripts/generate-mail-api-artifacts.mjs @@ -118,6 +118,10 @@ function withAgentAuthentication(document) { ); delete operation["x-hqbase-agent-capabilities"]; if (route === "/api/v2/messages/{id}/remote-media/trust") continue; + if ( + operation.security.some((requirement) => requirement.oauth2?.includes("signatures:manage")) + ) + continue; const capabilities = operation.security.find((requirement) => requirement.oauth2)?.oauth2; if (!capabilities) continue; operation.security.push({ agentBearer: [] }); diff --git a/scripts/mail-api-signatures.mjs b/scripts/mail-api-signatures.mjs index 7f3aeac4..4f2790ad 100644 --- a/scripts/mail-api-signatures.mjs +++ b/scripts/mail-api-signatures.mjs @@ -9,7 +9,21 @@ export function withSignatures(document, version) { result.components.schemas ??= {}; Object.assign(result.components.schemas, signatureSchemas()); - for (const name of ["DraftInput", "SendInput", "ReplyInput", "ForwardInput"]) { + const schemas = result.components.schemas; + const fields = structuredClone(schemas.DraftFields ?? schemas.DraftInput); + delete fields.properties.signature; + schemas.DraftFields = fields; + schemas.DraftInput = { + allOf: [ + { $ref: "#/components/schemas/DraftFields" }, + { + type: "object", + properties: { signature: { $ref: "#/components/schemas/SignatureSelection" } } + } + ] + }; + schemas.Draft.allOf[0] = { $ref: "#/components/schemas/DraftFields" }; + for (const name of ["SendInput", "ReplyInput", "ForwardInput"]) { result.components.schemas[name].properties.signature = { $ref: "#/components/schemas/SignatureSelection" }; @@ -51,6 +65,65 @@ export function withSignatures(document, version) { ] } }; + const managementSecurity = [{ oauth2: ["signatures:manage"] }, { cookieSession: [] }]; + result.components.securitySchemes.oauth2.flows.authorizationCode.scopes["signatures:manage"] = + "Manage signatures within the person's current access"; + const managementOperation = (summary, operationId, schema, status = 200) => ({ + summary, + operationId, + tags: ["Signatures"], + security: managementSecurity, + responses: { + [status]: { + description: summary, + ...(schema ? { content: { "application/json": { schema } } } : {}) + }, + 400: errorResponse("Invalid signature"), + 401: errorResponse("Missing or invalid authentication"), + 403: errorResponse("Insufficient scope or signature management access"), + 404: errorResponse("Signature or target not found"), + 409: errorResponse("Signature name already exists in this scope") + } + }); + result.paths[`${apiBasePath}/signatures/manage`] = { + get: managementOperation("List manageable signatures", "listManageableSignatures", { + type: "array", + items: { $ref: "#/components/schemas/Signature" } + }) + }; + result.paths[`${apiBasePath}/signatures`].post = { + ...managementOperation( + "Create a signature", + "createSignature", + { $ref: "#/components/schemas/Signature" }, + 201 + ), + requestBody: { + required: true, + content: { + "application/json": { schema: { $ref: "#/components/schemas/CreateSignatureInput" } } + } + } + }; + const idParameter = { name: "id", in: "path", required: true, schema: { type: "string" } }; + result.paths[`${apiBasePath}/signatures/{id}`] = { + patch: { + ...managementOperation("Update a signature", "updateSignature", { + $ref: "#/components/schemas/Signature" + }), + parameters: [idParameter], + requestBody: { + required: true, + content: { + "application/json": { schema: { $ref: "#/components/schemas/UpdateSignatureInput" } } + } + } + }, + delete: { + ...managementOperation("Delete a signature", "deleteSignature", null, 204), + parameters: [idParameter] + } + }; return result; } @@ -61,6 +134,32 @@ function signatureSchemas() { properties: { mode: { type: "string", const: value } } }); return { + CreateSignatureInput: { + type: "object", + required: ["name", "html", "scope"], + properties: { + name: { type: "string", minLength: 1, maxLength: 200 }, + html: { type: "string", maxLength: 400000 }, + scope: { + type: "object", + required: ["type", "id"], + properties: { + type: { type: "string", enum: ["user", "mailbox", "domain"] }, + id: { type: "string", minLength: 1, maxLength: 100 } + } + }, + isDefault: { type: "boolean", default: false } + } + }, + UpdateSignatureInput: { + type: "object", + anyOf: [{ required: ["name"] }, { required: ["html"] }, { required: ["isDefault"] }], + properties: { + name: { type: "string", minLength: 1, maxLength: 200 }, + html: { type: "string", maxLength: 400000 }, + isDefault: { type: "boolean" } + } + }, SignatureSelection: { oneOf: [ mode("automatic"), @@ -80,7 +179,11 @@ function signatureSchemas() { required: ["mode", "id", "name", "html", "text"], properties: { mode: { type: "string", enum: ["automatic", "selected", "none"] }, - id: { type: ["string", "null"] }, + id: { + anyOf: [{ type: "string" }, { type: "null" }], + description: + "Null when no signature was selected or the selected signature was deleted. Saved snapshot content remains available." + }, name: { type: "string" }, html: { type: "string" }, text: { type: "string" } @@ -117,7 +220,7 @@ function signatureSchemas() { type: "object", required: ["automaticSignatureId", "signatures"], properties: { - automaticSignatureId: { type: ["string", "null"] }, + automaticSignatureId: { anyOf: [{ type: "string" }, { type: "null" }] }, signatures: { type: "array", items: { $ref: "#/components/schemas/Signature" } diff --git a/test/e2e/staging/app-shell-smoke.spec.ts b/test/e2e/staging/app-shell-smoke.spec.ts index 739a6562..a025f623 100644 --- a/test/e2e/staging/app-shell-smoke.spec.ts +++ b/test/e2e/staging/app-shell-smoke.spec.ts @@ -46,7 +46,7 @@ test("deployed HQBase publishes the v2 Mail API OAuth resource", async ({ reques expect(JSON.parse(metadata)).toMatchObject({ resource: `${origin}/api/v2`, authorization_servers: [`${origin}/api/auth`], - scopes_supported: ["mail:read", "mail:write", "mail:send"] + scopes_supported: ["mail:read", "mail:write", "mail:send", "signatures:manage"] }); const v1Metadata = await getSuccessfulResponseBody( @@ -56,7 +56,7 @@ test("deployed HQBase publishes the v2 Mail API OAuth resource", async ({ reques expect(JSON.parse(v1Metadata)).toMatchObject({ resource: `${origin}/api/v1`, authorization_servers: [`${origin}/api/auth`], - scopes_supported: ["mail:read", "mail:write", "mail:send"] + scopes_supported: ["mail:read", "mail:write", "mail:send", "signatures:manage"] }); const v1OpenApi = await getSuccessfulResponseBody(request, "/api/v1/openapi.json"); diff --git a/test/e2e/staging/native-client-api.spec.ts b/test/e2e/staging/native-client-api.spec.ts new file mode 100644 index 00000000..32135f9b --- /dev/null +++ b/test/e2e/staging/native-client-api.spec.ts @@ -0,0 +1,143 @@ +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; +import { expect, test } from "@playwright/test"; +import { ensureStagingSetup } from "./setup"; + +test("a native v1 client manages signatures and refreshes after browser sign-out", async ({ + request +}) => { + await ensureStagingSetup(request); + const origin = new URL(process.env.HQBASE_STAGING_URL ?? "https://staging.invalid").origin; + const login = () => + request.post("/api/auth/sign-in/email", { + data: { + email: process.env.HQBASE_STAGING_OWNER_EMAIL, + password: process.env.HQBASE_STAGING_OWNER_PASSWORD + }, + headers: { origin } + }); + expect((await login()).status()).toBe(200); + const me = await request.get("/api/me"); + expect(me.status()).toBe(200); + const person = (await me.json()) as { id: string }; + const resource = `${origin}/api/v1`; + const scope = "mail:read mail:send signatures:manage offline_access"; + const registration = await request.post("/api/auth/oauth2/register", { + data: { + application_type: "native", + client_name: "HQBase native client staging test", + grant_types: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], + resources: [resource], + scope, + token_endpoint_auth_method: "none" + } + }); + expect(registration.status()).toBe(201); + const client = (await registration.json()) as { client_id: string }; + const device = await request.post("/api/auth/device/code", { + form: { client_id: client.client_id, resource, scope } + }); + expect(device.status()).toBe(200); + const code = (await device.json()) as { device_code: string; user_code: string }; + expect( + (await request.get(`/api/auth/device?user_code=${encodeURIComponent(code.user_code)}`)).status() + ).toBe(200); + expect( + ( + await request.post("/api/auth/device/approve", { data: { userCode: code.user_code } }) + ).status() + ).toBe(200); + const issued = await request.post("/api/auth/oauth2/token", { + form: { + client_id: client.client_id, + device_code: code.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + resource + } + }); + expect(issued.status()).toBe(200); + const token = (await issued.json()) as { access_token: string; refresh_token: string }; + let headers = { authorization: `Bearer ${token.access_token}` }; + let signatureId: string | undefined; + let draftId: string | undefined; + try { + const signature = await request.post("/api/v1/signatures", { + headers, + data: { + name: `Native staging ${crypto.randomUUID()}`, + html: "

Saved staging signature

", + scope: { type: "user", id: person.id } + } + }); + expect(signature.status()).toBe(201); + signatureId = ((await signature.json()) as { id: string }).id; + expect( + ( + await request.patch(`/api/v1/signatures/${signatureId}`, { + headers, + data: { html: "

Changed staging signature

" } + }) + ).status() + ).toBe(200); + const managed = await request.get("/api/v1/signatures/manage", { headers }); + expect(managed.status()).toBe(200); + expect(await managed.json()).toEqual( + expect.arrayContaining([expect.objectContaining({ id: signatureId })]) + ); + const draft = await request.post("/api/v1/drafts", { + headers, + data: { + from: process.env.HQBASE_STAGING_SENDER, + subject: "Native staging draft", + signature: { mode: "selected", id: signatureId } + } + }); + expect(draft.status()).toBe(201); + draftId = ((await draft.json()) as { id: string }).id; + expect((await request.delete(`/api/v1/signatures/${signatureId}`, { headers })).status()).toBe( + 204 + ); + signatureId = undefined; + const saved = await request.get(`/api/v1/drafts/${draftId}`, { headers }); + expect(saved.status()).toBe(200); + const savedDraft = await saved.json(); + expect(savedDraft).toMatchObject({ + signature: { mode: "selected", id: null, text: "Changed staging signature" }, + labels: [] + }); + const contract = await request.get("/api/v1/openapi.json"); + expect(contract.status()).toBe(200); + const document = await contract.json(); + const draftSchema = { + $ref: "#/components/schemas/Draft", + components: document.components + }; + const validate = new AjvJsonSchemaValidator().getValidator(draftSchema); + expect(validate(savedDraft).valid).toBe(true); + for (const path of ["messages", "conversations", "changes"]) { + expect((await request.get(`/api/v1/${path}?includeLabels=true`, { headers })).status()).toBe( + 200 + ); + } + expect((await request.post("/api/auth/sign-out", { data: {} })).status()).toBe(200); + const refreshed = await request.post("/api/auth/oauth2/token", { + form: { + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: token.refresh_token, + resource + } + }); + expect(refreshed.status()).toBe(200); + const next = (await refreshed.json()) as { access_token: string }; + headers = { authorization: `Bearer ${next.access_token}` }; + expect((await request.get("/api/v1/mailboxes", { headers })).status()).toBe(200); + expect((await request.get(`/api/v1/drafts/${draftId}`, { headers })).status()).toBe(200); + } finally { + expect((await login()).status()).toBe(200); + if (draftId) expect((await request.delete(`/api/v1/drafts/${draftId}`)).status()).toBe(204); + if (signatureId) + expect((await request.delete(`/api/v1/signatures/${signatureId}`)).status()).toBe(204); + expect((await request.delete(`/api/oauth-connections/${client.client_id}`)).status()).toBe(204); + } + expect((await request.get("/api/v1/mailboxes", { headers })).status()).toBe(401); +}); diff --git a/test/integration/worker/mail-api.test.ts b/test/integration/worker/mail-api.test.ts index 79ffbfc8..a259c167 100644 --- a/test/integration/worker/mail-api.test.ts +++ b/test/integration/worker/mail-api.test.ts @@ -268,7 +268,7 @@ describe("HQBase Mail API", () => { await expect(metadata.json()).resolves.toMatchObject({ resource: apiResource, authorization_servers: [`${origin}/api/auth`], - scopes_supported: scopes, + scopes_supported: [...scopes, "signatures:manage"], resource_name: "HQBase Mail API", resource_documentation: `${origin}/skills/hqbase-mail/SKILL.md` }); @@ -278,7 +278,7 @@ describe("HQBase Mail API", () => { await expect(v1Metadata.json()).resolves.toMatchObject({ resource: v1ApiResource, authorization_servers: [`${origin}/api/auth`], - scopes_supported: scopes + scopes_supported: [...scopes, "signatures:manage"] }); const rejected = await SELF.fetch(`${origin}/api/v2/messages`); @@ -666,6 +666,75 @@ describe("HQBase Mail API", () => { expect(await attachment.text()).toBe("hello"); }); + it("opts v1 into v2 label payloads without changing default responses or change cursors", async () => { + const now = new Date().toISOString(); + const labelId = "lbl_v1_opt_in"; + await env.DB.prepare( + "INSERT INTO labels (id, name, color, created_by_user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(labelId, "Opt in", "blue", userId, now, now) + .run(); + const baseline = await apiFetch("/api/v1/changes", v1ReadToken); + const cursor = (await baseline.json<{ nextCursor: string }>()).nextCursor; + expect( + (await apiFetch(`/api/v2/messages/msg_api/labels/${labelId}`, writeToken, { method: "PUT" })) + .status + ).toBe(200); + for (const path of [ + "/messages", + "/messages/msg_api", + "/messages/msg_api/thread", + "/conversations", + `/changes?cursor=${encodeURIComponent(cursor)}` + ]) { + const join = path.includes("?") ? "&" : "?"; + const plain = await apiFetch(`/api/v1${path}`, v1ReadToken); + const opted = await apiFetch(`/api/v1${path}${join}includeLabels=true`, v1ReadToken); + const v2 = await apiFetch(`/api/v2${path}`, readToken); + expect(plain.status).toBe(200); + expect(opted.status).toBe(200); + expect(v2.status).toBe(200); + const plainBody = await plain.json(); + const optedBody = await opted.json(); + expect(optedBody).toEqual(await v2.json()); + expect(JSON.stringify(plainBody)).not.toContain('"labels":'); + expect(JSON.stringify(optedBody)).toContain(labelId); + for (const value of ["false", "1"]) { + const disabled = await apiFetch(`/api/v1${path}${join}includeLabels=${value}`, v1ReadToken); + expect(await disabled.json()).toEqual(plainBody); + } + } + const added = await apiFetch( + `/api/v1/changes?cursor=${encodeURIComponent(cursor)}&includeLabels=true`, + v1ReadToken + ); + const next = (await added.json<{ nextCursor: string }>()).nextCursor; + expect( + ( + await apiFetch(`/api/v2/messages/msg_api/labels/${labelId}`, writeToken, { + method: "DELETE" + }) + ).status + ).toBe(200); + const removed = await apiFetch( + `/api/v1/changes?cursor=${encodeURIComponent(next)}&includeLabels=true`, + v1ReadToken + ); + await expect(removed.json()).resolves.toMatchObject({ + changes: [ + expect.objectContaining({ + type: "upsert", + message: expect.objectContaining({ id: "msg_api", labels: [] }) + }) + ] + }); + const filtered = await apiFetch( + `/api/v1/changes?includeLabels=true&labelId=${labelId}`, + v1ReadToken + ); + expect(filtered.status).toBe(400); + }); + it("returns visible content before and after separately classified reply history", async () => { const response = await apiFetch("/api/v2/messages/msg_api/html", readToken); expect(response.status, await response.clone().text()).toBe(200); @@ -1298,6 +1367,22 @@ describe("HQBase Mail API", () => { expect(second.headers.get("link")).toBeNull(); }); + it("keeps v1 label membership when following next-page links", async () => { + const first = await apiFetch( + "/api/v1/messages?mailboxId=mbx_page&includeLabels=true&limit=1", + v1ReadToken + ); + expect(first.status).toBe(200); + const next = nextPageUrl(first); + if (!next) throw new Error("Expected a v1 next-page link."); + expect(new URL(next).searchParams.get("includeLabels")).toBe("true"); + const second = await apiFetch(next.slice(origin.length), v1ReadToken); + expect(second.status).toBe(200); + const rows = await second.json>(); + expect(rows).toHaveLength(1); + expect(rows[0]?.labels).toEqual([]); + }); + it("never lists an unreadable mailbox on any page", async () => { const { ids } = await walkPages("/api/v2/messages?limit=50"); diff --git a/test/integration/worker/mcp.test.ts b/test/integration/worker/mcp.test.ts index 4cf5336e..6fba5e86 100644 --- a/test/integration/worker/mcp.test.ts +++ b/test/integration/worker/mcp.test.ts @@ -259,7 +259,7 @@ describe("HQBase MCP server", () => { expect(registration.status).toBe(201); const registered = (await registration.json()) as { scope?: string }; expect(registered.scope?.split(" ").sort()).toEqual( - ["mail:read", "mail:write", "mail:send", "offline_access"].sort() + ["mail:read", "mail:write", "mail:send", "offline_access", "signatures:manage"].sort() ); const fullRegistration = await SELF.fetch(metadata.registration_endpoint ?? "", { @@ -275,7 +275,7 @@ describe("HQBase MCP server", () => { expect(fullRegistration.status).toBe(201); const fullRegistered = (await fullRegistration.json()) as { scope?: string }; expect(fullRegistered.scope?.split(" ").sort()).toEqual( - [...fullScopes, "offline_access"].sort() + [...fullScopes, "offline_access", "signatures:manage"].sort() ); }); diff --git a/test/integration/worker/oauth-device.test.ts b/test/integration/worker/oauth-device.test.ts index 98082ae6..3b083d51 100644 --- a/test/integration/worker/oauth-device.test.ts +++ b/test/integration/worker/oauth-device.test.ts @@ -1,5 +1,5 @@ import { env, SELF } from "cloudflare:test"; -import { beforeAll, describe, expect, it } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { createAuth } from "../../../worker/auth/auth"; import { deviceCodeGrantType } from "../../../worker/auth/device-authorization"; @@ -23,6 +23,10 @@ type OAuthTokenResponse = { }; describe("OAuth Device Authorization Grant", () => { + beforeEach(async () => { + await env.DB.prepare("DELETE FROM rate_limits WHERE scope = 'oauth.device.verify.ip'").run(); + }); + beforeAll(async () => { await applyCurrentMigrations(); @@ -185,6 +189,143 @@ describe("OAuth Device Authorization Grant", () => { expect(afterSessionEnd.status).toBe(401); }); + it.each([ + "expired", + "signed-out" + ])("refreshes offline access after the browser session is %s", async (state) => { + const person = await signUp(`offline-${state}@login.example`, "Offline Person"); + const authorization = await requestDeviceCode("mail:read offline_access"); + await verifyCode(authorization.user_code, person.cookie); + expect((await approveCode(authorization.user_code, person.cookie)).status).toBe(200); + const issued = await pollToken(authorization.device_code); + expect(issued.status).toBe(200); + const token = await issued.json(); + if (state === "expired") { + await env.DB.prepare('UPDATE "session" SET expiresAt = ? WHERE id = ?') + .bind("2000-01-01T00:00:00.000Z", person.sessionId) + .run(); + const stillActive = await SELF.fetch(`${origin}/api/v2/mailboxes`, { + headers: { authorization: `Bearer ${token.access_token}` } + }); + expect(stillActive.status).toBe(200); + } else { + const signOut = await SELF.fetch(`${origin}/api/auth/sign-out`, { + method: "POST", + headers: { cookie: person.cookie, origin } + }); + expect(signOut.status).toBe(200); + } + const refreshed = await refreshToken(token.refresh_token); + expect(refreshed.status).toBe(200); + const next = await refreshed.json(); + const api = () => + SELF.fetch(`${origin}/api/v2/mailboxes`, { + headers: { authorization: `Bearer ${next.access_token}` } + }); + expect((await api()).status).toBe(200); + const session = await env.DB.prepare('SELECT expiresAt FROM "session" WHERE id = ?') + .bind(person.sessionId) + .first<{ expiresAt: string }>(); + expect(session?.expiresAt ?? null).toBe( + state === "expired" ? "2000-01-01T00:00:00.000Z" : null + ); + // Losing offline consent must restore the session requirement immediately. + const tokenOwner = await env.DB.prepare('SELECT id FROM "user" WHERE email = ?') + .bind(`offline-${state}@login.example`) + .first<{ id: string }>(); + if (!tokenOwner) throw new Error("Expected offline test user."); + await env.DB.prepare("UPDATE oauthConsent SET scopes = ? WHERE userId = ?") + .bind('["mail:read"]', tokenOwner.id) + .run(); + expect((await api()).status).toBe(401); + await env.DB.prepare("UPDATE oauthConsent SET scopes = ? WHERE userId = ?") + .bind('["mail:read","offline_access"]', tokenOwner.id) + .run(); + expect((await api()).status).toBe(200); + await env.DB.prepare('UPDATE "user" SET banned = 1 WHERE id = ?').bind(tokenOwner.id).run(); + expect((await api()).status).toBe(401); + await env.DB.prepare('UPDATE "user" SET banned = 0 WHERE id = ?').bind(tokenOwner.id).run(); + // Revoke through the connected-app route using a new browser login. + const signIn = await SELF.fetch(`${origin}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ + email: `offline-${state}@login.example`, + password: "device-test-password" + }) + }); + expect(signIn.status).toBe(200); + const revoke = await SELF.fetch(`${origin}/api/oauth-connections/${clientId}`, { + method: "DELETE", + headers: { cookie: extractSessionCookie(signIn), origin } + }); + expect(revoke.status).toBe(204); + expect((await api()).status).toBe(401); + expect((await refreshToken(next.refresh_token)).status).toBe(400); + }); + + it("revokes offline access and refresh tokens on password reset", async () => { + const email = "offline-reset@login.example"; + const person = await signUp(email, "Reset Person"); + const authorization = await requestDeviceCode("mail:read offline_access"); + await verifyCode(authorization.user_code, person.cookie); + expect((await approveCode(authorization.user_code, person.cookie)).status).toBe(200); + const issued = await pollToken(authorization.device_code); + expect(issued.status).toBe(200); + const token = await issued.json(); + const user = await env.DB.prepare('SELECT id FROM "user" WHERE email = ?') + .bind(email) + .first<{ id: string }>(); + if (!user) throw new Error("Expected reset test user."); + const now = new Date().toISOString(); + await env.DB.prepare( + "INSERT INTO verification (id, identifier, value, expiresAt, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind( + "offline_reset_verification", + "reset-password:offline-reset-test", + user.id, + new Date(Date.now() + 3600000).toISOString(), + now, + now + ) + .run(); + const reset = await SELF.fetch(`${origin}/api/auth/reset-password`, { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ token: "offline-reset-test", newPassword: "new-device-test-password" }) + }); + expect(reset.status).toBe(200); + const api = await SELF.fetch(`${origin}/api/v2/mailboxes`, { + headers: { authorization: `Bearer ${token.access_token}` } + }); + expect(api.status).toBe(401); + expect((await refreshToken(token.refresh_token)).status).toBe(400); + for (const table of ["oauthAccessToken", "oauthRefreshToken", "oauthConsent"]) { + const count = await env.DB.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE userId = ?`) + .bind(user.id) + .first<{ count: number }>(); + expect(count?.count).toBe(0); + } + }); + + it("requires an active session without offline access", async () => { + const person = await signUp("online-only@login.example", "Online Person"); + const authorization = await requestDeviceCode("mail:read"); + await verifyCode(authorization.user_code, person.cookie); + expect((await approveCode(authorization.user_code, person.cookie)).status).toBe(200); + const issued = await pollToken(authorization.device_code); + expect(issued.status).toBe(200); + const token = await issued.json(); + await env.DB.prepare('UPDATE "session" SET expiresAt = ? WHERE id = ?') + .bind("2000-01-01T00:00:00.000Z", person.sessionId) + .run(); + const response = await SELF.fetch(`${origin}/api/v2/mailboxes`, { + headers: { authorization: `Bearer ${token.access_token}` } + }); + expect(response.status).toBe(401); + }); + it("enforces the polling interval and returns a terminal denial", async () => { const user = await signUp("device-deny@login.example", "Deny Person"); const authorization = await requestDeviceCode("mail:read"); diff --git a/test/integration/worker/signature-routes.test.ts b/test/integration/worker/signature-routes.test.ts index c54d2f5f..de5ac151 100644 --- a/test/integration/worker/signature-routes.test.ts +++ b/test/integration/worker/signature-routes.test.ts @@ -3,6 +3,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { createAuth } from "../../../worker/auth/auth"; import { applyCurrentMigrations } from "./current-migrations"; +import { tokenRow } from "./mail-api-token-fixture"; const origin = "https://hqbase.test"; const pngDataUrl = "data:image/png;base64,iVBORw0KGgo="; @@ -202,6 +203,171 @@ describe("signature routes", () => { await expect(invalid.json()).resolves.toMatchObject({ error: { code: "SIGNATURE_INVALID" } }); }); + it.each([ + "v1", + "v2" + ])("manages signatures with a separate OAuth permission on %s", async (version) => { + const base = `/api/${version}/signatures`; + const client = `signature-manager-${version}`; + const bearer = `hqb_access_${client}`; + const sendBearer = `hqb_access_${client}-send`; + const session = await env.DB.prepare('SELECT id FROM "session" WHERE userId = ?') + .bind(memberId) + .first<{ id: string }>(); + if (!session) throw new Error("Expected member session."); + const now = new Date().toISOString(); + const future = new Date(Date.now() + 3600000).toISOString(); + await env.DB.batch([ + env.DB.prepare( + "INSERT INTO oauthClient (id, clientId, redirectUris, createdAt, updatedAt) VALUES (?, ?, '[]', ?, ?)" + ).bind(client, client, now, now), + env.DB.prepare( + "INSERT INTO oauthConsent (id, clientId, userId, scopes, resources, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)" + ).bind( + client, + client, + memberId, + '["signatures:manage","mail:send"]', + JSON.stringify([`${origin}/api/${version}`]), + now, + now + ), + await tokenRow( + env.DB, + client, + bearer, + client, + session.id, + memberId, + future, + ["signatures:manage"], + `${origin}/api/${version}` + ), + await tokenRow( + env.DB, + `${client}-send`, + sendBearer, + client, + session.id, + memberId, + future, + ["mail:send"], + `${origin}/api/${version}` + ) + ]); + const api = (path: string, method = "GET", body?: object, token = bearer) => + SELF.fetch(`${origin}${path}`, { + method, + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + ...(body ? { body: JSON.stringify(body) } : {}) + }); + const input = { + name: `API ${version}`, + html: `

Private signature

Logo`, + scope: { type: "user", id: memberId }, + isDefault: false + }; + for (const [path, method, body] of [ + [`${base}/manage`, "GET", undefined], + [base, "POST", input], + [`${base}/${personalId}`, "PATCH", { name: "Denied" }], + [`${base}/${personalId}`, "DELETE", undefined] + ] as const) { + const denied = await api(path, method, body, sendBearer); + expect(denied.status).toBe(403); + expect(denied.headers.get("www-authenticate")).toContain('scope="signatures:manage"'); + } + expect( + (await api(`${base}?from=team%40signature-routes.example`, "GET", undefined, sendBearer)) + .status + ).toBe(200); + const created = await api(base, "POST", input); + expect(created.status).toBe(201); + const signature = await created.json<{ id: string; html: string }>(); + expect(signature.html).toContain("data:image/png"); + expect((await api(base, "POST", input)).status).toBe(409); + expect( + ( + await api(base, "POST", { + ...input, + name: "Other person", + scope: { type: "user", id: ownerId } + }) + ).status + ).toBe(403); + expect( + ( + await api(base, "POST", { + ...input, + name: "Domain denied", + scope: { type: "domain", id: "dom_signature_routes" } + }) + ).status + ).toBe(403); + expect( + ( + await api(base, "POST", { + ...input, + name: "Mailbox denied", + scope: { type: "mailbox", id: "mbx_signature_routes" } + }) + ).status + ).toBe(403); + expect((await api(base, "POST", { ...input, html: "x".repeat(400001) })).status).toBe(400); + expect((await api(`${base}/${signature.id}`, "PATCH", {})).status).toBe(400); + const changed = await api(`${base}/${signature.id}`, "PATCH", { + name: `Changed ${version}`, + isDefault: true + }); + expect(changed.status).toBe(200); + await expect(changed.json()).resolves.toMatchObject({ + name: `Changed ${version}`, + isDefault: true + }); + const list = await api(`${base}/manage`); + expect(list.status).toBe(200); + await expect(list.json()).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ id: signature.id })]) + ); + await env.DB.prepare( + "UPDATE mailbox_grants SET access_level = 'manager' WHERE mailbox_id = ? AND principal_id = ?" + ) + .bind("mbx_signature_routes", memberId) + .run(); + try { + const shared = await api(base, "POST", { + ...input, + name: `Shared ${version}`, + scope: { type: "mailbox", id: "mbx_signature_routes" } + }); + expect(shared.status).toBe(201); + const sharedId = (await shared.json<{ id: string }>()).id; + expect((await api(`${base}/${sharedId}`, "DELETE")).status).toBe(204); + } finally { + await env.DB.prepare( + "UPDATE mailbox_grants SET access_level = 'agent' WHERE mailbox_id = ? AND principal_id = ?" + ) + .bind("mbx_signature_routes", memberId) + .run(); + } + expect((await api(`${base}/${signature.id}`, "DELETE")).status).toBe(204); + expect((await api(`${base}/${signature.id}`, "PATCH", { name: "Missing" })).status).toBe(404); + const audits = await env.DB.prepare( + "SELECT action, metadata_json FROM audit_events WHERE resource_id = ?" + ) + .bind(signature.id) + .all<{ action: string; metadata_json: string }>(); + expect(audits.results.map((row) => row.action)).toEqual( + expect.arrayContaining([ + "signature.create", + "signature.update", + "signature.delete", + "signature.default.change" + ]) + ); + expect(JSON.stringify(audits.results)).not.toContain("Private signature"); + }); + it("resolves explicit REST selections and preserves omission semantics", async () => { const selected = await send("/api/v2/send", { from: "team@signature-routes.example", diff --git a/test/unit/scripts/mail-api-artifacts.test.mjs b/test/unit/scripts/mail-api-artifacts.test.mjs index 4ce8febc..0cac9059 100644 --- a/test/unit/scripts/mail-api-artifacts.test.mjs +++ b/test/unit/scripts/mail-api-artifacts.test.mjs @@ -1,5 +1,7 @@ import { readFile } from "node:fs/promises"; +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; import { describe, expect, it } from "vitest"; +import { withSignatures } from "../../../scripts/mail-api-signatures.mjs"; const openApi = JSON.parse(await readFile("api/hqbase-mail-api-v2.openapi.json", "utf8")); const postman = JSON.parse( @@ -104,6 +106,118 @@ describe("Mail API public artifacts", () => { expect(JSON.stringify(openApi)).not.toContain("r2Key"); }); + it.each([ + [1, v1OpenApi], + [2, openApi] + ])("separates draft selections from saved snapshots in v%i", (version, document) => { + const schemas = document.components.schemas; + const validator = new AjvJsonSchemaValidator(); + const compile = (name) => + validator.getValidator({ + $ref: `#/components/schemas/${name}`, + components: document.components + }); + const draft = { + id: "drf_deleted_signature", + version: 3, + updatedAt: "2026-09-04T10:00:00.000Z", + mailboxId: null, + replyToMessageId: null, + forwardOfMessageId: null, + from: "owner@example.test", + to: ["a@example.test"], + cc: [], + bcc: [], + subject: "Hello", + text: "Body", + html: "", + attachments: [], + labels: [], + signature: { + mode: "selected", + id: null, + name: "Saved name", + html: "

Saved signature

", + text: "Saved signature" + } + }; + expect(compile("Draft")(draft).valid).toBe(true); + expect(compile("DraftInput")(draft).valid).toBe(false); + for (const signature of [ + { mode: "automatic" }, + { mode: "selected", id: "sig_1" }, + { mode: "none" } + ]) { + expect(compile("DraftInput")({ ...draft, signature }).valid).toBe(true); + } + + const signatures = (schema) => { + if (schema.$ref) return signatures(schemas[schema.$ref.split("/").at(-1)]); + return [ + ...(schema.properties?.signature ? [schema.properties.signature] : []), + ...(schema.allOf ?? []).flatMap(signatures) + ]; + }; + expect(signatures(schemas.DraftInput)).toEqual([ + { $ref: "#/components/schemas/SignatureSelection" } + ]); + expect(signatures(schemas.Draft)).toEqual([{ $ref: "#/components/schemas/SignatureSnapshot" }]); + expect(schemas.SignatureSnapshot.properties.id.anyOf).toContainEqual({ type: "null" }); + expect(schemas.SignatureSnapshot.properties.mode.enum).toContain("selected"); + expect( + schemas.SignatureSelection.oneOf.find((schema) => schema.properties.mode.const === "selected") + .properties.id + ).toMatchObject({ type: "string", minLength: 1 }); + expect(schemas.Draft.allOf[1].required).toContain("labels"); + expect(withSignatures(withSignatures(document, version), version)).toEqual( + withSignatures(document, version) + ); + const serialized = JSON.stringify(document); + expect(serialized).not.toContain('"type":["string","null"]'); + }); + + it.each([ + [1, v1OpenApi], + [2, openApi] + ])("publishes human-only signature management in v%i", (version, document) => { + for (const [path, method] of [ + ["/signatures/manage", "get"], + ["/signatures", "post"], + ["/signatures/{id}", "patch"], + ["/signatures/{id}", "delete"] + ]) { + const operation = document.paths[`/api/v${version}${path}`][method]; + expect(operation.security).toEqual([ + { oauth2: ["signatures:manage"] }, + { cookieSession: [] } + ]); + expect(operation["x-hqbase-agent-capabilities"]).toBeUndefined(); + } + expect(document.paths[`/api/v${version}/signatures`].get.security).toContainEqual({ + oauth2: ["mail:send"] + }); + }); + + it("documents optional v1 label membership and keeps v2 membership required", () => { + for (const path of [ + "/messages", + "/messages/{id}", + "/messages/{id}/thread", + "/conversations", + "/changes" + ]) { + expect(v1OpenApi.paths[`/api/v1${path}`].get.parameters).toContainEqual( + expect.objectContaining({ + name: "includeLabels", + schema: { type: "boolean", default: false } + }) + ); + } + expect(v1OpenApi.components.schemas.MessageSummary.properties.labels).toBeDefined(); + expect(v1OpenApi.components.schemas.MessageSummary.required).not.toContain("labels"); + expect(openApi.components.schemas.MessageSummary.required).toContain("labels"); + }); + it("publishes the v1 mailbox schema without reviving alias storage", () => { expect(v1OpenApi.openapi).toBe("3.1.0"); expect(v1OpenApi.info.version).toBe("1.0.0"); diff --git a/worker/auth/auth.ts b/worker/auth/auth.ts index 8a43c3f3..97be4801 100644 --- a/worker/auth/auth.ts +++ b/worker/auth/auth.ts @@ -88,6 +88,12 @@ export function createAuth( await sendPasswordSetupEmail(env, { user, url }); }, onPasswordReset: async ({ user }) => { + // Offline grants no longer depend on browser sessions. + await env.DB.batch([ + env.DB.prepare("DELETE FROM oauthAccessToken WHERE userId = ?").bind(user.id), + env.DB.prepare("DELETE FROM oauthRefreshToken WHERE userId = ?").bind(user.id), + env.DB.prepare("DELETE FROM oauthConsent WHERE userId = ?").bind(user.id) + ]); const completedSetup = await completePasswordSetup(env.DB, user.id); await recordAudit(env.DB, { correlationId: crypto.randomUUID(), @@ -123,7 +129,12 @@ export function createAuth( mcpFullResource(env, request), ...mailApiResources ], - clientRegistrationAllowedScopes: ["mail:write", "mail:send", "offline_access"], + clientRegistrationAllowedScopes: [ + "mail:write", + "mail:send", + "signatures:manage", + "offline_access" + ], clientRegistrationDefaultScopes: ["mail:read"], consentPage: "/oauth/consent", disableJwtPlugin: true, @@ -135,7 +146,7 @@ export function createAuth( opaqueAccessToken: "hqb_access_", refreshToken: "hqb_refresh_" }, - scopes: ["mail:read", "mail:write", "mail:send", "offline_access"], + scopes: ["mail:read", "mail:write", "mail:send", "signatures:manage", "offline_access"], storeTokens: { hash: hashOAuthToken }, resources: [mcpResource(env, request), mcpFullResource(env, request), ...mailApiResources], enforcePerClientResources: false diff --git a/worker/auth/mail-api.ts b/worker/auth/mail-api.ts index 352d052e..a3e74563 100644 --- a/worker/auth/mail-api.ts +++ b/worker/auth/mail-api.ts @@ -11,7 +11,7 @@ import { authenticateOAuthBearer, OAuthBearerError } from "./oauth-principal"; import { type AgentPrincipal, type HumanPrincipal, humanPrincipal } from "./principal"; import { type AuthContext, requireAuthContext } from "./session"; -const mailApiScopes = ["mail:read", "mail:write", "mail:send"] as const; +const mailApiScopes = ["mail:read", "mail:write", "mail:send", "signatures:manage"] as const; export type MailApiScope = (typeof mailApiScopes)[number]; const mailApiMetadataPrefix = "/.well-known/oauth-protected-resource"; const agentSkillPath = "/skills/hqbase-mail/SKILL.md"; @@ -19,7 +19,7 @@ const agentSkillPath = "/skills/hqbase-mail/SKILL.md"; export type MailApiPrincipal = | { principal: HumanPrincipal; - auth: AuthContext; + auth: Pick; authentication: "bearer" | "session"; scopes: ReadonlySet; } @@ -52,7 +52,7 @@ export async function requireMailApiContext( env: WorkerEnv, request: Request, requiredScope: MailApiScope -): Promise { +): Promise> { const result = await requireMailApiPrincipal(env, request, requiredScope); if (result.auth) return result.auth; throw new MailApiAuthError( @@ -152,7 +152,7 @@ export async function requireMailApiPrincipal( "insufficient_scope" ); } - const auth = { session: principal.session, user: principal.user }; + const auth = { user: principal.user }; return { principal: humanPrincipal(auth), auth, @@ -247,3 +247,10 @@ function mailApiResourceForRequest(env: WorkerEnv, request: Request): string { ? mailApiV1Resource(env, request) : mailApiResource(env, request); } + +export function includeMailApiLabels(request: Request): boolean { + return ( + mailApiBasePath(request) !== "/api/v1" || + new URL(request.url).searchParams.get("includeLabels") === "true" + ); +} diff --git a/worker/auth/oauth-principal.ts b/worker/auth/oauth-principal.ts index a5b6851c..d92fbd1d 100644 --- a/worker/auth/oauth-principal.ts +++ b/worker/auth/oauth-principal.ts @@ -20,11 +20,6 @@ export class OAuthBearerError extends Error { export type OAuthPrincipal = { clientId: string; scopes: ReadonlySet; - session: { - id: string; - userId: string; - createdAt: Date; - }; user: { id: string; email: string; @@ -41,17 +36,17 @@ export async function authenticateOAuthBearer( const bearer = readBearer(request); const row = await getRow( env.DB, - sql`SELECT at.userId, at.sessionId, at.clientId, at.scopes, at.resources, + sql`SELECT at.userId, at.clientId, at.scopes, at.resources, at.expiresAt AS tokenExpiresAt, at.revoked, c.disabled AS clientDisabled, oc.scopes AS consentScopes, oc.resources AS consentResources, u.email, u.name, u.role, u.banned, u.banExpires, - s.createdAt AS sessionCreatedAt, s.expiresAt AS sessionExpiresAt + s.expiresAt AS sessionExpiresAt FROM oauthAccessToken at JOIN oauthClient c ON c.clientId = at.clientId JOIN oauthConsent oc ON oc.clientId = at.clientId AND oc.userId = at.userId JOIN "user" u ON u.id = at.userId - JOIN "session" s ON s.id = at.sessionId AND s.userId = at.userId + LEFT JOIN "session" s ON s.id = at.sessionId AND s.userId = at.userId WHERE at.token = ${await hashOAuthToken(bearer.slice(accessTokenPrefix.length))} ORDER BY oc.updatedAt DESC LIMIT 1` @@ -60,14 +55,16 @@ export async function authenticateOAuthBearer( const now = Date.now(); const tokenExpiresAt = Date.parse(row?.tokenExpiresAt ?? ""); const sessionExpiresAt = Date.parse(row?.sessionExpiresAt ?? ""); + const tokenScopes = new Set(parseOAuthList(row?.scopes ?? null)); + const consentScopes = new Set(parseOAuthList(row?.consentScopes ?? null)); + const offlineAccess = tokenScopes.has("offline_access") && consentScopes.has("offline_access"); if ( !row || row.revoked !== null || row.clientDisabled === 1 || !Number.isFinite(tokenExpiresAt) || tokenExpiresAt <= now || - !Number.isFinite(sessionExpiresAt) || - sessionExpiresAt <= now + (!offlineAccess && (!Number.isFinite(sessionExpiresAt) || sessionExpiresAt <= now)) ) { throw new OAuthBearerError(); } @@ -93,22 +90,14 @@ export async function authenticateOAuthBearer( throw new OAuthBearerError(); } - const consentScopes = new Set(parseOAuthList(row.consentScopes)); const allowedScopes = new Set(options.allowedScopes); const scopes = new Set( - parseOAuthList(row.scopes).filter( - (scope) => consentScopes.has(scope) && allowedScopes.has(scope) - ) + [...tokenScopes].filter((scope) => consentScopes.has(scope) && allowedScopes.has(scope)) ); return { clientId: row.clientId, scopes, - session: { - id: row.sessionId, - userId: row.userId, - createdAt: new Date(row.sessionCreatedAt) - }, user: { id: row.userId, email: row.email, @@ -142,7 +131,6 @@ export function parseOAuthList(value: string | null): string[] { type OAuthTokenRow = { userId: string; - sessionId: string; clientId: string; scopes: string; resources: string | null; @@ -156,6 +144,5 @@ type OAuthTokenRow = { role: string | null; banned: number | null; banExpires: string | null; - sessionCreatedAt: string; - sessionExpiresAt: string; + sessionExpiresAt: string | null; }; diff --git a/worker/auth/principal.ts b/worker/auth/principal.ts index 7e957385..9e46517a 100644 --- a/worker/auth/principal.ts +++ b/worker/auth/principal.ts @@ -20,7 +20,7 @@ export type AgentPrincipal = { export type RequestPrincipal = HumanPrincipal | AgentPrincipal; -export function humanPrincipal(auth: AuthContext): HumanPrincipal { +export function humanPrincipal(auth: Pick): HumanPrincipal { return { id: auth.user.id, type: "user", diff --git a/worker/features/mail-api/discovery.ts b/worker/features/mail-api/discovery.ts index b51d3397..285a3291 100644 --- a/worker/features/mail-api/discovery.ts +++ b/worker/features/mail-api/discovery.ts @@ -154,6 +154,7 @@ function buildMailApiGuide(apiBase: string, openApiUrl: string): string { - \`mail:read\` — List visible mailboxes, shared labels, and conversations; search and open messages; render message HTML; and download attachments. - \`mail:write\` — Mark mail read or unread, add or remove labels and stars, archive or unarchive mail, move mail to Trash, and restore mail. A mailbox agent cannot change a person's remote-image trust preferences. - \`mail:send\` — Create and manage drafts and attachments, send new messages, reply, and forward. +- \`signatures:manage\` — Human OAuth only: manage personal and shared signatures within the person's current management access. Machine credentials cannot use this permission. Permissions do not override HQBase mailbox access. The caller must also have the necessary Read or Handle mail grant. Machine agents never inherit owner access or see unassigned catch-all mail. This API never grants Manager access. @@ -186,7 +187,7 @@ JSON errors contain a stable \`error.code\` and human-readable \`error.message\` ## API boundary and stability -The Mail API covers mailboxes, messages, conversations, labels, signatures, attachments, drafts, sending, replying, and forwarding. It does not manage people, mailbox grants, label definitions, signature definitions, domains, setup, updates, audits, sessions, notifications, app secrets, or Cloudflare credentials. +The Mail API covers mailboxes, messages, conversations, labels, signatures, attachments, drafts, sending, replying, and forwarding. It does not manage people, mailbox grants, label definitions, domains, setup, updates, audits, sessions, notifications, app secrets, or Cloudflare credentials. \`/api/v2\` is HQBase's stable public Mail API. Additive fields and endpoints may appear, so ignore unknown response fields. Breaking changes use a new versioned base path such as \`/api/v3\`. `; diff --git a/worker/features/messages/change-routes.ts b/worker/features/messages/change-routes.ts index 403c953d..ac567261 100644 --- a/worker/features/messages/change-routes.ts +++ b/worker/features/messages/change-routes.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; -import { mailApiBasePath, requireMailApiPrincipal } from "../../auth/mail-api"; +import { includeMailApiLabels, requireMailApiPrincipal } from "../../auth/mail-api"; import { accessibleMessageScope } from "../../auth/mailbox-access"; import type { HonoApp } from "../../lib/env"; import { AppError } from "../../lib/errors"; @@ -32,7 +32,7 @@ changeRoutes.get("/", async (c) => { limit: parseChangeLimit(c.req.query("limit")), scope }); - if (mailApiBasePath(c.req.raw) === "/api/v1") return c.json(page); + if (!includeMailApiLabels(c.req.raw)) return c.json(page); const assignments = await labelsForMessageIds( c.env.DB, page.changes.flatMap((change) => (change.type === "upsert" ? [change.message.id] : [])) diff --git a/worker/features/messages/conversation-routes.ts b/worker/features/messages/conversation-routes.ts index aef401f9..524b32e7 100644 --- a/worker/features/messages/conversation-routes.ts +++ b/worker/features/messages/conversation-routes.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { z } from "zod"; -import { mailApiBasePath, requireMailApiPrincipal } from "../../auth/mail-api"; +import { includeMailApiLabels, requireMailApiPrincipal } from "../../auth/mail-api"; import { accessibleMessageScope } from "../../auth/mailbox-access"; import type { HonoApp } from "../../lib/env"; import { AppError } from "../../lib/errors"; @@ -57,10 +57,9 @@ conversationRoutes.get("/", async (c) => { }); return c.json({ ...page, - conversations: - mailApiBasePath(c.req.raw) === "/api/v1" - ? page.conversations - : await withConversationLabels(c.env.DB, page.conversations, scope) + conversations: !includeMailApiLabels(c.req.raw) + ? page.conversations + : await withConversationLabels(c.env.DB, page.conversations, scope) }); }); diff --git a/worker/features/messages/routes.ts b/worker/features/messages/routes.ts index fb55cdc0..95bf6176 100644 --- a/worker/features/messages/routes.ts +++ b/worker/features/messages/routes.ts @@ -1,5 +1,9 @@ import { Hono } from "hono"; -import { mailApiBasePath, requireMailApiPrincipal } from "../../auth/mail-api"; +import { + includeMailApiLabels, + mailApiBasePath, + requireMailApiPrincipal +} from "../../auth/mail-api"; import { accessibleMessageScope } from "../../auth/mailbox-access"; import type { HonoApp } from "../../lib/env"; import { AppError } from "../../lib/errors"; @@ -70,7 +74,7 @@ messageRoutes.get("/", async (c) => { scope }); - const messages = includeLabels(c.req.raw) + const messages = includeMailApiLabels(c.req.raw) ? await withMessageLabels(c.env.DB, page.messages) : page.messages; const response = c.json(messages); @@ -96,7 +100,9 @@ messageRoutes.get("/:id/thread", async (c) => { const messages = ( await listThreadMessages(c.env.DB, message.threadId, scope, c.env.MAIL_OBJECTS) ).map(publicMessage); - return c.json(includeLabels(c.req.raw) ? await withMessageLabels(c.env.DB, messages) : messages); + return c.json( + includeMailApiLabels(c.req.raw) ? await withMessageLabels(c.env.DB, messages) : messages + ); }); messageRoutes.get("/:id", async (c) => { @@ -114,7 +120,9 @@ messageRoutes.get("/:id", async (c) => { } const publicDetail = publicMessage(message); return c.json( - includeLabels(c.req.raw) ? (await withMessageLabels(c.env.DB, [publicDetail]))[0] : publicDetail + includeMailApiLabels(c.req.raw) + ? (await withMessageLabels(c.env.DB, [publicDetail]))[0] + : publicDetail ); }); @@ -224,7 +232,7 @@ for (const action of actions) { c.executionCtx.waitUntil(ignoreMailEventFailure(publishMessageMailEvent(c.env, [target]))); } return c.json( - includeLabels(c.req.raw) ? (await withMessageLabels(c.env.DB, [message]))[0] : message + includeMailApiLabels(c.req.raw) ? (await withMessageLabels(c.env.DB, [message]))[0] : message ); }); } @@ -312,7 +320,7 @@ function parseMessageLimit(value: string | undefined): number { function nextMessagePageUrl(requestUrl: string, cursor: string): string { const url = new URL(requestUrl); const preserved = new URLSearchParams(); - for (const name of ["mailboxId", "folder", "labelId", "search", "limit"]) { + for (const name of ["mailboxId", "folder", "labelId", "search", "limit", "includeLabels"]) { const value = url.searchParams.get(name); if (value !== null) preserved.set(name, value); } @@ -324,10 +332,6 @@ function nextMessagePageUrl(requestUrl: string, cursor: string): string { return url.toString(); } -function includeLabels(request: Request): boolean { - return mailApiBasePath(request) !== "/api/v1"; -} - async function requireLabelMessageAccess( db: D1Database, principalId: string, diff --git a/worker/features/signatures/routes.ts b/worker/features/signatures/routes.ts index 1b85dd52..52cf7371 100644 --- a/worker/features/signatures/routes.ts +++ b/worker/features/signatures/routes.ts @@ -1,9 +1,8 @@ import { Hono } from "hono"; import type { z } from "zod"; -import { requireMailApiPrincipal } from "../../auth/mail-api"; +import { requireMailApiContext, requireMailApiPrincipal } from "../../auth/mail-api"; import { humanPrincipal } from "../../auth/principal"; -import { requireAuthContext } from "../../auth/session"; import type { HonoApp } from "../../lib/env"; import { AppError } from "../../lib/errors"; import { readJson } from "../../lib/json"; @@ -23,14 +22,15 @@ import { createSignatureSchema, updateSignatureSchema } from "./validation"; export const signatureRoutes = new Hono(); export const mailSignatureRoutes = new Hono(); +const signatureWriteRoutes = new Hono(); signatureRoutes.get("/", async (c) => { - const auth = await requireAuthContext(c.env, c.req.raw); + const auth = await requireMailApiContext(c.env, c.req.raw, "signatures:manage"); return c.json(await listManageableSignatures(c.env.DB, humanPrincipal(auth))); }); -signatureRoutes.post("/", async (c) => { - const auth = await requireAuthContext(c.env, c.req.raw); +signatureWriteRoutes.post("/", async (c) => { + const auth = await requireMailApiContext(c.env, c.req.raw, "signatures:manage"); const actor = humanPrincipal(auth); const input = signatureInput(createSignatureSchema, await readJson(c.req.raw)); try { @@ -71,8 +71,8 @@ signatureRoutes.post("/", async (c) => { } }); -signatureRoutes.patch("/:id", async (c) => { - const auth = await requireAuthContext(c.env, c.req.raw); +signatureWriteRoutes.patch("/:id", async (c) => { + const auth = await requireMailApiContext(c.env, c.req.raw, "signatures:manage"); const actor = humanPrincipal(auth); const id = c.req.param("id"); const input = signatureInput(updateSignatureSchema, await readJson(c.req.raw)); @@ -117,8 +117,8 @@ signatureRoutes.patch("/:id", async (c) => { } }); -signatureRoutes.delete("/:id", async (c) => { - const auth = await requireAuthContext(c.env, c.req.raw); +signatureWriteRoutes.delete("/:id", async (c) => { + const auth = await requireMailApiContext(c.env, c.req.raw, "signatures:manage"); const actor = humanPrincipal(auth); const id = c.req.param("id"); const current = await findSignature(c.env.DB, id); @@ -172,6 +172,13 @@ mailSignatureRoutes.get("/", async (c) => { return c.json(await listUsableSignatures(c.env.DB, auth.principal, from.data)); }); +mailSignatureRoutes.get("/manage", async (c) => { + const auth = await requireMailApiContext(c.env, c.req.raw, "signatures:manage"); + return c.json(await listManageableSignatures(c.env.DB, humanPrincipal(auth))); +}); +signatureRoutes.route("/", signatureWriteRoutes); +mailSignatureRoutes.route("/", signatureWriteRoutes); + function signatureInput(schema: Schema, value: unknown): z.infer { const result = schema.safeParse(value); if (!result.success) {