diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d03cdc5..1eb0c1e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -58,6 +58,9 @@ jobs:
- name: Test - GoExpress.Shipping
run: dotnet test tests/Parcel.NET.GoExpress.Shipping.Tests/Parcel.NET.GoExpress.Shipping.Tests.csproj -c Release --no-build -f ${{ matrix.target-framework }}
+ - name: Test - LetterXpress.Letters
+ run: dotnet test tests/Parcel.NET.LetterXpress.Letters.Tests/Parcel.NET.LetterXpress.Letters.Tests.csproj -c Release --no-build -f ${{ matrix.target-framework }}
+
- name: Check for vulnerable packages
if: matrix.target-framework == 'net10.0'
run: |
@@ -117,3 +120,4 @@ jobs:
check_changes "src/Parcel.NET.Abstractions/" "abstractions" "Parcel.NET.Abstractions"
check_changes "src/Parcel.NET.Dhl/" "dhl" "Parcel.NET.Dhl"
check_changes "src/Parcel.NET.GoExpress/" "goexpress" "Parcel.NET.GoExpress"
+ check_changes "src/Parcel.NET.LetterXpress/" "letterxpress" "Parcel.NET.LetterXpress"
diff --git a/.github/workflows/create-release-tag.yml b/.github/workflows/create-release-tag.yml
index d4ccfd5..336a7de 100644
--- a/.github/workflows/create-release-tag.yml
+++ b/.github/workflows/create-release-tag.yml
@@ -20,8 +20,11 @@ on:
- goexpress
- goexpress-shipping
- goexpress-tracking
+ - letterxpress
+ - letterxpress-letters
- dhl-all
- goexpress-all
+ - letterxpress-all
- all
version:
description: 'SemVer version (without v prefix, e.g. 1.0.0)'
diff --git a/.github/workflows/release-letterxpress-all.yml b/.github/workflows/release-letterxpress-all.yml
new file mode 100644
index 0000000..c63e0b6
--- /dev/null
+++ b/.github/workflows/release-letterxpress-all.yml
@@ -0,0 +1,19 @@
+name: Release Parcel.NET.LetterXpress.All
+
+on:
+ push:
+ tags: ['letterxpress-all/v*']
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ uses: ./.github/workflows/release-package.yml
+ with:
+ package: 'letterxpress-all'
+ csproj-path: 'src/Parcel.NET.LetterXpress.All/Parcel.NET.LetterXpress.All.csproj'
+ title-prefix: 'Parcel.NET.LetterXpress.All'
+ secrets:
+ nuget-api-key: ${{ secrets.NUGET_API_KEY }}
diff --git a/.github/workflows/release-letterxpress-letters.yml b/.github/workflows/release-letterxpress-letters.yml
new file mode 100644
index 0000000..13a1506
--- /dev/null
+++ b/.github/workflows/release-letterxpress-letters.yml
@@ -0,0 +1,19 @@
+name: Release Parcel.NET.LetterXpress.Letters
+
+on:
+ push:
+ tags: ['letterxpress-letters/v*']
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ uses: ./.github/workflows/release-package.yml
+ with:
+ package: 'letterxpress-letters'
+ csproj-path: 'src/Parcel.NET.LetterXpress.Letters/Parcel.NET.LetterXpress.Letters.csproj'
+ title-prefix: 'Parcel.NET.LetterXpress.Letters'
+ secrets:
+ nuget-api-key: ${{ secrets.NUGET_API_KEY }}
diff --git a/.github/workflows/release-letterxpress.yml b/.github/workflows/release-letterxpress.yml
new file mode 100644
index 0000000..f237814
--- /dev/null
+++ b/.github/workflows/release-letterxpress.yml
@@ -0,0 +1,19 @@
+name: Release Parcel.NET.LetterXpress
+
+on:
+ push:
+ tags: ['letterxpress/v*']
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ uses: ./.github/workflows/release-package.yml
+ with:
+ package: 'letterxpress'
+ csproj-path: 'src/Parcel.NET.LetterXpress/Parcel.NET.LetterXpress.csproj'
+ title-prefix: 'Parcel.NET.LetterXpress'
+ secrets:
+ nuget-api-key: ${{ secrets.NUGET_API_KEY }}
diff --git a/Parcel.NET.slnx b/Parcel.NET.slnx
index 733ad1e..1b5d7c7 100644
--- a/Parcel.NET.slnx
+++ b/Parcel.NET.slnx
@@ -1,37 +1,41 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 4fb3f4a..897490d 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
# Parcel.NET
-.NET libraries for logistics carrier APIs. Currently supports **DHL** (Shipping, Tracking, Pickup, Returns, Internetmarke, Location Finder) and **GO! Express** (Shipping, Tracking) — all targeting .NET 8, 9, and 10.
+.NET libraries for logistics carrier APIs. Currently supports **DHL** (Shipping, Tracking, Pickup, Returns, Internetmarke, Location Finder), **GO! Express** (Shipping, Tracking), and **LetterXpress** (Letters / SMART@MAIL) — all targeting .NET 8, 9, and 10.
[](https://github.com/emuuu/Parcel.NET/actions/workflows/ci.yml)
[](LICENSE)
@@ -21,9 +21,10 @@
- DHL Unified Location Finder v1 — search by address, geo-coordinates, or keyword
- GO! Express Shipping — create, cancel shipments, label generation
- GO! Express Tracking — track shipments
+- LetterXpress API v3 — print jobs, SMART@MAIL e-mail jobs, balance, price, transactions, invoices
- Builder-pattern DI registration with typed `HttpClient` pipeline
- OAuth ROPC, API key, and Basic Auth — handled automatically per carrier
-- Sandbox support for DHL and GO! Express
+- Sandbox / test mode support for DHL, GO! Express, and LetterXpress
## Packages
@@ -43,15 +44,19 @@
| `Parcel.NET.GoExpress` | [](https://www.nuget.org/packages/Parcel.NET.GoExpress) | GO! Express authentication, configuration, and DI extensions |
| `Parcel.NET.GoExpress.Shipping` | [](https://www.nuget.org/packages/Parcel.NET.GoExpress.Shipping) | GO! Express Shipping client |
| `Parcel.NET.GoExpress.Tracking` | [](https://www.nuget.org/packages/Parcel.NET.GoExpress.Tracking) | GO! Express Tracking client |
+| **LetterXpress** | | |
+| `Parcel.NET.LetterXpress` | [](https://www.nuget.org/packages/Parcel.NET.LetterXpress) | LetterXpress authentication, configuration, and DI extensions |
+| `Parcel.NET.LetterXpress.Letters` | [](https://www.nuget.org/packages/Parcel.NET.LetterXpress.Letters) | LetterXpress API v3 client (print jobs, SMART@MAIL e-mail jobs, price, balance, transactions, invoices) |
| **Meta-Packages** | | |
| `Parcel.NET.Dhl.All` | [](https://www.nuget.org/packages/Parcel.NET.Dhl.All) | All DHL packages |
| `Parcel.NET.GoExpress.All` | [](https://www.nuget.org/packages/Parcel.NET.GoExpress.All) | All GO! Express packages |
+| `Parcel.NET.LetterXpress.All` | [](https://www.nuget.org/packages/Parcel.NET.LetterXpress.All) | All LetterXpress packages |
| `Parcel.NET.All` | [](https://www.nuget.org/packages/Parcel.NET.All) | All Parcel.NET packages |
## Prerequisites
- .NET 8.0, 9.0, or 10.0
-- A DHL developer account and/or GO! Express credentials
+- A DHL developer account, GO! Express credentials, and/or a LetterXpress account
## Installation
@@ -60,10 +65,12 @@
dotnet add package Parcel.NET.Dhl.Shipping
dotnet add package Parcel.NET.Dhl.Tracking
dotnet add package Parcel.NET.GoExpress.Shipping
+dotnet add package Parcel.NET.LetterXpress.Letters
# Or install everything for a carrier
dotnet add package Parcel.NET.Dhl.All
dotnet add package Parcel.NET.GoExpress.All
+dotnet add package Parcel.NET.LetterXpress.All
# Or install all carriers at once
dotnet add package Parcel.NET.All
@@ -106,6 +113,41 @@ builder.Services.AddGoExpress(options =>
.AddGoExpressTracking();
```
+### 2b. Register LetterXpress services
+
+```csharp
+builder.Services.AddLetterXpress(options =>
+{
+ options.Username = "your-username";
+ options.ApiKey = "your-api-key";
+ options.UseTestMode = true; // jobs go to the Postbox instead of being processed
+})
+.AddLetterXpressLetters();
+```
+
+Send a letter as a print job:
+
+```csharp
+var letterXpress = serviceProvider.GetRequiredService();
+
+var balance = await letterXpress.GetBalanceAsync();
+
+var job = await letterXpress.CreatePrintJobAsync(new LetterRequest
+{
+ File = await File.ReadAllBytesAsync("invoice.pdf"),
+ Specification = new LetterSpecification
+ {
+ Color = LetterColor.BlackWhite,
+ Mode = PrintMode.Simplex,
+ Shipping = ShippingType.National
+ }
+});
+```
+
+> **Note:** The LetterXpress API is rate-limited to 120 requests/minute and accepts at most 50 MB per request.
+> The client validates the 50 MB limit locally; throttling/retry on HTTP 429 is left to the caller's `HttpClient`
+> pipeline (e.g. a `Microsoft.Extensions.Http.Resilience` handler).
+
### 3. Create a shipment
Use the carrier-agnostic `IShipmentService` or the carrier-specific client:
diff --git a/docs/Parcel.NET.Docs.Generator/Parcel.NET.Docs.Generator.csproj b/docs/Parcel.NET.Docs.Generator/Parcel.NET.Docs.Generator.csproj
index a29cbb4..d5f9985 100644
--- a/docs/Parcel.NET.Docs.Generator/Parcel.NET.Docs.Generator.csproj
+++ b/docs/Parcel.NET.Docs.Generator/Parcel.NET.Docs.Generator.csproj
@@ -21,6 +21,8 @@
+
+
diff --git a/docs/Parcel.NET.Docs.Generator/Program.cs b/docs/Parcel.NET.Docs.Generator/Program.cs
index 050e250..a369899 100644
--- a/docs/Parcel.NET.Docs.Generator/Program.cs
+++ b/docs/Parcel.NET.Docs.Generator/Program.cs
@@ -103,7 +103,9 @@ async Task GenerateApiDocs(string wwwrootPath)
typeof(Parcel.NET.Dhl.Shipping.IDhlShippingClient).Assembly,
typeof(Parcel.NET.Dhl.Tracking.DhlTrackingClient).Assembly,
typeof(Parcel.NET.GoExpress.GoExpressOptions).Assembly,
- typeof(Parcel.NET.GoExpress.Shipping.IGoExpressShippingClient).Assembly
+ typeof(Parcel.NET.GoExpress.Shipping.IGoExpressShippingClient).Assembly,
+ typeof(Parcel.NET.LetterXpress.LetterXpressOptions).Assembly,
+ typeof(Parcel.NET.LetterXpress.Letters.ILetterXpressClient).Assembly
};
var xmlDocs = new Dictionary();
diff --git a/docs/Parcel.NET.Docs/wwwroot/api-docs.json b/docs/Parcel.NET.Docs/wwwroot/api-docs.json
index 20cab6b..1e22e4c 100644
--- a/docs/Parcel.NET.Docs/wwwroot/api-docs.json
+++ b/docs/Parcel.NET.Docs/wwwroot/api-docs.json
@@ -1570,5 +1570,1037 @@
"description": "Gets or sets the weekend or holiday indicator for this time window."
}
]
+ },
+ {
+ "typeName": "LetterXpressBuilder",
+ "fullName": "Parcel.NET.LetterXpress.LetterXpressBuilder",
+ "members": [
+ {
+ "name": "Services",
+ "kind": "Property",
+ "type": "IServiceCollection",
+ "description": "Gets the underlying service collection."
+ }
+ ]
+ },
+ {
+ "typeName": "LetterXpressOptions",
+ "fullName": "Parcel.NET.LetterXpress.LetterXpressOptions",
+ "members": [
+ {
+ "name": "Username",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets the LetterXpress account username. Generated under \u0022Mein Konto \u003E Zugangsdaten \u003E LXP API\u0022."
+ },
+ {
+ "name": "ApiKey",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets the LetterXpress API key. Generated under \u0022Mein Konto \u003E Zugangsdaten \u003E LXP API\u0022."
+ },
+ {
+ "name": "UseTestMode",
+ "kind": "Property",
+ "type": "bool",
+ "description": "Gets or sets a value indicating whether to use the LetterXpress test mode. In test mode (\u0022mode\u0022: \u0022test\u0022) submitted jobs are not processed but stored in the \u0022Postbox\u0022 where they can be reviewed, deleted, or sent. Postbox jobs are deleted after 7 days. When , jobs are processed directly (\u0022mode\u0022: \u0022live\u0022)."
+ },
+ {
+ "name": "CustomBaseUrl",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets a custom base URL override. When set, this takes precedence over the default production URL."
+ },
+ {
+ "name": "BaseUrl",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the effective API base URL. Uses if set, otherwise the production URL. A trailing slash is appended if missing so that relative request URIs (e.g. balance, printjobs) resolve correctly."
+ }
+ ]
+ },
+ {
+ "typeName": "LetterXpressServiceCollectionExtensions",
+ "fullName": "Parcel.NET.LetterXpress.LetterXpressServiceCollectionExtensions",
+ "members": [
+ {
+ "name": "AddLetterXpress(IServiceCollection, Action\u003CLetterXpressOptions\u003E)",
+ "kind": "Method",
+ "type": "LetterXpressBuilder",
+ "description": null
+ }
+ ]
+ },
+ {
+ "typeName": "Balance",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.Balance",
+ "members": [
+ {
+ "name": "Amount",
+ "kind": "Property",
+ "type": "Decimal",
+ "description": "Gets the current balance."
+ },
+ {
+ "name": "Currency",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the currency (e.g. EUR)."
+ }
+ ]
+ },
+ {
+ "typeName": "BankForm",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.BankForm",
+ "members": [
+ {
+ "name": "Included",
+ "kind": "Property",
+ "type": "bool",
+ "description": "Gets or sets a value indicating whether the bank form is already included in the PDF\u0027s last sheet. When , the payment detail fields below are ignored and the last (blank) sheet is used."
+ },
+ {
+ "name": "Payee",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets the payee. Max. 27 characters. Ignored when is ."
+ },
+ {
+ "name": "Iban",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets the IBAN. Max. 34 characters."
+ },
+ {
+ "name": "Bic",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets the BIC. Max. 11 characters."
+ },
+ {
+ "name": "Amount",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets the amount. Max. 12 characters."
+ },
+ {
+ "name": "PurposeOfPayment",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets the purpose of payment. Max. 27 characters."
+ },
+ {
+ "name": "PurposeOfPayment2",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets the second purpose of payment line. Max. 27 characters."
+ }
+ ]
+ },
+ {
+ "typeName": "EmailJob",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.EmailJob",
+ "members": [
+ {
+ "name": "Id",
+ "kind": "Property",
+ "type": "Int64",
+ "description": "Gets the e-mail job ID."
+ },
+ {
+ "name": "EmailSender",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the sender e-mail address."
+ },
+ {
+ "name": "EmailReceiver",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the recipient e-mail address."
+ },
+ {
+ "name": "EmailOption",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the SMART@MAIL option (maildirect, mailplus, mailsecure)."
+ },
+ {
+ "name": "SentAt",
+ "kind": "Property",
+ "type": "DateTimeOffset?",
+ "description": "Gets the timestamp the e-mail was sent, if available."
+ },
+ {
+ "name": "Amount",
+ "kind": "Property",
+ "type": "Decimal",
+ "description": "Gets the net amount charged."
+ },
+ {
+ "name": "Vat",
+ "kind": "Property",
+ "type": "Decimal",
+ "description": "Gets the VAT."
+ },
+ {
+ "name": "Status",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the job status (e.g. queue, hold, success)."
+ },
+ {
+ "name": "Subject",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the e-mail subject."
+ },
+ {
+ "name": "Content",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the e-mail content."
+ },
+ {
+ "name": "Footer",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the e-mail footer."
+ },
+ {
+ "name": "CreatedAt",
+ "kind": "Property",
+ "type": "DateTimeOffset?",
+ "description": "Gets the creation timestamp."
+ },
+ {
+ "name": "PrintJobId",
+ "kind": "Property",
+ "type": "Int64?",
+ "description": "Gets the ID of the associated print job, when the e-mail job is part of a serial response."
+ },
+ {
+ "name": "PrintJob",
+ "kind": "Property",
+ "type": "PrintJob",
+ "description": "Gets the associated print job, returned for MailPlus jobs."
+ }
+ ]
+ },
+ {
+ "typeName": "EmailJobResult",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.EmailJobResult",
+ "members": [
+ {
+ "name": "EmailJobs",
+ "kind": "Property",
+ "type": "IReadOnlyList\u003CEmailJob\u003E",
+ "description": "Gets the created e-mail jobs."
+ },
+ {
+ "name": "PrintJobs",
+ "kind": "Property",
+ "type": "IReadOnlyList\u003CPrintJob\u003E",
+ "description": "Gets any print jobs created alongside the e-mail jobs (e.g. for MailPlus or pages without a white code)."
+ }
+ ]
+ },
+ {
+ "typeName": "EmailLetterOptions",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.EmailLetterOptions",
+ "members": [
+ {
+ "name": "EmailOption",
+ "kind": "Property",
+ "type": "EmailOption",
+ "description": "Gets or sets the SMART@MAIL delivery option."
+ },
+ {
+ "name": "EmailReceiver",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets the valid recipient e-mail address."
+ }
+ ]
+ },
+ {
+ "typeName": "ILetterXpressClient",
+ "fullName": "Parcel.NET.LetterXpress.Letters.ILetterXpressClient",
+ "members": [
+ {
+ "name": "GetBalanceAsync(CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CBalance\u003E",
+ "description": null
+ },
+ {
+ "name": "GetPriceAsync(PriceRequest, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPriceResult\u003E",
+ "description": null
+ },
+ {
+ "name": "ListPrintJobsAsync(PrintJobFilter?, int?, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPagedResult\u003CPrintJob\u003E\u003E",
+ "description": null
+ },
+ {
+ "name": "CreatePrintJobAsync(LetterRequest, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPrintJob\u003E",
+ "description": null
+ },
+ {
+ "name": "GetPrintJobAsync(Int64, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPrintJob\u003E",
+ "description": null
+ },
+ {
+ "name": "UpdatePrintJobAsync(Int64, PrintJobUpdate, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPrintJob\u003E",
+ "description": null
+ },
+ {
+ "name": "DeletePrintJobAsync(Int64, CancellationToken)",
+ "kind": "Method",
+ "type": "Task",
+ "description": null
+ },
+ {
+ "name": "CreateEmailJobAsync(LetterRequest, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CEmailJobResult\u003E",
+ "description": null
+ },
+ {
+ "name": "GetEmailJobAsync(Int64, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CEmailJob\u003E",
+ "description": null
+ },
+ {
+ "name": "UpdateEmailJobAsync(Int64, string, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CEmailJob\u003E",
+ "description": null
+ },
+ {
+ "name": "DeleteEmailJobAsync(Int64, CancellationToken)",
+ "kind": "Method",
+ "type": "Task",
+ "description": null
+ },
+ {
+ "name": "ListEmailJobsAsync(EmailJobFilter?, int?, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPagedResult\u003CEmailJob\u003E\u003E",
+ "description": null
+ },
+ {
+ "name": "ListTransactionsAsync(TransactionFilter?, int?, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPagedResult\u003CTransaction\u003E\u003E",
+ "description": null
+ },
+ {
+ "name": "ListInvoicesAsync(int?, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPagedResult\u003CInvoice\u003E\u003E",
+ "description": null
+ },
+ {
+ "name": "GetInvoiceAsync(Int64, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CInvoice\u003E",
+ "description": null
+ }
+ ]
+ },
+ {
+ "typeName": "Invoice",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.Invoice",
+ "members": [
+ {
+ "name": "Id",
+ "kind": "Property",
+ "type": "Int64",
+ "description": "Gets the invoice ID."
+ },
+ {
+ "name": "Amount",
+ "kind": "Property",
+ "type": "Decimal",
+ "description": "Gets the net amount."
+ },
+ {
+ "name": "Vat",
+ "kind": "Property",
+ "type": "Decimal",
+ "description": "Gets the VAT."
+ },
+ {
+ "name": "InvoiceDate",
+ "kind": "Property",
+ "type": "DateOnly?",
+ "description": "Gets the invoice date."
+ },
+ {
+ "name": "Base64Data",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the invoice PDF as base64, returned only when retrieving a single invoice."
+ }
+ ]
+ },
+ {
+ "typeName": "LetterBackgrounds",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.LetterBackgrounds",
+ "members": [
+ {
+ "name": "FirstPage",
+ "kind": "Property",
+ "type": "byte[]",
+ "description": "Gets or sets the background PDF for the first page."
+ },
+ {
+ "name": "OtherPages",
+ "kind": "Property",
+ "type": "byte[]",
+ "description": "Gets or sets the background PDF for all other pages."
+ }
+ ]
+ },
+ {
+ "typeName": "LetterRequest",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.LetterRequest",
+ "members": [
+ {
+ "name": "File",
+ "kind": "Property",
+ "type": "byte[]",
+ "description": "Gets or sets the letter PDF as raw bytes. Max. 50 MB."
+ },
+ {
+ "name": "Specification",
+ "kind": "Property",
+ "type": "LetterSpecification",
+ "description": "Gets or sets the print and shipping specification."
+ },
+ {
+ "name": "FilenameOriginal",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets the original file name (e.g. Rechnung-123456.pdf). Optional; necessary for LXP SMART@MAIL and returned when retrieving jobs."
+ },
+ {
+ "name": "Registered",
+ "kind": "Property",
+ "type": "RegisteredMail",
+ "description": "Gets or sets the registered mail option. Registered mail can only be sent nationally. Defaults to ."
+ },
+ {
+ "name": "DispatchDate",
+ "kind": "Property",
+ "type": "DateOnly?",
+ "description": "Gets or sets the dispatch date. Optional; must be in the future."
+ },
+ {
+ "name": "SerialLetter",
+ "kind": "Property",
+ "type": "SerialLetterOptions",
+ "description": "Gets or sets serial letter options. Cannot be combined with ."
+ },
+ {
+ "name": "EmailLetter",
+ "kind": "Property",
+ "type": "EmailLetterOptions",
+ "description": "Gets or sets LXP SMART@MAIL e-mail options for a single job. Cannot be combined with ."
+ },
+ {
+ "name": "Attachments",
+ "kind": "Property",
+ "type": "IReadOnlyList\u003Cbyte[]\u003E",
+ "description": "Gets or sets additional attachments as raw PDF bytes. Each attachment must be A4 portrait (29.7 x 21.0 cm). The order is preserved."
+ },
+ {
+ "name": "Backgrounds",
+ "kind": "Property",
+ "type": "LetterBackgrounds",
+ "description": "Gets or sets the letter background PDFs."
+ },
+ {
+ "name": "TermsAndConditions",
+ "kind": "Property",
+ "type": "TermsAndConditions",
+ "description": "Gets or sets the terms and conditions PDF."
+ },
+ {
+ "name": "BankForm",
+ "kind": "Property",
+ "type": "BankForm",
+ "description": "Gets or sets the bank transfer form."
+ },
+ {
+ "name": "Notice",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets an arbitrary notice stored with the job. Max. 255 characters."
+ }
+ ]
+ },
+ {
+ "typeName": "LetterSpecification",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.LetterSpecification",
+ "members": [
+ {
+ "name": "Color",
+ "kind": "Property",
+ "type": "LetterColor",
+ "description": "Gets or sets the print color. Defaults to ."
+ },
+ {
+ "name": "Mode",
+ "kind": "Property",
+ "type": "PrintMode",
+ "description": "Gets or sets the print mode. Defaults to ."
+ },
+ {
+ "name": "Shipping",
+ "kind": "Property",
+ "type": "ShippingType",
+ "description": "Gets or sets the shipping destination. Defaults to ."
+ },
+ {
+ "name": "C4",
+ "kind": "Property",
+ "type": "bool",
+ "description": "Gets or sets a value indicating whether to use a C4 envelope. Optional, applies to letters under 9 sheets."
+ }
+ ]
+ },
+ {
+ "typeName": "LetterXpressClient",
+ "fullName": "Parcel.NET.LetterXpress.Letters.LetterXpressClient",
+ "members": [
+ {
+ "name": "GetBalanceAsync(CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CBalance\u003E",
+ "description": null
+ },
+ {
+ "name": "GetPriceAsync(PriceRequest, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPriceResult\u003E",
+ "description": null
+ },
+ {
+ "name": "ListPrintJobsAsync(PrintJobFilter?, int?, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPagedResult\u003CPrintJob\u003E\u003E",
+ "description": null
+ },
+ {
+ "name": "CreatePrintJobAsync(LetterRequest, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPrintJob\u003E",
+ "description": null
+ },
+ {
+ "name": "GetPrintJobAsync(Int64, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPrintJob\u003E",
+ "description": null
+ },
+ {
+ "name": "UpdatePrintJobAsync(Int64, PrintJobUpdate, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPrintJob\u003E",
+ "description": null
+ },
+ {
+ "name": "DeletePrintJobAsync(Int64, CancellationToken)",
+ "kind": "Method",
+ "type": "Task",
+ "description": null
+ },
+ {
+ "name": "CreateEmailJobAsync(LetterRequest, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CEmailJobResult\u003E",
+ "description": null
+ },
+ {
+ "name": "GetEmailJobAsync(Int64, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CEmailJob\u003E",
+ "description": null
+ },
+ {
+ "name": "UpdateEmailJobAsync(Int64, string, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CEmailJob\u003E",
+ "description": null
+ },
+ {
+ "name": "DeleteEmailJobAsync(Int64, CancellationToken)",
+ "kind": "Method",
+ "type": "Task",
+ "description": null
+ },
+ {
+ "name": "ListEmailJobsAsync(EmailJobFilter?, int?, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPagedResult\u003CEmailJob\u003E\u003E",
+ "description": null
+ },
+ {
+ "name": "ListTransactionsAsync(TransactionFilter?, int?, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPagedResult\u003CTransaction\u003E\u003E",
+ "description": null
+ },
+ {
+ "name": "ListInvoicesAsync(int?, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CPagedResult\u003CInvoice\u003E\u003E",
+ "description": null
+ },
+ {
+ "name": "GetInvoiceAsync(Int64, CancellationToken)",
+ "kind": "Method",
+ "type": "Task\u003CInvoice\u003E",
+ "description": null
+ }
+ ]
+ },
+ {
+ "typeName": "LetterXpressLettersExtensions",
+ "fullName": "Parcel.NET.LetterXpress.Letters.LetterXpressLettersExtensions",
+ "members": [
+ {
+ "name": "AddLetterXpressLetters(LetterXpressBuilder)",
+ "kind": "Method",
+ "type": "LetterXpressBuilder",
+ "description": null
+ }
+ ]
+ },
+ {
+ "typeName": "PagedResult\u00601",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.PagedResult\u00601",
+ "members": [
+ {
+ "name": "Items",
+ "kind": "Property",
+ "type": "IReadOnlyList\u003CT\u003E",
+ "description": "Gets the items on the current page."
+ },
+ {
+ "name": "Pagination",
+ "kind": "Property",
+ "type": "Pagination",
+ "description": "Gets the pagination metadata, if available."
+ }
+ ]
+ },
+ {
+ "typeName": "Pagination",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.Pagination",
+ "members": [
+ {
+ "name": "Total",
+ "kind": "Property",
+ "type": "int",
+ "description": "Gets the total number of items."
+ },
+ {
+ "name": "Count",
+ "kind": "Property",
+ "type": "int",
+ "description": "Gets the number of items on the current page."
+ },
+ {
+ "name": "CurrentPage",
+ "kind": "Property",
+ "type": "int",
+ "description": "Gets the current page number."
+ },
+ {
+ "name": "LastPage",
+ "kind": "Property",
+ "type": "int",
+ "description": "Gets the last page number."
+ },
+ {
+ "name": "PerPage",
+ "kind": "Property",
+ "type": "int",
+ "description": "Gets the number of items per page."
+ },
+ {
+ "name": "FirstPageUrl",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the URL of the first page, if any."
+ },
+ {
+ "name": "LastPageUrl",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the URL of the last page, if any."
+ },
+ {
+ "name": "NextPageUrl",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the URL of the next page, if any."
+ },
+ {
+ "name": "PrevPageUrl",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the URL of the previous page, if any."
+ }
+ ]
+ },
+ {
+ "typeName": "PriceRequest",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.PriceRequest",
+ "members": [
+ {
+ "name": "Pages",
+ "kind": "Property",
+ "type": "int",
+ "description": "Gets or sets the number of pages."
+ },
+ {
+ "name": "Color",
+ "kind": "Property",
+ "type": "LetterColor",
+ "description": "Gets or sets the print color. Defaults to ."
+ },
+ {
+ "name": "Mode",
+ "kind": "Property",
+ "type": "PrintMode",
+ "description": "Gets or sets the print mode. Defaults to ."
+ },
+ {
+ "name": "Shipping",
+ "kind": "Property",
+ "type": "ShippingType",
+ "description": "Gets or sets the shipping destination. Only and are valid for price requests."
+ },
+ {
+ "name": "C4",
+ "kind": "Property",
+ "type": "bool",
+ "description": "Gets or sets a value indicating whether to use a C4 envelope."
+ },
+ {
+ "name": "EmailOption",
+ "kind": "Property",
+ "type": "EmailOption?",
+ "description": "Gets or sets the SMART@MAIL e-mail option. Optional."
+ },
+ {
+ "name": "Registered",
+ "kind": "Property",
+ "type": "RegisteredMail",
+ "description": "Gets or sets the registered mail option. Optional; only national shipping is supported for registered mail."
+ }
+ ]
+ },
+ {
+ "typeName": "PriceResult",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.PriceResult",
+ "members": [
+ {
+ "name": "Price",
+ "kind": "Property",
+ "type": "Decimal",
+ "description": "Gets the calculated gross price."
+ },
+ {
+ "name": "Pages",
+ "kind": "Property",
+ "type": "int?",
+ "description": "Gets the number of pages the price was calculated for."
+ },
+ {
+ "name": "Color",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the print color the price was calculated for."
+ },
+ {
+ "name": "Mode",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the print mode the price was calculated for."
+ },
+ {
+ "name": "Shipping",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the shipping destination the price was calculated for."
+ }
+ ]
+ },
+ {
+ "typeName": "PrintJob",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.PrintJob",
+ "members": [
+ {
+ "name": "Id",
+ "kind": "Property",
+ "type": "Int64",
+ "description": "Gets the print job ID."
+ },
+ {
+ "name": "Shipping",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the shipping destination (e.g. national)."
+ },
+ {
+ "name": "Mode",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the print mode (e.g. simplex)."
+ },
+ {
+ "name": "Color",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the print color (1 for black/white, 4 for color)."
+ },
+ {
+ "name": "C4",
+ "kind": "Property",
+ "type": "bool",
+ "description": "Gets a value indicating whether a C4 envelope is used."
+ },
+ {
+ "name": "Registered",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the registered mail option (r1, r2), if any."
+ },
+ {
+ "name": "BankForm",
+ "kind": "Property",
+ "type": "bool",
+ "description": "Gets a value indicating whether a bank form is attached."
+ },
+ {
+ "name": "Notice",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the notice stored with the job."
+ },
+ {
+ "name": "Status",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the job status (e.g. queue, hold, done, canceled, draft)."
+ },
+ {
+ "name": "DispatchDate",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the dispatch date, if set."
+ },
+ {
+ "name": "FilenameOriginal",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the original file name, if provided."
+ },
+ {
+ "name": "CreatedAt",
+ "kind": "Property",
+ "type": "DateTimeOffset?",
+ "description": "Gets the creation timestamp. The API reports times in German local time (Europe/Berlin)."
+ },
+ {
+ "name": "UpdatedAt",
+ "kind": "Property",
+ "type": "DateTimeOffset?",
+ "description": "Gets the last update timestamp. The API reports times in German local time (Europe/Berlin)."
+ },
+ {
+ "name": "Items",
+ "kind": "Property",
+ "type": "IReadOnlyList\u003CPrintJobItem\u003E",
+ "description": "Gets the recipient items of the job."
+ }
+ ]
+ },
+ {
+ "typeName": "PrintJobItem",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.PrintJobItem",
+ "members": [
+ {
+ "name": "Address",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the recipient address as a single line."
+ },
+ {
+ "name": "Pages",
+ "kind": "Property",
+ "type": "int",
+ "description": "Gets the number of pages."
+ },
+ {
+ "name": "Amount",
+ "kind": "Property",
+ "type": "Decimal",
+ "description": "Gets the net amount charged for this item."
+ },
+ {
+ "name": "Vat",
+ "kind": "Property",
+ "type": "Decimal",
+ "description": "Gets the VAT for this item."
+ },
+ {
+ "name": "Status",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the item status (e.g. queue, sent)."
+ },
+ {
+ "name": "TrackingCode",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the registered mail tracking code, if available."
+ },
+ {
+ "name": "Base64Data",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the rendered letter PDF as base64, returned only when retrieving a single job."
+ }
+ ]
+ },
+ {
+ "typeName": "PrintJobUpdate",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.PrintJobUpdate",
+ "members": [
+ {
+ "name": "DispatchDate",
+ "kind": "Property",
+ "type": "DateOnly?",
+ "description": "Gets or sets the new dispatch date."
+ },
+ {
+ "name": "Registered",
+ "kind": "Property",
+ "type": "RegisteredMail?",
+ "description": "Gets or sets the new registered mail option."
+ },
+ {
+ "name": "Notice",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets the new notice."
+ },
+ {
+ "name": "Color",
+ "kind": "Property",
+ "type": "LetterColor?",
+ "description": "Gets or sets the new print color."
+ },
+ {
+ "name": "Mode",
+ "kind": "Property",
+ "type": "PrintMode?",
+ "description": "Gets or sets the new print mode."
+ },
+ {
+ "name": "Shipping",
+ "kind": "Property",
+ "type": "ShippingType?",
+ "description": "Gets or sets the new shipping destination."
+ },
+ {
+ "name": "C4",
+ "kind": "Property",
+ "type": "bool?",
+ "description": "Gets or sets the new C4 envelope flag."
+ }
+ ]
+ },
+ {
+ "typeName": "SerialLetterOptions",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.SerialLetterOptions",
+ "members": [
+ {
+ "name": "PagesSeparatorType",
+ "kind": "Property",
+ "type": "SeparatorType",
+ "description": "Gets or sets the separator type."
+ },
+ {
+ "name": "PagesSeparatorValue",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets or sets the separator value: a keyword when is , or a page count when it is ."
+ }
+ ]
+ },
+ {
+ "typeName": "TermsAndConditions",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.TermsAndConditions",
+ "members": [
+ {
+ "name": "Terms",
+ "kind": "Property",
+ "type": "byte[]",
+ "description": "Gets or sets the terms and conditions PDF."
+ },
+ {
+ "name": "OnAllPages",
+ "kind": "Property",
+ "type": "bool",
+ "description": "Gets or sets a value indicating whether the terms are inserted behind all pages () or only behind the first page ()."
+ }
+ ]
+ },
+ {
+ "typeName": "Transaction",
+ "fullName": "Parcel.NET.LetterXpress.Letters.Models.Transaction",
+ "members": [
+ {
+ "name": "Amount",
+ "kind": "Property",
+ "type": "Decimal",
+ "description": "Gets the amount (negative for charges, positive for top-ups)."
+ },
+ {
+ "name": "Currency",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the currency (e.g. EUR)."
+ },
+ {
+ "name": "Description",
+ "kind": "Property",
+ "type": "string",
+ "description": "Gets the description."
+ },
+ {
+ "name": "CreatedAt",
+ "kind": "Property",
+ "type": "DateTimeOffset?",
+ "description": "Gets the creation timestamp."
+ }
+ ]
}
]
\ No newline at end of file
diff --git a/docs/Parcel.NET.Docs/wwwroot/content-index.json b/docs/Parcel.NET.Docs/wwwroot/content-index.json
index 0742a2b..74225f2 100644
--- a/docs/Parcel.NET.Docs/wwwroot/content-index.json
+++ b/docs/Parcel.NET.Docs/wwwroot/content-index.json
@@ -61,6 +61,28 @@
],
"searchText": "Overview The GoExpressShippingClient implements the GO! Express Realtime Order \u0026amp; Label API (GO! Connect), providing: Shipment creation with label generation Label re-generation for existing shipments Shipment cancellation Important: AX4 Web-Portal Interaction If you also use the AX4 Web-Portal provided by Siemens, be aware of the following: Delayed visibility : Shipments created via GO! Connect are not immediately visible in the AX4 Web-Portal. They are imported as EDI orders with a delay. C"
},
+ {
+ "slug": "carriers/letterxpress-letters",
+ "title": "LetterXpress Letters",
+ "category": "Carriers",
+ "subcategory": "LetterXpress",
+ "order": 1,
+ "description": "Send physical letters and SMART@MAIL e-mails, query prices and balance, and manage print jobs with the LetterXpress (A\u0026O Fischer) API v3.",
+ "apiRef": "ILetterXpressClient",
+ "headings": [
+ "Overview",
+ "Installation",
+ "Registration",
+ "Authentication \u0026amp; modes",
+ "Examples",
+ "Balance and price",
+ "Create a print job",
+ "SMART@MAIL e-mail job",
+ "Listing and filtering",
+ "Notes \u0026amp; limits"
+ ],
+ "searchText": "Overview LetterXpressClient implements the LetterXpress (A\u0026amp;O Fischer GmbH \u0026amp; Co. KG) API v3 \u2014 a hybrid print-and-mail service: you upload a PDF, LetterXpress prints it and sends it as a physical letter, optionally combined with the SMART@MAIL e-mail product. Because LetterXpress is a letter/print-mail product rather than a parcel carrier, it does not implement the carrier-agnostic IShipmentService / ITrackingService abstractions. It exposes its own ILetterXpressClient interface, mirroring"
+ },
{
"slug": "carriers/dhl-tracking",
"title": "DHL Tracking",
diff --git a/docs/Parcel.NET.Docs/wwwroot/content/carriers/letterxpress-letters.md b/docs/Parcel.NET.Docs/wwwroot/content/carriers/letterxpress-letters.md
new file mode 100644
index 0000000..9203808
--- /dev/null
+++ b/docs/Parcel.NET.Docs/wwwroot/content/carriers/letterxpress-letters.md
@@ -0,0 +1,126 @@
+---
+title: LetterXpress Letters
+category: Carriers
+subcategory: LetterXpress
+order: 1
+description: Send physical letters and SMART@MAIL e-mails, query prices and balance, and manage print jobs with the LetterXpress (A&O Fischer) API v3.
+apiRef: ILetterXpressClient
+---
+
+## Overview
+
+`LetterXpressClient` implements the [LetterXpress](https://www.letterxpress.de/) (A&O Fischer GmbH & Co. KG) API v3 — a hybrid print-and-mail service: you upload a PDF, LetterXpress prints it and sends it as a physical letter, optionally combined with the **SMART@MAIL** e-mail product.
+
+Because LetterXpress is a letter/print-mail product rather than a parcel carrier, it does **not** implement the carrier-agnostic `IShipmentService`/`ITrackingService` abstractions. It exposes its own `ILetterXpressClient` interface, mirroring the DHL Internetmarke design.
+
+Supported operations:
+
+- **Account** — query balance and price
+- **Print jobs** — create, retrieve (incl. rendered PDF), update, delete, and list
+- **E-mail jobs (SMART@MAIL)** — create (`maildirect` / `mailplus` / `mailsecure`), retrieve, update, delete, and list
+- **Accounting** — list transactions and invoices (incl. invoice PDF)
+
+## Installation
+
+```bash
+dotnet add package Parcel.NET.LetterXpress.Letters
+# or the meta-package
+dotnet add package Parcel.NET.LetterXpress.All
+```
+
+## Registration
+
+```csharp
+builder.Services.AddLetterXpress(options =>
+{
+ options.Username = "your-username"; // Mein Konto > Zugangsdaten > LXP API
+ options.ApiKey = "your-api-key";
+ options.UseTestMode = true; // true => Postbox, no real processing
+})
+.AddLetterXpressLetters();
+```
+
+## Authentication & modes
+
+Authentication is sent in the JSON **body** of every request (an `auth` object with `username`, `apikey`, and `mode`) — there is no auth header. The client handles this for you.
+
+- **Test mode** (`UseTestMode = true`): jobs are placed in the **Postbox** instead of being processed (and therefore not dispatched). Postbox jobs can be reviewed, deleted, or sent, and are deleted automatically after 7 days.
+- **Live mode** (`UseTestMode = false`): jobs are processed and dispatched directly.
+
+> LetterXpress is a server-to-server API and intentionally does **not** enable CORS. Use it from your backend, never directly from a browser — that would expose your API key.
+
+## Examples
+
+### Balance and price
+
+```csharp
+var client = serviceProvider.GetRequiredService();
+
+Balance balance = await client.GetBalanceAsync();
+Console.WriteLine($"{balance.Amount} {balance.Currency}");
+
+PriceResult price = await client.GetPriceAsync(new PriceRequest
+{
+ Pages = 1,
+ Color = LetterColor.BlackWhite,
+ Mode = PrintMode.Simplex,
+ Shipping = ShippingType.National // price supports only National or International
+});
+```
+
+### Create a print job
+
+The PDF is supplied as raw bytes; the client computes the base64 encoding and MD5 checksum automatically.
+
+```csharp
+PrintJob job = await client.CreatePrintJobAsync(new LetterRequest
+{
+ File = await File.ReadAllBytesAsync("invoice.pdf"),
+ FilenameOriginal = "invoice.pdf",
+ Specification = new LetterSpecification
+ {
+ Color = LetterColor.Color,
+ Mode = PrintMode.Duplex,
+ Shipping = ShippingType.National
+ },
+ Registered = RegisteredMail.Einschreiben // national only
+});
+
+// Within 15 minutes you can still update or delete it:
+await client.UpdatePrintJobAsync(job.Id, new PrintJobUpdate { Notice = "Customer 4711" });
+await client.DeletePrintJobAsync(job.Id);
+```
+
+### SMART@MAIL e-mail job
+
+```csharp
+EmailJobResult result = await client.CreateEmailJobAsync(new LetterRequest
+{
+ File = await File.ReadAllBytesAsync("invoice.pdf"),
+ Specification = new LetterSpecification { Shipping = ShippingType.National },
+ EmailLetter = new EmailLetterOptions
+ {
+ EmailOption = EmailOption.MailDirect,
+ EmailReceiver = "customer@example.com"
+ }
+});
+
+foreach (var email in result.EmailJobs)
+ Console.WriteLine($"{email.Id}: {email.EmailReceiver} ({email.Status})");
+```
+
+### Listing and filtering
+
+```csharp
+PagedResult queued = await client.ListPrintJobsAsync(PrintJobFilter.Queue);
+PagedResult sent = await client.ListEmailJobsAsync(EmailJobFilter.Success);
+PagedResult tx = await client.ListTransactionsAsync(TransactionFilter.PrintJobs);
+PagedResult invoices = await client.ListInvoicesAsync();
+```
+
+## Notes & limits
+
+- **Limits:** max. 50 MB per request (validated locally) and 120 requests/minute. Throttling/retry on HTTP 429 is left to your `HttpClient` pipeline (e.g. a `Microsoft.Extensions.Http.Resilience` handler).
+- **Timestamps:** the API returns timestamps without a timezone designator, in German local time (Europe/Berlin, CET/CEST). The client parses them into `DateTimeOffset` with the correct (DST-aware) offset.
+- **Constraints enforced by the client:** registered mail is national-only; a serial letter cannot be combined with a single `EmailLetter`; price requests accept only `National`/`International` shipping.
+- **Errors** surface as `LetterXpressException` carrying the HTTP/body status code and the raw response.
diff --git a/docs/Parcel.NET.Docs/wwwroot/sitemap.xml b/docs/Parcel.NET.Docs/wwwroot/sitemap.xml
index 219c2bc..7bcdbb7 100644
--- a/docs/Parcel.NET.Docs/wwwroot/sitemap.xml
+++ b/docs/Parcel.NET.Docs/wwwroot/sitemap.xml
@@ -4,6 +4,7 @@
https://emuuu.github.io/Parcel.NET/docs/api/overview
https://emuuu.github.io/Parcel.NET/docs/carriers/dhl-shipping
https://emuuu.github.io/Parcel.NET/docs/carriers/goexpress-shipping
+ https://emuuu.github.io/Parcel.NET/docs/carriers/letterxpress-letters
https://emuuu.github.io/Parcel.NET/docs/carriers/dhl-tracking
https://emuuu.github.io/Parcel.NET/docs/carriers/goexpress-tracking
https://emuuu.github.io/Parcel.NET/docs/carriers/dhl-authentication
diff --git a/src/Parcel.NET.All/Parcel.NET.All.csproj b/src/Parcel.NET.All/Parcel.NET.All.csproj
index 89e71a7..3397da7 100644
--- a/src/Parcel.NET.All/Parcel.NET.All.csproj
+++ b/src/Parcel.NET.All/Parcel.NET.All.csproj
@@ -2,13 +2,14 @@
Parcel.NET.All
- Meta-package that includes all Parcel.NET carrier packages (DHL, GO! Express).
- shipping;tracking;logistics;dhl;goexpress;parcel;meta-package
+ Meta-package that includes all Parcel.NET carrier packages (DHL, GO! Express, LetterXpress).
+ shipping;tracking;logistics;dhl;goexpress;letterxpress;parcel;meta-package
+
diff --git a/src/Parcel.NET.LetterXpress.All/Parcel.NET.LetterXpress.All.csproj b/src/Parcel.NET.LetterXpress.All/Parcel.NET.LetterXpress.All.csproj
new file mode 100644
index 0000000..77c31c7
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.All/Parcel.NET.LetterXpress.All.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Parcel.NET.LetterXpress.All
+ Meta-package that includes all LetterXpress (A&O Fischer) carrier packages for Parcel.NET.
+ letter;mail;post;logistics;letterxpress;meta-package
+
+
+
+
+
+
+
+
diff --git a/src/Parcel.NET.LetterXpress.Letters/ILetterXpressClient.cs b/src/Parcel.NET.LetterXpress.Letters/ILetterXpressClient.cs
new file mode 100644
index 0000000..cb071e7
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.Letters/ILetterXpressClient.cs
@@ -0,0 +1,151 @@
+using Parcel.NET.LetterXpress.Letters.Models;
+
+namespace Parcel.NET.LetterXpress.Letters;
+
+///
+/// Client for the LetterXpress (A&O Fischer) API v3.
+///
+public interface ILetterXpressClient
+{
+ ///
+ /// Retrieves the account balance (GET /v3/balance).
+ ///
+ /// A cancellation token.
+ /// The account balance.
+ /// Thrown when the API returns an error.
+ Task GetBalanceAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Calculates the price for a letter (GET /v3/price).
+ ///
+ /// The price request.
+ /// A cancellation token.
+ /// The price result.
+ /// Thrown when the API returns an error.
+ Task GetPriceAsync(PriceRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists print jobs (GET /v3/printjobs), ordered from newest to oldest.
+ ///
+ /// Optional status filter.
+ /// Optional page number.
+ /// A cancellation token.
+ /// A paged list of print jobs.
+ /// Thrown when the API returns an error.
+ Task> ListPrintJobsAsync(PrintJobFilter? filter = null, int? page = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Submits a print job (POST /v3/printjobs).
+ ///
+ /// The letter to submit.
+ /// A cancellation token.
+ /// The created print job.
+ /// Thrown when the API returns an error.
+ Task CreatePrintJobAsync(LetterRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves a single print job, including the rendered PDF (GET /v3/printjobs/{id}).
+ ///
+ /// The print job ID.
+ /// A cancellation token.
+ /// The print job.
+ /// Thrown when the API returns an error.
+ Task GetPrintJobAsync(long id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Changes a print job (PUT /v3/printjobs/{id}). Only possible within the first 15 minutes
+ /// after submission, and the PDF itself cannot be changed.
+ ///
+ /// The print job ID.
+ /// The changes to apply.
+ /// A cancellation token.
+ /// The updated print job.
+ /// Thrown when the API returns an error.
+ Task UpdatePrintJobAsync(long id, PrintJobUpdate update, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a print job (DELETE /v3/printjobs/{id}). Only possible within the first 15 minutes
+ /// after submission; jobs with status done cannot be deleted.
+ ///
+ /// The print job ID.
+ /// A cancellation token.
+ /// Thrown when the API returns an error.
+ Task DeletePrintJobAsync(long id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Submits an e-mail job (POST /v3/emailjobs) using LXP SMART@MAIL.
+ ///
+ /// The letter to submit. Provide for a single
+ /// job, or omit it and use a white code in the PDF for serial processing.
+ /// A cancellation token.
+ /// The created e-mail job(s) and any associated print jobs.
+ /// Thrown when the API returns an error.
+ Task CreateEmailJobAsync(LetterRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves a single e-mail job (GET /v3/emailjobs/{id}).
+ ///
+ /// The e-mail job ID.
+ /// A cancellation token.
+ /// The e-mail job.
+ /// Thrown when the API returns an error.
+ Task GetEmailJobAsync(long id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Changes an e-mail job's recipient (PUT /v3/emailjobs/{id}). Only possible within the first
+ /// 15 minutes after submission.
+ ///
+ /// The e-mail job ID.
+ /// The new recipient e-mail address.
+ /// A cancellation token.
+ /// The updated e-mail job.
+ /// Thrown when the API returns an error.
+ Task UpdateEmailJobAsync(long id, string emailReceiver, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes an e-mail job (DELETE /v3/emailjobs/{id}). Only possible within the first 15 minutes
+ /// after submission; jobs with status success cannot be deleted.
+ ///
+ /// The e-mail job ID.
+ /// A cancellation token.
+ /// Thrown when the API returns an error.
+ Task DeleteEmailJobAsync(long id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists e-mail jobs (GET /v3/emailjobs).
+ ///
+ /// Optional status filter.
+ /// Optional page number.
+ /// A cancellation token.
+ /// A paged list of e-mail jobs.
+ /// Thrown when the API returns an error.
+ Task> ListEmailJobsAsync(EmailJobFilter? filter = null, int? page = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists account transactions (GET /v3/transactions).
+ ///
+ /// Optional transaction filter.
+ /// Optional page number.
+ /// A cancellation token.
+ /// A paged list of transactions.
+ /// Thrown when the API returns an error.
+ Task> ListTransactionsAsync(TransactionFilter? filter = null, int? page = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists invoices (GET /v3/invoices).
+ ///
+ /// Optional page number.
+ /// A cancellation token.
+ /// A paged list of invoices.
+ /// Thrown when the API returns an error.
+ Task> ListInvoicesAsync(int? page = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves a single invoice, including the PDF (GET /v3/invoices/{id}).
+ ///
+ /// The invoice ID.
+ /// A cancellation token.
+ /// The invoice.
+ /// Thrown when the API returns an error.
+ Task GetInvoiceAsync(long id, CancellationToken cancellationToken = default);
+}
diff --git a/src/Parcel.NET.LetterXpress.Letters/Internal/LetterXpressApiModels.cs b/src/Parcel.NET.LetterXpress.Letters/Internal/LetterXpressApiModels.cs
new file mode 100644
index 0000000..bfe929a
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.Letters/Internal/LetterXpressApiModels.cs
@@ -0,0 +1,262 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+
+namespace Parcel.NET.LetterXpress.Letters.Internal;
+
+// All property names are mapped to snake_case via the JsonContext naming policy,
+// except where an explicit [JsonPropertyName] overrides it (e.g. "apikey").
+
+internal sealed class LxAuth
+{
+ public string Username { get; set; } = "";
+
+ [JsonPropertyName("apikey")]
+ public string ApiKey { get; set; } = "";
+
+ public string Mode { get; set; } = "";
+}
+
+internal sealed class LxAuthRequest
+{
+ public LxAuth Auth { get; set; } = new();
+}
+
+internal sealed class LxSpecificationWire
+{
+ public string? Color { get; set; }
+ public string? Mode { get; set; }
+ public string? Shipping { get; set; }
+ public int? C4 { get; set; }
+ public int? Pages { get; set; }
+ public string? EmailOption { get; set; }
+}
+
+internal sealed class LxSerialWire
+{
+ public string PagesSeparatorType { get; set; } = "";
+
+ // Either a JSON string (keyword) or a JSON number (page count), depending on the separator type.
+ public JsonNode? PagesSeparatorValue { get; set; }
+}
+
+internal sealed class LxEmailLetterWire
+{
+ public string EmailOption { get; set; } = "";
+ public string EmailReceiver { get; set; } = "";
+}
+
+internal sealed class LxBackgroundsWire
+{
+ public string? Base64BackgroundFirstPage { get; set; }
+ public string? Base64BackgroundOtherPages { get; set; }
+}
+
+internal sealed class LxTermsWire
+{
+ public string Base64Terms { get; set; } = "";
+ public int TermsOnAllPages { get; set; }
+}
+
+internal sealed class LxBankFormWire
+{
+ public int BankFormIncluded { get; set; }
+ public string? Payee { get; set; }
+ public string? Iban { get; set; }
+ public string? Bic { get; set; }
+ public string? Amount { get; set; }
+ public string? PurposeOfPayment { get; set; }
+ public string? PurposeOfPayment2 { get; set; }
+}
+
+internal sealed class LxLetterWire
+{
+ public string Base64File { get; set; } = "";
+ public string Base64FileChecksum { get; set; } = "";
+ public LxSpecificationWire Specification { get; set; } = new();
+ public string? FilenameOriginal { get; set; }
+ public string? Registered { get; set; }
+ public string? DispatchDate { get; set; }
+ public LxSerialWire? SerialLetter { get; set; }
+ public LxEmailLetterWire? EmailLetter { get; set; }
+ public List? Base64Attachments { get; set; }
+ public LxBackgroundsWire? Backgrounds { get; set; }
+ public LxTermsWire? TermsAndConditions { get; set; }
+ public LxBankFormWire? BankForm { get; set; }
+ public string? Notice { get; set; }
+}
+
+internal sealed class LxLetterRequest
+{
+ public LxAuth Auth { get; set; } = new();
+ public LxLetterWire Letter { get; set; } = new();
+}
+
+internal sealed class LxPriceLetterWire
+{
+ public LxSpecificationWire Specification { get; set; } = new();
+ public string? Registered { get; set; }
+}
+
+internal sealed class LxPriceRequest
+{
+ public LxAuth Auth { get; set; } = new();
+ public LxPriceLetterWire Letter { get; set; } = new();
+}
+
+internal sealed class LxUpdateLetterWire
+{
+ public string? DispatchDate { get; set; }
+ public string? Registered { get; set; }
+ public string? Notice { get; set; }
+ public LxSpecificationWire? Specification { get; set; }
+}
+
+internal sealed class LxUpdateRequest
+{
+ public LxAuth Auth { get; set; } = new();
+ public LxUpdateLetterWire Letter { get; set; } = new();
+}
+
+internal sealed class LxEmailWire
+{
+ public string EmailReceiver { get; set; } = "";
+}
+
+internal sealed class LxEmailUpdateRequest
+{
+ public LxAuth Auth { get; set; } = new();
+ public LxEmailWire Email { get; set; } = new();
+}
+
+// ---- Response envelopes -------------------------------------------------
+
+internal sealed class LxResponse
+{
+ public int Status { get; set; }
+ public string? Message { get; set; }
+ public T? Data { get; set; }
+}
+
+internal sealed class LxBalanceData
+{
+ public decimal Balance { get; set; }
+ public string? Currency { get; set; }
+}
+
+internal sealed class LxPriceEcho
+{
+ public LxSpecificationWire? Specification { get; set; }
+}
+
+internal sealed class LxPriceData
+{
+ public decimal Price { get; set; }
+ public LxPriceEcho? Letter { get; set; }
+}
+
+internal sealed class LxPrintJobItemWire
+{
+ public string? Address { get; set; }
+ public int Pages { get; set; }
+ public decimal Amount { get; set; }
+ public decimal Vat { get; set; }
+ public string? Status { get; set; }
+ public string? TrackingCode { get; set; }
+ public string? Base64Data { get; set; }
+}
+
+internal sealed class LxPrintJobWire
+{
+ public long Id { get; set; }
+ public string? Shipping { get; set; }
+ public string? Mode { get; set; }
+ public string? Color { get; set; }
+ public int C4 { get; set; }
+ public string? Registered { get; set; }
+ public int BankForm { get; set; }
+ public string? Notice { get; set; }
+ public string? Status { get; set; }
+ public string? DispatchDate { get; set; }
+ public string? FilenameOriginal { get; set; }
+ public string? CreatedAt { get; set; }
+ public string? UpdatedAt { get; set; }
+ public List? Items { get; set; }
+}
+
+internal sealed class LxPaginationWire
+{
+ public int Total { get; set; }
+ public int Count { get; set; }
+ public int CurrentPage { get; set; }
+ public int LastPage { get; set; }
+ public int PerPage { get; set; }
+ public string? FirstPageUrl { get; set; }
+ public string? LastPageUrl { get; set; }
+ public string? NextPageUrl { get; set; }
+ public string? PrevPageUrl { get; set; }
+}
+
+internal sealed class LxPrintJobsData
+{
+ [JsonPropertyName("printjobs")]
+ public List? PrintJobs { get; set; }
+
+ public LxPaginationWire? Pagination { get; set; }
+}
+
+internal sealed class LxEmailJobWire
+{
+ public long Id { get; set; }
+ public string? EmailSender { get; set; }
+ public string? EmailReceiver { get; set; }
+ public string? EmailOption { get; set; }
+ public string? SentAt { get; set; }
+ public decimal Amount { get; set; }
+ public decimal Vat { get; set; }
+ public string? Status { get; set; }
+ public string? Subject { get; set; }
+ public string? Content { get; set; }
+ public string? Footer { get; set; }
+ public string? CreatedAt { get; set; }
+ public long? PrintjobId { get; set; }
+ public LxPrintJobWire? Printjob { get; set; }
+}
+
+internal sealed class LxEmailCreateData
+{
+ [JsonPropertyName("printjobs")]
+ public List? PrintJobs { get; set; }
+
+ [JsonPropertyName("emailjobs")]
+ public List? EmailJobs { get; set; }
+}
+
+internal sealed class LxTransactionWire
+{
+ public decimal Amount { get; set; }
+ public string? Currency { get; set; }
+ public string? Description { get; set; }
+ public string? CreatedAt { get; set; }
+}
+
+internal sealed class LxTransactionsData
+{
+ public List? Transactions { get; set; }
+ public LxPaginationWire? Pagination { get; set; }
+}
+
+internal sealed class LxInvoiceWire
+{
+ public long Id { get; set; }
+ public decimal Amount { get; set; }
+ public decimal Vat { get; set; }
+ public string? InvoiceDate { get; set; }
+ public string? Base64Data { get; set; }
+}
+
+internal sealed class LxInvoicesData
+{
+ public List? Invoices { get; set; }
+ public LxPaginationWire? Pagination { get; set; }
+}
diff --git a/src/Parcel.NET.LetterXpress.Letters/Internal/LetterXpressJsonContext.cs b/src/Parcel.NET.LetterXpress.Letters/Internal/LetterXpressJsonContext.cs
new file mode 100644
index 0000000..9d5bcef
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.Letters/Internal/LetterXpressJsonContext.cs
@@ -0,0 +1,36 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Parcel.NET.LetterXpress.Letters.Internal;
+
+// Requests
+[JsonSerializable(typeof(LxAuthRequest))]
+[JsonSerializable(typeof(LxLetterRequest))]
+[JsonSerializable(typeof(LxPriceRequest))]
+[JsonSerializable(typeof(LxUpdateRequest))]
+[JsonSerializable(typeof(LxEmailUpdateRequest))]
+// Responses
+[JsonSerializable(typeof(LxResponse))]
+[JsonSerializable(typeof(LxResponse))]
+[JsonSerializable(typeof(LxResponse))]
+[JsonSerializable(typeof(LxResponse))]
+[JsonSerializable(typeof(LxResponse))]
+[JsonSerializable(typeof(LxResponse))]
+[JsonSerializable(typeof(LxResponse))]
+[JsonSerializable(typeof(LxResponse))]
+[JsonSerializable(typeof(LxResponse))]
+[JsonSerializable(typeof(LxResponse))]
+[JsonSerializable(typeof(JsonElement))]
+// Standalone types for JsonElement.Deserialize on polymorphic e-mail responses.
+[JsonSerializable(typeof(LxEmailJobWire))]
+[JsonSerializable(typeof(LxPrintJobWire))]
+[JsonSerializable(typeof(LxEmailCreateData))]
+[JsonSerializable(typeof(LxPaginationWire))]
+// WICHTIG: snake_case-Namenskonvention (base64_file, email_receiver, dispatch_date …).
+// WhenWritingNull, damit optionale Felder weggelassen werden.
+// AllowReadingFromString, falls die API numerische Werte als Strings liefert.
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ NumberHandling = JsonNumberHandling.AllowReadingFromString)]
+internal partial class LetterXpressJsonContext : JsonSerializerContext;
diff --git a/src/Parcel.NET.LetterXpress.Letters/LetterXpressClient.cs b/src/Parcel.NET.LetterXpress.Letters/LetterXpressClient.cs
new file mode 100644
index 0000000..2f6accb
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.Letters/LetterXpressClient.cs
@@ -0,0 +1,836 @@
+using System.Globalization;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.Options;
+using Parcel.NET.LetterXpress.Internal;
+using Parcel.NET.LetterXpress.Letters.Internal;
+using Parcel.NET.LetterXpress.Letters.Models;
+
+namespace Parcel.NET.LetterXpress.Letters;
+
+///
+/// LetterXpress (A&O Fischer) API v3 client implementing .
+///
+public class LetterXpressClient : ILetterXpressClient
+{
+ private readonly HttpClient _httpClient;
+ private readonly LetterXpressOptions _options;
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The configured HTTP client for LetterXpress API requests.
+ /// LetterXpress configuration options.
+ public LetterXpressClient(HttpClient httpClient, IOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(httpClient);
+ ArgumentNullException.ThrowIfNull(options);
+ _httpClient = httpClient;
+ _options = options.Value;
+ }
+
+ ///
+ public async Task GetBalanceAsync(CancellationToken cancellationToken = default)
+ {
+ var response = await SendAsync(
+ HttpMethod.Get, "balance", AuthBody(),
+ LetterXpressJsonContext.Default.LxResponseLxBalanceData, cancellationToken).ConfigureAwait(false);
+
+ var data = response.Data ?? throw new LetterXpressException("LetterXpress balance response contained no data.");
+ return new Balance { Amount = data.Balance, Currency = data.Currency };
+ }
+
+ ///
+ public async Task GetPriceAsync(PriceRequest request, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ if (request.Pages <= 0)
+ {
+ throw new ArgumentException("Price requests require a positive page count.", nameof(request));
+ }
+
+ if (request.Shipping == ShippingType.Auto)
+ {
+ throw new ArgumentException(
+ "Price requests support only National or International shipping, not Auto.", nameof(request));
+ }
+
+ if (request.Registered != RegisteredMail.None && request.Shipping == ShippingType.International)
+ {
+ throw new ArgumentException(
+ "Registered mail can only be sent nationally.", nameof(request));
+ }
+
+ var payload = new LxPriceRequest
+ {
+ Auth = BuildAuth(),
+ Letter = new LxPriceLetterWire
+ {
+ Specification = new LxSpecificationWire
+ {
+ Pages = request.Pages,
+ Color = ColorToWire(request.Color),
+ Mode = ModeToWire(request.Mode),
+ Shipping = ShippingToWire(request.Shipping),
+ C4 = request.C4 ? 1 : 0,
+ EmailOption = request.EmailOption is { } eo ? EmailOptionToWire(eo) : null
+ },
+ Registered = RegisteredToWire(request.Registered)
+ }
+ };
+
+ var response = await SendAsync(
+ HttpMethod.Get, "price",
+ JsonContent.Create(payload, LetterXpressJsonContext.Default.LxPriceRequest),
+ LetterXpressJsonContext.Default.LxResponseLxPriceData, cancellationToken).ConfigureAwait(false);
+
+ var data = response.Data ?? throw new LetterXpressException("LetterXpress price response contained no data.");
+ var spec = data.Letter?.Specification;
+ return new PriceResult
+ {
+ Price = data.Price,
+ Pages = spec?.Pages,
+ Color = spec?.Color,
+ Mode = spec?.Mode,
+ Shipping = spec?.Shipping
+ };
+ }
+
+ ///
+ public async Task> ListPrintJobsAsync(
+ PrintJobFilter? filter = null, int? page = null, CancellationToken cancellationToken = default)
+ {
+ var url = BuildListUrl("printjobs", filter is { } f ? PrintJobFilterToWire(f) : null, page);
+
+ var response = await SendAsync(
+ HttpMethod.Get, url, AuthBody(),
+ LetterXpressJsonContext.Default.LxResponseLxPrintJobsData, cancellationToken).ConfigureAwait(false);
+
+ var data = response.Data;
+ return new PagedResult
+ {
+ Items = data?.PrintJobs?.Select(MapPrintJob).ToList() ?? [],
+ Pagination = MapPagination(data?.Pagination)
+ };
+ }
+
+ ///
+ public async Task CreatePrintJobAsync(LetterRequest request, CancellationToken cancellationToken = default)
+ {
+ ValidateLetterRequest(request);
+
+ var payload = new LxLetterRequest { Auth = BuildAuth(), Letter = BuildLetterWire(request) };
+
+ var response = await SendAsync(
+ HttpMethod.Post, "printjobs",
+ JsonContent.Create(payload, LetterXpressJsonContext.Default.LxLetterRequest),
+ LetterXpressJsonContext.Default.LxResponseLxPrintJobWire, cancellationToken).ConfigureAwait(false);
+
+ var data = response.Data ?? throw new LetterXpressException("LetterXpress print job response contained no data.");
+ return MapPrintJob(data);
+ }
+
+ ///
+ public async Task GetPrintJobAsync(long id, CancellationToken cancellationToken = default)
+ {
+ var response = await SendAsync(
+ HttpMethod.Get, $"printjobs/{id}", AuthBody(),
+ LetterXpressJsonContext.Default.LxResponseLxPrintJobWire, cancellationToken).ConfigureAwait(false);
+
+ var data = response.Data ?? throw new LetterXpressException("LetterXpress print job response contained no data.");
+ return MapPrintJob(data);
+ }
+
+ ///
+ public async Task UpdatePrintJobAsync(long id, PrintJobUpdate update, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(update);
+
+ LxSpecificationWire? spec = null;
+ if (update.Color is not null || update.Mode is not null || update.Shipping is not null || update.C4 is not null)
+ {
+ spec = new LxSpecificationWire
+ {
+ Color = update.Color is { } c ? ColorToWire(c) : null,
+ Mode = update.Mode is { } m ? ModeToWire(m) : null,
+ Shipping = update.Shipping is { } s ? ShippingToWire(s) : null,
+ C4 = update.C4 is { } c4 ? (c4 ? 1 : 0) : null
+ };
+ }
+
+ var payload = new LxUpdateRequest
+ {
+ Auth = BuildAuth(),
+ Letter = new LxUpdateLetterWire
+ {
+ DispatchDate = update.DispatchDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
+ Registered = update.Registered is { } r ? RegisteredToWire(r) : null,
+ Notice = update.Notice,
+ Specification = spec
+ }
+ };
+
+ var response = await SendAsync(
+ HttpMethod.Put, $"printjobs/{id}",
+ JsonContent.Create(payload, LetterXpressJsonContext.Default.LxUpdateRequest),
+ LetterXpressJsonContext.Default.LxResponseLxPrintJobWire, cancellationToken).ConfigureAwait(false);
+
+ var data = response.Data ?? throw new LetterXpressException("LetterXpress print job response contained no data.");
+ return MapPrintJob(data);
+ }
+
+ ///
+ public async Task DeletePrintJobAsync(long id, CancellationToken cancellationToken = default)
+ {
+ await SendAsync(
+ HttpMethod.Delete, $"printjobs/{id}", AuthBody(),
+ LetterXpressJsonContext.Default.LxResponseJsonElement, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task CreateEmailJobAsync(LetterRequest request, CancellationToken cancellationToken = default)
+ {
+ ValidateLetterRequest(request);
+
+ var payload = new LxLetterRequest { Auth = BuildAuth(), Letter = BuildLetterWire(request) };
+
+ var response = await SendAsync(
+ HttpMethod.Post, "emailjobs",
+ JsonContent.Create(payload, LetterXpressJsonContext.Default.LxLetterRequest),
+ LetterXpressJsonContext.Default.LxResponseJsonElement, cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ return ParseEmailCreate(response.Data);
+ }
+ catch (JsonException ex)
+ {
+ throw new LetterXpressException(
+ "Failed to parse LetterXpress e-mail job response.", null, null, RawTextOrNull(response.Data), ex);
+ }
+ }
+
+ ///
+ public async Task GetEmailJobAsync(long id, CancellationToken cancellationToken = default)
+ {
+ var response = await SendAsync(
+ HttpMethod.Get, $"emailjobs/{id}", AuthBody(),
+ LetterXpressJsonContext.Default.LxResponseLxEmailJobWire, cancellationToken).ConfigureAwait(false);
+
+ var data = response.Data ?? throw new LetterXpressException("LetterXpress e-mail job response contained no data.");
+ return MapEmailJob(data);
+ }
+
+ ///
+ public async Task UpdateEmailJobAsync(long id, string emailReceiver, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(emailReceiver);
+
+ var payload = new LxEmailUpdateRequest
+ {
+ Auth = BuildAuth(),
+ Email = new LxEmailWire { EmailReceiver = emailReceiver }
+ };
+
+ var response = await SendAsync(
+ HttpMethod.Put, $"emailjobs/{id}",
+ JsonContent.Create(payload, LetterXpressJsonContext.Default.LxEmailUpdateRequest),
+ LetterXpressJsonContext.Default.LxResponseLxEmailJobWire, cancellationToken).ConfigureAwait(false);
+
+ var data = response.Data ?? throw new LetterXpressException("LetterXpress e-mail job response contained no data.");
+ return MapEmailJob(data);
+ }
+
+ ///
+ public async Task DeleteEmailJobAsync(long id, CancellationToken cancellationToken = default)
+ {
+ await SendAsync(
+ HttpMethod.Delete, $"emailjobs/{id}", AuthBody(),
+ LetterXpressJsonContext.Default.LxResponseJsonElement, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task> ListEmailJobsAsync(
+ EmailJobFilter? filter = null, int? page = null, CancellationToken cancellationToken = default)
+ {
+ var url = BuildListUrl("emailjobs", filter is { } f ? EmailJobFilterToWire(f) : null, page);
+
+ var response = await SendAsync(
+ HttpMethod.Get, url, AuthBody(),
+ LetterXpressJsonContext.Default.LxResponseJsonElement, cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ return ParseEmailList(response.Data);
+ }
+ catch (JsonException ex)
+ {
+ throw new LetterXpressException(
+ "Failed to parse LetterXpress e-mail job list response.", null, null, RawTextOrNull(response.Data), ex);
+ }
+ }
+
+ ///
+ public async Task> ListTransactionsAsync(
+ TransactionFilter? filter = null, int? page = null, CancellationToken cancellationToken = default)
+ {
+ var url = BuildListUrl("transactions", filter is { } f ? TransactionFilterToWire(f) : null, page);
+
+ var response = await SendAsync(
+ HttpMethod.Get, url, AuthBody(),
+ LetterXpressJsonContext.Default.LxResponseLxTransactionsData, cancellationToken).ConfigureAwait(false);
+
+ var data = response.Data;
+ return new PagedResult
+ {
+ Items = data?.Transactions?.Select(MapTransaction).ToList() ?? [],
+ Pagination = MapPagination(data?.Pagination)
+ };
+ }
+
+ ///
+ public async Task> ListInvoicesAsync(int? page = null, CancellationToken cancellationToken = default)
+ {
+ var url = BuildListUrl("invoices", null, page);
+
+ var response = await SendAsync(
+ HttpMethod.Get, url, AuthBody(),
+ LetterXpressJsonContext.Default.LxResponseLxInvoicesData, cancellationToken).ConfigureAwait(false);
+
+ var data = response.Data;
+ return new PagedResult
+ {
+ Items = data?.Invoices?.Select(MapInvoice).ToList() ?? [],
+ Pagination = MapPagination(data?.Pagination)
+ };
+ }
+
+ ///
+ public async Task GetInvoiceAsync(long id, CancellationToken cancellationToken = default)
+ {
+ var response = await SendAsync(
+ HttpMethod.Get, $"invoices/{id}", AuthBody(),
+ LetterXpressJsonContext.Default.LxResponseLxInvoiceWire, cancellationToken).ConfigureAwait(false);
+
+ var data = response.Data ?? throw new LetterXpressException("LetterXpress invoice response contained no data.");
+ return MapInvoice(data);
+ }
+
+ // ---- HTTP plumbing --------------------------------------------------
+
+ private LxAuth BuildAuth() => new()
+ {
+ Username = _options.Username,
+ ApiKey = _options.ApiKey,
+ Mode = _options.Mode
+ };
+
+ private HttpContent AuthBody() =>
+ JsonContent.Create(new LxAuthRequest { Auth = BuildAuth() }, LetterXpressJsonContext.Default.LxAuthRequest);
+
+ private async Task> SendAsync(
+ HttpMethod method,
+ string url,
+ HttpContent content,
+ System.Text.Json.Serialization.Metadata.JsonTypeInfo> responseInfo,
+ CancellationToken cancellationToken)
+ {
+ // LetterXpress requires the Content-Type to be exactly "application/json".
+ // JsonContent/StringContent would otherwise append "; charset=utf-8", which the API rejects with HTTP 400.
+ content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
+
+ using var request = new HttpRequestMessage(method, url) { Content = content };
+ using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
+
+ var rawBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var detail = LetterXpressErrorHelper.TryParseErrorDetail(rawBody);
+ throw new LetterXpressException(
+ $"LetterXpress API returned {(int)response.StatusCode}: {detail}",
+ response.StatusCode,
+ ((int)response.StatusCode).ToString(),
+ rawBody);
+ }
+
+ LxResponse? parsed;
+ try
+ {
+ parsed = JsonSerializer.Deserialize(rawBody, responseInfo);
+ }
+ catch (JsonException ex)
+ {
+ throw new LetterXpressException(
+ "Failed to deserialize LetterXpress response.", null, null, rawBody, ex);
+ }
+
+ if (parsed is null)
+ {
+ throw new LetterXpressException(
+ "LetterXpress response was empty.", null, null, rawBody);
+ }
+
+ // The API also reports application-level errors via the body "status" field,
+ // which mirrors the documented HTTP status codes (400/401/403/...).
+ if (parsed.Status is not 0 and not 200)
+ {
+ var statusCode = Enum.IsDefined(typeof(HttpStatusCode), parsed.Status)
+ ? (HttpStatusCode?)parsed.Status
+ : null;
+ throw new LetterXpressException(
+ $"LetterXpress API returned status {parsed.Status}: {parsed.Message}",
+ statusCode,
+ parsed.Status.ToString(CultureInfo.InvariantCulture),
+ rawBody);
+ }
+
+ return parsed;
+ }
+
+ private static string BuildListUrl(string path, string? filter, int? page)
+ {
+ var query = new List();
+ if (filter is not null) query.Add($"filter={Uri.EscapeDataString(filter)}");
+ if (page is { } p) query.Add($"page={p.ToString(CultureInfo.InvariantCulture)}");
+ return query.Count == 0 ? path : $"{path}?{string.Join('&', query)}";
+ }
+
+ // ---- Request building -----------------------------------------------
+
+ // The API accepts at most 50 MB per PDF / request.
+ private const long MaxFileBytes = 50L * 1024 * 1024;
+
+ private static void ValidateLetterRequest(LetterRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(request.File);
+ ArgumentNullException.ThrowIfNull(request.Specification);
+
+ if (request.File.Length == 0)
+ {
+ throw new ArgumentException("The letter file must not be empty.", nameof(request));
+ }
+
+ // The 50 MB limit applies per request, so count the main file plus all secondary PDFs.
+ long totalBytes = request.File.LongLength;
+ if (request.Attachments is { } attachments)
+ {
+ totalBytes += attachments.Where(a => a is not null).Sum(a => a.LongLength);
+ }
+ if (request.Backgrounds is { } bg)
+ {
+ totalBytes += (bg.FirstPage?.LongLength ?? 0) + (bg.OtherPages?.LongLength ?? 0);
+ }
+ if (request.TermsAndConditions?.Terms is { } terms)
+ {
+ totalBytes += terms.LongLength;
+ }
+
+ if (totalBytes > MaxFileBytes)
+ {
+ throw new ArgumentException(
+ $"The request exceeds the {MaxFileBytes / (1024 * 1024)} MB limit (PDFs total {totalBytes} bytes).",
+ nameof(request));
+ }
+
+ // Per the spec, a serial letter cannot be combined with an email_letter object.
+ if (request.SerialLetter is not null && request.EmailLetter is not null)
+ {
+ throw new ArgumentException(
+ "SerialLetter and EmailLetter cannot be combined in a single request.", nameof(request));
+ }
+
+ // A numeric serial separator must actually be a positive whole number, otherwise the wire
+ // shape (pages_separator_type = "number") would carry a string value, violating the spec.
+ if (request.SerialLetter is { PagesSeparatorType: SeparatorType.Number } serial
+ && (!long.TryParse(serial.PagesSeparatorValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pages) || pages <= 0))
+ {
+ throw new ArgumentException(
+ "SerialLetter.PagesSeparatorValue must be a positive whole number when PagesSeparatorType is Number.",
+ nameof(request));
+ }
+
+ // Registered mail can only be sent nationally.
+ if (request.Registered != RegisteredMail.None && request.Specification.Shipping == ShippingType.International)
+ {
+ throw new ArgumentException(
+ "Registered mail can only be sent nationally.", nameof(request));
+ }
+
+ // The dispatch date, if set, must not be in the past (the server enforces "in the future").
+ if (request.DispatchDate is { } dispatch && dispatch < DateOnly.FromDateTime(DateTime.UtcNow))
+ {
+ throw new ArgumentException("DispatchDate must not be in the past.", nameof(request));
+ }
+ }
+
+ private static LxLetterWire BuildLetterWire(LetterRequest request)
+ {
+ var (base64File, checksum) = EncodeFile(request.File);
+ var spec = request.Specification;
+
+ var wire = new LxLetterWire
+ {
+ Base64File = base64File,
+ Base64FileChecksum = checksum,
+ Specification = new LxSpecificationWire
+ {
+ Color = ColorToWire(spec.Color),
+ Mode = ModeToWire(spec.Mode),
+ Shipping = ShippingToWire(spec.Shipping),
+ C4 = spec.C4 ? 1 : 0
+ },
+ FilenameOriginal = request.FilenameOriginal,
+ Registered = RegisteredToWire(request.Registered),
+ DispatchDate = request.DispatchDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
+ Notice = request.Notice
+ };
+
+ if (request.SerialLetter is { } sl)
+ {
+ // For separator type "number" the API expects a JSON number, otherwise a JSON string (keyword).
+ JsonNode separatorValue =
+ sl.PagesSeparatorType == SeparatorType.Number
+ && long.TryParse(sl.PagesSeparatorValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pageCount)
+ ? JsonValue.Create(pageCount)
+ : JsonValue.Create(sl.PagesSeparatorValue);
+
+ wire.SerialLetter = new LxSerialWire
+ {
+ PagesSeparatorType = SeparatorTypeToWire(sl.PagesSeparatorType),
+ PagesSeparatorValue = separatorValue
+ };
+ }
+
+ if (request.EmailLetter is { } el)
+ {
+ wire.EmailLetter = new LxEmailLetterWire
+ {
+ EmailOption = EmailOptionToWire(el.EmailOption),
+ EmailReceiver = el.EmailReceiver
+ };
+ }
+
+ if (request.Attachments is { Count: > 0 } attachments)
+ {
+ wire.Base64Attachments = attachments.Select(Convert.ToBase64String).ToList();
+ }
+
+ if (request.Backgrounds is { } bg)
+ {
+ wire.Backgrounds = new LxBackgroundsWire
+ {
+ Base64BackgroundFirstPage = bg.FirstPage is null ? null : Convert.ToBase64String(bg.FirstPage),
+ Base64BackgroundOtherPages = bg.OtherPages is null ? null : Convert.ToBase64String(bg.OtherPages)
+ };
+ }
+
+ if (request.TermsAndConditions is { } tc)
+ {
+ wire.TermsAndConditions = new LxTermsWire
+ {
+ Base64Terms = Convert.ToBase64String(tc.Terms),
+ TermsOnAllPages = tc.OnAllPages ? 1 : 0
+ };
+ }
+
+ if (request.BankForm is { } bf)
+ {
+ wire.BankForm = new LxBankFormWire
+ {
+ BankFormIncluded = bf.Included ? 1 : 0,
+ Payee = bf.Payee,
+ Iban = bf.Iban,
+ Bic = bf.Bic,
+ Amount = bf.Amount,
+ PurposeOfPayment = bf.PurposeOfPayment,
+ PurposeOfPayment2 = bf.PurposeOfPayment2
+ };
+ }
+
+ return wire;
+ }
+
+ internal static (string Base64File, string Checksum) EncodeFile(byte[] file)
+ {
+ var base64File = Convert.ToBase64String(file);
+ var hash = MD5.HashData(Encoding.UTF8.GetBytes(base64File));
+ var checksum = Convert.ToHexString(hash).ToLowerInvariant();
+ return (base64File, checksum);
+ }
+
+ // ---- Response mapping -----------------------------------------------
+
+ private EmailJobResult ParseEmailCreate(JsonElement data)
+ {
+ if (data.ValueKind == JsonValueKind.Array)
+ {
+ return new EmailJobResult { EmailJobs = DeserializeEmailJobs(data) };
+ }
+
+ if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("emailjobs", out _))
+ {
+ var combined = data.Deserialize(LetterXpressJsonContext.Default.LxEmailCreateData);
+ return new EmailJobResult
+ {
+ EmailJobs = combined?.EmailJobs?.Select(MapEmailJob).ToList() ?? [],
+ PrintJobs = combined?.PrintJobs?.Select(MapPrintJob).ToList() ?? []
+ };
+ }
+
+ if (data.ValueKind == JsonValueKind.Object)
+ {
+ var single = data.Deserialize(LetterXpressJsonContext.Default.LxEmailJobWire);
+ return new EmailJobResult { EmailJobs = single is null ? [] : [MapEmailJob(single)] };
+ }
+
+ return new EmailJobResult();
+ }
+
+ private PagedResult ParseEmailList(JsonElement data)
+ {
+ if (data.ValueKind == JsonValueKind.Array)
+ {
+ return new PagedResult { Items = DeserializeEmailJobs(data) };
+ }
+
+ if (data.ValueKind != JsonValueKind.Object)
+ {
+ return new PagedResult();
+ }
+
+ Pagination? pagination = null;
+ if (data.TryGetProperty("pagination", out var paginationElement))
+ {
+ pagination = MapPagination(paginationElement.Deserialize(LetterXpressJsonContext.Default.LxPaginationWire));
+ }
+
+ var jobs = new List();
+ if (data.TryGetProperty("emailjobs", out var emailJobsElement) && emailJobsElement.ValueKind == JsonValueKind.Array)
+ {
+ jobs = DeserializeEmailJobs(emailJobsElement);
+ }
+ else if (data.TryGetProperty("id", out _))
+ {
+ // Defensive: a single e-mail job object without an explicit array wrapper.
+ var single = data.Deserialize(LetterXpressJsonContext.Default.LxEmailJobWire);
+ if (single is not null) jobs.Add(MapEmailJob(single));
+ }
+
+ return new PagedResult { Items = jobs, Pagination = pagination };
+ }
+
+ private static string? RawTextOrNull(JsonElement element) =>
+ element.ValueKind == JsonValueKind.Undefined ? null : element.GetRawText();
+
+ private List DeserializeEmailJobs(JsonElement array)
+ {
+ var jobs = new List();
+ foreach (var element in array.EnumerateArray())
+ {
+ var wire = element.Deserialize(LetterXpressJsonContext.Default.LxEmailJobWire);
+ if (wire is not null) jobs.Add(MapEmailJob(wire));
+ }
+ return jobs;
+ }
+
+ private static PrintJob MapPrintJob(LxPrintJobWire w) => new()
+ {
+ Id = w.Id,
+ Shipping = w.Shipping,
+ Mode = w.Mode,
+ Color = w.Color,
+ C4 = w.C4 != 0,
+ Registered = w.Registered,
+ BankForm = w.BankForm != 0,
+ Notice = w.Notice,
+ Status = w.Status,
+ DispatchDate = w.DispatchDate,
+ FilenameOriginal = w.FilenameOriginal,
+ CreatedAt = ParseDateTime(w.CreatedAt),
+ UpdatedAt = ParseDateTime(w.UpdatedAt),
+ Items = w.Items?.Select(MapPrintJobItem).ToList() ?? []
+ };
+
+ private static PrintJobItem MapPrintJobItem(LxPrintJobItemWire w) => new()
+ {
+ Address = w.Address,
+ Pages = w.Pages,
+ Amount = w.Amount,
+ Vat = w.Vat,
+ Status = w.Status,
+ TrackingCode = w.TrackingCode,
+ Base64Data = w.Base64Data
+ };
+
+ private static EmailJob MapEmailJob(LxEmailJobWire w) => new()
+ {
+ Id = w.Id,
+ EmailSender = w.EmailSender,
+ EmailReceiver = w.EmailReceiver,
+ EmailOption = w.EmailOption,
+ SentAt = ParseDateTime(w.SentAt),
+ Amount = w.Amount,
+ Vat = w.Vat,
+ Status = w.Status,
+ Subject = w.Subject,
+ Content = w.Content,
+ Footer = w.Footer,
+ CreatedAt = ParseDateTime(w.CreatedAt),
+ PrintJobId = w.PrintjobId,
+ PrintJob = w.Printjob is null ? null : MapPrintJob(w.Printjob)
+ };
+
+ private static Transaction MapTransaction(LxTransactionWire w) => new()
+ {
+ Amount = w.Amount,
+ Currency = w.Currency,
+ Description = w.Description,
+ CreatedAt = ParseDateTime(w.CreatedAt)
+ };
+
+ private static Invoice MapInvoice(LxInvoiceWire w) => new()
+ {
+ Id = w.Id,
+ Amount = w.Amount,
+ Vat = w.Vat,
+ InvoiceDate = ParseDate(w.InvoiceDate),
+ Base64Data = w.Base64Data
+ };
+
+ private static Pagination? MapPagination(LxPaginationWire? w) => w is null ? null : new Pagination
+ {
+ Total = w.Total,
+ Count = w.Count,
+ CurrentPage = w.CurrentPage,
+ LastPage = w.LastPage,
+ PerPage = w.PerPage,
+ FirstPageUrl = w.FirstPageUrl,
+ LastPageUrl = w.LastPageUrl,
+ NextPageUrl = w.NextPageUrl,
+ PrevPageUrl = w.PrevPageUrl
+ };
+
+ // LetterXpress returns timestamps without a timezone designator, in German local time
+ // (Europe/Berlin, i.e. CET/CEST). Verified against the live API.
+ private static readonly TimeZoneInfo? BerlinTimeZone = ResolveBerlinTimeZone();
+
+ private static TimeZoneInfo? ResolveBerlinTimeZone()
+ {
+ foreach (var id in (string[])["Europe/Berlin", "W. Europe Standard Time"])
+ {
+ try
+ {
+ return TimeZoneInfo.FindSystemTimeZoneById(id);
+ }
+ catch (TimeZoneNotFoundException)
+ {
+ // Try the next identifier.
+ }
+ catch (InvalidTimeZoneException)
+ {
+ // Try the next identifier.
+ }
+ }
+
+ return null;
+ }
+
+ private static DateTimeOffset? ParseDateTime(string? value)
+ {
+ if (!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
+ {
+ return null;
+ }
+
+ var unspecified = DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified);
+ var offset = BerlinTimeZone?.GetUtcOffset(unspecified) ?? TimeSpan.Zero;
+ return new DateTimeOffset(unspecified, offset);
+ }
+
+ private static DateOnly? ParseDate(string? value) =>
+ DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
+ ? parsed
+ : null;
+
+ // ---- Enum mapping ---------------------------------------------------
+
+ private static string ColorToWire(LetterColor color) => color switch
+ {
+ LetterColor.BlackWhite => "1",
+ LetterColor.Color => "4",
+ _ => "1"
+ };
+
+ private static string ModeToWire(PrintMode mode) => mode switch
+ {
+ PrintMode.Simplex => "simplex",
+ PrintMode.Duplex => "duplex",
+ _ => "simplex"
+ };
+
+ private static string ShippingToWire(ShippingType shipping) => shipping switch
+ {
+ ShippingType.National => "national",
+ ShippingType.International => "international",
+ ShippingType.Auto => "auto",
+ _ => "auto"
+ };
+
+ private static string? RegisteredToWire(RegisteredMail registered) => registered switch
+ {
+ RegisteredMail.EinschreibenEinwurf => "r1",
+ RegisteredMail.Einschreiben => "r2",
+ _ => null
+ };
+
+ private static string EmailOptionToWire(EmailOption option) => option switch
+ {
+ EmailOption.MailDirect => "maildirect",
+ EmailOption.MailPlus => "mailplus",
+ EmailOption.MailSecure => "mailsecure",
+ _ => "maildirect"
+ };
+
+ private static string SeparatorTypeToWire(SeparatorType type) => type switch
+ {
+ SeparatorType.String => "string",
+ SeparatorType.Number => "number",
+ _ => "string"
+ };
+
+ private static string PrintJobFilterToWire(PrintJobFilter filter) => filter switch
+ {
+ PrintJobFilter.Queue => "queue",
+ PrintJobFilter.Hold => "hold",
+ PrintJobFilter.Done => "done",
+ PrintJobFilter.Canceled => "canceled",
+ PrintJobFilter.Draft => "draft",
+ _ => "queue"
+ };
+
+ private static string EmailJobFilterToWire(EmailJobFilter filter) => filter switch
+ {
+ EmailJobFilter.Queue => "queue",
+ EmailJobFilter.Hold => "hold",
+ EmailJobFilter.Canceled => "canceled",
+ EmailJobFilter.Draft => "draft",
+ EmailJobFilter.Success => "success",
+ _ => "queue"
+ };
+
+ private static string TransactionFilterToWire(TransactionFilter filter) => filter switch
+ {
+ TransactionFilter.PayIns => "payins",
+ TransactionFilter.PayOuts => "payouts",
+ TransactionFilter.PrintJobs => "printjobs",
+ _ => "payins"
+ };
+}
diff --git a/src/Parcel.NET.LetterXpress.Letters/LetterXpressLettersExtensions.cs b/src/Parcel.NET.LetterXpress.Letters/LetterXpressLettersExtensions.cs
new file mode 100644
index 0000000..9d0eebc
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.Letters/LetterXpressLettersExtensions.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Parcel.NET.LetterXpress.Letters;
+
+///
+/// Extension methods for registering the LetterXpress letters client.
+///
+public static class LetterXpressLettersExtensions
+{
+ ///
+ /// Adds the LetterXpress letters client to the service collection, registered as
+ /// .
+ ///
+ /// The LetterXpress builder.
+ /// The builder for chaining.
+ public static LetterXpressBuilder AddLetterXpressLetters(this LetterXpressBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Services.AddHttpClient((sp, client) =>
+ {
+ var options = sp.GetRequiredService>().Value;
+ client.BaseAddress = new Uri(options.BaseUrl);
+ });
+
+ builder.Services.AddTransient(sp => sp.GetRequiredService());
+
+ return builder;
+ }
+}
diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/AccountModels.cs b/src/Parcel.NET.LetterXpress.Letters/Models/AccountModels.cs
new file mode 100644
index 0000000..f7f5cb4
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.Letters/Models/AccountModels.cs
@@ -0,0 +1,155 @@
+namespace Parcel.NET.LetterXpress.Letters.Models;
+
+///
+/// Account balance.
+///
+public class Balance
+{
+ ///
+ /// Gets the current balance.
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// Gets the currency (e.g. EUR).
+ ///
+ public string? Currency { get; init; }
+}
+
+///
+/// Result of a price calculation.
+///
+public class PriceResult
+{
+ ///
+ /// Gets the calculated gross price.
+ ///
+ public decimal Price { get; init; }
+
+ ///
+ /// Gets the number of pages the price was calculated for.
+ ///
+ public int? Pages { get; init; }
+
+ ///
+ /// Gets the print color the price was calculated for.
+ ///
+ public string? Color { get; init; }
+
+ ///
+ /// Gets the print mode the price was calculated for.
+ ///
+ public string? Mode { get; init; }
+
+ ///
+ /// Gets the shipping destination the price was calculated for.
+ ///
+ public string? Shipping { get; init; }
+}
+
+///
+/// An account transaction.
+///
+public class Transaction
+{
+ ///
+ /// Gets the amount (negative for charges, positive for top-ups).
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// Gets the currency (e.g. EUR).
+ ///
+ public string? Currency { get; init; }
+
+ ///
+ /// Gets the description.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// Gets the creation timestamp.
+ ///
+ public DateTimeOffset? CreatedAt { get; init; }
+}
+
+///
+/// An invoice.
+///
+public class Invoice
+{
+ ///
+ /// Gets the invoice ID.
+ ///
+ public long Id { get; init; }
+
+ ///
+ /// Gets the net amount.
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// Gets the VAT.
+ ///
+ public decimal Vat { get; init; }
+
+ ///
+ /// Gets the invoice date.
+ ///
+ public DateOnly? InvoiceDate { get; init; }
+
+ ///
+ /// Gets the invoice PDF as base64, returned only when retrieving a single invoice.
+ ///
+ public string? Base64Data { get; init; }
+}
+
+///
+/// Pagination metadata returned by list endpoints.
+///
+public class Pagination
+{
+ /// Gets the total number of items.
+ public int Total { get; init; }
+
+ /// Gets the number of items on the current page.
+ public int Count { get; init; }
+
+ /// Gets the current page number.
+ public int CurrentPage { get; init; }
+
+ /// Gets the last page number.
+ public int LastPage { get; init; }
+
+ /// Gets the number of items per page.
+ public int PerPage { get; init; }
+
+ /// Gets the URL of the first page, if any.
+ public string? FirstPageUrl { get; init; }
+
+ /// Gets the URL of the last page, if any.
+ public string? LastPageUrl { get; init; }
+
+ /// Gets the URL of the next page, if any.
+ public string? NextPageUrl { get; init; }
+
+ /// Gets the URL of the previous page, if any.
+ public string? PrevPageUrl { get; init; }
+}
+
+///
+/// A paged list result.
+///
+/// The item type.
+public class PagedResult
+{
+ ///
+ /// Gets the items on the current page.
+ ///
+ public IReadOnlyList Items { get; init; } = [];
+
+ ///
+ /// Gets the pagination metadata, if available.
+ ///
+ public Pagination? Pagination { get; init; }
+}
diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/EmailJob.cs b/src/Parcel.NET.LetterXpress.Letters/Models/EmailJob.cs
new file mode 100644
index 0000000..9ca689f
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.Letters/Models/EmailJob.cs
@@ -0,0 +1,94 @@
+namespace Parcel.NET.LetterXpress.Letters.Models;
+
+///
+/// A LetterXpress SMART@MAIL e-mail job.
+///
+public class EmailJob
+{
+ ///
+ /// Gets the e-mail job ID.
+ ///
+ public long Id { get; init; }
+
+ ///
+ /// Gets the sender e-mail address.
+ ///
+ public string? EmailSender { get; init; }
+
+ ///
+ /// Gets the recipient e-mail address.
+ ///
+ public string? EmailReceiver { get; init; }
+
+ ///
+ /// Gets the SMART@MAIL option (maildirect, mailplus, mailsecure).
+ ///
+ public string? EmailOption { get; init; }
+
+ ///
+ /// Gets the timestamp the e-mail was sent, if available.
+ ///
+ public DateTimeOffset? SentAt { get; init; }
+
+ ///
+ /// Gets the net amount charged.
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// Gets the VAT.
+ ///
+ public decimal Vat { get; init; }
+
+ ///
+ /// Gets the job status (e.g. queue, hold, success).
+ ///
+ public string? Status { get; init; }
+
+ ///
+ /// Gets the e-mail subject.
+ ///
+ public string? Subject { get; init; }
+
+ ///
+ /// Gets the e-mail content.
+ ///
+ public string? Content { get; init; }
+
+ ///
+ /// Gets the e-mail footer.
+ ///
+ public string? Footer { get; init; }
+
+ ///
+ /// Gets the creation timestamp.
+ ///
+ public DateTimeOffset? CreatedAt { get; init; }
+
+ ///
+ /// Gets the ID of the associated print job, when the e-mail job is part of a serial response.
+ ///
+ public long? PrintJobId { get; init; }
+
+ ///
+ /// Gets the associated print job, returned for MailPlus jobs.
+ ///
+ public PrintJob? PrintJob { get; init; }
+}
+
+///
+/// Result of submitting an e-mail job. Depending on the request this contains a single e-mail job,
+/// multiple e-mail jobs (serial processing), and/or print jobs created for pages without a white code.
+///
+public class EmailJobResult
+{
+ ///
+ /// Gets the created e-mail jobs.
+ ///
+ public IReadOnlyList EmailJobs { get; init; } = [];
+
+ ///
+ /// Gets any print jobs created alongside the e-mail jobs (e.g. for MailPlus or pages without a white code).
+ ///
+ public IReadOnlyList PrintJobs { get; init; } = [];
+}
diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/Enums.cs b/src/Parcel.NET.LetterXpress.Letters/Models/Enums.cs
new file mode 100644
index 0000000..f744b66
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.Letters/Models/Enums.cs
@@ -0,0 +1,140 @@
+namespace Parcel.NET.LetterXpress.Letters.Models;
+
+///
+/// Print color of a letter.
+///
+public enum LetterColor
+{
+ /// Black/white printing (API value "1").
+ BlackWhite,
+
+ /// Color printing (API value "4").
+ Color
+}
+
+///
+/// Print mode of a letter.
+///
+public enum PrintMode
+{
+ /// Single-sided printing (API value "simplex").
+ Simplex,
+
+ /// Double-sided printing (API value "duplex").
+ Duplex
+}
+
+///
+/// Shipping destination of a letter.
+///
+public enum ShippingType
+{
+ /// National shipping (API value "national").
+ National,
+
+ /// International shipping (API value "international").
+ International,
+
+ /// Automatic detection (API value "auto"). Not valid for price requests.
+ Auto
+}
+
+///
+/// Registered mail option. Registered mail can only be sent nationally.
+///
+public enum RegisteredMail
+{
+ /// No registered mail.
+ None,
+
+ /// Einschreiben Einwurf (API value "r1").
+ EinschreibenEinwurf,
+
+ /// Einschreiben (API value "r2").
+ Einschreiben
+}
+
+///
+/// LXP SMART@MAIL e-mail delivery option.
+///
+public enum EmailOption
+{
+ /// maildirect.
+ MailDirect,
+
+ /// mailplus.
+ MailPlus,
+
+ /// mailsecure.
+ MailSecure
+}
+
+///
+/// Type of the serial letter page separator.
+///
+public enum SeparatorType
+{
+ /// A keyword string is used to separate the individual letters (API value "string").
+ String,
+
+ /// A fixed number of pages is used to separate the individual letters (API value "number").
+ Number
+}
+
+///
+/// Filter for listing print jobs.
+///
+public enum PrintJobFilter
+{
+ /// Jobs in the queue.
+ Queue,
+
+ /// Held jobs.
+ Hold,
+
+ /// Processed jobs.
+ Done,
+
+ /// Cancelled jobs.
+ Canceled,
+
+ /// Jobs in the Postbox.
+ Draft
+}
+
+///
+/// Filter for listing e-mail jobs. Accepted values were verified against the live API
+/// (the e-mail equivalent of the print job's done status is success).
+///
+public enum EmailJobFilter
+{
+ /// Jobs in the queue.
+ Queue,
+
+ /// Held jobs.
+ Hold,
+
+ /// Cancelled jobs.
+ Canceled,
+
+ /// Jobs in the Postbox.
+ Draft,
+
+ /// Successfully sent jobs.
+ Success
+}
+
+///
+/// Filter for listing transactions.
+///
+public enum TransactionFilter
+{
+ /// Pay-ins / vouchers / kickbacks.
+ PayIns,
+
+ /// Pay-outs.
+ PayOuts,
+
+ /// Print jobs.
+ PrintJobs
+}
diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/LetterOptions.cs b/src/Parcel.NET.LetterXpress.Letters/Models/LetterOptions.cs
new file mode 100644
index 0000000..cdc4a30
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.Letters/Models/LetterOptions.cs
@@ -0,0 +1,112 @@
+namespace Parcel.NET.LetterXpress.Letters.Models;
+
+///
+/// Options for splitting a serial letter into individual letters.
+///
+public class SerialLetterOptions
+{
+ ///
+ /// Gets or sets the separator type.
+ ///
+ public SeparatorType PagesSeparatorType { get; set; }
+
+ ///
+ /// Gets or sets the separator value: a keyword when is
+ /// , or a page count when it is .
+ ///
+ public required string PagesSeparatorValue { get; set; }
+}
+
+///
+/// LXP SMART@MAIL options for transmitting a single job as an e-mail letter.
+/// Cannot be combined with a serial letter.
+///
+public class EmailLetterOptions
+{
+ ///
+ /// Gets or sets the SMART@MAIL delivery option.
+ ///
+ public EmailOption EmailOption { get; set; }
+
+ ///
+ /// Gets or sets the valid recipient e-mail address.
+ ///
+ public required string EmailReceiver { get; set; }
+}
+
+///
+/// Background PDFs inserted behind a letter's pages. Each background must be a single-page
+/// A4 portrait (29.7 x 21.0 cm) PDF.
+///
+public class LetterBackgrounds
+{
+ ///
+ /// Gets or sets the background PDF for the first page.
+ ///
+ public byte[]? FirstPage { get; set; }
+
+ ///
+ /// Gets or sets the background PDF for all other pages.
+ ///
+ public byte[]? OtherPages { get; set; }
+}
+
+///
+/// Terms and conditions PDF appended to a letter. Must be a single-page A4 portrait
+/// (29.7 x 21.0 cm) PDF. Printing is automatically double-sided.
+///
+public class TermsAndConditions
+{
+ ///
+ /// Gets or sets the terms and conditions PDF.
+ ///
+ public required byte[] Terms { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the terms are inserted behind all pages
+ /// () or only behind the first page ().
+ ///
+ public bool OnAllPages { get; set; }
+}
+
+///
+/// Bank transfer form attached as the last sheet of a letter.
+///
+public class BankForm
+{
+ ///
+ /// Gets or sets a value indicating whether the bank form is already included in the PDF's last sheet.
+ /// When , the payment detail fields below are ignored and the last (blank) sheet is used.
+ ///
+ public bool Included { get; set; }
+
+ ///
+ /// Gets or sets the payee. Max. 27 characters. Ignored when is .
+ ///
+ public string? Payee { get; set; }
+
+ ///
+ /// Gets or sets the IBAN. Max. 34 characters.
+ ///
+ public string? Iban { get; set; }
+
+ ///
+ /// Gets or sets the BIC. Max. 11 characters.
+ ///
+ public string? Bic { get; set; }
+
+ ///
+ /// Gets or sets the amount. Max. 12 characters.
+ ///
+ public string? Amount { get; set; }
+
+ ///
+ /// Gets or sets the purpose of payment. Max. 27 characters.
+ ///
+ public string? PurposeOfPayment { get; set; }
+
+ ///
+ /// Gets or sets the second purpose of payment line. Max. 27 characters.
+ ///
+ public string? PurposeOfPayment2 { get; set; }
+}
diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/LetterRequest.cs b/src/Parcel.NET.LetterXpress.Letters/Models/LetterRequest.cs
new file mode 100644
index 0000000..10346d1
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.Letters/Models/LetterRequest.cs
@@ -0,0 +1,156 @@
+namespace Parcel.NET.LetterXpress.Letters.Models;
+
+///
+/// Request for submitting a letter (print job or e-mail job). The PDF is supplied as raw bytes;
+/// the client computes the base64 encoding and MD5 checksum automatically.
+///
+public class LetterRequest
+{
+ ///
+ /// Gets or sets the letter PDF as raw bytes. Max. 50 MB.
+ ///
+ public required byte[] File { get; set; }
+
+ ///
+ /// Gets or sets the print and shipping specification.
+ ///
+ public required LetterSpecification Specification { get; set; }
+
+ ///
+ /// Gets or sets the original file name (e.g. Rechnung-123456.pdf).
+ /// Optional; necessary for LXP SMART@MAIL and returned when retrieving jobs.
+ ///
+ public string? FilenameOriginal { get; set; }
+
+ ///
+ /// Gets or sets the registered mail option. Registered mail can only be sent nationally.
+ /// Defaults to .
+ ///
+ public RegisteredMail Registered { get; set; } = RegisteredMail.None;
+
+ ///
+ /// Gets or sets the dispatch date. Optional; must be in the future.
+ ///
+ public DateOnly? DispatchDate { get; set; }
+
+ ///
+ /// Gets or sets serial letter options. Cannot be combined with .
+ ///
+ public SerialLetterOptions? SerialLetter { get; set; }
+
+ ///
+ /// Gets or sets LXP SMART@MAIL e-mail options for a single job. Cannot be combined with .
+ ///
+ public EmailLetterOptions? EmailLetter { get; set; }
+
+ ///
+ /// Gets or sets additional attachments as raw PDF bytes. Each attachment must be A4 portrait
+ /// (29.7 x 21.0 cm). The order is preserved.
+ ///
+ public IReadOnlyList? Attachments { get; set; }
+
+ ///
+ /// Gets or sets the letter background PDFs.
+ ///
+ public LetterBackgrounds? Backgrounds { get; set; }
+
+ ///
+ /// Gets or sets the terms and conditions PDF.
+ ///
+ public TermsAndConditions? TermsAndConditions { get; set; }
+
+ ///
+ /// Gets or sets the bank transfer form.
+ ///
+ public BankForm? BankForm { get; set; }
+
+ ///
+ /// Gets or sets an arbitrary notice stored with the job. Max. 255 characters.
+ ///
+ public string? Notice { get; set; }
+}
+
+///
+/// Request for changing an existing print job within the first 15 minutes after submission.
+/// Only the specification and shipping options can be changed; the PDF cannot.
+/// All properties are optional — only set ones are sent.
+///
+public class PrintJobUpdate
+{
+ ///
+ /// Gets or sets the new dispatch date.
+ ///
+ public DateOnly? DispatchDate { get; set; }
+
+ ///
+ /// Gets or sets the new registered mail option.
+ ///
+ public RegisteredMail? Registered { get; set; }
+
+ ///
+ /// Gets or sets the new notice.
+ ///
+ public string? Notice { get; set; }
+
+ ///
+ /// Gets or sets the new print color.
+ ///
+ public LetterColor? Color { get; set; }
+
+ ///
+ /// Gets or sets the new print mode.
+ ///
+ public PrintMode? Mode { get; set; }
+
+ ///
+ /// Gets or sets the new shipping destination.
+ ///
+ public ShippingType? Shipping { get; set; }
+
+ ///
+ /// Gets or sets the new C4 envelope flag.
+ ///
+ public bool? C4 { get; set; }
+}
+
+///
+/// Request for a price calculation.
+///
+public class PriceRequest
+{
+ ///
+ /// Gets or sets the number of pages.
+ ///
+ public required int Pages { get; set; }
+
+ ///
+ /// Gets or sets the print color. Defaults to .
+ ///
+ public LetterColor Color { get; set; } = LetterColor.BlackWhite;
+
+ ///
+ /// Gets or sets the print mode. Defaults to .
+ ///
+ public PrintMode Mode { get; set; } = PrintMode.Simplex;
+
+ ///
+ /// Gets or sets the shipping destination. Only and
+ /// are valid for price requests.
+ ///
+ public ShippingType Shipping { get; set; } = ShippingType.National;
+
+ ///
+ /// Gets or sets a value indicating whether to use a C4 envelope.
+ ///
+ public bool C4 { get; set; }
+
+ ///
+ /// Gets or sets the SMART@MAIL e-mail option. Optional.
+ ///
+ public EmailOption? EmailOption { get; set; }
+
+ ///
+ /// Gets or sets the registered mail option. Optional; only national shipping is supported for registered mail.
+ ///
+ public RegisteredMail Registered { get; set; } = RegisteredMail.None;
+}
diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/LetterSpecification.cs b/src/Parcel.NET.LetterXpress.Letters/Models/LetterSpecification.cs
new file mode 100644
index 0000000..00bed4d
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.Letters/Models/LetterSpecification.cs
@@ -0,0 +1,28 @@
+namespace Parcel.NET.LetterXpress.Letters.Models;
+
+///
+/// Print and shipping specification of a letter.
+///
+public class LetterSpecification
+{
+ ///
+ /// Gets or sets the print color. Defaults to .
+ ///
+ public LetterColor Color { get; set; } = LetterColor.BlackWhite;
+
+ ///
+ /// Gets or sets the print mode. Defaults to .
+ ///
+ public PrintMode Mode { get; set; } = PrintMode.Simplex;
+
+ ///
+ /// Gets or sets the shipping destination. Defaults to .
+ ///
+ public ShippingType Shipping { get; set; } = ShippingType.Auto;
+
+ ///
+ /// Gets or sets a value indicating whether to use a C4 envelope.
+ /// Optional, applies to letters under 9 sheets.
+ ///
+ public bool C4 { get; set; }
+}
diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/PrintJob.cs b/src/Parcel.NET.LetterXpress.Letters/Models/PrintJob.cs
new file mode 100644
index 0000000..f9829fe
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.Letters/Models/PrintJob.cs
@@ -0,0 +1,118 @@
+namespace Parcel.NET.LetterXpress.Letters.Models;
+
+///
+/// A single recipient item of a print job.
+///
+public class PrintJobItem
+{
+ ///
+ /// Gets the recipient address as a single line.
+ ///
+ public string? Address { get; init; }
+
+ ///
+ /// Gets the number of pages.
+ ///
+ public int Pages { get; init; }
+
+ ///
+ /// Gets the net amount charged for this item.
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// Gets the VAT for this item.
+ ///
+ public decimal Vat { get; init; }
+
+ ///
+ /// Gets the item status (e.g. queue, sent).
+ ///
+ public string? Status { get; init; }
+
+ ///
+ /// Gets the registered mail tracking code, if available.
+ ///
+ public string? TrackingCode { get; init; }
+
+ ///
+ /// Gets the rendered letter PDF as base64, returned only when retrieving a single job.
+ ///
+ public string? Base64Data { get; init; }
+}
+
+///
+/// A LetterXpress print job.
+///
+public class PrintJob
+{
+ ///
+ /// Gets the print job ID.
+ ///
+ public long Id { get; init; }
+
+ ///
+ /// Gets the shipping destination (e.g. national).
+ ///
+ public string? Shipping { get; init; }
+
+ ///
+ /// Gets the print mode (e.g. simplex).
+ ///
+ public string? Mode { get; init; }
+
+ ///
+ /// Gets the print color (1 for black/white, 4 for color).
+ ///
+ public string? Color { get; init; }
+
+ ///
+ /// Gets a value indicating whether a C4 envelope is used.
+ ///
+ public bool C4 { get; init; }
+
+ ///
+ /// Gets the registered mail option (r1, r2), if any.
+ ///
+ public string? Registered { get; init; }
+
+ ///
+ /// Gets a value indicating whether a bank form is attached.
+ ///
+ public bool BankForm { get; init; }
+
+ ///
+ /// Gets the notice stored with the job.
+ ///
+ public string? Notice { get; init; }
+
+ ///
+ /// Gets the job status (e.g. queue, hold, done, canceled, draft).
+ ///
+ public string? Status { get; init; }
+
+ ///
+ /// Gets the dispatch date, if set.
+ ///
+ public string? DispatchDate { get; init; }
+
+ ///
+ /// Gets the original file name, if provided.
+ ///
+ public string? FilenameOriginal { get; init; }
+
+ ///
+ /// Gets the creation timestamp. The API reports times in German local time (Europe/Berlin).
+ ///
+ public DateTimeOffset? CreatedAt { get; init; }
+
+ ///
+ /// Gets the last update timestamp. The API reports times in German local time (Europe/Berlin).
+ ///
+ public DateTimeOffset? UpdatedAt { get; init; }
+
+ ///
+ /// Gets the recipient items of the job.
+ ///
+ public IReadOnlyList Items { get; init; } = [];
+}
diff --git a/src/Parcel.NET.LetterXpress.Letters/Parcel.NET.LetterXpress.Letters.csproj b/src/Parcel.NET.LetterXpress.Letters/Parcel.NET.LetterXpress.Letters.csproj
new file mode 100644
index 0000000..9c19692
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress.Letters/Parcel.NET.LetterXpress.Letters.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Parcel.NET.LetterXpress.Letters
+ LetterXpress (A&O Fischer) API v3 client for Parcel.NET — print jobs, SMART@MAIL e-mail jobs, balance, price, transactions, and invoices.
+ letter;mail;post;smartmail;logistics;letterxpress
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Parcel.NET.LetterXpress/Internal/LetterXpressErrorHelper.cs b/src/Parcel.NET.LetterXpress/Internal/LetterXpressErrorHelper.cs
new file mode 100644
index 0000000..035c884
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress/Internal/LetterXpressErrorHelper.cs
@@ -0,0 +1,33 @@
+using System.Text.Json;
+
+namespace Parcel.NET.LetterXpress.Internal;
+
+internal static class LetterXpressErrorHelper
+{
+ internal static string TryParseErrorDetail(string rawBody)
+ {
+ if (string.IsNullOrWhiteSpace(rawBody))
+ {
+ return rawBody;
+ }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(rawBody);
+ if (doc.RootElement.TryGetProperty("message", out var message))
+ {
+ return message.GetString() ?? rawBody;
+ }
+ if (doc.RootElement.TryGetProperty("error", out var error))
+ {
+ return error.GetString() ?? rawBody;
+ }
+ }
+ catch (JsonException)
+ {
+ // Fall through - raw body is not valid JSON
+ }
+
+ return rawBody;
+ }
+}
diff --git a/src/Parcel.NET.LetterXpress/LetterXpressBuilder.cs b/src/Parcel.NET.LetterXpress/LetterXpressBuilder.cs
new file mode 100644
index 0000000..4fd45bb
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress/LetterXpressBuilder.cs
@@ -0,0 +1,20 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Parcel.NET.LetterXpress;
+
+///
+/// Builder for chaining LetterXpress sub-service registrations after calling
+/// .
+///
+public class LetterXpressBuilder
+{
+ ///
+ /// Gets the underlying service collection.
+ ///
+ public IServiceCollection Services { get; }
+
+ internal LetterXpressBuilder(IServiceCollection services)
+ {
+ Services = services;
+ }
+}
diff --git a/src/Parcel.NET.LetterXpress/LetterXpressException.cs b/src/Parcel.NET.LetterXpress/LetterXpressException.cs
new file mode 100644
index 0000000..d635515
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress/LetterXpressException.cs
@@ -0,0 +1,32 @@
+using System.Net;
+using Parcel.NET.Abstractions.Exceptions;
+
+namespace Parcel.NET.LetterXpress;
+
+///
+/// Exception thrown when a LetterXpress API operation fails.
+///
+public class LetterXpressException : ParcelException
+{
+ ///
+ /// Initializes a new instance of .
+ ///
+ public LetterXpressException(string message) : base(message) { }
+
+ ///
+ /// Initializes a new instance of with an inner exception.
+ ///
+ public LetterXpressException(string message, Exception innerException)
+ : base(message, innerException) { }
+
+ ///
+ /// Initializes a new instance of with carrier API error details.
+ ///
+ public LetterXpressException(
+ string message,
+ HttpStatusCode? statusCode,
+ string? errorCode,
+ string? rawResponse,
+ Exception? innerException = null)
+ : base(message, statusCode, errorCode, rawResponse, innerException) { }
+}
diff --git a/src/Parcel.NET.LetterXpress/LetterXpressOptions.cs b/src/Parcel.NET.LetterXpress/LetterXpressOptions.cs
new file mode 100644
index 0000000..353f968
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress/LetterXpressOptions.cs
@@ -0,0 +1,50 @@
+namespace Parcel.NET.LetterXpress;
+
+///
+/// Configuration options for the LetterXpress (A&O Fischer) API v3 integration.
+///
+public class LetterXpressOptions
+{
+ private const string ProductionBaseUrl = "https://api.letterxpress.de/v3/";
+
+ ///
+ /// Gets or sets the LetterXpress account username.
+ /// Generated under "Mein Konto > Zugangsdaten > LXP API".
+ ///
+ public required string Username { get; set; }
+
+ ///
+ /// Gets or sets the LetterXpress API key.
+ /// Generated under "Mein Konto > Zugangsdaten > LXP API".
+ ///
+ public required string ApiKey { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to use the LetterXpress test mode.
+ /// In test mode ("mode": "test") submitted jobs are not processed but stored in the
+ /// "Postbox" where they can be reviewed, deleted, or sent. Postbox jobs are deleted after 7 days.
+ /// When , jobs are processed directly ("mode": "live").
+ ///
+ public bool UseTestMode { get; set; }
+
+ ///
+ /// Gets or sets a custom base URL override.
+ /// When set, this takes precedence over the default production URL.
+ ///
+ public string? CustomBaseUrl { get; set; }
+
+ ///
+ /// Gets the effective API base URL. Uses if set,
+ /// otherwise the production URL. A trailing slash is appended if missing so that
+ /// relative request URIs (e.g. balance, printjobs) resolve correctly.
+ ///
+ public string BaseUrl => EnsureTrailingSlash(CustomBaseUrl) ?? ProductionBaseUrl;
+
+ private static string? EnsureTrailingSlash(string? url) =>
+ url is null ? null : url.EndsWith('/') ? url : url + "/";
+
+ ///
+ /// Gets the mode value sent in the request auth object (test or live).
+ ///
+ internal string Mode => UseTestMode ? "test" : "live";
+}
diff --git a/src/Parcel.NET.LetterXpress/LetterXpressServiceCollectionExtensions.cs b/src/Parcel.NET.LetterXpress/LetterXpressServiceCollectionExtensions.cs
new file mode 100644
index 0000000..095dd92
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress/LetterXpressServiceCollectionExtensions.cs
@@ -0,0 +1,39 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Parcel.NET.LetterXpress;
+
+///
+/// Extension methods for registering LetterXpress services with the dependency injection container.
+///
+public static class LetterXpressServiceCollectionExtensions
+{
+ ///
+ /// Adds LetterXpress core services including configuration validation.
+ ///
+ /// The service collection.
+ /// Action to configure .
+ /// A for chaining LetterXpress sub-service registrations.
+ public static LetterXpressBuilder AddLetterXpress(this IServiceCollection services, Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ services.AddOptionsWithValidateOnStart()
+ .Configure(configure)
+ .Validate(o => !string.IsNullOrWhiteSpace(o.Username), "LetterXpress Username is required.")
+ .Validate(o => !string.IsNullOrWhiteSpace(o.ApiKey), "LetterXpress ApiKey is required.")
+ .Validate(o => IsHttpsOrLocalhost(o.CustomBaseUrl), "CustomBaseUrl must use HTTPS.");
+
+ return new LetterXpressBuilder(services);
+ }
+
+ private static bool IsHttpsOrLocalhost(string? url)
+ {
+ if (url is null) return true;
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
+ if (uri.Scheme == Uri.UriSchemeHttps) return true;
+
+ var host = uri.Host;
+ return host is "localhost" or "127.0.0.1" or "::1";
+ }
+}
diff --git a/src/Parcel.NET.LetterXpress/Parcel.NET.LetterXpress.csproj b/src/Parcel.NET.LetterXpress/Parcel.NET.LetterXpress.csproj
new file mode 100644
index 0000000..5f23cf9
--- /dev/null
+++ b/src/Parcel.NET.LetterXpress/Parcel.NET.LetterXpress.csproj
@@ -0,0 +1,23 @@
+
+
+
+ Parcel.NET.LetterXpress
+ LetterXpress (A&O Fischer) authentication, shared configuration, and DI extensions for Parcel.NET.
+ letter;mail;post;logistics;letterxpress;authentication
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Parcel.NET.LetterXpress.Letters.Tests/LetterXpressClientCoverageTests.cs b/tests/Parcel.NET.LetterXpress.Letters.Tests/LetterXpressClientCoverageTests.cs
new file mode 100644
index 0000000..b76b6b5
--- /dev/null
+++ b/tests/Parcel.NET.LetterXpress.Letters.Tests/LetterXpressClientCoverageTests.cs
@@ -0,0 +1,355 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Options;
+using Parcel.NET.LetterXpress.Letters.Models;
+using Shouldly;
+using Xunit;
+
+namespace Parcel.NET.LetterXpress.Letters.Tests;
+
+public class LetterXpressClientCoverageTests
+{
+ private static readonly byte[] SamplePdf = Encoding.UTF8.GetBytes("%PDF-1.4 sample");
+
+ private static (LetterXpressClient Client, MockHttpMessageHandler Handler) CreateClient(
+ string responseBody, HttpStatusCode status = HttpStatusCode.OK)
+ {
+ var handler = new MockHttpMessageHandler(responseBody, status);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.letterxpress.de/v3/") };
+ var options = Options.Create(new LetterXpressOptions { Username = "u", ApiKey = "k" });
+ return (new LetterXpressClient(httpClient, options), handler);
+ }
+
+ private static JsonElement BodyRoot(MockHttpMessageHandler handler) =>
+ JsonDocument.Parse(handler.LastRequestBody!).RootElement;
+
+ private static LetterRequest BasicLetter() => new()
+ {
+ File = SamplePdf,
+ Specification = new LetterSpecification { Shipping = ShippingType.National }
+ };
+
+ private const string OkPrintJob = """{"status":200,"message":"OK","data":{"id":1,"status":"queue"}}""";
+
+ // ---- Validation -----------------------------------------------------
+
+ [Fact]
+ public async Task CreatePrintJobAsync_EmptyFile_Throws()
+ {
+ var (client, _) = CreateClient(OkPrintJob);
+ var req = new LetterRequest { File = [], Specification = new LetterSpecification() };
+
+ await Should.ThrowAsync(() => client.CreatePrintJobAsync(req));
+ }
+
+ [Fact]
+ public async Task CreateEmailJobAsync_SerialAndEmailCombined_Throws()
+ {
+ var (client, _) = CreateClient(OkPrintJob);
+ var req = BasicLetter();
+ req.SerialLetter = new SerialLetterOptions { PagesSeparatorType = SeparatorType.Number, PagesSeparatorValue = "2" };
+ req.EmailLetter = new EmailLetterOptions { EmailOption = EmailOption.MailDirect, EmailReceiver = "a@x.de" };
+
+ await Should.ThrowAsync(() => client.CreateEmailJobAsync(req));
+ }
+
+ [Fact]
+ public async Task CreatePrintJobAsync_RegisteredInternational_Throws()
+ {
+ var (client, _) = CreateClient(OkPrintJob);
+ var req = new LetterRequest
+ {
+ File = SamplePdf,
+ Specification = new LetterSpecification { Shipping = ShippingType.International },
+ Registered = RegisteredMail.Einschreiben
+ };
+
+ await Should.ThrowAsync(() => client.CreatePrintJobAsync(req));
+ }
+
+ [Fact]
+ public async Task GetPriceAsync_AutoShipping_Throws()
+ {
+ var (client, _) = CreateClient(OkPrintJob);
+
+ await Should.ThrowAsync(() =>
+ client.GetPriceAsync(new PriceRequest { Pages = 1, Shipping = ShippingType.Auto }));
+ }
+
+ [Fact]
+ public async Task GetPriceAsync_NonPositivePages_Throws()
+ {
+ var (client, _) = CreateClient(OkPrintJob);
+
+ await Should.ThrowAsync(() =>
+ client.GetPriceAsync(new PriceRequest { Pages = 0, Shipping = ShippingType.National }));
+ }
+
+ [Fact]
+ public async Task CreatePrintJobAsync_SerialNumberWithNonNumericValue_Throws()
+ {
+ var (client, _) = CreateClient(OkPrintJob);
+ var req = BasicLetter();
+ req.SerialLetter = new SerialLetterOptions { PagesSeparatorType = SeparatorType.Number, PagesSeparatorValue = "abc" };
+
+ await Should.ThrowAsync(() => client.CreatePrintJobAsync(req));
+ }
+
+ [Fact]
+ public async Task CreatePrintJobAsync_PastDispatchDate_Throws()
+ {
+ var (client, _) = CreateClient(OkPrintJob);
+ var req = BasicLetter();
+ req.DispatchDate = new DateOnly(2000, 1, 1);
+
+ await Should.ThrowAsync(() => client.CreatePrintJobAsync(req));
+ }
+
+ [Fact]
+ public async Task CreatePrintJobAsync_OversizedAttachmentsTotal_Throws()
+ {
+ var (client, _) = CreateClient(OkPrintJob);
+ var req = BasicLetter();
+ // Main file is tiny, but an attachment pushes the request over the 50 MB limit.
+ req.Attachments = [new byte[51 * 1024 * 1024]];
+
+ await Should.ThrowAsync(() => client.CreatePrintJobAsync(req));
+ }
+
+ // ---- Serial letter separator serialization --------------------------
+
+ [Fact]
+ public async Task CreatePrintJobAsync_SerialNumber_SerializesAsJsonNumber()
+ {
+ var (client, handler) = CreateClient(OkPrintJob);
+ var req = BasicLetter();
+ req.SerialLetter = new SerialLetterOptions { PagesSeparatorType = SeparatorType.Number, PagesSeparatorValue = "3" };
+
+ await client.CreatePrintJobAsync(req);
+
+ var serial = BodyRoot(handler).GetProperty("letter").GetProperty("serial_letter");
+ serial.GetProperty("pages_separator_type").GetString().ShouldBe("number");
+ var value = serial.GetProperty("pages_separator_value");
+ value.ValueKind.ShouldBe(JsonValueKind.Number);
+ value.GetInt32().ShouldBe(3);
+ }
+
+ [Fact]
+ public async Task CreatePrintJobAsync_SerialString_SerializesAsJsonString()
+ {
+ var (client, handler) = CreateClient(OkPrintJob);
+ var req = BasicLetter();
+ req.SerialLetter = new SerialLetterOptions { PagesSeparatorType = SeparatorType.String, PagesSeparatorValue = "TRENN" };
+
+ await client.CreatePrintJobAsync(req);
+
+ var value = BodyRoot(handler).GetProperty("letter").GetProperty("serial_letter").GetProperty("pages_separator_value");
+ value.ValueKind.ShouldBe(JsonValueKind.String);
+ value.GetString().ShouldBe("TRENN");
+ }
+
+ // ---- Complex payload building ---------------------------------------
+
+ [Fact]
+ public async Task CreatePrintJobAsync_AttachmentsAndBankForm_Serialized()
+ {
+ var (client, handler) = CreateClient(OkPrintJob);
+ var req = BasicLetter();
+ req.Attachments = [Encoding.UTF8.GetBytes("att1")];
+ req.BankForm = new BankForm { Included = false, Iban = "DE0001", Amount = "12,00" };
+
+ await client.CreatePrintJobAsync(req);
+
+ var letter = BodyRoot(handler).GetProperty("letter");
+ var attachments = letter.GetProperty("base64_attachments");
+ attachments.GetArrayLength().ShouldBe(1);
+ attachments[0].GetString().ShouldBe(Convert.ToBase64String(Encoding.UTF8.GetBytes("att1")));
+
+ var bank = letter.GetProperty("bank_form");
+ bank.GetProperty("bank_form_included").GetInt32().ShouldBe(0);
+ bank.GetProperty("iban").GetString().ShouldBe("DE0001");
+ }
+
+ // ---- Update partial-spec --------------------------------------------
+
+ [Fact]
+ public async Task UpdatePrintJobAsync_NoticeOnly_OmitsSpecification()
+ {
+ var (client, handler) = CreateClient(OkPrintJob);
+
+ await client.UpdatePrintJobAsync(1, new PrintJobUpdate { Notice = "only notice" });
+
+ var letter = BodyRoot(handler).GetProperty("letter");
+ letter.GetProperty("notice").GetString().ShouldBe("only notice");
+ letter.TryGetProperty("specification", out _).ShouldBeFalse();
+ letter.TryGetProperty("dispatch_date", out _).ShouldBeFalse();
+ letter.TryGetProperty("registered", out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task UpdatePrintJobAsync_ColorOnly_SendsPartialSpecification()
+ {
+ var (client, handler) = CreateClient(OkPrintJob);
+
+ await client.UpdatePrintJobAsync(1, new PrintJobUpdate { Color = LetterColor.Color });
+
+ var spec = BodyRoot(handler).GetProperty("letter").GetProperty("specification");
+ spec.GetProperty("color").GetString().ShouldBe("4");
+ spec.TryGetProperty("mode", out _).ShouldBeFalse();
+ spec.TryGetProperty("shipping", out _).ShouldBeFalse();
+ spec.TryGetProperty("c4", out _).ShouldBeFalse();
+ }
+
+ // ---- Body-level error -----------------------------------------------
+
+ [Fact]
+ public async Task SendAsync_BodyLevelStatusError_ThrowsWithStatusCode()
+ {
+ // HTTP 200 but the body reports an application-level error.
+ const string json = """{"status":400,"message":"Bad Request"}""";
+ var (client, _) = CreateClient(json, HttpStatusCode.OK);
+
+ var ex = await Should.ThrowAsync(() => client.GetBalanceAsync());
+
+ ex.ErrorCode.ShouldBe("400");
+ ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ ex.RawResponse.ShouldNotBeNull();
+ ex.RawResponse!.ShouldContain("Bad Request");
+ }
+
+ // ---- Missing endpoint coverage --------------------------------------
+
+ [Fact]
+ public async Task GetPrintJobAsync_ReturnsRenderedPdf()
+ {
+ const string json = """
+ {"status":200,"message":"OK","data":{"id":99,"status":"done","c4":0,"bank_form":0,"items":[{"address":"X","pages":1,"amount":0.67,"vat":0.13,"status":"sent","tracking_code":"RC1DE","base64_data":"QUJD"}]}}
+ """;
+ var (client, handler) = CreateClient(json);
+
+ var job = await client.GetPrintJobAsync(99);
+
+ handler.LastRequest!.Method.ShouldBe(HttpMethod.Get);
+ handler.LastRequest.RequestUri!.AbsolutePath.ShouldEndWith("/v3/printjobs/99");
+ var item = job.Items.ShouldHaveSingleItem();
+ item.TrackingCode.ShouldBe("RC1DE");
+ item.Base64Data.ShouldBe("QUJD");
+ }
+
+ [Fact]
+ public async Task GetEmailJobAsync_MapsNestedPrintJob()
+ {
+ const string json = """
+ {"status":200,"message":"OK","data":{"id":918,"email_receiver":"j@aof.de","email_option":"mailplus","amount":0.1,"vat":0.02,"status":"success","printjob":{"id":138037,"status":"done","c4":0,"bank_form":0,"items":[{"address":"X","pages":1,"amount":0.67,"vat":0.13,"status":"sent"}]}}}
+ """;
+ var (client, _) = CreateClient(json);
+
+ var job = await client.GetEmailJobAsync(918);
+
+ job.Id.ShouldBe(918);
+ job.PrintJob.ShouldNotBeNull();
+ job.PrintJob!.Id.ShouldBe(138037);
+ }
+
+ [Fact]
+ public async Task DeleteEmailJobAsync_UsesDeleteMethod()
+ {
+ const string json = """{"status":200,"message":"Email job deleted successfully"}""";
+ var (client, handler) = CreateClient(json);
+
+ await client.DeleteEmailJobAsync(3902);
+
+ handler.LastRequest!.Method.ShouldBe(HttpMethod.Delete);
+ handler.LastRequest.RequestUri!.AbsolutePath.ShouldEndWith("/v3/emailjobs/3902");
+ }
+
+ [Fact]
+ public async Task UpdateEmailJobAsync_SendsNewReceiver()
+ {
+ const string json = """{"status":200,"message":"OK","data":{"id":3902,"email_receiver":"new@aof.de","email_option":"maildirect","amount":0.05,"vat":0.01,"status":"queue"}}""";
+ var (client, handler) = CreateClient(json);
+
+ var job = await client.UpdateEmailJobAsync(3902, "new@aof.de");
+
+ handler.LastRequest!.Method.ShouldBe(HttpMethod.Put);
+ BodyRoot(handler).GetProperty("email").GetProperty("email_receiver").GetString().ShouldBe("new@aof.de");
+ job.EmailReceiver.ShouldBe("new@aof.de");
+ }
+
+ [Fact]
+ public async Task ListEmailJobsAsync_WithFilter_ParsesAndBuildsQuery()
+ {
+ const string json = """
+ {"status":200,"message":"OK","data":{"emailjobs":[{"id":3093,"email_receiver":"j@aof.de","email_option":"mailplus","amount":0.1,"vat":0.02,"status":"hold"}],"pagination":{"total":1,"count":1,"current_page":1,"last_page":1,"per_page":15,"first_page_url":"https://api.letterxpress.de/v3/emailjobs?page=1","last_page_url":"https://api.letterxpress.de/v3/emailjobs?page=1"}}}
+ """;
+ var (client, handler) = CreateClient(json);
+
+ var result = await client.ListEmailJobsAsync(EmailJobFilter.Hold);
+
+ handler.LastRequest!.RequestUri!.Query.ShouldContain("filter=hold");
+ result.Items.ShouldHaveSingleItem().Id.ShouldBe(3093);
+ result.Pagination!.FirstPageUrl.ShouldNotBeNull();
+ result.Pagination.LastPageUrl.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task ListInvoicesAsync_MapsItems()
+ {
+ const string json = """
+ {"status":200,"message":"OK","data":{"invoices":[{"id":25402,"amount":0.67,"vat":0.13,"invoice_date":"2020-05-31"}],"pagination":{"total":1,"count":1,"current_page":1,"last_page":1,"per_page":15}}}
+ """;
+ var (client, _) = CreateClient(json);
+
+ var result = await client.ListInvoicesAsync();
+
+ var inv = result.Items.ShouldHaveSingleItem();
+ inv.Id.ShouldBe(25402);
+ inv.InvoiceDate.ShouldBe(new DateOnly(2020, 5, 31));
+ }
+
+ [Fact]
+ public async Task ListPrintJobsAsync_NoFilterOrPage_HasNoQuery()
+ {
+ const string json = """{"status":200,"message":"OK","data":{"printjobs":[],"pagination":{"total":0,"count":0,"current_page":1,"last_page":1,"per_page":50}}}""";
+ var (client, handler) = CreateClient(json);
+
+ await client.ListPrintJobsAsync();
+
+ handler.LastRequest!.RequestUri!.Query.ShouldBeEmpty();
+ }
+
+ // ---- Timezone (DST) -------------------------------------------------
+
+ [Fact]
+ public async Task ParseDateTime_WinterDate_UsesCetPlusOne()
+ {
+ var berlin = TryGetBerlin();
+ if (berlin is null)
+ {
+ Assert.Skip("Europe/Berlin timezone data not available on this machine.");
+ }
+
+ const string json = """
+ {"status":200,"message":"OK","data":{"transactions":[{"amount":-1.0,"currency":"EUR","description":"x","created_at":"2024-01-15 09:00:00"}],"pagination":{"total":1,"count":1,"current_page":1,"last_page":1,"per_page":15}}}
+ """;
+ var (client, _) = CreateClient(json);
+
+ var result = await client.ListTransactionsAsync();
+
+ // January => CET (UTC+1).
+ result.Items.ShouldHaveSingleItem().CreatedAt
+ .ShouldBe(new DateTimeOffset(2024, 1, 15, 9, 0, 0, TimeSpan.FromHours(1)));
+ }
+
+ private static TimeZoneInfo? TryGetBerlin()
+ {
+ foreach (var id in (string[])["Europe/Berlin", "W. Europe Standard Time"])
+ {
+ try { return TimeZoneInfo.FindSystemTimeZoneById(id); }
+ catch (TimeZoneNotFoundException) { }
+ catch (InvalidTimeZoneException) { }
+ }
+ return null;
+ }
+}
diff --git a/tests/Parcel.NET.LetterXpress.Letters.Tests/LetterXpressClientTests.cs b/tests/Parcel.NET.LetterXpress.Letters.Tests/LetterXpressClientTests.cs
new file mode 100644
index 0000000..56e44ea
--- /dev/null
+++ b/tests/Parcel.NET.LetterXpress.Letters.Tests/LetterXpressClientTests.cs
@@ -0,0 +1,305 @@
+using System.Net;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Options;
+using Parcel.NET.LetterXpress.Letters.Models;
+using Shouldly;
+using Xunit;
+
+namespace Parcel.NET.LetterXpress.Letters.Tests;
+
+public class LetterXpressClientTests
+{
+ private static readonly byte[] SamplePdf = Encoding.UTF8.GetBytes("%PDF-1.4 sample");
+
+ private static (LetterXpressClient Client, MockHttpMessageHandler Handler) CreateClient(
+ string responseBody, HttpStatusCode status = HttpStatusCode.OK, bool testMode = false)
+ {
+ var handler = new MockHttpMessageHandler(responseBody, status);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.letterxpress.de/v3/") };
+ var options = Options.Create(new LetterXpressOptions
+ {
+ Username = "testuser",
+ ApiKey = "secret-key",
+ UseTestMode = testMode
+ });
+ return (new LetterXpressClient(httpClient, options), handler);
+ }
+
+ private static JsonElement BodyRoot(MockHttpMessageHandler handler) =>
+ JsonDocument.Parse(handler.LastRequestBody!).RootElement;
+
+ [Fact]
+ public async Task GetBalanceAsync_ReturnsBalance()
+ {
+ const string json = """
+ {"status":200,"message":"OK","data":{"balance":54.89,"currency":"EUR"}}
+ """;
+ var (client, _) = CreateClient(json);
+
+ var balance = await client.GetBalanceAsync();
+
+ balance.Amount.ShouldBe(54.89m);
+ balance.Currency.ShouldBe("EUR");
+ }
+
+ [Fact]
+ public async Task GetBalanceAsync_SendsAuthInBody()
+ {
+ const string json = """{"status":200,"message":"OK","data":{"balance":1,"currency":"EUR"}}""";
+ var (client, handler) = CreateClient(json, testMode: true);
+
+ await client.GetBalanceAsync();
+
+ var auth = BodyRoot(handler).GetProperty("auth");
+ auth.GetProperty("username").GetString().ShouldBe("testuser");
+ auth.GetProperty("apikey").GetString().ShouldBe("secret-key");
+ auth.GetProperty("mode").GetString().ShouldBe("test");
+ }
+
+ [Fact]
+ public async Task GetBalanceAsync_UsesGetMethod()
+ {
+ const string json = """{"status":200,"message":"OK","data":{"balance":1,"currency":"EUR"}}""";
+ var (client, handler) = CreateClient(json);
+
+ await client.GetBalanceAsync();
+
+ handler.LastRequest!.Method.ShouldBe(HttpMethod.Get);
+ handler.LastRequest.RequestUri!.AbsolutePath.ShouldEndWith("/v3/balance");
+ }
+
+ [Fact]
+ public async Task CreatePrintJobAsync_EncodesFileAndChecksum()
+ {
+ const string json = """
+ {"status":200,"message":"OK","data":{"id":6035143,"shipping":"national","mode":"simplex","color":"4","c4":0,"bank_form":0,"status":"queue","created_at":"2022-08-01 13:38:07","updated_at":"2022-08-01 13:38:07","items":[{"address":"Jim Knopf","pages":1,"amount":0.68,"vat":0.13,"status":"queue"}]}}
+ """;
+ var (client, handler) = CreateClient(json);
+
+ var job = await client.CreatePrintJobAsync(new LetterRequest
+ {
+ File = SamplePdf,
+ Specification = new LetterSpecification
+ {
+ Color = LetterColor.Color,
+ Mode = PrintMode.Simplex,
+ Shipping = ShippingType.National
+ }
+ });
+
+ var letter = BodyRoot(handler).GetProperty("letter");
+ var expectedBase64 = Convert.ToBase64String(SamplePdf);
+ var expectedChecksum = Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(expectedBase64))).ToLowerInvariant();
+
+ letter.GetProperty("base64_file").GetString().ShouldBe(expectedBase64);
+ letter.GetProperty("base64_file_checksum").GetString().ShouldBe(expectedChecksum);
+ letter.GetProperty("specification").GetProperty("color").GetString().ShouldBe("4");
+ letter.GetProperty("specification").GetProperty("shipping").GetString().ShouldBe("national");
+
+ job.Id.ShouldBe(6035143);
+ job.Status.ShouldBe("queue");
+ job.Items.ShouldHaveSingleItem().Amount.ShouldBe(0.68m);
+ }
+
+ [Fact]
+ public async Task CreatePrintJobAsync_MapsRegisteredAndDispatchDate()
+ {
+ const string json = """{"status":200,"message":"OK","data":{"id":1,"status":"queue"}}""";
+ var (client, handler) = CreateClient(json);
+
+ await client.CreatePrintJobAsync(new LetterRequest
+ {
+ File = SamplePdf,
+ Specification = new LetterSpecification { Shipping = ShippingType.National },
+ Registered = RegisteredMail.EinschreibenEinwurf,
+ DispatchDate = new DateOnly(2026, 9, 1),
+ Notice = "KdNr. 4711"
+ });
+
+ var letter = BodyRoot(handler).GetProperty("letter");
+ letter.GetProperty("registered").GetString().ShouldBe("r1");
+ letter.GetProperty("dispatch_date").GetString().ShouldBe("2026-09-01");
+ letter.GetProperty("notice").GetString().ShouldBe("KdNr. 4711");
+ }
+
+ [Fact]
+ public async Task GetPriceAsync_SendsPagesAndReturnsPrice()
+ {
+ const string json = """
+ {"status":200,"message":"OK","data":{"price":4.99,"letter":{"specification":{"pages":1,"color":"4","mode":"simplex","shipping":"national"}}}}
+ """;
+ var (client, handler) = CreateClient(json);
+
+ var price = await client.GetPriceAsync(new PriceRequest
+ {
+ Pages = 1,
+ Color = LetterColor.Color,
+ Shipping = ShippingType.National,
+ EmailOption = EmailOption.MailSecure
+ });
+
+ var spec = BodyRoot(handler).GetProperty("letter").GetProperty("specification");
+ spec.GetProperty("pages").GetInt32().ShouldBe(1);
+ spec.GetProperty("email_option").GetString().ShouldBe("mailsecure");
+
+ price.Price.ShouldBe(4.99m);
+ price.Pages.ShouldBe(1);
+ price.Shipping.ShouldBe("national");
+ }
+
+ [Fact]
+ public async Task ListPrintJobsAsync_BuildsFilterAndPageQuery()
+ {
+ const string json = """
+ {"status":200,"message":"OK","data":{"printjobs":[{"id":1,"status":"hold","c4":0,"bank_form":0,"items":[]}],"pagination":{"total":7,"count":7,"current_page":1,"last_page":1,"per_page":15}}}
+ """;
+ var (client, handler) = CreateClient(json);
+
+ var result = await client.ListPrintJobsAsync(PrintJobFilter.Hold, page: 2);
+
+ handler.LastRequest!.RequestUri!.Query.ShouldContain("filter=hold");
+ handler.LastRequest.RequestUri.Query.ShouldContain("page=2");
+ result.Items.ShouldHaveSingleItem().Id.ShouldBe(1);
+ result.Pagination!.Total.ShouldBe(7);
+ }
+
+ [Fact]
+ public async Task DeletePrintJobAsync_UsesDeleteMethod()
+ {
+ const string json = """{"status":200,"message":"Print job deleted successfully"}""";
+ var (client, handler) = CreateClient(json);
+
+ await client.DeletePrintJobAsync(6035143);
+
+ handler.LastRequest!.Method.ShouldBe(HttpMethod.Delete);
+ handler.LastRequest.RequestUri!.AbsolutePath.ShouldEndWith("/v3/printjobs/6035143");
+ }
+
+ [Fact]
+ public async Task CreateEmailJobAsync_SingleObject_ReturnsOneEmailJob()
+ {
+ const string json = """
+ {"status":200,"message":"OK","data":{"id":3096,"email_receiver":"max@letterxpress.email","email_option":"mailplus","amount":0.1,"vat":0.02,"status":"queue","subject":"Ihre Rechnung"}}
+ """;
+ var (client, _) = CreateClient(json);
+
+ var result = await client.CreateEmailJobAsync(new LetterRequest
+ {
+ File = SamplePdf,
+ Specification = new LetterSpecification(),
+ EmailLetter = new EmailLetterOptions { EmailOption = EmailOption.MailPlus, EmailReceiver = "max@letterxpress.email" }
+ });
+
+ var job = result.EmailJobs.ShouldHaveSingleItem();
+ job.Id.ShouldBe(3096);
+ job.EmailOption.ShouldBe("mailplus");
+ result.PrintJobs.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task CreateEmailJobAsync_Array_ReturnsMultipleEmailJobs()
+ {
+ const string json = """
+ {"status":200,"message":"OK","data":[{"id":3813,"email_receiver":"a@x.de","email_option":"maildirect","amount":0.05,"vat":0.01,"status":"queue"},{"id":3814,"email_receiver":"b@x.de","email_option":"maildirect","amount":0.05,"vat":0.01,"status":"queue"}]}
+ """;
+ var (client, _) = CreateClient(json);
+
+ var result = await client.CreateEmailJobAsync(new LetterRequest
+ {
+ File = SamplePdf,
+ Specification = new LetterSpecification()
+ });
+
+ result.EmailJobs.Count.ShouldBe(2);
+ result.EmailJobs[1].Id.ShouldBe(3814);
+ }
+
+ [Fact]
+ public async Task CreateEmailJobAsync_CombinedPrintAndEmail_ReturnsBoth()
+ {
+ const string json = """
+ {"status":200,"message":"OK","data":{"printjobs":[{"id":138606,"status":"queue","c4":0,"bank_form":0,"items":[]}],"emailjobs":[{"id":3837,"email_receiver":"a@x.de","email_option":"mailplus","amount":0.1,"vat":0.02,"status":"queue","printjob_id":138606}]}}
+ """;
+ var (client, _) = CreateClient(json);
+
+ var result = await client.CreateEmailJobAsync(new LetterRequest
+ {
+ File = SamplePdf,
+ Specification = new LetterSpecification()
+ });
+
+ result.PrintJobs.ShouldHaveSingleItem().Id.ShouldBe(138606);
+ var job = result.EmailJobs.ShouldHaveSingleItem();
+ job.PrintJobId.ShouldBe(138606);
+ }
+
+ [Fact]
+ public async Task GetInvoiceAsync_ReturnsInvoiceWithPdf()
+ {
+ const string json = """
+ {"status":200,"message":"OK","data":{"id":25402,"amount":0.67,"vat":0.13,"invoice_date":"2020-05-31","base64_data":"xxxxxxxx"}}
+ """;
+ var (client, _) = CreateClient(json);
+
+ var invoice = await client.GetInvoiceAsync(25402);
+
+ invoice.Id.ShouldBe(25402);
+ invoice.InvoiceDate.ShouldBe(new DateOnly(2020, 5, 31));
+ invoice.Base64Data.ShouldBe("xxxxxxxx");
+ }
+
+ [Fact]
+ public async Task ListTransactionsAsync_MapsItems()
+ {
+ const string json = """
+ {"status":200,"message":"OK","data":{"transactions":[{"amount":-1.04,"currency":"EUR","description":"Farbe","created_at":"2022-09-21 15:32:45"}],"pagination":{"total":62,"count":15,"current_page":1,"last_page":5,"per_page":15,"next_page_url":"http://api.letterxpress.de/v3/transactions?page=2"}}}
+ """;
+ var (client, handler) = CreateClient(json);
+
+ var result = await client.ListTransactionsAsync(TransactionFilter.PrintJobs);
+
+ handler.LastRequest!.RequestUri!.Query.ShouldContain("filter=printjobs");
+ var item = result.Items.ShouldHaveSingleItem();
+ item.Amount.ShouldBe(-1.04m);
+ // Timestamps are German local time (Europe/Berlin). September => CEST (UTC+2).
+ item.CreatedAt.ShouldBe(new DateTimeOffset(2022, 9, 21, 15, 32, 45, TimeSpan.FromHours(2)));
+ result.Pagination!.NextPageUrl.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task SendAsync_SetsContentTypeWithoutCharset()
+ {
+ // The live API rejects "application/json; charset=utf-8" with HTTP 400.
+ const string json = """{"status":200,"message":"OK","data":{"balance":1,"currency":"EUR"}}""";
+ var (client, handler) = CreateClient(json);
+
+ await client.GetBalanceAsync();
+
+ handler.LastRequest!.Content!.Headers.ContentType!.ToString().ShouldBe("application/json");
+ }
+
+ [Fact]
+ public async Task UnauthorizedResponse_ThrowsLetterXpressException()
+ {
+ const string json = """{"message":"Unauthorized."}""";
+ var (client, _) = CreateClient(json, HttpStatusCode.Unauthorized);
+
+ var ex = await Should.ThrowAsync(() => client.GetBalanceAsync());
+
+ ex.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
+ ex.Message.ShouldContain("Unauthorized");
+ }
+
+ [Fact]
+ public async Task LiveMode_SendsLiveInAuth()
+ {
+ const string json = """{"status":200,"message":"OK","data":{"balance":1,"currency":"EUR"}}""";
+ var (client, handler) = CreateClient(json, testMode: false);
+
+ await client.GetBalanceAsync();
+
+ BodyRoot(handler).GetProperty("auth").GetProperty("mode").GetString().ShouldBe("live");
+ }
+}
diff --git a/tests/Parcel.NET.LetterXpress.Letters.Tests/MockHttpMessageHandler.cs b/tests/Parcel.NET.LetterXpress.Letters.Tests/MockHttpMessageHandler.cs
new file mode 100644
index 0000000..7ea79cf
--- /dev/null
+++ b/tests/Parcel.NET.LetterXpress.Letters.Tests/MockHttpMessageHandler.cs
@@ -0,0 +1,36 @@
+using System.Net;
+
+namespace Parcel.NET.LetterXpress.Letters.Tests;
+
+///
+/// Test handler that captures the outgoing request (method, URI, and body) and returns a queued response.
+///
+internal sealed class MockHttpMessageHandler : HttpMessageHandler
+{
+ private readonly HttpStatusCode _statusCode;
+ private readonly string _responseBody;
+
+ public HttpRequestMessage? LastRequest { get; private set; }
+ public string? LastRequestBody { get; private set; }
+
+ public MockHttpMessageHandler(string responseBody, HttpStatusCode statusCode = HttpStatusCode.OK)
+ {
+ _responseBody = responseBody;
+ _statusCode = statusCode;
+ }
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ LastRequest = request;
+ if (request.Content is not null)
+ {
+ LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ return new HttpResponseMessage(_statusCode)
+ {
+ Content = new StringContent(_responseBody),
+ };
+ }
+}
diff --git a/tests/Parcel.NET.LetterXpress.Letters.Tests/Parcel.NET.LetterXpress.Letters.Tests.csproj b/tests/Parcel.NET.LetterXpress.Letters.Tests/Parcel.NET.LetterXpress.Letters.Tests.csproj
new file mode 100644
index 0000000..29b2987
--- /dev/null
+++ b/tests/Parcel.NET.LetterXpress.Letters.Tests/Parcel.NET.LetterXpress.Letters.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0;net9.0;net10.0
+ false
+ false
+ $(NoWarn);xUnit1051
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+