diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ce35d93 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +/StalwartSDK/StalwartClient.cs \ No newline at end of file diff --git a/.forgejo/workflows/package-debug.yaml b/.forgejo/workflows/package-debug.yaml new file mode 100644 index 0000000..30b183a --- /dev/null +++ b/.forgejo/workflows/package-debug.yaml @@ -0,0 +1,40 @@ +on: + push: + branches: + - "**" + pull_request: + types: [ opened, synchronize, reopened ] +jobs: + publish_debug: + runs-on: docker + container: + image: ghcr.io/catthehacker/ubuntu:act-22.04 + steps: + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: | + git.spgrn.com/${{ env.GITHUB_REPOSITORY }}-web + tags: | + type=ref,event=pr + type=ref,event=branch + type=sha + - name: Checkout + uses: actions/checkout@v4 + - name: Login to Container Registry + uses: docker/login-action@v3 + with: + registry: git.spgrn.com + username: seang96 + password: ${{ secrets.TOKEN }} + - name: Build Web Application + uses: docker/build-push-action@v5 + with: + context: . + file: ./StalwartSimpleLoginMiddleware/Dockerfile + platforms: linux/amd64 + push: true + BUILD_CONFIGURATION=Debug + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.forgejo/workflows/package.yaml b/.forgejo/workflows/package.yaml new file mode 100644 index 0000000..02a52e7 --- /dev/null +++ b/.forgejo/workflows/package.yaml @@ -0,0 +1,40 @@ +on: + push: + tags: + - "v*.*.*" +jobs: + publish: + runs-on: docker + container: + image: ghcr.io/catthehacker/ubuntu:act-22.04 + steps: + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: | + git.spgrn.com/${{ env.GITHUB_REPOSITORY }}-web + tags: | + type=ref,event=tag + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest + type=sha + - name: Checkout + uses: actions/checkout@v4 + - name: Login to Container Registry + uses: docker/login-action@v3 + with: + registry: git.spgrn.com + username: seang96 + password: ${{ secrets.TOKEN }} + - name: Build Web Application + uses: docker/build-push-action@v5 + with: + context: . + file: ./StalwartSimpleLoginMiddleware/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 57940fd..9e1873e 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,10 @@ fabric.properties # Android studio 3.1+ serialized cache file .idea/caches/build_file_checksums.ser +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ +/StalwartSDK/StalwartClient.cs +launchSettings.json \ No newline at end of file diff --git a/Stalwart Simple Login Middleware.sln b/Stalwart Simple Login Middleware.sln new file mode 100644 index 0000000..82c743b --- /dev/null +++ b/Stalwart Simple Login Middleware.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StalwartSimpleLoginMiddleware", "StalwartSimpleLoginMiddleware\StalwartSimpleLoginMiddleware.csproj", "{AFC6627E-CA72-440D-B405-384AB62F12C2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StalwartSDK", "StalwartSDK\StalwartSDK.csproj", "{AD805E73-59A5-402C-8EE3-41113DDE86E7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AFC6627E-CA72-440D-B405-384AB62F12C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AFC6627E-CA72-440D-B405-384AB62F12C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AFC6627E-CA72-440D-B405-384AB62F12C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AFC6627E-CA72-440D-B405-384AB62F12C2}.Release|Any CPU.Build.0 = Release|Any CPU + {AD805E73-59A5-402C-8EE3-41113DDE86E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD805E73-59A5-402C-8EE3-41113DDE86E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD805E73-59A5-402C-8EE3-41113DDE86E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD805E73-59A5-402C-8EE3-41113DDE86E7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/StalwartSDK/StalwartClientFactory.cs b/StalwartSDK/StalwartClientFactory.cs new file mode 100644 index 0000000..70a1a77 --- /dev/null +++ b/StalwartSDK/StalwartClientFactory.cs @@ -0,0 +1,23 @@ +using System.Net.Http.Headers; + +namespace AdOrbitSDK; + +public partial class Client +{ + private readonly string _apiKey; + + public Client(HttpClient httpClient, string apiKey) + : this(httpClient) + { + _apiKey = apiKey; + } + + partial void PrepareRequest(HttpClient client, HttpRequestMessage request, string url) + { + if (client.BaseAddress == null) throw new NullReferenceException("Base address cannot be null"); + + var authHeader = new AuthenticationHeaderValue("Bearer", _apiKey); + + request.Headers.Authorization = authHeader; + } +} \ No newline at end of file diff --git a/StalwartSDK/StalwartSDK.csproj b/StalwartSDK/StalwartSDK.csproj new file mode 100644 index 0000000..2e7d275 --- /dev/null +++ b/StalwartSDK/StalwartSDK.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/StalwartSDK/nswag.json b/StalwartSDK/nswag.json new file mode 100644 index 0000000..9a13066 --- /dev/null +++ b/StalwartSDK/nswag.json @@ -0,0 +1,103 @@ +{ + "runtime": "Net80", + "defaultVariables": null, + "documentGenerator": { + "fromDocument": { + "url": "openapi.yml", + "output": null, + "newLineBehavior": "Auto" + } + }, + "codeGenerators": { + "openApiToCSharpClient": { + "clientBaseClass": null, + "configurationClass": null, + "generateClientClasses": true, + "suppressClientClassesOutput": false, + "generateClientInterfaces": false, + "suppressClientInterfacesOutput": false, + "clientBaseInterface": null, + "injectHttpClient": true, + "disposeHttpClient": true, + "protectedMethods": [], + "generateExceptionClasses": true, + "exceptionClass": "ApiException", + "wrapDtoExceptions": true, + "useHttpClientCreationMethod": false, + "httpClientType": "System.Net.Http.HttpClient", + "useHttpRequestMessageCreationMethod": false, + "useBaseUrl": false, + "generateBaseUrlProperty": true, + "generateSyncMethods": false, + "generatePrepareRequestAndProcessResponseAsAsyncMethods": false, + "exposeJsonSerializerSettings": false, + "clientClassAccessModifier": "public", + "typeAccessModifier": "public", + "propertySetterAccessModifier": "", + "generateNativeRecords": false, + "generateContractsOutput": false, + "contractsNamespace": null, + "contractsOutputFilePath": null, + "parameterDateTimeFormat": "yyyy-MM-dd HH:mm:ss", + "parameterDateFormat": "yyyy-MM-dd", + "generateUpdateJsonSerializerSettingsMethod": true, + "useRequestAndResponseSerializationSettings": false, + "serializeTypeInformation": false, + "queryNullValue": "", + "className": "{controller}Client", + "operationGenerationMode": "MultipleClientsFromOperationId", + "additionalNamespaceUsages": [], + "additionalContractNamespaceUsages": [], + "generateOptionalParameters": true, + "generateJsonMethods": false, + "enforceFlagEnums": false, + "parameterArrayType": "System.Collections.Generic.IEnumerable", + "parameterDictionaryType": "System.Collections.Generic.IDictionary", + "responseArrayType": "System.Collections.Generic.ICollection", + "responseDictionaryType": "System.Collections.Generic.IDictionary", + "wrapResponses": false, + "wrapResponseMethods": [], + "generateResponseClasses": true, + "responseClass": "SwaggerResponse", + "namespace": "AdOrbitSDK", + "requiredPropertiesMustBeDefined": true, + "dateType": "System.DateTimeOffset", + "jsonConverters": null, + "anyType": "object", + "dateTimeType": "System.DateTimeOffset", + "timeType": "System.TimeSpan", + "timeSpanType": "System.TimeSpan", + "arrayType": "System.Collections.Generic.ICollection", + "arrayInstanceType": "System.Collections.ObjectModel.Collection", + "dictionaryType": "System.Collections.Generic.IDictionary", + "dictionaryInstanceType": "System.Collections.Generic.Dictionary", + "arrayBaseType": "System.Collections.ObjectModel.Collection", + "dictionaryBaseType": "System.Collections.Generic.Dictionary", + "classStyle": "Poco", + "jsonLibrary": "NewtonsoftJson", + "generateDefaultValues": true, + "generateDataAnnotations": true, + "excludedTypeNames": [], + "excludedParameterNames": [ + "Accept" + ], + "handleReferences": false, + "generateImmutableArrayProperties": false, + "generateImmutableDictionaryProperties": false, + "jsonSerializerSettingsTransformationMethod": null, + "inlineNamedArrays": false, + "inlineNamedDictionaries": false, + "inlineNamedTuples": true, + "inlineNamedAny": false, + "generateDtoTypes": true, + "generateOptionalPropertiesAsNullable": true, + "generateNullableReferenceTypes": false, + "templateDirectory": null, + "serviceHost": null, + "serviceSchemes": null, + "output": "StalwartClient.cs", + "newLineBehavior": "Auto", + "AllowAdditionalProperties": true + } + } +} \ No newline at end of file diff --git a/StalwartSDK/openapi.yml b/StalwartSDK/openapi.yml new file mode 100644 index 0000000..6c0896a --- /dev/null +++ b/StalwartSDK/openapi.yml @@ -0,0 +1,2590 @@ +openapi: 3.0.0 +info: + title: Stalwart Mail Server API + version: 1.0.0 +servers: + - url: https://mail.example.org/api + description: Sample server +paths: + /oauth: + post: + summary: Obtain OAuth token + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + code: + type: string + permissions: + type: array + items: + type: string + version: + type: string + isEnterprise: + type: boolean + example: + data: + code: 4YmRFLu9Df1t4JO7Iffnuney4B8tVLAxjimdRxEg + permissions: + - webadmin-update + - spam-filter-update + - dkim-signature-get + - dkim-signature-create + - undelete + - fts-reindex + - purge-account + - purge-in-memory-store + - purge-data-store + - purge-blob-store + version: 0.11.0 + isEnterprise: true + "401": + description: Unauthorized + content: + application/json: + schema: + type: object + properties: + type: + type: string + status: + type: number + title: + type: string + detail: + type: string + example: + type: about:blank + status: 401 + title: Unauthorized + detail: You have to authenticate first. + requestBody: + content: + application/json: + schema: + type: object + properties: + type: + type: string + client_id: + type: string + redirect_uri: + type: string + nonce: + type: string + example: + type: code + client_id: webadmin + redirect_uri: stalwart://auth + nonce: ttsaXca3qx + /telemetry/metrics: + get: + summary: Fetch Telemetry Metrics + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + error: + type: string + details: + type: string + reason: + type: string + example: + error: other + details: No metrics store has been defined + reason: + You need to configure a metrics store in order to use this + feature. + parameters: + - name: after + in: query + required: false + schema: + type: string + /telemetry/live/metrics-token: + get: + summary: Obtain Metrics Telemetry token + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: string + example: + data: 2GO4RahIkSAms6S00R9BRsroo97ZdYTz4QVxFCOwGrGkr7zguP0AVyTMA/iha3Vz/////w8DhZi1+ALBmLX4AndlYg== + /telemetry/metrics/live: + get: + summary: Live Metrics + responses: + "200": + description: OK + content: { } + parameters: + - name: metrics + in: query + required: false + schema: + type: string + - name: interval + in: query + required: false + schema: + type: number + - name: token + in: query + required: false + schema: + type: string + /principal: + get: + summary: List Principals + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: + type: array + items: { } + total: + type: number + example: + data: + items: [ ] + total: 0 + parameters: + - name: page + in: query + required: false + schema: + type: number + - name: limit + in: query + required: false + schema: + type: number + - name: types + in: query + required: false + schema: + type: string + post: + summary: Create Principal + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: number + example: + data: 50 + requestBody: + content: + application/json: + schema: + type: object + properties: + type: + type: string + quota: + type: number + name: + type: string + description: + type: string + secrets: + type: array + items: { } + emails: + type: array + items: { } + urls: + type: array + items: { } + memberOf: + type: array + items: { } + roles: + type: array + items: { } + lists: + type: array + items: { } + members: + type: array + items: { } + enabledPermissions: + type: array + items: { } + disabledPermissions: + type: array + items: { } + externalMembers: + type: array + items: { } + example: + type: domain + quota: 0 + name: example.org + description: Example domain + secrets: [ ] + emails: [ ] + urls: [ ] + memberOf: [ ] + roles: [ ] + lists: [ ] + members: [ ] + enabledPermissions: [ ] + disabledPermissions: [ ] + externalMembers: [ ] + /dkim: + post: + summary: Create DKIM Signature + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: object + nullable: true + algorithm: + type: string + domain: + type: string + selector: + type: object + nullable: true + example: + id: + algorithm: Ed25519 + domain: example.org + selector: + /principal/{principal_id}: + get: + summary: Fetch Principal + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + id: + type: number + type: + type: string + secrets: + type: string + name: + type: string + quota: + type: number + description: + type: string + emails: + type: string + roles: + type: array + items: + type: string + lists: + type: array + items: + type: string + example: + data: + id: 90 + type: individual + secrets: $6$ONjGT6nQtmPNaxw0$NNF5DXtPfOay2mfVnPJ0uQ77C.L3LNxXO/QMyphP/DzpODqbDBBGd4/gCnckYPQj3st6pqwY8/KeBsCJ.oe1Y1 + name: jane + quota: 0 + description: Jane Doe + emails: jane@example.org + roles: + - user + lists: + - all + parameters: + - name: principal_id + in: path + required: true + schema: + type: string + patch: + summary: Update Principal + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + parameters: + - name: principal_id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: array + items: + type: object + properties: + action: + type: string + field: + type: string + value: + type: string + example: + - action: set + field: name + value: jane.doe + - action: set + field: description + value: Jane Mary Doe + - action: addItem + field: emails + value: jane-doe@example.org + - action: removeItem + field: emails + value: jane@example.org + delete: + summary: Delete Principal + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + parameters: + - name: principal_id + in: path + required: true + schema: + type: string + /queue/messages: + get: + summary: List Queued Messages + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: + type: array + items: { } + total: + type: number + status: + type: boolean + example: + data: + items: [ ] + total: 0 + status: true + parameters: + - name: page + in: query + required: false + schema: + type: number + - name: max-total + in: query + required: false + schema: + type: number + - name: limit + in: query + required: false + schema: + type: number + - name: values + in: query + required: false + schema: + type: number + patch: + summary: Reschedule Queued Messages + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + example: + data: true + parameters: + - name: filter + in: query + required: false + schema: + type: string + delete: + summary: Delete Queued Messages + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + example: + data: true + parameters: + - name: text + in: query + required: false + schema: + type: string + /queue/reports: + get: + summary: List Queued Reports + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: + type: array + items: { } + total: + type: number + example: + data: + items: [ ] + total: 0 + parameters: + - name: max-total + in: query + required: false + schema: + type: number + - name: limit + in: query + required: false + schema: + type: number + - name: page + in: query + required: false + schema: + type: number + /reports/dmarc: + get: + summary: List Incoming DMARC Reports + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: + type: array + items: { } + total: + type: number + example: + data: + items: [ ] + total: 0 + parameters: + - name: max-total + in: query + required: false + schema: + type: number + - name: limit + in: query + required: false + schema: + type: number + - name: page + in: query + required: false + schema: + type: number + /reports/tls: + get: + summary: List Incoming TLS Reports + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: + type: array + items: { } + total: + type: number + example: + data: + items: [ ] + total: 0 + parameters: + - name: limit + in: query + required: false + schema: + type: number + - name: max-total + in: query + required: false + schema: + type: number + - name: page + in: query + required: false + schema: + type: number + /reports/arf: + get: + summary: List Incoming ARF Reports + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: + type: array + items: { } + total: + type: number + example: + data: + items: [ ] + total: 0 + parameters: + - name: page + in: query + required: false + schema: + type: number + - name: limit + in: query + required: false + schema: + type: number + - name: max-total + in: query + required: false + schema: + type: number + /telemetry/traces: + get: + summary: List Stored Traces + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + error: + type: string + details: + type: string + example: + error: unsupported + details: No tracing store has been configured + parameters: + - name: type + in: query + required: false + schema: + type: string + - name: page + in: query + required: false + schema: + type: number + - name: limit + in: query + required: false + schema: + type: number + - name: values + in: query + required: false + schema: + type: number + /logs: + get: + summary: Quere Log Files + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: + type: array + items: + type: object + properties: + timestamp: + type: string + level: + type: string + event: + type: string + event_id: + type: string + details: + type: string + total: + type: number + example: + data: + items: + - timestamp: "2025-01-05T14:06:29Z" + level: TRACE + event: HTTP request body + event_id: http.request-body + details: + listenerId = "http", localPort = 1443, remoteIp = ::1, + remotePort = 57223, contents = "", size = 0 + - timestamp: "2025-01-05T14:06:29Z" + level: TRACE + event: Write batch operation + event_id: store.data-write + details: elapsed = 0ms, total = 2 + - timestamp: "2025-01-05T14:06:29Z" + level: TRACE + event: Expression evaluation result + event_id: eval.result + details: + listenerId = "http", localPort = 1443, remoteIp = ::1, + remotePort = 57223, id = "server.http.allowed-endpoint", result + = "Integer(200)" + - timestamp: "2025-01-05T14:06:29Z" + level: DEBUG + event: HTTP request URL + event_id: http.request-url + details: + listenerId = "http", localPort = 1443, remoteIp = ::1, + remotePort = 57223, url = "/api/logs?page=1&limit=50&" + - timestamp: "2025-01-05T14:06:23Z" + level: TRACE + event: HTTP response body + event_id: http.response-body + details: + listenerId = "http", localPort = 1443, remoteIp = ::1, + remotePort = 57223, contents = "{"error":"unsupported","details":"No + tracing store has been configured"}", code = 200, size = 72 + - timestamp: "2025-01-05T14:06:23Z" + level: DEBUG + event: Management operation not supported + event_id: manage.not-supported + details: + listenerId = "http", localPort = 1443, remoteIp = ::1, + remotePort = 57223, details = No tracing store has been configured + - timestamp: "2025-01-05T14:06:23Z" + level: TRACE + event: HTTP request body + event_id: http.request-body + details: + listenerId = "http", localPort = 1443, remoteIp = ::1, + remotePort = 57223, contents = "", size = 0 + - timestamp: "2025-01-05T14:06:23Z" + level: TRACE + event: Write batch operation + event_id: store.data-write + details: elapsed = 0ms, total = 2 + - timestamp: "2025-01-05T14:06:23Z" + level: TRACE + event: Expression evaluation result + event_id: eval.result + details: + listenerId = "http", localPort = 1443, remoteIp = ::1, + remotePort = 57223, id = "server.http.allowed-endpoint", result + = "Integer(200)" + - timestamp: "2025-01-05T14:06:23Z" + level: DEBUG + event: HTTP request URL + event_id: http.request-url + details: + listenerId = "http", localPort = 1443, remoteIp = ::1, + remotePort = 57223, url = "/api/telemetry/traces?page=1&type=delivery.attempt-start&limit=10&values=1&" + total: 100 + parameters: + - name: page + in: query + required: false + schema: + type: number + - name: limit + in: query + required: false + schema: + type: number + /spam-filter/train/spam: + post: + summary: Train Spam Filter as Spam + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: string + example: "From: john@example.org\nTo: list@example.org\nSubject: Testing, please ignore\nContent-Type: text/plain; charset" + /spam-filter/train/ham: + post: + summary: Train Spam Filter as Ham + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: string + example: "From: john@example.org\nTo: list@example.org\nSubject: Testing, please ignore\nContent-Type: text/plain; charset" + /spam-filter/train/spam/{account_id}: + post: + summary: Train Account's Spam Filter as Spam + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + parameters: + - name: account_id + in: path + required: true + schema: + type: string + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: string + example: "From: john@example.org\nTo: list@example.org\nSubject: Testing, please ignore\nContent-Type: text/plain; charset" + /spam-filter/train/ham/{account_id}: + post: + summary: Train Account's Spam Filter as Ham + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + parameters: + - name: account_id + in: path + required: true + schema: + type: string + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: string + example: "From: john@example.org\nTo: list@example.org\nSubject: Testing, please ignore\nContent-Type: text/plain; charset" + /spam-filter/classify: + post: + summary: Test Spam Filter Classification + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + score: + type: number + tags: + type: object + properties: + FROM_NO_DN: + type: object + properties: + action: + type: string + value: + type: number + SOURCE_ASN_15169: + type: object + properties: + action: + type: string + value: + type: number + SOURCE_COUNTRY_US: + type: object + properties: + action: + type: string + value: + type: number + MISSING_DATE: + type: object + properties: + action: + type: string + value: + type: number + FROMHOST_NORES_A_OR_MX: + type: object + properties: + action: + type: string + value: + type: number + MISSING_MIME_VERSION: + type: object + properties: + action: + type: string + value: + type: number + FORGED_SENDER: + type: object + properties: + action: + type: string + value: + type: number + SPF_NA: + type: object + properties: + action: + type: string + value: + type: number + X_HDR_TO: + type: object + properties: + action: + type: string + value: + type: number + HELO_IPREV_MISMATCH: + type: object + properties: + action: + type: string + value: + type: number + X_HDR_CONTENT_TYPE: + type: object + properties: + action: + type: string + value: + type: number + AUTH_NA: + type: object + properties: + action: + type: string + value: + type: number + FORGED_RECIPIENTS: + type: object + properties: + action: + type: string + value: + type: number + RBL_SENDERSCORE_REPUT_BLOCKED: + type: object + properties: + action: + type: string + value: + type: number + RCVD_COUNT_ZERO: + type: object + properties: + action: + type: string + value: + type: number + X_HDR_SUBJECT: + type: object + properties: + action: + type: string + value: + type: number + X_HDR_FROM: + type: object + properties: + action: + type: string + value: + type: number + RCPT_COUNT_ONE: + type: object + properties: + action: + type: string + value: + type: number + MISSING_MID: + type: object + properties: + action: + type: string + value: + type: number + TO_DOM_EQ_FROM_DOM: + type: object + properties: + action: + type: string + value: + type: number + ARC_NA: + type: object + properties: + action: + type: string + value: + type: number + RCVD_TLS_LAST: + type: object + properties: + action: + type: string + value: + type: number + X_HDR_CONTENT_TRANSFER_ENCODING: + type: object + properties: + action: + type: string + value: + type: number + HELO_NORES_A_OR_MX: + type: object + properties: + action: + type: string + value: + type: number + TO_DN_NONE: + type: object + properties: + action: + type: string + value: + type: number + FROM_NEQ_ENV_FROM: + type: object + properties: + action: + type: string + value: + type: number + DMARC_NA: + type: object + properties: + action: + type: string + value: + type: number + SINGLE_SHORT_PART: + type: object + properties: + action: + type: string + value: + type: number + DKIM_NA: + type: object + properties: + action: + type: string + value: + type: number + disposition: + type: object + properties: + action: + type: string + value: + type: string + example: + data: + score: 12.7 + tags: + FROM_NO_DN: + action: allow + value: 0.0 + SOURCE_ASN_15169: + action: allow + value: 0.0 + SOURCE_COUNTRY_US: + action: allow + value: 0.0 + MISSING_DATE: + action: allow + value: 1.0 + FROMHOST_NORES_A_OR_MX: + action: allow + value: 1.5 + MISSING_MIME_VERSION: + action: allow + value: 2.0 + FORGED_SENDER: + action: allow + value: 0.3 + SPF_NA: + action: allow + value: 0.0 + X_HDR_TO: + action: allow + value: 0.0 + HELO_IPREV_MISMATCH: + action: allow + value: 1.0 + X_HDR_CONTENT_TYPE: + action: allow + value: 0.0 + AUTH_NA: + action: allow + value: 1.0 + FORGED_RECIPIENTS: + action: allow + value: 2.0 + RBL_SENDERSCORE_REPUT_BLOCKED: + action: allow + value: 0.0 + RCVD_COUNT_ZERO: + action: allow + value: 0.1 + X_HDR_SUBJECT: + action: allow + value: 0.0 + X_HDR_FROM: + action: allow + value: 0.0 + RCPT_COUNT_ONE: + action: allow + value: 0.0 + MISSING_MID: + action: allow + value: 2.5 + TO_DOM_EQ_FROM_DOM: + action: allow + value: 0.0 + ARC_NA: + action: allow + value: 0.0 + RCVD_TLS_LAST: + action: allow + value: 0.0 + X_HDR_CONTENT_TRANSFER_ENCODING: + action: allow + value: 0.0 + HELO_NORES_A_OR_MX: + action: allow + value: 0.3 + TO_DN_NONE: + action: allow + value: 0.0 + FROM_NEQ_ENV_FROM: + action: allow + value: 0.0 + DMARC_NA: + action: allow + value: 1.0 + SINGLE_SHORT_PART: + action: allow + value: 0.0 + DKIM_NA: + action: allow + value: 0.0 + disposition: + action: allow + value: + "X-Spam-Result: ARC_NA (0.00),\r\n\tDKIM_NA (0.00),\r\n + \tFROM_NEQ_ENV_FROM (0.00),\r\n\tFROM_NO_DN (0.00),\r\n\tRBL_SENDERSCORE_REPUT_BLOCKED + (0.00),\r\n\tRCPT_COUNT_ONE (0.00),\r\n\tRCVD_TLS_LAST (0.00),\r + \n\tSINGLE_SHORT_PART (0.00),\r\n\tSPF_NA (0.00),\r\n\tTO_DN_NONE + (0.00),\r\n\tTO_DOM_EQ_FROM_DOM (0.00),\r\n\tRCVD_COUNT_ZERO + (0.10),\r\n\tFORGED_SENDER (0.30),\r\n\tHELO_NORES_A_OR_MX (0.30),\r + \n\tAUTH_NA (1.00),\r\n\tDMARC_NA (1.00),\r\n\tHELO_IPREV_MISMATCH + (1.00),\r\n\tMISSING_DATE (1.00),\r\n\tFROMHOST_NORES_A_OR_MX + (1.50),\r\n\tFORGED_RECIPIENTS (2.00),\r\n\tMISSING_MIME_VERSION + (2.00),\r\n\tMISSING_MID (2.50)\r\nX-Spam-Status: Yes, score=12.70\r\ + \n" + requestBody: + content: + application/json: + schema: + type: object + properties: + message: + type: string + remoteIp: + type: string + ehloDomain: + type: string + authenticatedAs: + type: object + nullable: true + isTls: + type: boolean + envFrom: + type: string + envFromFlags: + type: number + envRcptTo: + type: array + items: + type: string + example: + message: + "From: john@example.org\nTo: list@example.org\nSubject: Testing, + please ignore\nContent-Type: text/plain; charset=\"utf-8\"\nContent-Transfer-Encoding: + 8bit\n\nTesting 1, 2, 3\n" + remoteIp: 8.8.8.8 + ehloDomain: foo.org + authenticatedAs: + isTls: true + envFrom: bill@foo.org + envFromFlags: 0 + envRcptTo: + - john@example.org + /troubleshoot/token: + get: + summary: Obtain a Troubleshooting Token + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: string + example: + data: +bS1rCUcrjoEtl9f7Vz1P6daqVs4nywxa56bHltPIASijRFrj1JrwvHxJCWphPKs/////w8E8p21+AKunrX4AndlYg== + /troubleshoot/delivery/{recipient}: + get: + summary: Run Delivery Troubleshooting + responses: + "200": + description: OK + content: { } + parameters: + - name: recipient + in: path + required: true + schema: + type: string + - name: token + in: query + required: false + schema: + type: string + /troubleshoot/dmarc: + post: + summary: Run DMARC Troubleshooting + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + spfEhloDomain: + type: string + spfEhloResult: + type: object + properties: + type: + type: string + spfMailFromDomain: + type: string + spfMailFromResult: + type: object + properties: + type: + type: string + details: + type: object + nullable: true + ipRevResult: + type: object + properties: + type: + type: string + ipRevPtr: + type: array + items: + type: string + dkimResults: + type: array + items: { } + dkimPass: + type: boolean + arcResult: + type: object + properties: + type: + type: string + dmarcResult: + type: object + properties: + type: + type: string + dmarcPass: + type: boolean + dmarcPolicy: + type: string + elapsed: + type: number + example: + data: + spfEhloDomain: mx.google.com + spfEhloResult: + type: none + spfMailFromDomain: google.com + spfMailFromResult: + type: softFail + details: + ipRevResult: + type: pass + ipRevPtr: + - dns.google. + dkimResults: [ ] + dkimPass: false + arcResult: + type: none + dmarcResult: + type: none + dmarcPass: false + dmarcPolicy: reject + elapsed: 200 + requestBody: + content: + application/json: + schema: + type: object + properties: + remoteIp: + type: string + ehloDomain: + type: string + mailFrom: + type: string + body: + type: string + example: + remoteIp: 8.8.8.8 + ehloDomain: mx.google.com + mailFrom: john@google.com + body: + "From: john@example.org\nTo: list@example.org\nSubject: Testing, + please ignore\nContent-Type: text/plain; charset=\"utf-8\"\nContent-Transfer-Encoding: + 8bit\n\nTesting 1, 2, 3\n" + /reload: + get: + summary: Reload Settings + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + warnings: + type: object + properties: { } + errors: + type: object + properties: { } + example: + data: + warnings: { } + errors: { } + parameters: + - name: dry-run + in: query + required: false + schema: + type: string + /update/spam-filter: + get: + summary: Update Spam Filter + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + /update/webadmin: + get: + summary: Update WebAdmin + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + /store/reindex: + get: + summary: Request FTS Reindex + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + /store/purge/in-memory/default/bayes-global: + get: + summary: Delete Global Bayes Model + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + /settings/keys: + get: + summary: List Settings by Key + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + lookup.default.hostname: + type: string + example: + data: + lookup.default.hostname: mx.fr.email + parameters: + - name: prefixes + in: query + required: false + schema: + type: string + - name: keys + in: query + required: false + schema: + type: string + /settings/group: + get: + summary: List Settings by Group + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + total: + type: number + items: + type: array + items: + type: object + properties: + _id: + type: string + bind: + type: string + protocol: + type: string + example: + data: + total: 11 + items: + - _id: http + bind: "[::]:1443" + protocol: http + - bind: "[::]:443" + _id: https + protocol: http + tls.implicit: "true" + - protocol: imap + bind: "[::]:143" + _id: imap + - bind: "[::]:1143" + tls.implicit: "false" + _id: imapnotls + protocol: imap + proxy.override: "false" + tls.override: "false" + tls.enable: "false" + socket.override: "false" + - bind: "[::]:993" + tls.implicit: "true" + protocol: imap + _id: imaptls + - bind: "[::]:110" + protocol: pop3 + _id: pop3 + - tls.implicit: "true" + _id: pop3s + protocol: pop3 + bind: "[::]:995" + - protocol: managesieve + _id: sieve + bind: "[::]:4190" + - bind: "[::]:25" + _id: smtp + protocol: smtp + - _id: submission + bind: "[::]:587" + protocol: smtp + parameters: + - name: limit + in: query + required: false + schema: + type: number + - name: page + in: query + required: false + schema: + type: number + - name: suffix + in: query + required: false + schema: + type: string + - name: prefix + in: query + required: false + schema: + type: string + /settings/list: + get: + summary: List Settings + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + total: + type: number + items: + type: object + properties: + enable: + type: string + format: + type: string + limits.entries: + type: string + limits.entry-size: + type: string + limits.size: + type: string + refresh: + type: string + retry: + type: string + timeout: + type: string + url: + type: string + example: + data: + total: 9 + items: + enable: "true" + format: list + limits.entries: "100000" + limits.entry-size: "512" + limits.size: "104857600" + refresh: 12h + retry: 1h + timeout: 30s + url: https://openphish.com/feed.txt + parameters: + - name: prefix + in: query + required: false + schema: + type: string + /settings: + post: + summary: Update Settings + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + requestBody: + content: + application/json: + schema: + type: array + items: + type: object + properties: + type: + type: string + prefix: + type: string + example: + - type: clear + prefix: spam-filter.rule.stwt_arc_signed. + /account/crypto: + get: + summary: Obtain Encryption-at-Rest Settings + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + type: + type: string + example: + data: + type: disabled + post: + summary: Update Encryption-at-Rest Settings + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: number + example: + data: 1 + requestBody: + content: + application/json: + schema: + type: object + properties: + type: + type: string + algo: + type: string + certs: + type: string + example: + type: pGP + algo: Aes256 + certs: + "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxsFNBGTGHwkBEADRB5EEtfsnUwgF2ZRg6h1fp2E8LNhv4lb9AWersI8KNFoWM6qx\n + Bk/MfEpgILSPdW3g7PWHOxPV/hxjtStFHfbU/Ye5VvfbkU49faIPiw1V3MQJJ171\n + cN6kgMnABfdixNiutDkHP4f34ABrEqexX2myOP+btxL24gI/N9UpOD5PiKTyKR7i\n + GwNpi+O022rs/KvjlWR7iSJ4vk7bGFfTNHvWI6dZworey1tZoTIZ0CgvgMeB/F1q\n + OOa0FvrJdNYR227RpHmICqFqTptNZ2EfdkJ6QUXW7bZ9dWgL36ds9QPJOGcG3c5i\n + JebeX5YdJnniBefiWjfZElcqh/N6SqVuEwoTLyMCnMZ6gjNMn6tddwPH24kavZhT\n + p6+vhTHmyq8XBqK/XEt9r+clSfg2hi5s7GO7hQV+W26xRjX7sQJY41PfzkgYJ0BM\n + 6+w09X1ZO/iMjEp44t2rd3xSudwGYhlbazXbdB+OJaa3RtyjOAeFgY8OyNlODx3V\n + xXLtF+104HGSL7nkpBsu6LLighSgEEF2Vok43grr0omyb1NPhWoAZhM8sT5iv5gW\n + fKvB1O13c+hDc/iGTAvcrtdLLnF2Cs+6HD7r7zPPM4L6DrD1+oQt510H/oOEE5NZ\n + wIS9CmBf0txqwk7n1U5V95lonaCK9nfoKeQ1fKl/tu01dCeERRbMXG2nCQARAQAB\n + zRtKb2huIERvZSA8am9obkBleGFtcGxlLm9yZz7CwYcEEwEIADEWIQQWwx1eM+Aa\n + o8okGzL45grMTSggxQUCZMYfCQIbAwQLCQgHBRUICQoLBRYCAwEAAAoJEPjmCsxN\n + KCDFWP4QAI3eS5nPxmU0AC9/h8jeKNgjgpENroNQZKeWZQ8x4PfncDRkcbsJfT7Y\n + IVZl4zw6gFKY5EoB1s1KkYJxPgYsqicmKNiR7Tnzabb3mzomU48FKaIyVCBzFUnJ\n + YMroL/rm7QhoW2WWLvT+CPCPway/tA3By8Be/YOjhavJ8mf1W3rPzt87/4Vo6erf\n + yzL0lN+FQmmhKfT4j42jF4SMSyyC2yzvfC7PT49u+KUKQm/LpQsfKHpwXZ/VI6+X\n + GtZjTqsc+uglJYRo69oosImLzieA/ST1ltjmUutZQOSvlQFpDUEFrMej8XZ0qsrf\n + 0gP2iwxyl0vkhV8c6wO6CacDHPivvQEHed9H1PNGn3DBfKb7Mq/jado2DapRtJg3\n + 2OH0F0HTvQ0uNKl30xMUcwGQB0cKOlaFtksZT1LsosQPhtPLpFy1TuWaXOInpQLq\n + JmNVcTbydOsCKq0mb6bgGcvhElC1q39tclKP3rOEDOnJ8hE6wYNaMGrt6WSKr3Tt\n + h52M6KwTXOuMAecMvpDBSS3UFEVQ+T5puzInDTkjINxmj23ip+swA1x3HH2IgNrO\n + VJ7O20oEf0+qC47R5rTRUxrvh/U0U3DRE5xt2J2T3xetFDT2mnQv0jcyMg/UlXXv\n + GpGVfwNkvN0Cxmb1tFiBNLKCcPVizxq4MLrwx+MVfQBaRCwjJrUszsFNBGTGHwoB\n + EACr5lA+j5pH0Er6Q76btbS4q9JgNjDNrjKJwX9brdBY1oXIUeBqCW9ekoqDTFpn\n + xA5EFGJvPO++/0ZCa+zXE4IAcXS9+I9HVBouenPYBLETnXK0Phws+OCLoe0cAIvG\n + e9Xo9VrHcGXCs9tJruVSAW3NF04YejHmnHNfEuD8mbaUdxVn5zc23w/2gLaY/ABL\n + ZfNV8XZw0jBVBm3YXS3Ob3uIO+RvsNqBgnhGYN/C51QI9hdxXWUDlD1vdRacXmcI\n + LDCYC3w6u8caxL0ktXTS4zwN+hEu7jHxBNiKcovCeIF5VZ5NcPpp6+6Y+vNdmmXw\n + +lWNwAzj3ah6iu+y25LKSsz+7IkCh5liOwwYohO+YI7SjtTD+gL9HiHYAIO+PtBh\n + 7GudmUwFoARu/q54hE4ThpzkeOzJzPqGkM/CzmwdKKM3u81ze+72ptJOqVKbFEsQ\n + 3+RURrIAfyYyeJj4VVCfHNzrRRVpARZc9hJm1AXefxPnDN9dxbikjQgbg5UxrKaJ\n + cjVU+go5CH5lg2D1LRGfKqTJtfiWFPjtztNgMp/SeslkhhFXsyJ0RJDcU8VfRBrO\n + DBnZvPnZi4nLaWCL1LdHA8Y9EJgSwVOsfdRqL/Xk9qxqgl5R8m8lsNKZN2EYkfMN\n + 4Vd+/8UBbmibHYoGIQi7UlNSPthc0XQcRzFen+3H4sg5kQARAQABwsF2BBgBCAAg\n + FiEEFsMdXjPgGqPKJBsy+OYKzE0oIMUFAmTGHwsCGwwACgkQ+OYKzE0oIMXn4hAA\n + lUWeF7tDdyENsOYyhsbtLIuLipYe6orHFY5m68NNOoLWwqEeTvutJgFeDT4WxYi0\n + PJaNQYFPyGVyg7N0hCx5cGwajdnwGpb5zpSNyvG2Yes9I1O/u7+FFrbSwOuo61t1\n + scGa8YlgTKoyGc9cwxl5U8krrlEwXTWQ/qF1Gq2wHG23wm1D2d2PXFDRvw3gPxJn\n + yWkrx5k26ru1kguM7XFVyRi7B+uG4vdvMlxMBXM3jpH1CJRr82VvzYPv7f05Z5To\n + C7XDqHpWKx3+AQvh/ZsSBpBhzK8qaixysMwnawe05rOPydWvsLlnMCGManKVnq9Y\n + Wek1P2dwYT9zuroBR5nmrECY+xVWk7vhsDasKsYlQ/LdDyzSL7qh0Vq3DjcoHxLI\n + uL7qQ3O0YRcKGfmQibpKdDzvIqA+48Nfh2nDnTxvfuwOxb41zdLTZQftaSXc0Xwd\n + HgquBAFbRDr5TyWlUUc8iACowKkk01pEPc8coxPCp6F/hz6kgmebRevzs7sxwrS7\n + aUWycSls783JC7WO267DRD30FNx+9S7SY4ECzhDGjLdne6wIoib1L9SFkk1AAKb3\n + m2+6BB/HxCXtMqi95pFeCjV99bp+PBqoifx9SlFYZq9qcGDr/jyrdG8V2Wf/HF4n\n + K8RIPxB+daAPMLTpj4WBhNquSE6mRQvABEf0GPi2eLA=\n=0TDv\n-----END PGP + PUBLIC KEY BLOCK-----\n\n\n" + /account/auth: + get: + summary: Obtain Account Authentication Settings + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + otpEnabled: + type: boolean + appPasswords: + type: array + items: { } + example: + data: + otpEnabled: false + appPasswords: [ ] + post: + summary: Update Account Authentication Settings + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + error: + type: string + details: + type: string + reason: + type: object + nullable: true + example: + error: other + details: Fallback administrator accounts do not support 2FA or AppPasswords + reason: + "401": + description: Unauthorized + content: + application/json: + schema: + type: object + properties: + type: + type: string + status: + type: number + title: + type: string + detail: + type: string + example: + type: about:blank + status: 401 + title: Unauthorized + detail: You have to authenticate first. + requestBody: + content: + application/json: + schema: + type: array + items: + type: object + properties: + type: + type: string + name: + type: string + password: + type: string + example: + - type: addAppPassword + name: dGVzdCQyMDI1LTAxLTA1VDE0OjEyOjUxLjg0NyswMDowMA== + password: $6$4M/5LmG7b13r0cdE$6zb.i6wJ3pAQHA2MRHkKg0t8bgSYb2IeqiIU115t.NugwW6VXifE0VKI5n2BQUNwdeDMUzaX82TmhuVVgC0Gx1 + /reload/: + get: + summary: Reload Settings + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + warnings: + type: object + properties: { } + errors: + type: object + properties: { } + example: + data: + warnings: { } + errors: { } + /queue/status/stop: + patch: + summary: Stop Queue Processing + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + example: + data: true + /queue/status/start: + patch: + summary: Resume Queue Processing + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + example: + data: false + /queue/messages/{message_id}: + get: + summary: Obtain Queued Message Details + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + id: + type: number + return_path: + type: string + domains: + type: array + items: + type: object + properties: + name: + type: string + status: + type: string + recipients: + type: array + items: + type: object + properties: + address: + type: string + status: + type: string + retry_num: + type: number + next_retry: + type: string + next_notify: + type: string + expires: + type: string + created: + type: string + size: + type: number + blob_hash: + type: string + example: + data: + id: 217700302698266624 + return_path: pepe@pepe.com + domains: + - name: example.org + status: scheduled + recipients: + - address: john@example.org + status: scheduled + retry_num: 0 + next_retry: "2025-01-05T14:33:15Z" + next_notify: "2025-01-06T14:33:15Z" + expires: "2025-01-10T14:33:15Z" + created: "2025-01-05T14:33:15Z" + size: 1451 + blob_hash: ykrZ_KghvdG2AdjH4AZajkSvZvcsxP_oI2HEZvw-tS0 + "404": + description: Not Found + content: + application/json: + schema: + type: object + properties: + type: + type: string + status: + type: number + title: + type: string + detail: + type: string + example: + type: about:blank + status: 404 + title: Not Found + detail: The requested resource does not exist on this server. + parameters: + - name: message_id + in: path + required: true + schema: + type: string + patch: + summary: Reschedule Delivery of Queued Message + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + example: + data: true + parameters: + - name: message_id + in: path + required: true + schema: + type: string + delete: + summary: Cancel Delivery of Queued Message + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + example: + data: true + parameters: + - name: message_id + in: path + required: true + schema: + type: string + /store/blobs/{blob_id}: + get: + summary: Fetch Blob by ID + responses: + "200": + description: OK + content: { } + parameters: + - name: blob_id + in: path + required: true + schema: + type: string + - name: limit + in: query + required: false + schema: + type: number + /telemetry/trace/{trace_id}: + get: + summary: Obtain Trace Details + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + text: + type: string + details: + type: string + createdAt: + type: string + type: + type: string + data: + type: object + properties: + listenerId: + type: string + localPort: + type: number + remoteIp: + type: string + remotePort: + type: number + example: + data: + - text: SMTP connection started + details: A new SMTP connection was started + createdAt: "2025-01-05T14:34:50Z" + type: smtp.connection-start + data: + listenerId: smtp + localPort: 25 + remoteIp: ::1 + remotePort: 57513 + - text: SMTP EHLO command + details: The remote server sent an EHLO command + createdAt: "2025-01-05T14:34:50Z" + type: smtp.ehlo + data: + domain: test.eml + - text: SPF EHLO check failed + details: EHLO identity failed SPF check + createdAt: "2025-01-05T14:34:50Z" + type: smtp.spf-ehlo-fail + data: + domain: test.eml + result: + type: spf.none + text: No SPF record + details: No SPF record was found + data: { } + elapsed: 24 + - text: IPREV check passed + details: Reverse IP check passed + createdAt: "2025-01-05T14:34:50Z" + type: smtp.iprev-pass + data: + domain: test.eml + result: + type: iprev.pass + text: IPREV check passed + details: The IPREV check has passed + data: + details: + - localhost. + elapsed: 0 + - text: SPF From check failed + details: MAIL FROM identity failed SPF check + createdAt: "2025-01-05T14:34:50Z" + type: smtp.spf-from-fail + data: + domain: test.eml + from: pepe@pepe.com + result: + type: spf.none + text: No SPF record + details: No SPF record was found + data: { } + elapsed: 18 + - text: SMTP MAIL FROM command + details: The remote client sent a MAIL FROM command + createdAt: "2025-01-05T14:34:50Z" + type: smtp.mail-from + data: + from: pepe@pepe.com + - text: SMTP RCPT TO command + details: The remote client sent an RCPT TO command + createdAt: "2025-01-05T14:34:50Z" + type: smtp.rcpt-to + data: + to: john@example.org + - text: DKIM verification failed + details: Failed to verify DKIM signature + createdAt: "2025-01-05T14:34:50Z" + type: smtp.dkim-fail + data: + strict: false + result: [ ] + elapsed: 0 + - text: ARC verification passed + details: Successful ARC verification + createdAt: "2025-01-05T14:34:50Z" + type: smtp.arc-pass + data: + strict: false + result: + type: dkim.none + text: No DKIM signature + details: No DKIM signature was found + data: { } + elapsed: 0 + - text: DMARC check failed + details: Failed to verify DMARC policy + createdAt: "2025-01-05T14:34:50Z" + type: smtp.dmarc-fail + data: + strict: false + domain: example.org + policy: reject + result: + type: dmarc.none + text: No DMARC record + details: No DMARC record was found + data: { } + elapsed: 0 + parameters: + - name: trace_id + in: path + required: true + schema: + type: string + /telemetry/live/tracing-token: + get: + summary: Request a Tracing Token + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: string + example: + data: VLxkixOwgDF8Frj0wi8kPhx3SpzKqtsDvbo25wgKw2tBIz/O8La0dwioQw9pN11c/////w8Ctau1+ALxq7X4AndlYg== + /telemetry/traces/live: + get: + summary: Start Live Tracing + responses: + "200": + description: OK + content: { } + parameters: + - name: filter + in: query + required: false + schema: + type: string + - name: token + in: query + required: false + schema: + type: string + /dns/records/{domain}: + get: + summary: Obtain DNS Records for Domain + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + type: + type: string + name: + type: string + content: + type: string + example: + data: + - type: MX + name: example.org. + content: 10 mx.fr.email. + - type: CNAME + name: mail.example.org. + content: mx.fr.email. + - type: TXT + name: 202501e._domainkey.example.org. + content: v=DKIM1; k=ed25519; h=sha256; p=82LqzMGRHEBI2HGDogjojWGz+Crrv0TAi8pcaOBd1vw= + - type: TXT + name: 202501r._domainkey.example.org. + content: v=DKIM1; k=rsa; h=sha256; + p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1qtCbIlrZffIqm7gHqpihPUlxOq1zD6K3j1RO/enhkZRp5dEdCqcLbyFk5d+rqRsVIWwUZiU4HXHWqMTN1hlKojUlzmU1JYtlHRMwtM5vN4mzG4x1KA0i8ZHxkahE8ITsP+kPByDF9x0vAySHXpyErNXq3BeFyu/VW+6X+fmUW6x39PfWq7kQQTcwU0Ogo447oJfmAX9H4Z+/cD5WJVNiLgvLY6faVgoXm0mJJjRU5xoEStXoUcKwrwbl7G3K7JfxtmWsgEn97auV6v4he2LRRfTxbY9smkqUtcJs61E9iyyYroJv0iRda2pv71qg8e4wTb2sqBloZv/F2FZQhM+wIDAQAB + - type: TXT + name: example.org. + content: v=spf1 mx ra=postmaster -all + - type: SRV + name: _jmap._tcp.example.org. + content: 0 1 443 mx.fr.email. + - type: SRV + name: _imaps._tcp.example.org. + content: 0 1 993 mx.fr.email. + - type: SRV + name: _imap._tcp.example.org. + content: 0 1 143 mx.fr.email. + - type: SRV + name: _imap._tcp.example.org. + content: 0 1 1143 mx.fr.email. + - type: SRV + name: _pop3s._tcp.example.org. + content: 0 1 995 mx.fr.email. + parameters: + - name: domain + in: path + required: true + schema: + type: string + /store/purge/account/{account_id}: + get: + summary: Purge Account + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + parameters: + - name: account_id + in: path + required: true + schema: + type: string + /store/purge/in-memory/default/bayes-account/{account_id}: + get: + summary: Delete Bayes Model for Account + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + parameters: + - name: account_id + in: path + required: true + schema: + type: string + /store/undelete/{account_id}: + get: + summary: List Deleted Messages + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: + type: array + items: { } + total: + type: number + example: + data: + items: [ ] + total: 0 + parameters: + - name: account_id + in: path + required: true + schema: + type: string + - name: limit + in: query + required: false + schema: + type: number + - name: page + in: query + required: false + schema: + type: number + post: + summary: Undelete Messages + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + type: + type: string + example: + data: + - type: success + parameters: + - name: account_id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: array + items: + type: object + properties: + hash: + type: string + collection: + type: string + restoreTime: + type: string + cancelDeletion: + type: string + example: + - hash: 9pDYGrkDlLYuBNl062qhi0wStnDYyq4ZWalnj2vXbLY + collection: email + restoreTime: "2025-01-05T14:50:13Z" + cancelDeletion: "2025-02-04T14:50:13Z" + /queue/status: + get: + summary: Obtain Queue Status + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + example: + data: true + /store/purge/blob: + get: + summary: Purge Blob Store + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + /store/purge/data: + get: + summary: Purge Data Store + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + /store/purge/in-memory: + get: + summary: Purge In-Memory Store + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + /store/purge/account: + get: + summary: Purge All Accounts + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + nullable: true + example: + data: + /store/uids/{account_id}: + delete: + summary: Reset IMAP UIDs for Account + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: number + example: + data: + - 0 + - 0 + parameters: + - name: account_id + in: path + required: true + schema: + type: string diff --git a/StalwartSimpleLoginMiddleware/Constants/EnvironmentVariable.cs b/StalwartSimpleLoginMiddleware/Constants/EnvironmentVariable.cs new file mode 100644 index 0000000..69e7c84 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Constants/EnvironmentVariable.cs @@ -0,0 +1,10 @@ +namespace StalwartSimpleLoginMiddleware.Constants; + +public class EnvironmentVariable +{ + public const string MailServerUri = "MAIL_SERVER_URI"; + public const string MailServerApiKey = "MAIL_SERVER_API_KEY"; + public const string PostgresUrl = "POSTGRES_URL"; + public const string PostgresMinPoolSize = "POSTGRES_MIN_POOL_SIZE"; + public const string PostgresMaxPoolSize = "POSTGRES_MAX_POOL_SIZE"; +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Contexts/ApiKeyContext.cs b/StalwartSimpleLoginMiddleware/Contexts/ApiKeyContext.cs new file mode 100644 index 0000000..83d3ddc --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Contexts/ApiKeyContext.cs @@ -0,0 +1,146 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using StalwartSimpleLoginMiddleware.Constants; +using StalwartSimpleLoginMiddleware.Entities; +using StalwartSimpleLoginMiddleware.Utilities; + +namespace StalwartSimpleLoginMiddleware.Contexts; + +public class ApiKeyContext : DbContext +{ + public DbSet ApiKeys { get; set; } + public DbSet Members { get; set; } + + public ApiKeyContext() + { + } + + public ApiKeyContext(DbContextOptions options) : base(options) + { + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + ConfigureOptions(optionsBuilder); + } + + public static DbContextOptionsBuilder ConfigureOptions(DbContextOptionsBuilder optionsBuilder) + { + if (optionsBuilder.IsConfigured) return optionsBuilder; + + var connectionString = Environment.GetEnvironmentVariable(EnvironmentVariable.PostgresUrl); + + if (string.IsNullOrEmpty(connectionString)) + { + // Default connection + var defaultConnectionString = "Host=localhost;Port=5432;Database=postgres;"; + optionsBuilder.UseNpgsql(defaultConnectionString, + nsqlOptions => nsqlOptions.MigrationsAssembly("StalwartSimpleLoginMiddleware")); + return optionsBuilder; + } + + var pgConnectionString = ConnectionHelper.GetPostgresConnectionString(connectionString); + + // Configure pooling + var connectionStringBuilder = new NpgsqlConnectionStringBuilder(pgConnectionString) + { + MinPoolSize = 10 + }; + if (int.TryParse(Environment.GetEnvironmentVariable(EnvironmentVariable.PostgresMinPoolSize), + out var minPoolSize)) + { + connectionStringBuilder.MinPoolSize = minPoolSize; + } + + if (int.TryParse(Environment.GetEnvironmentVariable(EnvironmentVariable.PostgresMaxPoolSize), + out var maxPoolSize)) + { + connectionStringBuilder.MaxPoolSize = maxPoolSize; + } + + var pooledConnectionString = connectionStringBuilder.ToString(); + + optionsBuilder.UseLazyLoadingProxies() + .UseNpgsql(pooledConnectionString, + nsqlOptions => nsqlOptions.MigrationsAssembly("StalwartSimpleLoginMiddleware")); + return optionsBuilder; + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasKey(a => a.Key); // Primary key + + // Members navigation + entity.HasMany(a => a.Members) + .WithOne(m => m.ApiKey) + .HasForeignKey(m => m.ApiKeyId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + }); + + // Configure Member entity + modelBuilder.Entity(entity => + { + // Composite key: ApiKeyId + Email + entity.HasKey(m => new { m.ApiKeyId, m.Email }); + + // Configure foreign key relationship with ApiKey + entity.HasOne(m => m.ApiKey) + .WithMany(a => a.Members) + .HasForeignKey(m => m.ApiKeyId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + }); + } + + // Method to check and apply migrations + public async Task EnsureDatabaseMigrated(ILogger logger) + { + logger.LogInformation("Checking available migrations..."); + + // Get all migrations and log them + var migrations = Database.GetMigrations().ToList(); + logger.LogInformation($"Total migrations found: {migrations.Count}"); + + foreach (var migration in migrations) + { + logger.LogInformation($"Migration: {migration}"); + } + + // Check applied migrations + var pendingMigrations = (await Database.GetPendingMigrationsAsync()).ToArray(); + var appliedMigrations = (await Database.GetAppliedMigrationsAsync()).ToArray(); + + logger.LogInformation($"Applied migrations: {string.Join(", ", appliedMigrations)}"); + + if (pendingMigrations.Any()) + { + logger.LogInformation($"Applying migrations: {string.Join(", ", pendingMigrations)}"); + await Database.MigrateAsync(); + logger.LogInformation("Database migrated."); + + if (appliedMigrations.Length == 0) + { + // Create Admin key on first migration + var apiKey = ApiKeyHelper.GenerateKey(); + ApiKeys.Add(new ApiKey + { + Key = apiKey, + OwnerEmail = "admin@domain.tld", + IsAdmin = true + }); + await SaveChangesAsync(); + + Console.WriteLine($"Generated Admin API Key: {apiKey}"); + } + } + else + { + logger.LogInformation("No pending migrations found."); + } + } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Controllers/AdminController.cs b/StalwartSimpleLoginMiddleware/Controllers/AdminController.cs new file mode 100644 index 0000000..0d1fc29 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Controllers/AdminController.cs @@ -0,0 +1,109 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using StalwartSimpleLoginMiddleware.Contexts; +using StalwartSimpleLoginMiddleware.Entities; +using StalwartSimpleLoginMiddleware.Models; +using StalwartSimpleLoginMiddleware.Utilities; + +namespace StalwartSimpleLoginMiddleware.Controllers; + +[ApiController] +[Authorize(Roles = "Admin")] +[Route("api/[controller]/[action]")] +public class AdminController : ControllerBase +{ + private readonly ApiKeyContext context; + + public AdminController(ApiKeyContext context) + { + this.context = context; + } + + [HttpGet] + public async Task ListApiKeys([FromQuery] int page = 0, [FromQuery] int limit = 100) + { + return await context.ApiKeys + .Include(apiKey => apiKey.Members) + .Skip(page * limit) + .Take(limit).ToArrayAsync(); + } + + [HttpGet] + public async Task GetApiKey([FromQuery] string key) + { + var apiKey = await context.ApiKeys + .Include(apiKey => apiKey.Members) + .Select(apiKey => new ApiKey + { + Key = apiKey.Key, + OwnerEmail = apiKey.OwnerEmail, + IsAdmin = apiKey.IsAdmin, + Members = apiKey.Members.ToArray() + }) + .FirstOrDefaultAsync(apiKey => apiKey.Key == key); + if (apiKey == null) throw new BadHttpRequestException("API Key is invalid."); + + return apiKey; + } + + [HttpPost] + public async Task UpdateApiKeyOwnerEmail([FromBody] UpdateOwnerEmailInput input) + { + var rows = await context.ApiKeys.Where(apiKey => apiKey.Key == input.ApiKey) + .ExecuteUpdateAsync(apiKey => apiKey.SetProperty(p => p.OwnerEmail, input.OwnerEmail)); + if (rows == 0) return NotFound(); + return Ok(); + } + + [HttpPost] + public async Task CreateApiKey([FromBody] ApiKeyInput newApiKeyInput) + { + if (string.IsNullOrEmpty(ApiKeyHelper.GetEmailDomain(newApiKeyInput.OwnerEmail))) + return BadRequest("Owner Email must be a valid email address."); + var apiKey = new ApiKey + { + Key = ApiKeyHelper.GenerateKey(), + OwnerEmail = newApiKeyInput.OwnerEmail, + IsAdmin = newApiKeyInput.IsAdmin, + Members = newApiKeyInput.Members.Select(m => new Member { Email = m.Email, IsExternal = m.IsExternal }) + .ToArray() + }; + context.ApiKeys.Add(apiKey); + await context.SaveChangesAsync(); + return CreatedAtAction(nameof(GetApiKey), new { key = apiKey.Key }, apiKey); + } + + [HttpPost] + public async Task CreateApiKeyMember([FromBody] AddApiKeyMemberInput input) + { + var member = new Member + { + ApiKeyId = input.ApiKey, + Email = input.Member.Email, + IsExternal = input.Member.IsExternal + }; + context.Members.Add(member); + await context.SaveChangesAsync(); + return CreatedAtAction(nameof(GetApiKey), new { key = input.ApiKey }); + } + + [HttpDelete] + public async Task DeleteApiKey([FromQuery] string key) + { + var rows = await context.ApiKeys.Where(apiKey => apiKey.Key == key) + .ExecuteDeleteAsync(); + if (rows == 0) return NotFound(); + return Ok(); + } + + [HttpDelete] + public async Task DeleteApiKeyMemberEmail([FromQuery] string key, [FromQuery] string email) + { + var rows = await context.Members.Where(member => member.ApiKeyId == key) + .Where(member => member.Email == email) + .ExecuteDeleteAsync(); + if (rows == 0) return NotFound(); + return Ok(); + } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Controllers/AliasController.cs b/StalwartSimpleLoginMiddleware/Controllers/AliasController.cs new file mode 100644 index 0000000..39459e9 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Controllers/AliasController.cs @@ -0,0 +1,56 @@ +using AdOrbitSDK; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using StalwartSimpleLoginMiddleware.Models; +using StalwartSimpleLoginMiddleware.Services; + +namespace StalwartSimpleLoginMiddleware.Controllers; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public class AliasController(StalwartClient stalwartClient) : ControllerBase +{ + [Route("random/new")] + [HttpPost] + public async Task NewRandomAlias([FromQuery] string? hostname, + [FromQuery] string? mode, + [FromBody] NewRandomAliasInput body) + { + var apiKeyAccessor = HttpContext.RequestServices.GetRequiredService(); + var randomAlias = Get8CharacterRandomString(); + var client = await stalwartClient.GetClient(); + var requestBody = new Body2 + { + Type = "list", + Name = randomAlias, + Description = body.Note, + Emails = new List { $"{randomAlias}@{apiKeyAccessor.Metadata.Domain}" }, + Members = apiKeyAccessor.Metadata.Members as ICollection, + ExternalMembers = apiKeyAccessor.Metadata.ExternalMembers as ICollection + }; + await client.PrincipalPOSTAsync(requestBody); + + return Created(null as string, new NewRandomAliasOutput + { + CreationDate = DateTime.Today.Date, + CreationTimestamp = DateTime.Now.Ticks, + Email = requestBody.Emails?.OfType().FirstOrDefault() ?? string.Empty, + Alias = requestBody.Emails?.OfType().FirstOrDefault() ?? string.Empty, + Name = requestBody.Name ?? string.Empty, + Enabled = true, + Id = new Random().Next(), + Mailbox = new Mailbox { Id = new Random().Next(), Email = apiKeyAccessor.Metadata.Members.First() }, + Mailboxes = apiKeyAccessor.Metadata.Members.Concat(apiKeyAccessor.Metadata.ExternalMembers) + .Select(email => new Mailbox { Id = new Random().Next(), Email = email.ToString() }), + Note = body.Note ?? string.Empty, + }); + } + + private static string Get8CharacterRandomString() + { + var path = Path.GetRandomFileName(); + path = path.Replace(".", ""); // Remove period + return path.Substring(0, 8); // Return 8 character string + } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Dockerfile b/StalwartSimpleLoginMiddleware/Dockerfile new file mode 100644 index 0000000..f659570 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Dockerfile @@ -0,0 +1,29 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled-extra AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:8.0-jammy AS build +ARG BUILD_CONFIGURATION=Release +ENV DOTNET_TOOLS_PATH=/build/.dotnet-tools +ENV PATH="$DOTNET_TOOLS_PATH:$PATH" + +RUN dotnet tool install NSwag.ConsoleCore --tool-path $DOTNET_TOOLS_PATH --version 14.2.0.0 + +WORKDIR /src +COPY StalwartSimpleLoginMiddleware/StalwartSimpleLoginMiddleware.csproj StalwartSimpleLoginMiddleware/ +COPY StalwartSDK/StalwartSDK.csproj StalwartSDK/ + +RUN dotnet restore "StalwartSimpleLoginMiddleware/StalwartSimpleLoginMiddleware.csproj" + +COPY . . +# Run NSwag for StalwartSDK +RUN cd /src/StalwartSDK && \ + nswag run nswag.json + +RUN dotnet publish StalwartSimpleLoginMiddleware/StalwartSimpleLoginMiddleware.csproj -c $BUILD_CONFIGURATION --no-restore -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "StalwartSimpleLoginMiddleware.dll"] \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Entities/ApiKey.cs b/StalwartSimpleLoginMiddleware/Entities/ApiKey.cs new file mode 100644 index 0000000..aba350c --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Entities/ApiKey.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; + +namespace StalwartSimpleLoginMiddleware.Entities; + +public class ApiKey +{ + [Key] + [StringLength(88)] + [Unicode(false)] + public string Key { get; set; } + + [Required] [StringLength(254)] public string OwnerEmail { get; set; } + + public bool IsAdmin { get; set; } + + public virtual ICollection Members { get; set; } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Entities/Member.cs b/StalwartSimpleLoginMiddleware/Entities/Member.cs new file mode 100644 index 0000000..c08a96b --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Entities/Member.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace StalwartSimpleLoginMiddleware.Entities; + +public class Member +{ + [Required] [StringLength(88)] public string ApiKeyId { get; set; } + + [Required] [StringLength(254)] public string Email { get; set; } + + public bool IsExternal { get; set; } + + public virtual ApiKey ApiKey { get; set; } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Migrations/20250510085312_Initial.Designer.cs b/StalwartSimpleLoginMiddleware/Migrations/20250510085312_Initial.Designer.cs new file mode 100644 index 0000000..0eae5a9 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Migrations/20250510085312_Initial.Designer.cs @@ -0,0 +1,83 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using StalwartSimpleLoginMiddleware.Contexts; + +#nullable disable + +namespace StalwartSimpleLoginMiddleware.Migrations +{ + [DbContext(typeof(ApiKeyContext))] + [Migration("20250510085312_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.ApiKey", b => + { + b.Property("Key") + .HasMaxLength(88) + .IsUnicode(false) + .HasColumnType("character varying(88)"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("OwnerEmail") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.HasKey("Key"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.Member", b => + { + b.Property("ApiKeyId") + .HasMaxLength(88) + .HasColumnType("character varying(88)"); + + b.Property("Email") + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("IsExternal") + .HasColumnType("boolean"); + + b.HasKey("ApiKeyId", "Email"); + + b.ToTable("Members"); + }); + + modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.Member", b => + { + b.HasOne("StalwartSimpleLoginMiddleware.Entities.ApiKey", "ApiKey") + .WithMany("Members") + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApiKey"); + }); + + modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.ApiKey", b => + { + b.Navigation("Members"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StalwartSimpleLoginMiddleware/Migrations/20250510085312_Initial.cs b/StalwartSimpleLoginMiddleware/Migrations/20250510085312_Initial.cs new file mode 100644 index 0000000..520765f --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Migrations/20250510085312_Initial.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StalwartSimpleLoginMiddleware.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ApiKeys", + columns: table => new + { + Key = table.Column(type: "character varying(88)", unicode: false, maxLength: 88, nullable: false), + OwnerEmail = table.Column(type: "character varying(254)", maxLength: 254, nullable: false), + IsAdmin = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiKeys", x => x.Key); + }); + + migrationBuilder.CreateTable( + name: "Members", + columns: table => new + { + ApiKeyId = table.Column(type: "character varying(88)", maxLength: 88, nullable: false), + Email = table.Column(type: "character varying(254)", maxLength: 254, nullable: false), + IsExternal = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Members", x => new { x.ApiKeyId, x.Email }); + table.ForeignKey( + name: "FK_Members_ApiKeys_ApiKeyId", + column: x => x.ApiKeyId, + principalTable: "ApiKeys", + principalColumn: "Key", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Members"); + + migrationBuilder.DropTable( + name: "ApiKeys"); + } + } +} diff --git a/StalwartSimpleLoginMiddleware/Migrations/ApiKeyContextModelSnapshot.cs b/StalwartSimpleLoginMiddleware/Migrations/ApiKeyContextModelSnapshot.cs new file mode 100644 index 0000000..db28f08 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Migrations/ApiKeyContextModelSnapshot.cs @@ -0,0 +1,80 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using StalwartSimpleLoginMiddleware.Contexts; + +#nullable disable + +namespace StalwartSimpleLoginMiddleware.Migrations +{ + [DbContext(typeof(ApiKeyContext))] + partial class ApiKeyContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.ApiKey", b => + { + b.Property("Key") + .HasMaxLength(88) + .IsUnicode(false) + .HasColumnType("character varying(88)"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("OwnerEmail") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.HasKey("Key"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.Member", b => + { + b.Property("ApiKeyId") + .HasMaxLength(88) + .HasColumnType("character varying(88)"); + + b.Property("Email") + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("IsExternal") + .HasColumnType("boolean"); + + b.HasKey("ApiKeyId", "Email"); + + b.ToTable("Members"); + }); + + modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.Member", b => + { + b.HasOne("StalwartSimpleLoginMiddleware.Entities.ApiKey", "ApiKey") + .WithMany("Members") + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApiKey"); + }); + + modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.ApiKey", b => + { + b.Navigation("Members"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StalwartSimpleLoginMiddleware/Models/AddApiKeyMemberInput.cs b/StalwartSimpleLoginMiddleware/Models/AddApiKeyMemberInput.cs new file mode 100644 index 0000000..259fbb8 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Models/AddApiKeyMemberInput.cs @@ -0,0 +1,7 @@ +namespace StalwartSimpleLoginMiddleware.Models; + +public class AddApiKeyMemberInput +{ + public string ApiKey { get; set; } + public MemberInput Member { get; set; } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Models/ApiKeyAccessor.cs b/StalwartSimpleLoginMiddleware/Models/ApiKeyAccessor.cs new file mode 100644 index 0000000..2a018ff --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Models/ApiKeyAccessor.cs @@ -0,0 +1,9 @@ +using AspNetCore.Authentication.ApiKey; + +namespace StalwartSimpleLoginMiddleware.Models; + +public class ApiKeyAccessor : IApiKeyAccessor +{ + public IApiKey ApiKey { get; set; } + public KeyMetadata Metadata { get; set; } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Models/ApiKeyInput.cs b/StalwartSimpleLoginMiddleware/Models/ApiKeyInput.cs new file mode 100644 index 0000000..e526008 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Models/ApiKeyInput.cs @@ -0,0 +1,8 @@ +namespace StalwartSimpleLoginMiddleware.Models; + +public class ApiKeyInput +{ + public string OwnerEmail { get; set; } + public bool IsAdmin { get; set; } + public ICollection Members { get; set; } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Models/DbApiKey.cs b/StalwartSimpleLoginMiddleware/Models/DbApiKey.cs new file mode 100644 index 0000000..7c7bac0 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Models/DbApiKey.cs @@ -0,0 +1,11 @@ +using System.Security.Claims; +using AspNetCore.Authentication.ApiKey; + +namespace StalwartSimpleLoginMiddleware.Models; + +public class DbApiKey : IApiKey +{ + public string Key { get; set; } + public string OwnerName { get; set; } + public IReadOnlyCollection Claims { get; set; } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Models/IApiKeyAccessor.cs b/StalwartSimpleLoginMiddleware/Models/IApiKeyAccessor.cs new file mode 100644 index 0000000..94cce95 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Models/IApiKeyAccessor.cs @@ -0,0 +1,9 @@ +using AspNetCore.Authentication.ApiKey; + +namespace StalwartSimpleLoginMiddleware.Models; + +public interface IApiKeyAccessor +{ + IApiKey ApiKey { get; set; } + KeyMetadata Metadata { get; set; } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Models/KeyMetadata.cs b/StalwartSimpleLoginMiddleware/Models/KeyMetadata.cs new file mode 100644 index 0000000..7b50da1 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Models/KeyMetadata.cs @@ -0,0 +1,8 @@ +namespace StalwartSimpleLoginMiddleware.Models; + +public class KeyMetadata +{ + public string Domain { get; set; } + public ICollection Members { get; set; } + public ICollection ExternalMembers { get; set; } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Models/MemberInput.cs b/StalwartSimpleLoginMiddleware/Models/MemberInput.cs new file mode 100644 index 0000000..e5b4633 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Models/MemberInput.cs @@ -0,0 +1,7 @@ +namespace StalwartSimpleLoginMiddleware.Models; + +public class MemberInput +{ + public string Email { get; set; } + public bool IsExternal { get; set; } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Models/NewRandomAliasInput.cs b/StalwartSimpleLoginMiddleware/Models/NewRandomAliasInput.cs new file mode 100644 index 0000000..9eca5b0 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Models/NewRandomAliasInput.cs @@ -0,0 +1,6 @@ +namespace StalwartSimpleLoginMiddleware.Models; + +public class NewRandomAliasInput +{ + public string? Note { get; set; } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Models/NewRandomAliasOutput.cs b/StalwartSimpleLoginMiddleware/Models/NewRandomAliasOutput.cs new file mode 100644 index 0000000..4a1a43e --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Models/NewRandomAliasOutput.cs @@ -0,0 +1,21 @@ +namespace StalwartSimpleLoginMiddleware.Models; + +public class NewRandomAliasOutput +{ + public DateTime CreationDate { get; set; } + public long CreationTimestamp { get; set; } + public string Email { get; set; } + public string Alias { get; set; } + public string Name { get; set; } + public bool Enabled { get; set; } + public int Id { get; set; } + public Mailbox Mailbox { get; set; } + public IEnumerable Mailboxes { get; set; } + public string Note { get; set; } +} + +public class Mailbox +{ + public string Email { get; set; } + public int Id { get; set; } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Models/UpdateOwnerEmailInput.cs b/StalwartSimpleLoginMiddleware/Models/UpdateOwnerEmailInput.cs new file mode 100644 index 0000000..74be35b --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Models/UpdateOwnerEmailInput.cs @@ -0,0 +1,7 @@ +namespace StalwartSimpleLoginMiddleware.Models; + +public class UpdateOwnerEmailInput +{ + public string ApiKey { get; set; } + public string OwnerEmail { get; set; } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Program.cs b/StalwartSimpleLoginMiddleware/Program.cs new file mode 100644 index 0000000..d3205d0 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Program.cs @@ -0,0 +1,116 @@ +using System.Text.Json.Serialization; +using AspNetCore.Authentication.ApiKey; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.OpenApi.Models; +using Npgsql; +using StalwartSimpleLoginMiddleware.Contexts; +using StalwartSimpleLoginMiddleware.Models; +using StalwartSimpleLoginMiddleware.Repositories; +using StalwartSimpleLoginMiddleware.Services; + +var logger = LoggerFactory.Create(logging => logging.AddConsole()).CreateLogger("Startup"); +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddDbContextPool((_, options) => ApiKeyContext.ConfigureOptions(options)); + +builder.Services.AddHealthChecks() + .AddCheck("WebService", () => HealthCheckResult.Healthy("The web service is running.")) + .AddDbContextCheck(); + +builder.Services.AddScoped() + .AddScoped(); + +builder.Services.AddAuthentication(ApiKeyDefaults.AuthenticationScheme) + .AddApiKeyInHeader(options => + { + options.Realm = "StalwartSimpleLoginMiddleware"; + options.KeyName = "Authentication"; + options.Events = new ApiKeyProvider(); + }); + +builder.Services.AddAuthorization(options => +{ + options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(); +}); + +builder.Services.AddControllers() + .AddJsonOptions(options => { options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; }); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + // Add the API Key Security Definition + c.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme + { + Description = + "API Key needed to access endpoints. Add it to the request headers as 'Authentication: '", + Type = SecuritySchemeType.ApiKey, + Name = "Authentication", // Header name for the API Key + In = ParameterLocation.Header + }); + + // Add security requirements to ensure the header is required + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "ApiKey" + } + }, + new string[] { } // No specific scopes + } + }); +}); + +builder.Services.AddSingleton() + .AddSingleton(); + +var app = builder.Build(); + +// Ensure the database is migrated at startup. +using (var scope = app.Services.CreateScope()) +{ + try + { + var services = scope.ServiceProvider; + var context = services.GetRequiredService(); + await context.EnsureDatabaseMigrated(logger); + } + catch (NpgsqlException ex) + { + if (ex.SqlState is PostgresErrorCodes.ConnectionFailure or PostgresErrorCodes.ConnectionException || + ex.Message.StartsWith("Failed to connect")) + logger.LogCritical($"Database connection failed: {ex.Message}"); + else if (ex.SqlState is PostgresErrorCodes.InvalidPassword + or PostgresErrorCodes.InvalidAuthorizationSpecification) + logger.LogCritical("Failed to connect. Invalid password."); + else + logger.LogCritical($"An unknown error occurred:{Environment.NewLine}{ex.StackTrace}"); + } +} + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseRouting(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +app.MapHealthChecks("/health"); +app.Run(); \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Repositories/ApiKeyContextRepository.cs b/StalwartSimpleLoginMiddleware/Repositories/ApiKeyContextRepository.cs new file mode 100644 index 0000000..3d808d9 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Repositories/ApiKeyContextRepository.cs @@ -0,0 +1,28 @@ +using AspNetCore.Authentication.ApiKey; +using Microsoft.EntityFrameworkCore; +using StalwartSimpleLoginMiddleware.Contexts; +using StalwartSimpleLoginMiddleware.Models; +using StalwartSimpleLoginMiddleware.Utilities; + +namespace StalwartSimpleLoginMiddleware.Repositories; + +public class ApiKeyContextRepository(ApiKeyContext context) : IApiKeyRepository +{ + public async Task GetApiKeyAsync(string key) + { + var dbKey = await context.ApiKeys.AsNoTracking() + .FirstOrDefaultAsync(api => api.Key == key); + if (dbKey == null) return null; + + return ApiKeyHelper.CreateDbApiKey(dbKey); + } + + public async Task GetMetadataAsync(string key) + { + var dbKey = await context.ApiKeys.AsNoTracking() + .Include(api => api.Members) + .FirstAsync(api => api.Key == key); + + return ApiKeyHelper.CreateKeyMetadata(dbKey); + } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Repositories/IApiKeyRepository.cs b/StalwartSimpleLoginMiddleware/Repositories/IApiKeyRepository.cs new file mode 100644 index 0000000..ff14206 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Repositories/IApiKeyRepository.cs @@ -0,0 +1,10 @@ +using AspNetCore.Authentication.ApiKey; +using StalwartSimpleLoginMiddleware.Models; + +namespace StalwartSimpleLoginMiddleware.Repositories; + +public interface IApiKeyRepository +{ + Task GetApiKeyAsync(string key); + Task GetMetadataAsync(string key); +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Services/ApiKeyProvider.cs b/StalwartSimpleLoginMiddleware/Services/ApiKeyProvider.cs new file mode 100644 index 0000000..4df6cb0 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Services/ApiKeyProvider.cs @@ -0,0 +1,40 @@ +using AspNetCore.Authentication.ApiKey; +using StalwartSimpleLoginMiddleware.Models; +using StalwartSimpleLoginMiddleware.Repositories; + +namespace StalwartSimpleLoginMiddleware.Services; + +public class ApiKeyProvider : ApiKeyEvents +{ + public ApiKeyProvider() + { + OnValidateKey = OnValidateKeyAsync; + } + + private static async Task OnValidateKeyAsync(ApiKeyValidateKeyContext context) + { + var apiKeyRepository = context.HttpContext.RequestServices.GetRequiredService(); + var apiKey = await apiKeyRepository.GetApiKeyAsync(context.ApiKey); + + if (apiKey == null || !apiKey.Key.Equals(context.ApiKey, StringComparison.OrdinalIgnoreCase)) + { + context.ValidationFailed(); + return; + } + + context.ValidationSucceeded(apiKey.OwnerName, apiKey.Claims); + + var apiKeyAccessor = context.HttpContext.RequestServices.GetRequiredService(); + apiKeyAccessor.ApiKey = apiKey; + apiKeyAccessor.Metadata = await apiKeyRepository.GetMetadataAsync(context.ApiKey); + } + + public override async Task HandleChallengeAsync(ApiKeyHandleChallengeContext context) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + + await context.Response.WriteAsync("{\"Unauthorized\": 401}"); + + context.Handled(); + } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Services/StalwartClient.cs b/StalwartSimpleLoginMiddleware/Services/StalwartClient.cs new file mode 100644 index 0000000..7890ff7 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Services/StalwartClient.cs @@ -0,0 +1,41 @@ +using AdOrbitSDK; +using StalwartSimpleLoginMiddleware.Constants; + +namespace StalwartSimpleLoginMiddleware.Services; + +public class StalwartClient() : IDisposable +{ + private Client _userClient; + + private static HttpClient HttpClient => new() + { + BaseAddress = new Uri(Environment.GetEnvironmentVariable(EnvironmentVariable.MailServerUri) + "/api/") + }; + + public async Task GetClient() + { + if (_userClient != null) return _userClient; + + return GetClient(Environment.GetEnvironmentVariable(EnvironmentVariable.MailServerApiKey) ?? string.Empty); + } + + private Client GetClient(string apiKey) + { + if (_userClient != null) return _userClient; + + if (string.IsNullOrEmpty(HttpClient.BaseAddress?.AbsoluteUri)) + throw new NullReferenceException($"{EnvironmentVariable.MailServerUri} is missing in environment."); + + if (string.IsNullOrEmpty(apiKey)) + throw new NullReferenceException($"{EnvironmentVariable.MailServerApiKey} is missing in environment."); + + _userClient = new Client(HttpClient, apiKey); + return _userClient; + } + + public void Dispose() + { + HttpClient.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/StalwartSimpleLoginMiddleware.csproj b/StalwartSimpleLoginMiddleware/StalwartSimpleLoginMiddleware.csproj new file mode 100644 index 0000000..1a9f3e3 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/StalwartSimpleLoginMiddleware.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + enable + enable + Linux + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + .dockerignore + + + + + + + + + + + + diff --git a/StalwartSimpleLoginMiddleware/Utilities/ApiKeyHelper.cs b/StalwartSimpleLoginMiddleware/Utilities/ApiKeyHelper.cs new file mode 100644 index 0000000..0dbacbf --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Utilities/ApiKeyHelper.cs @@ -0,0 +1,66 @@ +using System.Net.Mail; +using System.Security.Cryptography; +using StalwartSimpleLoginMiddleware.Entities; +using StalwartSimpleLoginMiddleware.Models; + +namespace StalwartSimpleLoginMiddleware.Utilities; + +public class ApiKeyHelper +{ + public static string GenerateKey() + { + var key = new byte[64]; + using (var generator = RandomNumberGenerator.Create()) + generator.GetBytes(key); + return Convert.ToBase64String(key); + } + + public static DbApiKey CreateDbApiKey(ApiKey dbKey) + { + return new DbApiKey + { + Key = dbKey.Key, + OwnerName = dbKey.OwnerEmail, + Claims = ClaimsHelper.BuildClaims(dbKey) + }; + } + + public static KeyMetadata CreateKeyMetadata(ApiKey dbKey) + { + return new KeyMetadata + { + Domain = GetEmailDomain(dbKey.OwnerEmail), + Members = GetMembers(dbKey, false), + ExternalMembers = GetMembers(dbKey, true) + }; + } + + public static ICollection GetMembers(ApiKey dbKey, bool isExternal) + { + var members = dbKey.Members.Where(m => m.IsExternal == isExternal) + .Select(m => m.Email); + + if (!isExternal) + { + return members.Append(dbKey.OwnerEmail) + .ToArray(); + } + + return members.ToArray(); + } + + public static string GetEmailDomain(string email) + { + if (string.IsNullOrEmpty(email)) + { + throw new ArgumentException("Email cannot be null or empty.", nameof(email)); + } + + if (MailAddress.TryCreate(email, out var address)) + { + return address.Host; + } + + throw new ArgumentException($"Invalid email format: {email}", nameof(email)); + } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Utilities/ClaimsHelper.cs b/StalwartSimpleLoginMiddleware/Utilities/ClaimsHelper.cs new file mode 100644 index 0000000..8660a77 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Utilities/ClaimsHelper.cs @@ -0,0 +1,19 @@ +using System.Security.Claims; +using StalwartSimpleLoginMiddleware.Entities; + +namespace StalwartSimpleLoginMiddleware.Utilities; + +public static class ClaimsHelper +{ + public static List BuildClaims(ApiKey apiKey) + { + var claims = new List(); + + if (apiKey.IsAdmin) + { + claims.Add(new Claim(ClaimTypes.Role, "Admin")); + } + + return claims; + } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/Utilities/ConnectionHelper.cs b/StalwartSimpleLoginMiddleware/Utilities/ConnectionHelper.cs new file mode 100644 index 0000000..a58cff4 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/Utilities/ConnectionHelper.cs @@ -0,0 +1,22 @@ +using Npgsql; + +namespace StalwartSimpleLoginMiddleware.Utilities; + +public static class ConnectionHelper +{ + public static string GetPostgresConnectionString(string url) + { + var uri = new Uri(url); + var userInfo = uri.UserInfo.Split(':'); + var builder = new NpgsqlConnectionStringBuilder + { + Host = uri.Host, + Port = uri.Port, + Database = uri.AbsolutePath.Trim('/'), + Username = userInfo[0], + Password = userInfo[1], + SslMode = SslMode.Prefer + }; + return builder.ToString(); + } +} \ No newline at end of file diff --git a/StalwartSimpleLoginMiddleware/appsettings.Development.json b/StalwartSimpleLoginMiddleware/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/StalwartSimpleLoginMiddleware/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/StalwartSimpleLoginMiddleware/appsettings.json b/StalwartSimpleLoginMiddleware/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/StalwartSimpleLoginMiddleware/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}