Compare commits

...

5 Commits
v0.4.3 ... main

51 changed files with 1979 additions and 392 deletions

View File

@ -6,14 +6,77 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic
## [Unreleased]
## [0.4.9] - 2026-11-05
### Changed
- Sidebar **All emails** renamed to **All inboxes**, with a tooltip that it only combines each account's Inbox (not labels or other folders).
## [0.4.8] - 2026-11-05
Sidebar **All emails** combines every Inbox. Sharing an account moves its archive and sign-in secrets with the setting. IMAP no longer stalls mid-folder after a partial FETCH.
### Changed
- Account setting **Share this account with other users on this PC** moves the mailbox archive and its sign-in secrets: on -> `ProgramData` shared folder (+ `service-secrets.bin`), off -> this user's account folder and `secrets.bin` (replaces a stale archive copy at the destination).
- Folder sidebar starts with **All emails** (combined Inboxes from every account) above the account list.
### Fixed
- Identity Hub sign-in window: **Back** returns from Privacy/Terms (WebView history, or reload of the desktop-login URL).
- IMAP folder sync no longer marks a label done after a partial FETCH: empty/partial pages retry (then skip after a few misses), and when SEARCH ALL returns fewer UIDs than EXISTS the plan is filled from sequence UIDs (Ebay-style 15-of-29 stalls).
## [0.4.7] - 2026-11-04
### Changed
- Hub sign-in window title/status use **MaksIT Postclient** / “Waiting for sign-in…”, not “Identity Hub”.
## [0.4.6] - 2026-11-03
Folder sync keeps paging older IMAP mail after the newest page is already archived. Identity Hub desktop login passes the covered app id.
### Fixed
- Folder sync no longer stops after the newest page when those messages are already archived. Headless and in-app sync keep paging older mail (Gmail and other IMAP) until the folder is fully listed.
- IMAP header sync pages by UID SEARCH instead of shifting sequence numbers, keeps the list cursor across soft cancel and transient errors, and retries an empty FETCH page instead of skipping it.
### Changed
- Bump **MaksIT.IdentityHub.Client** to **4.0.0** (covered applications `{ Id, Name }`; desktop login `?app={Id}`).
- Identity Hub desktop login URL includes covered app id: `/desktop-login?provider=…&app=MaksIT.PostClient` (`IdentityHubAddress.CoveredApplicationId`).
## [0.4.5] - 2026-10-31
Unread and flag updates stay local when the server store is cancelled or races with sync. Pending Seen/Flagged wait for STORE ack before background sync can overwrite them.
### Fixed
- Opening an unread message no longer brings the unread dot back when you select another row: mark-as-read is not cancelled with the reading pane, and IMAP cancel is not treated as a store failure.
- Manual Mark Read/Unread/Flag and folder mark-all-read keep optimistic state off the busy `_work` token; IMAP flag stores use a non-folder CTS and pending archive flags until STORE succeeds (or soft "not connected", which stays pending and flushes on reconnect).
- Background sync and header upserts no longer clobber newer local Seen/Flagged; `UpsertHeaders` skips flag overwrite by default and `UpdateFlags` respects pending UIDs.
- IMAP MOVE no longer falls back to copy-then-delete on cancel; remaining UIDs are checked before copy/expunge. Cross-mailbox moves delete each source UID after a successful copy.
- Rules and headless sync write archive flags only after a successful STORE. SyncFolder no longer advances past a folder on list failure.
- POP3 Mark Read works locally; spam junk/inbox destinations resolve per message mailbox; Priority is restored on failed flag updates.
## [0.4.4] - 2026-10-29
HTML mail blocks remote images by default. Windows setup upgrades keep Identity Hub sibling DLLs. Store MSIX is built with each release.
### Added
- **View -> Block remote content** stops HTML mail from loading images and tracking pixels from the internet (on by default). Inline attachments still show; **Show images** loads remotes for the open message only.
### Changed
- Release builds a Store **MSIX** (`postclient-{version}.msix`) as well as the setup exe and portable zip. Partner Center identity: `MAKS-IT.Postclient` / Store ID `9N7F29XPK5W4` (see CONTRIBUTING).
### Fixed
- Windows setup (WiX): upgrades remove the previous product only after the new files are installed, file components keep stable GUIDs, and the pack step requires Identity Hub Client + Contracts DLLs in the publish folder so sibling assemblies cannot be dropped from the installer.
## [0.4.3] - 2026-10-29
Gmail and Microsoft mailbox sign-in uses Identity Hub IdP-only. Hub keeps the IdP refresh in its vault; Postclient renews mailbox tokens automatically. Help shows What’s New after upgrade. Store and Flathub packaging notes.
Gmail and Microsoft mailbox sign-in uses Identity Hub IdP-only. Hub keeps the IdP refresh in its vault; Postclient renews mailbox tokens automatically. Help shows What's New after upgrade. Store and Flathub packaging notes.
### Added
- Microsoft Store and Flathub submission notes under `packaging/` (listing copy in English, Italian, French, German, and Spanish).
- **Help → What’s New** shows the changelog once after each upgrade (seen version is remembered in settings).
- **Help -> What's New** shows the changelog once after each upgrade (seen version is remembered in settings).
- Screenshot tour helpers under `utils/tools/` and `assets/screenshots/` for store listings.
### Changed
@ -24,7 +87,7 @@ Gmail and Microsoft mailbox sign-in uses Identity Hub IdP-only. Hub keeps the Id
### Fixed
- Opening a message, a subfolder, or the star uses the window's own mailbox connection. Background sync keeps a separate connection, so a long sync no longer blocks those updates or puts the old unread mark back.
- Reading a message from an Outlook data file after a write no longer fails with “End of stream”: EML export allows dropping opaque MAPI metadata that has no portable equivalent.
- Reading a message from an Outlook data file after a write no longer fails with "End of stream": EML export allows dropping opaque MAPI metadata that has no portable equivalent.
## [0.4.2] - 2026-10-04
@ -47,12 +110,12 @@ Create bundle asks for the passphrase once, then opens Save. About lists the com
### Changed
- **Help → About** lists Info, Privacy, Security, and Support (`info@`, `privacy@`, `security@`, `support@maks-it.com`) instead of one personal address.
- **Help -> About** lists Info, Privacy, Security, and Support (`info@`, `privacy@`, `security@`, `support@maks-it.com`) instead of one personal address.
- The English, Italian, French, German, and Spanish READMEs describe folder stores, Easy Migration, background sync, spam marks, and the current menus.
### Fixed
- **File → Easy Migration → Create bundle…** asks for the passphrase and the repeat in one window. The save dialog opens after that window closes.
- **File -> Easy Migration -> Create bundle...** asks for the passphrase and the repeat in one window. The save dialog opens after that window closes.
- Windows setup keeps an existing desktop shortcut when the checkbox is off on upgrade. Installing the shortcut removes a second desktop icon, including the "(2)" copy.
## [0.4.0] - 2026-09-27
@ -61,10 +124,10 @@ Background sync runs at boot with nobody signed in. Easy Migration packs this PC
### Added
- **File → Easy Migration** writes a `.postbundle` locked with a passphrase (mail, accounts, secrets, and the shared model folder). **Open bundle…** restores it into this PC’s Postclient folders and rewrites store paths. The background sync service is not reinstalled.
- **File -> Easy Migration** writes a `.postbundle` locked with a passphrase (mail, accounts, secrets, and the shared model folder). **Open bundle...** restores it into this PC's Postclient folders and rewrites store paths. The background sync service is not reinstalled.
- The message search box has an × that clears the text.
- **Settings → Background sync** installs a boot-time service (Windows service as Local Service, Linux systemd system unit). It syncs every saved mailbox with nobody signed in. Passwords are copied into the machine store so the service can sign in to the mail servers. **Share this account** only lets other people on this PC open that mailbox.
- **Mark as spam** remembers that message on this PC and teaches every mailbox, even after the message or mailbox is gone. Similar mail is only a hint. **Not spam** drops that memory and returns the message to Inbox. Moving mail into Junk does not teach the filter. **Settings → Indices… → Spam marks…** lists what was learned.
- **Settings -> Background sync** installs a boot-time service (Windows service as Local Service, Linux systemd system unit). It syncs every saved mailbox with nobody signed in. Passwords are copied into the machine store so the service can sign in to the mail servers. **Share this account** only lets other people on this PC open that mailbox.
- **Mark as spam** remembers that message on this PC and teaches every mailbox, even after the message or mailbox is gone. Similar mail is only a hint. **Not spam** drops that memory and returns the message to Inbox. Moving mail into Junk does not teach the filter. **Settings -> Indices... -> Spam marks...** lists what was learned.
- The meaning-search model lives in the same machine folder (`models` under ProgramData or `/var/lib/maksit/postclient`), so every account and the sync service share one download. An existing copy in the user profile is copied across on startup.
- Layout upgrades live in `Upgrades` (folders, models, archive files, PST stores). Each one records `InstallsOver`, the last release that still has the old layout, so it can be removed once that version is no longer supported. SQL schema stays with the archive store.
@ -137,7 +200,7 @@ First sync paints mail as it arrives, meaning search can use a discrete GPU, and
### Added
- **Help → Logs** opens a copyable window over the app log folder.
- **Help -> Logs** opens a copyable window over the app log folder.
### Changed
@ -160,7 +223,7 @@ Expired folder mail goes to Trash; Trash can be kept forever or purged after a n
### Changed
- **Settings → Retention…**: folder days (`0` = keep forever) now **move** older messages to Trash after Get Messages, instead of deleting them permanently. The Trash row uses the same days field: `0` keeps Trash forever, a number of days **permanently deletes** old Trash. The grid shows the action per row.
- **Settings -> Retention...**: folder days (`0` = keep forever) now **move** older messages to Trash after Get Messages, instead of deleting them permanently. The Trash row uses the same days field: `0` keeps Trash forever, a number of days **permanently deletes** old Trash. The grid shows the action per row.
- Delete (toolbar, **Del**, empty folder, delete rules) moves messages to Trash. If Trash is missing, it is created. Deleting from Trash is still permanent. Local stores honor Trash when emptying a folder.
### Fixed
@ -180,8 +243,8 @@ Folder stores replace attached Outlook data files, Gmail/Microsoft sign-in goes
### Added
- Folder **stores** replace Outlook data files as mailboxes: **File → New store…**, **Attach store…**, **Move store…**, and **Detach store**. Import PST copies into a store under `%LocalAppData%\Postclient\stores\` (or portable `data/stores/`). Each IMAP/POP3 account and each store has its own `mail.db` (FTS + meaning index). Search and move merge or copy across those databases without rebuilding embeddings.
- **Settings → Retention…**: per-folder days (`0` = keep forever). Older mail is deleted permanently (not Trash) after Get Messages.
- Folder **stores** replace Outlook data files as mailboxes: **File -> New store...**, **Attach store...**, **Move store...**, and **Detach store**. Import PST copies into a store under `%LocalAppData%\Postclient\stores\` (or portable `data/stores/`). Each IMAP/POP3 account and each store has its own `mail.db` (FTS + meaning index). Search and move merge or copy across those databases without rebuilding embeddings.
- **Settings -> Retention...**: per-folder days (`0` = keep forever). Older mail is deleted permanently (not Trash) after Get Messages.
- IMAP/POP3 usage on the account node in the folder tree. Gmail and Microsoft sign-in go through **Identity Hub** (`https://identity.maks-it.com/desktop-login?provider=`). Hub JWT is stored in `secrets.bin`; IMAP uses a short-lived mailbox token (XOAUTH2). Override the Hub origin with `POSTCLIENT_IDENTITY_HUB`.
- Folder tree expand/collapse for accounts and nested folders is stored in `settings.json`.
- **Shift+click** and **Ctrl+click** select several messages or folders. Delete (including the **Del** key) and drag-move apply to the whole selection.
@ -212,13 +275,13 @@ On-device meaning search, Outlook data files as mailboxes, rules, extra UI langu
### Added
- **Settings → Indices…**: keyword index (FTS) and optional meaning index (RAG) with EmbeddingGemma 300M. The ~300 MB ONNX package downloads from Hugging Face (`onnx-community/embeddinggemma-300m-ONNX`: graph, weights sidecar, tokenizer). GitHub releases do not carry those files. Vectors stay in `mail.db` on this PC. Device Auto / CPU / GPU (Windows DirectML). Rebuild keyword, rebuild meaning, or repair leftover rows.
- **File → Check for updates…** asks GitHub for the latest release and can download the matching package. **File → About Postclient** matches the WVC210 About card (brand, version, credits, email, license, copyright, maks-it.com).
- Attach a `.pst` / `.ost` as its own mailbox (File menu or account type *data file*). Unicode `.pst` is writable (folders, flags, move, delete). An `.ost` or ANSI `.pst` is copied to a Unicode `.pst` on first write. **New data file** creates an empty Unicode `.pst`. Right-click an attached store to **Detach data file**; the file stays on disk. File → Import still copies mail into another account.
- **Settings → Rules**: import Outlook `.rwz` (Rules Wizard) or JSON, export JSON, and edit rules (enable, conditions, move/delete/flag/label). Each rule belongs to an account; a move can target a folder on another mailbox, including an attached data file. A move folder name stays red until it matches that mailbox (name, full path, or last path segment). **Run all rules** applies them to existing mail. Saved in `settings.json`. They also run on Get Messages and on import.
- **Settings -> Indices...**: keyword index (FTS) and optional meaning index (RAG) with EmbeddingGemma 300M. The ~300 MB ONNX package downloads from Hugging Face (`onnx-community/embeddinggemma-300m-ONNX`: graph, weights sidecar, tokenizer). GitHub releases do not carry those files. Vectors stay in `mail.db` on this PC. Device Auto / CPU / GPU (Windows DirectML). Rebuild keyword, rebuild meaning, or repair leftover rows.
- **File -> Check for updates...** asks GitHub for the latest release and can download the matching package. **File -> About Postclient** matches the WVC210 About card (brand, version, credits, email, license, copyright, maks-it.com).
- Attach a `.pst` / `.ost` as its own mailbox (File menu or account type *data file*). Unicode `.pst` is writable (folders, flags, move, delete). An `.ost` or ANSI `.pst` is copied to a Unicode `.pst` on first write. **New data file** creates an empty Unicode `.pst`. Right-click an attached store to **Detach data file**; the file stays on disk. File -> Import still copies mail into another account.
- **Settings -> Rules**: import Outlook `.rwz` (Rules Wizard) or JSON, export JSON, and edit rules (enable, conditions, move/delete/flag/label). Each rule belongs to an account; a move can target a folder on another mailbox, including an attached data file. A move folder name stays red until it matches that mailbox (name, full path, or last path segment). **Run all rules** applies them to existing mail. Saved in `settings.json`. They also run on Get Messages and on import.
- Compose: **Send attachments as ZIP** packs dropped files into one archive before SMTP. A prompt asks for the ZIP name (from the subject) and an optional password. Attach files by dragging them onto the compose window.
- View → Language: **Français**, **Deutsch**, and **Español** (English and Italian were already there).
- **Settings → Features**: country packs for certified-mail UI (Italy PEC/FatturaPA/fascicolo, Europe eIDAS REM, France/Germany/Spain/Switzerland evidence labels). Ordinary IMAP stays on. Header checkboxes turn a whole pack on or off.
- View -> Language: **Français**, **Deutsch**, and **Español** (English and Italian were already there).
- **Settings -> Features**: country packs for certified-mail UI (Italy PEC/FatturaPA/fascicolo, Europe eIDAS REM, France/Germany/Spain/Switzerland evidence labels). Ordinary IMAP stays on. Header checkboxes turn a whole pack on or off.
### Fixed
@ -227,15 +290,15 @@ On-device meaning search, Outlook data files as mailboxes, rules, extra UI langu
- Selecting one or more messages enables Delete, Move, Flag, and mark read/unread again after drag-and-drop or a list refresh. Opening a message no longer locks the toolbar.
- Move and delete drop keyword and meaning rows for those messages; the meaning indexer wakes so destination mail is re-indexed.
- Indices window stays usable while the meaning model downloads; **Download model** is disabled when the files are already in the models folder.
- **Settings → Rules**: **Import rules…** is the same button for this app’s JSON export and for Outlook `.rwz`.
- **Settings -> Rules**: **Import rules...** is the same button for this app's JSON export and for Outlook `.rwz`.
### Changed
- Keyword/body indexing processes all messages in one queue (like the meaning index), not folder by folder. The status bar shows `Indexing N left` instead of a folder name.
- Toolbar layout control is one toggle: list above reading vs three columns.
- Settings menu: **Indices…** (was Semantic search) manages the keyword index and the meaning index together.
- Settings menu: **Indices...** (was Semantic search) manages the keyword index and the meaning index together.
- Meaning-model download is Hugging Face only (public onnx-community repo). GitHub is not a source for the weights.
- After SMTP send, Sent reconciles gestore ricevute only for PEC/REM accounts (presets, or IMAP/POP3 with mailbox kind PEC/REM). Ordinary IMAP/Gmail/Outlook just reports sent. Leftover “Waiting for ricevuta” marks from ordinary mailboxes are dropped.
- After SMTP send, Sent reconciles gestore ricevute only for PEC/REM accounts (presets, or IMAP/POP3 with mailbox kind PEC/REM). Ordinary IMAP/Gmail/Outlook just reports sent. Leftover "Waiting for ricevuta" marks from ordinary mailboxes are dropped.
- Account Settings: IMAP/POP3 accounts set **mailbox kind** (ordinary, Italian PEC, EU REM) so a custom-domain gestore still waits for ricevute. Presets keep their kind; Gmail, Outlook, and PST stay ordinary.
## [0.1.0] - 2026-09-13
@ -257,5 +320,5 @@ First release of Postclient: a desktop mail client for Windows, Linux, and macOS
- Print / PDF / selected `.eml` fascicolo; save all attachments as ZIP; FatturaPA XML preview (parser, not AI).
- Import EML, Thunderbird mbox, and Outlook PST/OST (no Outlook install).
- Self-contained `win-x64` portable zip and Windows setup exe, Linux Flatpak via WSL Debian (no .NET SDK on the user PC).
- Apple-style squircle product icon (envelope glyph, MAKS.IT cyan→navy) for the exe, window chrome, tray, WiX setup, and Flatpak. Not the brand origami M.
- Apple-style squircle product icon (envelope glyph, MAKS.IT cyan->navy) for the exe, window chrome, tray, WiX setup, and Flatpak. Not the brand origami M.
- Unsigned `osx-arm64` and `osx-x64` DMGs attached when a GitHub Release is published (first launch: Open from the context menu).

View File

@ -33,20 +33,22 @@ WinGet listing is not in this repo yet: it will point at the same GitHub setup e
## Microsoft Store (MSIX)
`MsixPack` is present and disabled. It writes `releases/postclient-{version}.msix` from the win-x64 publish once `enabled` is true and `publisher` is the Partner Center identity. It is a full-trust desktop package (`runFullTrust`), x64. The package language stays English (`en-us`): the app picks English, Italian, French, German, or Spanish at runtime. Upload that file on an **MSIX** product in Partner Center. The Store re-signs it. An EXE/MSI product listing cannot take this file. The listing will be published soon. The README badge stays a placeholder until the Store ID exists. Add a Store listing for each UI language. The paste text is under [packaging/microsoft-store](packaging/microsoft-store/description.md).
`MsixPack` is enabled. Each release writes `releases/postclient-{version}.msix` from the win-x64 publish (Windows SDK `makeappx` / `signtool`). It is a full-trust desktop package (`runFullTrust`), x64. The package language stays English (`en-us`): the app picks English, Italian, French, German, or Spanish at runtime. Upload that file on an **MSIX** product in Partner Center. The Store re-signs it. An EXE/MSI product listing cannot take this file. Add a Store listing for each UI language. The paste text is under [packaging/microsoft-store](packaging/microsoft-store/description.md).
The `.msix` is not a GitHub release asset.
Partner Center package identity for this product. The publisher and Store ID are not reserved yet:
Partner Center package identity for this product:
| Field | Value |
|-------|--------|
| Package/Identity/Name | `MAKS-IT.Postclient` |
| Package/Identity/Publisher | not reserved. `CN=PartnerCenter` stops `MsixPack` |
| Package/Identity/Publisher | `CN=FCC8C0E7-6D5F-4028-B8EE-903B88C0C8F9` |
| PublisherDisplayName | `MaksIT` |
| Store ID | not reserved |
| Package Family Name | `MAKS-IT.Postclient_pt3s39h1tn26a` |
| Package SID | `S-1-15-2-1760430311-4196969058-3832155388-2991238057-1261587593-1009432215-1213270591` |
| Store ID | `9N7F29XPK5W4` |
Those name, publisher, and publisher display strings are `packageName`, `publisher`, and `publisherDisplayName` in `utils/engines/release/scriptSettings.json`. Replace `CN=PartnerCenter`, then set `enabled` to true.
Those name, publisher, and publisher display strings are `packageName`, `publisher`, and `publisherDisplayName` in `utils/engines/release/scriptSettings.json`.
The Store deep link and the Web Store URL are available after the product is live. The `.msix` is not a GitHub release asset.
Store listing copy:
@ -61,7 +63,7 @@ Store listing copy:
### System requirements (Properties)
Partner Center → **Properties** → **System requirements**. A blank cell stays unset. Minimum is what the Store may warn on; Recommended does not warn.
Partner Center -> **Properties** -> **System requirements**. A blank cell stays unset. Minimum is what the Store may warn on; Recommended does not warn.
The app is a win-x64 desktop mail client. It does not use a camera, microphone, radio, gamepad, or a specific GPU.

View File

@ -75,7 +75,7 @@ Mehrere Postfächer sind speicherbar. Profile füllen Hosts und Ports:
Zertifizierte IMAP-Postfächer nutzen das Gestore-Passwort oder ein App-Passwort bei 2FA. **De-Mail** ist in der Regel **kein** IMAP. Dasselbe gilt für **AR24** (Frankreich), **IncaMail** (Schweiz) und **Lleida** (Spanien). Hat der Betreiber IMAP/POP3-Hosts genannt, wählen Sie **IMAP / POP3**. Sonst öffnet dieser Client das Postfach nicht; ETSI-REM-Nachweise auf importierten `.eml` kann er trotzdem anzeigen.
Gmail und Microsoft 365 laufen über **Identity Hub** (`https://identity.maks-it.com`). Kontoeinstellungen öffnen `/desktop-login?provider=Google` oder `Microsoft`. Hub-JWT und Refresh-Token bleiben in `secrets.bin`; IMAP nutzt ein kurzlebiges Mailbox-Token (XOAUTH2). Beim Verbinden erneuert der Client die Hub-Sitzung aus dem Refresh-Token, damit Gmail und Outlook nach dem Beenden angemeldet bleiben. Hat ein älterer Build nur das JWT gespeichert, einmal neu in den Kontoeinstellungen anmelden. Override: `POSTCLIENT_IDENTITY_HUB`. Passwort bleibt für PEC und generisches IMAP gültig.
Gmail und Microsoft 365 laufen über **Identity Hub** (`https://identity.maks-it.com`, App-Id `MaksIT.PostClient`). Kontoeinstellungen öffnen `/desktop-login?provider=Google|Microsoft&app=MaksIT.PostClient`. Hub-JWT und Refresh-Token bleiben in `secrets.bin`; IMAP nutzt ein kurzlebiges Mailbox-Token (XOAUTH2). Beim Verbinden erneuert der Client die Hub-Sitzung aus dem Refresh-Token, damit Gmail und Outlook nach dem Beenden angemeldet bleiben. Hat ein älterer Build nur das JWT gespeichert, einmal neu in den Kontoeinstellungen anmelden. Override: `POSTCLIENT_IDENTITY_HUB`. Passwort bleibt für PEC und generisches IMAP gültig.
Passwörter und OAuth-Refresh-Token liegen neben den Einstellungen (`DPAPI` unter Windows, Dateimodus `600` unter Linux/macOS), nie in `settings.json`.
@ -121,6 +121,7 @@ Systemhinweise erscheinen bei neuer PEC, neuen Ricevute und neuer REM (Windows-T
- Der Baum folgt dem Server (Posteingang, Ricevute, Entwürfe, Gesendet, Archiv, Junk, Papierkorb, plus eigene Ordner). Verschachtelte IMAP-Pfade erscheinen als Unterordner, auch Gmail-Labels `[Gmail]/…` unter `[Gmail]`. Entwürfe, Gesendet und Papierkorb als `INBOX.Drafts` / `INBOX.Sent` / `INBOX.Trash` stehen neben dem Posteingang, nicht darunter. Gmail trennt mit `/`, daher bleibt ein Label wie `P.IVA` ein Ordner. Systemordner lassen sich nicht löschen. Ordner anlegen, leeren (in den Papierkorb bzw. endgültig im Papierkorb), alles gelesen/ungelesen, eigenen Ordner löschen, Nachrichten auf einen Ordner ziehen. Welche Konten und Unterordner aufgeklappt oder zugeklappt sind, bleibt in `settings.json`.
- Spalten: ungelesen, Kennzeichnung, Anlagen, Zustellung, Typ (PEC / RIC / REM / SIG), von, Betreff, Datum (lokal `yyyy-MM-dd HH:mm`), Größe, Mandatskennzeichen.
- Ansicht: HTML (Web-Engine des Systems), Text, Quelltext oder **FatturaPA**. Layout: Liste über dem Lesen, oder drei Spalten (Ordner, Liste, Lesen).
- **Externe Inhalte blockieren** (standardmäßig an) verhindert, dass HTML-Mail Bilder oder Tracking-Pixel aus dem Internet lädt; **Bilder anzeigen** lädt Remotes nur für die geöffnete Nachricht. Eingebettete Anhänge bleiben sichtbar.
- **Unterhaltungen gruppieren** rückt Antworten in der Liste ein (Message-ID / In-Reply-To / References, Tiefe bis 8). PEC-Nachweise zum selben Original stehen beieinander. Es gibt kein eigenes Unterhaltungsfenster.
- Aktionen: antworten, allen antworten, weiterleiten, in neuem Fenster, gelesen/ungelesen, Kennzeichnung, Priorität, als Spam markieren, kein Spam, löschen. **Umschalt+Klick** und **Strg+Klick** wählen mehrere Nachrichten oder Ordner. Löschen (Leiste, **Entf**, Ordner leeren) verschiebt die Auswahl in den Papierkorb. Aus dem Papierkorb ist Löschen endgültig.
- Das Verfassen ist **Klartext**. An, Cc, Bcc sind Adress-Chips. **Anlagen als ZIP senden** packt per Drag-and-Drop übergebene Dateien (optionales Passwort). Der Versand nutzt nur die SMTP-Identität des gewählten Postfachs.

View File

@ -75,7 +75,7 @@ Se pueden guardar varios buzones. Los perfiles rellenan hosts y puertos:
Los buzones IMAP certificados usan la contraseña del gestor, o una contraseña de aplicación si hay 2FA. **Lleida.net** y otras notificaciones certificadas españolas **no** suelen ser IMAP. Tampoco **AR24** (Francia), **De-Mail** (Alemania) ni **IncaMail** (Suiza). Si el operador dio hosts IMAP/POP3, elija **IMAP / POP3**. Si no, este cliente no abre ese buzón; sí puede mostrar evidencia ETSI REM en mensajes importados como `.eml`.
Gmail y Microsoft 365 pasan por **Identity Hub** (`https://identity.maks-it.com`). Ajustes de cuenta abre `/desktop-login?provider=Google` o `Microsoft`. El JWT y el token de actualización del Hub quedan en `secrets.bin`; IMAP usa un token de buzón de corta vida (XOAUTH2). Al conectar, el cliente renueva la sesión Hub con ese token de actualización para que Gmail y Outlook sigan conectados tras cerrar la app. Si una versión anterior guardó solo el JWT, inicie sesión otra vez en Ajustes de cuenta. Override: `POSTCLIENT_IDENTITY_HUB`. La contraseña sigue valiendo para PEC e IMAP genérico.
Gmail y Microsoft 365 pasan por **Identity Hub** (`https://identity.maks-it.com`, app id `MaksIT.PostClient`). Ajustes de cuenta abre `/desktop-login?provider=Google|Microsoft&app=MaksIT.PostClient`. El JWT y el token de actualización del Hub quedan en `secrets.bin`; IMAP usa un token de buzón de corta vida (XOAUTH2). Al conectar, el cliente renueva la sesión Hub con ese token de actualización para que Gmail y Outlook sigan conectados tras cerrar la app. Si una versión anterior guardó solo el JWT, inicie sesión otra vez en Ajustes de cuenta. Override: `POSTCLIENT_IDENTITY_HUB`. La contraseña sigue valiendo para PEC e IMAP genérico.
Las contraseñas y los tokens OAuth quedan junto a la configuración (`DPAPI` en Windows, modo `600` en Linux/macOS), nunca en `settings.json`.
@ -121,6 +121,7 @@ Las notificaciones del sistema avisan de PEC, acuses y REM nuevos (toast de Wind
- El árbol sigue al servidor (Bandeja de entrada, Ricevute, Borradores, Enviados, Archivo, No deseado, Papelera, más carpetas propias). Las rutas IMAP anidadas se muestran como carpetas anidadas, incluidas las etiquetas de Gmail `[Gmail]/…` bajo `[Gmail]`. Borradores, Enviados y Papelera que el servidor guarda como `INBOX.Drafts` / `INBOX.Sent` / `INBOX.Trash` quedan junto a la bandeja, no debajo. Gmail usa `/` como separador, así que una etiqueta como `P.IVA` sigue siendo una sola carpeta. Las de sistema no se pueden borrar. Se puede crear una carpeta, vaciarla (a Papelera, o de forma definitiva en Papelera), marcar todo leído/no leído, eliminar una carpeta propia y arrastrar mensajes a una carpeta. Qué cuentas y carpetas anidadas están abiertas o cerradas se guarda en `settings.json`.
- Columnas: no leído, marca, adjuntos, entrega, tipo (PEC / RIC / REM / SIG), de, asunto, fecha (local `yyyy-MM-dd HH:mm`), tamaño, etiqueta de expediente.
- Vista: HTML (motor web del sistema), texto, fuente o **FatturaPA**. Disposición: lista encima de la lectura, o tres columnas (carpetas, lista, lectura).
- **Bloquear contenido remoto** (activado por defecto) evita que el correo HTML cargue imágenes o píxeles de seguimiento de Internet; **Mostrar imágenes** los carga solo para el mensaje abierto. Los adjuntos incrustados siguen visibles.
- **Agrupar conversaciones** sangra las respuestas en la lista (Message-ID / In-Reply-To / References, profundidad máxima 8). Los acuses PEC del mismo original quedan juntos. No hay un panel de conversación aparte.
- Acciones: responder, responder a todos, reenviar, abrir en ventana nueva, leído/no leído, marca, prioridad, marcar como spam, no es spam, eliminar. **Mayús+clic** y **Ctrl+clic** seleccionan varios mensajes o carpetas. Eliminar (barra, **Supr**, vaciar carpeta) mueve la selección a Papelera. Desde Papelera, eliminar es definitivo.
- La redacción es **texto sin formato**. Para, Cc, Cco son chips de dirección. **Enviar adjuntos como ZIP** empaqueta los archivos soltados (contraseña opcional). El envío usa solo la identidad SMTP del buzón elegido.

View File

@ -75,7 +75,7 @@ Plusieurs boîtes peuvent être enregistrées. Les profils remplissent hôtes et
Les boîtes IMAP certifiées utilisent le mot de passe du gestionnaire, ou un mot de passe d’application si la 2FA est active. **AR24**, **La Poste LRE** et d’autres LRE françaises ne sont en général **pas** de l’IMAP. **De-Mail** (Allemagne), **IncaMail** (Suisse) et **Lleida** (Espagne) non plus. Si l’opérateur a fourni des hôtes IMAP/POP3, choisissez **IMAP / POP3**. Sinon ce client n’ouvre pas cette boîte ; il peut encore afficher une preuve ETSI REM sur des messages importés en `.eml`.
Gmail et Microsoft 365 passent par **Identity Hub** (`https://identity.maks-it.com`). Les paramètres de compte ouvrent `/desktop-login?provider=Google` ou `Microsoft`. Le JWT Hub et le jeton de rafraîchissement restent dans `secrets.bin` ; IMAP utilise un jeton mailbox de courte durée (XOAUTH2). À la connexion, le client renouvelle la session Hub à partir de ce jeton, pour que Gmail et Outlook restent connectés après la fermeture. Si une version antérieure n’a enregistré que le JWT, reconnectez-vous une fois dans les paramètres de compte. Surcharge : `POSTCLIENT_IDENTITY_HUB`. Le mot de passe reste valable pour PEC et IMAP générique.
Gmail et Microsoft 365 passent par **Identity Hub** (`https://identity.maks-it.com`, id d’app `MaksIT.PostClient`). Les paramètres de compte ouvrent `/desktop-login?provider=Google|Microsoft&app=MaksIT.PostClient`. Le JWT Hub et le jeton de rafraîchissement restent dans `secrets.bin` ; IMAP utilise un jeton mailbox de courte durée (XOAUTH2). À la connexion, le client renouvelle la session Hub à partir de ce jeton, pour que Gmail et Outlook restent connectés après la fermeture. Si une version antérieure n’a enregistré que le JWT, reconnectez-vous une fois dans les paramètres de compte. Surcharge : `POSTCLIENT_IDENTITY_HUB`. Le mot de passe reste valable pour PEC et IMAP générique.
Mots de passe et jetons OAuth restent à côté des réglages (`DPAPI` sous Windows, mode `600` sous Linux/macOS), jamais dans `settings.json`.
@ -121,6 +121,7 @@ Les notifications système signalent les nouvelles PEC, avis et REM (toast Windo
- L’arbre suit le serveur (Boîte de réception, Ricevute, Brouillons, Envoyés, Archive, Indésirable, Corbeille, plus dossiers personnalisés). Les chemins IMAP imbriqués apparaissent comme des dossiers imbriqués, y compris les libellés Gmail `[Gmail]/…` sous `[Gmail]`. Brouillons, Envoyés et Corbeille nommés `INBOX.Drafts` / `INBOX.Sent` / `INBOX.Trash` restent à côté de la boîte de réception, pas en dessous. Gmail utilise `/` comme séparateur : un libellé tel que `P.IVA` reste un seul dossier. Les dossiers système ne se suppriment pas. On peut créer un dossier, le vider (vers la Corbeille, ou définitivement dans la Corbeille), tout marquer lu/non lu, supprimer un dossier personnalisé, glisser des messages sur un dossier. L’état ouvert ou fermé des comptes et des dossiers imbriqués est conservé dans `settings.json`.
- Colonnes : non lu, drapeau, pièces jointes, livraison, type (PEC / RIC / REM / SIG), de, objet, date (locale `yyyy-MM-dd HH:mm`), taille, étiquette de dossier.
- Vue : HTML (moteur web du système), texte, source, ou **FatturaPA**. Disposition : liste au-dessus de la lecture, ou trois colonnes (dossiers, liste, lecture).
- **Bloquer le contenu distant** (activé par défaut) empêche le HTML de charger images et pixels de suivi depuis Internet ; **Afficher les images** les charge seulement pour le message ouvert. Les pièces jointes intégrées restent visibles.
- **Grouper les conversations** indente les réponses dans la liste (Message-ID / In-Reply-To / References, profondeur max. 8). Les avis PEC du même original restent ensemble. Il n’y a pas de volet conversation séparé.
- Actions : répondre, répondre à tous, transférer, ouvrir dans une nouvelle fenêtre, lu/non lu, drapeau, priorité, marquer comme spam, pas du spam, supprimer. **Maj+clic** et **Ctrl+clic** sélectionnent plusieurs messages ou dossiers. Supprimer (barre, **Suppr**, vider le dossier) envoie la sélection à la Corbeille. Depuis la Corbeille, la suppression est définitive.
- La rédaction est en **texte brut**. À, Cc, Cci sont des pastilles d’adresse. **Envoyer les pièces en ZIP** archive les fichiers déposés (mot de passe optionnel). L’envoi n’utilise que l’identité SMTP de la boîte choisie.

View File

@ -80,7 +80,7 @@ Si possono usare più caselle insieme. I profili compilano host e porte:
Le caselle IMAP certificate usano la password del gestore, o una password per le app se c’è il 2FA. **AR24** (Francia), **De-Mail** (Germania), **IncaMail** (Svizzera) e **Lleida** (Spagna) di solito **non** sono IMAP. Se l’operatore ha dato host IMAP/POP3, scegli **IMAP / POP3**. Altrimenti questa applicazione non apre quella casella; può comunque mostrare l’evidenza ETSI REM su messaggi importati come `.eml`.
Gmail e Microsoft 365 accedono tramite **Identity Hub** (`https://identity.maks-it.com`). Impostazioni account apre `/desktop-login?provider=Google` o `Microsoft`. Il JWT Hub e il refresh token restano in `secrets.bin`; IMAP usa un mailbox-token a breve scadenza (XOAUTH2). All’avvio il client rinnova la sessione Hub da quel refresh token, così Gmail e Outlook restano collegati dopo la chiusura. Se una build precedente ha salvato solo il JWT, accedi di nuovo da Impostazioni account. Override: `POSTCLIENT_IDENTITY_HUB`. La password resta valida per PEC e IMAP generico.
Gmail e Microsoft 365 accedono tramite **Identity Hub** (`https://identity.maks-it.com`, app id `MaksIT.PostClient`). Impostazioni account apre `/desktop-login?provider=Google|Microsoft&app=MaksIT.PostClient`. Il JWT Hub e il refresh token restano in `secrets.bin`; IMAP usa un mailbox-token a breve scadenza (XOAUTH2). All’avvio il client rinnova la sessione Hub da quel refresh token, così Gmail e Outlook restano collegati dopo la chiusura. Se una build precedente ha salvato solo il JWT, accedi di nuovo da Impostazioni account. Override: `POSTCLIENT_IDENTITY_HUB`. La password resta valida per PEC e IMAP generico.
Password e token OAuth stanno accanto alle impostazioni (`DPAPI` su Windows, mode `600` su Linux/macOS), mai in `settings.json`.
@ -126,6 +126,7 @@ Le notifiche del sistema segnalano nuove PEC, ricevute e REM (toast Windows, not
- L’albero segue il server (Posta in arrivo, Ricevute, Bozze, Inviata, Archivio, Indesiderata, Cestino, più cartelle personalizzate). I percorsi IMAP nidificati sono cartelle nidificate, comprese le etichette Gmail `[Gmail]/…` sotto `[Gmail]`. Bozze, Inviata e Cestino che il server chiama `INBOX.Drafts` / `INBOX.Sent` / `INBOX.Trash` stanno accanto a Posta in arrivo, non sotto. Gmail usa `/` come separatore: un’etichetta come `P.IVA` resta una sola cartella. Le cartelle di sistema non si eliminano. Si può creare una cartella, svuotarla (nel Cestino, o in modo definitivo nel Cestino), segnare tutto letto/non letto, eliminare una cartella personalizzata, trascinare i messaggi su una cartella. Quali account e cartelle nidificate sono aperti o chiusi resta in `settings.json`.
- Colonne elenco: non letto, stella, allegati, consegna, tipo (PEC / RIC / REM / SIG), da, oggetto, data (locale `yyyy-MM-dd HH:mm`), dimensione, etichetta di pratica.
- Vista: HTML (motore web del sistema), testo, sorgente, o **FatturaPA**. Disposizione: elenco sopra la lettura, oppure tre colonne (cartelle, elenco, lettura).
- **Blocca contenuto remoto** (attivo di default) evita che la posta HTML carichi immagini o pixel di tracking da Internet; **Mostra immagini** li carica solo per il messaggio aperto. Gli allegati incorporati restano visibili.
- **Raggruppa conversazioni** indenta le risposte nell’elenco (Message-ID / In-Reply-To / References, profondità massima 8). Le ricevute PEC dello stesso originale stanno insieme. Non c’è un riquadro conversazioni separato.
- Azioni: rispondi, rispondi a tutti, inoltra, apri in nuova finestra, letto/non letto, stella, priorità, segna come spam, non spam, elimina. **Maiusc+clic** e **Ctrl+clic** selezionano più messaggi o cartelle. Elimina (barra, **Canc**, svuota cartella) sposta la selezione nel Cestino. Dal Cestino l’eliminazione è definitiva.
- La composizione è **testo semplice**. A, Cc, Ccn sono chip di indirizzo. Gli allegati sono elencati nel compositore. **Invia allegati come ZIP** impacca i file trascinati (password opzionale). L’invio usa solo l’identità SMTP della casella scelta.

101
README.md
View File

@ -1,8 +1,8 @@
# Postclient
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-60.4%25-green)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-47.9%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-62.8%25-green)
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-60.5%25-green)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-46.7%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.9%25-green)
![.NET](https://img.shields.io/badge/.NET-10-512BD4)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue)](LICENSE.md)
![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS-0078D6)
@ -20,12 +20,12 @@ It is not a qualified trust service provider, not a PEC operator, not a timestam
![Inbox](assets/screenshots/inbox.png)
![Microsoft Store](https://img.shields.io/badge/Microsoft%20Store-published%20soon-5C2D91)
[![Microsoft Store](https://img.shields.io/badge/Microsoft%20Store-9N7F29XPK5W4-5C2D91)](https://apps.microsoft.com/store/detail/9N7F29XPK5W4?cid=DevShareMCLPCB)
[![Windows](https://img.shields.io/badge/Windows-zip%20%26%20setup-0078D6)](https://github.com/MAKS-IT-COM/maksit-postclient/releases/latest)
[![Linux](https://img.shields.io/badge/Linux-Flatpak-0078D6)](https://github.com/MAKS-IT-COM/maksit-postclient/releases/latest)
[![macOS](https://img.shields.io/badge/macOS-DMG-000000)](https://github.com/MAKS-IT-COM/maksit-postclient/releases/latest)
macOS builds are unsigned: first launch is **Open** from the context menu. The Microsoft Store listing will be published soon.
macOS builds are unsigned: first launch is **Open** from the context menu. The Microsoft Store product is reserved (`9N7F29XPK5W4`); the public listing goes live after the first submission.
Changes: [CHANGELOG.md](CHANGELOG.md). Contributing: [CONTRIBUTING.md](CONTRIBUTING.md).
@ -59,23 +59,23 @@ If you find this project useful, please consider supporting its development:
Typical desktop and web mail treats a PEC **busta** or an ETSI REM evidence part as just another attachment. Postclient is written around that evidence:
- **Unwrap PEC / REM envelopes** — read the inner `postacert.eml` (the message the recipient was meant to see) instead of the gestore wrapper; Type column **PEC**, **RIC**, **REM**, or digitally signed **SIG**.
- **Delivery column from operator evidence** — accettazione, avvenuta-consegna, ETSI `SubmissionAcceptance` / `Delivery` / `Retrieval`, LRE and AR24 avis, De-Mail Zustellung and Abholbestätigung, acuse de recibo. After SMTP send, **Sent** is matched to Inbox/Ricevute ricevute by Message-Id, `daticert.xml`, and subject prefixes. Italian PEC has **no read receipt**: avvenuta consegna is deposit in the certified mailbox.
- **FatturaPA preview** — Italian e-invoice XML as a readable view (XML parser, not generative AI).
- **Fascicolo of original `.eml`** — export selected messages as the files a notaio, PA desk, bank, or court can keep.
- **From is a mailbox picker** — you cannot type a free From; send uses the SMTP identity of the connected account.
- **Practice labels on this PC** — tag a condominium, a client file, an IMU notice. Labels are local, not a studio-wide database.
- **Full-folder local archive** — Get Messages stores the **whole IMAP folder** in `mail.db` plus `.eml` on disk (not a last-N window). Keyword FTS on subject, body, PDF/XML/HTML/CSV/JSON attachment text, and labels.
- **On-device meaning search (RAG)** — optional EmbeddingGemma 300M in **Settings → Indices…**. Vectors stay in `mail.db`. Mail never leaves the machine. Rebuild or repair keyword and meaning indices.
- **Rules across mailboxes** — move, delete, flag, label; a destination folder can live on another account, including an attached **folder store**. Import JSON or a legacy `.rwz` file; export is JSON.
- **Folder stores** — create, attach, detach, or move a directory that holds `.eml` plus its own `mail.db` (keyword + meaning index). **Import Outlook PST…** copies into a store; it does not register the `.pst` as a mailbox. Default store path: `%LocalAppData%\Postclient\stores\{name}` (portable: `{install}/data/stores/{name}`).
- **Country packs** — **Settings → Features** turns Italy (PEC, FatturaPA, fascicolo), Europe (eIDAS REM), France, Germany, Spain, and Switzerland evidence labels on or off. Ordinary IMAP stays available.
- **Unwrap PEC / REM envelopes** - read the inner `postacert.eml` (the message the recipient was meant to see) instead of the gestore wrapper; Type column **PEC**, **RIC**, **REM**, or digitally signed **SIG**.
- **Delivery column from operator evidence** - accettazione, avvenuta-consegna, ETSI `SubmissionAcceptance` / `Delivery` / `Retrieval`, LRE and AR24 avis, De-Mail Zustellung and Abholbestätigung, acuse de recibo. After SMTP send, **Sent** is matched to Inbox/Ricevute ricevute by Message-Id, `daticert.xml`, and subject prefixes. Italian PEC has **no read receipt**: avvenuta consegna is deposit in the certified mailbox.
- **FatturaPA preview** - Italian e-invoice XML as a readable view (XML parser, not generative AI).
- **Fascicolo of original `.eml`** - export selected messages as the files a notaio, PA desk, bank, or court can keep.
- **From is a mailbox picker** - you cannot type a free From; send uses the SMTP identity of the connected account.
- **Practice labels on this PC** - tag a condominium, a client file, an IMU notice. Labels are local, not a studio-wide database.
- **Full-folder local archive** - Get Messages stores the **whole IMAP folder** in `mail.db` plus `.eml` on disk (not a last-N window). Keyword FTS on subject, body, PDF/XML/HTML/CSV/JSON attachment text, and labels.
- **On-device meaning search (RAG)** - optional EmbeddingGemma 300M in **Settings -> Indices...**. Vectors stay in `mail.db`. Mail never leaves the machine. Rebuild or repair keyword and meaning indices.
- **Rules across mailboxes** - move, delete, flag, label; a destination folder can live on another account, including an attached **folder store**. Import JSON or a legacy `.rwz` file; export is JSON.
- **Folder stores** - create, attach, detach, or move a directory that holds `.eml` plus its own `mail.db` (keyword + meaning index). **Import Outlook PST...** copies into a store; it does not register the `.pst` as a mailbox. Default store path: `%LocalAppData%\Postclient\stores\{name}` (portable: `{install}/data/stores/{name}`).
- **Country packs** - **Settings -> Features** turns Italy (PEC, FatturaPA, fascicolo), Europe (eIDAS REM), France, Germany, Spain, and Switzerland evidence labels on or off. Ordinary IMAP stays available.
## Who it is for
### Private individuals
Daily client for one or more addresses: Gmail or Microsoft 365 next to a personal **PEC** (Italy) or an IMAP **REM** mailbox (EU). Search a condominium notice, a tax file, or a PA communication years later without leaving the originals on someone else’s server.
Daily client for one or more addresses: Gmail or Microsoft 365 next to a personal **PEC** (Italy) or an IMAP **REM** mailbox (EU). Search a condominium notice, a tax file, or a PA communication years later without leaving the originals on someone else's server.
Typical use: certified and ordinary mail in one window; see whether the gestore accepted and deposited a PEC; print or save a PDF; export the original `.eml` if a municipality, bank, or court asks for it.
@ -95,19 +95,19 @@ Several mailboxes at once. Presets fill hosts and ports:
|--|--|
| Gmail | IMAP/SMTP; Google sign-in through Identity Hub (`identity.maks-it.com`) or a Gmail app password |
| Microsoft 365 | IMAP/SMTP; Microsoft sign-in through Identity Hub. Most of these accounts reject a mailbox password |
| IT PEC — Aruba | `imaps.pec.aruba.it` / `smtps.pec.aruba.it` (POP3: `pop3s.pec.aruba.it`) |
| IT PEC — InfoCert Legalmail | `mbox.cert.legalmail.it` / `sendm.cert.legalmail.it`. Username is often the InfoCert User ID, not the address |
| IT/EU PEC — Namirial | `imaps.sicurezzapostale.it` / `smtps.sicurezzapostale.it` |
| IT PEC — Poste Italiane | `mail.postecert.it` |
| IT PEC — Register.it | `imap.pec-email.com` / `smtp.pec-email.com` |
| IT PEC — Libero | `mail.postacert.it.net` |
| EU — Intesi Group | `imap.ig-trustmail.com` / `smtp.ig-trustmail.com` |
| IT PEC - Aruba | `imaps.pec.aruba.it` / `smtps.pec.aruba.it` (POP3: `pop3s.pec.aruba.it`) |
| IT PEC - InfoCert Legalmail | `mbox.cert.legalmail.it` / `sendm.cert.legalmail.it`. Username is often the InfoCert User ID, not the address |
| IT/EU PEC - Namirial | `imaps.sicurezzapostale.it` / `smtps.sicurezzapostale.it` |
| IT PEC - Poste Italiane | `mail.postecert.it` |
| IT PEC - Register.it | `imap.pec-email.com` / `smtp.pec-email.com` |
| IT PEC - Libero | `mail.postacert.it.net` |
| EU - Intesi Group | `imap.ig-trustmail.com` / `smtp.ig-trustmail.com` |
| IMAP / POP3 | Any host the operator gave you. Set **mailbox kind** to Italian PEC or EU REM if that mailbox is certified (including a custom domain) |
| Folder store | Local bucket on disk (`postclient.store.json` + `mail.db` + `.eml`). No password and no SMTP. Create, attach, detach, or move from the File menu. |
Certified IMAP mailboxes use the gestore password, or an app password if the gestore has 2FA. **AR24** (France), **De-Mail** (Germany), **IncaMail** (Switzerland), and **Lleida** (Spain) are usually not IMAP. If that operator gave you IMAP/POP3 hosts, choose **IMAP / POP3**. Otherwise this client cannot open that mailbox; it can still display ETSI REM evidence on messages you import as `.eml`.
Gmail and Microsoft 365 sign-in uses **Identity Hub** at `https://identity.maks-it.com` (covered app `MaksIT.PostClient`). Account Settings opens `/desktop-login?provider=Google` or `Microsoft` in the built-in WebView. Hub session material stays in `secrets.bin`; IMAP/SMTP use a short-lived **IdP mailbox token** (XOAUTH2). Hub keeps the IdP refresh in its vault and is transparent to mail protocols — on connect PostClient asks Hub for a fresh mailbox token (and renews the Hub session when needed) so Gmail and Outlook stay signed in. If an older build stored a session without a refresh token, sign in once more from Account Settings. Override the origin with `POSTCLIENT_IDENTITY_HUB` (Compose: `http://localhost:8080`). Password / app password still work for PEC and generic IMAP.
Gmail and Microsoft 365 sign-in uses **Identity Hub** at `https://identity.maks-it.com` (covered app id `MaksIT.PostClient`, commercial name **MaksIT Postclient**). Account Settings opens `/desktop-login?provider=Google|Microsoft&app=MaksIT.PostClient` in the built-in WebView. Hub session material stays in `secrets.bin`; IMAP/SMTP use a short-lived **IdP mailbox token** (XOAUTH2). Hub keeps the IdP refresh in its vault and is transparent to mail protocols - on connect PostClient asks Hub for a fresh mailbox token (and renews the Hub session when needed) so Gmail and Outlook stay signed in. If an older build stored a session without a refresh token, sign in once more from Account Settings. Override the origin with `POSTCLIENT_IDENTITY_HUB` (Compose: `http://localhost:8080`). Password / app password still work for PEC and generic IMAP.
Passwords and Hub tokens stay next to settings (`DPAPI` on Windows, file mode `600` on Linux/macOS), never in `settings.json`.
@ -118,29 +118,29 @@ Italian certified mail is a **busta** produced by the gestore:
- transport message with `X-Trasporto: posta-certificata`, `daticert.xml`, and the original as `postacert.eml`;
- ricevute (`X-Ricevuta`) in **Inbox** and **Ricevute**: accettazione, presa-in-carico, avvenuta-consegna, non-accettazione, mancata-consegna, rilevazione-virus, preavviso-errore-consegna.
View → **Unwrap PEC/REM envelope** shows the inner `postacert.eml`. The Type column marks **PEC** (transport) or **RIC** (ricevuta).
View -> **Unwrap PEC/REM envelope** shows the inner `postacert.eml`. The Type column marks **PEC** (transport) or **RIC** (ricevuta).
Italian PEC has **no read receipt**. **Avvenuta consegna** means the message was deposited in the recipient’s certified mailbox, not that a person opened it.
Italian PEC has **no read receipt**. **Avvenuta consegna** means the message was deposited in the recipient's certified mailbox, not that a person opened it.
## European REM (eIDAS registered electronic mail)
When the message carries ETSI REM evidence (`REMEvidence` / `urn:etsi:rem`, or a part name such as `remevidence` / `rem-md`), the client treats it as **REM**. Event types map onto the same delivery column as PEC:
- `SubmissionAcceptance` → accepted by the QTSP;
- `Delivery` / LRE or AR24 avis de réception / De-Mail Zustellung / acuse de recibo → deposited;
- `ContentConsignment` / `Retrieval` / De-Mail `Abholbestätigung` → the recipient retrieved the content (closest European equivalent of “read”);
- rejection, virus, non-delivery, `DeliveryExpiration` → failed.
- `SubmissionAcceptance` -> accepted by the QTSP;
- `Delivery` / LRE or AR24 avis de réception / De-Mail Zustellung / acuse de recibo -> deposited;
- `ContentConsignment` / `Retrieval` / De-Mail `Abholbestätigung` -> the recipient retrieved the content (closest European equivalent of "read");
- rejection, virus, non-delivery, `DeliveryExpiration` -> failed.
S/MIME (`smime.p7s` / `smime.p7m`) is shown as digitally signed; it is not PEC and not REM.
## Delivery column after send
After SMTP send, Postclient stores the outbound **Message-Id** and, when you open **Sent**, reconciles gestore ricevute from **Inbox** and **Ricevute**. That wait applies only to accounts whose kind is PEC or REM (a PEC/REM preset, or IMAP/POP3 with that mailbox kind). Ordinary Gmail, Microsoft 365, and IMAP just report as sent. Matching uses, in order: `daticert.xml` `<msgid>`, `X-Riferimento-Message-ID`, `In-Reply-To`, PEC `identificativo`, then subject prefixes (`ACCETTAZIONE:`, `CONSEGNA:`, `AVIS DE RECEPTION:`, `ACUSE DE RECIBO:`, `ZUSTELLBESTAETIGUNG:`, `ABHOLBESTAETIGUNG:`, …).
After SMTP send, Postclient stores the outbound **Message-Id** and, when you open **Sent**, reconciles gestore ricevute from **Inbox** and **Ricevute**. That wait applies only to accounts whose kind is PEC or REM (a PEC/REM preset, or IMAP/POP3 with that mailbox kind). Ordinary Gmail, Microsoft 365, and IMAP just report as sent. Matching uses, in order: `daticert.xml` `<msgid>`, `X-Riferimento-Message-ID`, `In-Reply-To`, PEC `identificativo`, then subject prefixes (`ACCETTAZIONE:`, `CONSEGNA:`, `AVIS DE RECEPTION:`, `ACUSE DE RECIBO:`, `ZUSTELLBESTAETIGUNG:`, `ABHOLBESTAETIGUNG:`, ...).
| Evidence | Mark | Meaning |
|--|--|--|
| PEC `accettazione` / `presa-in-carico`, ETSI `SubmissionAcceptance` | Accepted | The gestore took the message |
| PEC `avvenuta-consegna`, ETSI `Delivery`, LRE/AR24 avis, De-Mail Zustellung | Delivered | Deposited in the recipient **certified mailbox** — Italian PEC has **no read receipt** |
| PEC `avvenuta-consegna`, ETSI `Delivery`, LRE/AR24 avis, De-Mail Zustellung | Delivered | Deposited in the recipient **certified mailbox** - Italian PEC has **no read receipt** |
| ETSI `ContentConsignment` / `Retrieval`, De-Mail `Abholbestätigung` | Retrieved | Recipient picked up the content |
| `non-accettazione`, `mancata-consegna`, virus, ETSI rejection | Failed | Gestore reported non-acceptance or failed delivery |
@ -150,12 +150,13 @@ Desktop notices fire for new PEC, ricevute, and REM (Windows toast, macOS notifi
## Reading, folders, compose
- Folder tree follows the server (Inbox, Ricevute/Receipts, Drafts, Sent, Archive, Junk, Trash, plus custom folders). Nested IMAP paths show as nested folders, including Gmail’s `[Gmail]/…` labels under `[Gmail]`. Standard folders that the server stores as `INBOX.Drafts` / `INBOX.Sent` / `INBOX.Trash` sit next to Inbox, not under it. Gmail uses `/` as the separator, so a label like `P.IVA` stays one folder. System folders cannot be deleted. You can create a folder, empty one (to Trash, or permanently in Trash), mark all read/unread, delete a custom folder, and drag messages onto a folder. Which accounts and nested folders are expanded or collapsed is kept in `settings.json`.
- Folder tree follows the server (Inbox, Ricevute/Receipts, Drafts, Sent, Archive, Junk, Trash, plus custom folders). Nested IMAP paths show as nested folders, including Gmail's `[Gmail]/...` labels under `[Gmail]`. Standard folders that the server stores as `INBOX.Drafts` / `INBOX.Sent` / `INBOX.Trash` sit next to Inbox, not under it. Gmail uses `/` as the separator, so a label like `P.IVA` stays one folder. System folders cannot be deleted. You can create a folder, empty one (to Trash, or permanently in Trash), mark all read/unread, delete a custom folder, and drag messages onto a folder. Which accounts and nested folders are expanded or collapsed is kept in `settings.json`.
- List columns: unread, flag, attachments, delivery, type (PEC / RIC / REM / SIG), from, subject, date (local `yyyy-MM-dd HH:mm`), size, practice label.
- View: HTML (OS web engine), plain text, raw source, or **FatturaPA**. Layout: list above reading, or three columns (folders, list, reading).
- **Block remote content** (on by default) keeps HTML mail from loading images or tracking pixels from the internet; use **Show images** on a message when you want remotes for that view only. Inline attachments still show.
- **Group conversations** indents replies in the folder list using Message-ID / In-Reply-To / References (depth up to 8). PEC ricevute that share the same original sit together. There is no separate conversation pane.
- Message actions: reply, reply all, forward, open in a new window, read/unread, flag, priority, mark as spam, not spam, delete. **Shift+click** and **Ctrl+click** select several messages or folders. Delete (toolbar, **Del**, empty folder) moves the selection to Trash. Deleting from Trash is permanent.
- Compose is **plain text**. To, Cc, Bcc are address chips. Attachments are listed on the composer. **Send attachments as ZIP** packs dropped files (optional password). Send uses the selected mailbox’s SMTP identity only.
- Compose is **plain text**. To, Cc, Bcc are address chips. Attachments are listed on the composer. **Send attachments as ZIP** packs dropped files (optional password). Send uses the selected mailbox's SMTP identity only.
HTML uses the OS engine: **WebView2** on Windows (profile under the data folder), **WebKitGTK** on Linux, **WKWebView** on macOS. If the engine is missing, the text body is shown instead.
@ -171,11 +172,11 @@ Fedora: `gtk3 webkit2gtk4.1 libsoup3`.
Get Messages indexes the **whole IMAP folder** into a local SQLite store (`mail.db`) plus `.eml` objects on disk. POP3 only fills Inbox. Indexing runs in the background; the status bar shows progress.
- **Settings → Indices…**: keyword index (FTS) plus optional EmbeddingGemma 300M (~300 MB) meaning index. The ONNX package downloads once into the machine folder (`ProgramData\MaksIT\Postclient\models`, or `/var/lib/maksit/postclient/models` on Linux) and is shared by every account and the sync service. Vectors stay in `mail.db` on this PC. Device: Auto / CPU / GPU (Windows DirectML). Rebuild or repair if search looks wrong. Weights use Google’s Gemma Terms.
- **Settings -> Indices...**: keyword index (FTS) plus optional EmbeddingGemma 300M (~300 MB) meaning index. The ONNX package downloads once into the machine folder (`ProgramData\MaksIT\Postclient\models`, or `/var/lib/maksit/postclient/models` on Linux) and is shared by every account and the sync service. Vectors stay in `mail.db` on this PC. Device: Auto / CPU / GPU (Windows DirectML). Rebuild or repair if search looks wrong. Weights use Google's Gemma Terms.
Search (current folder) covers subject, body, attachment text (PDF and text/XML/HTML/CSV/JSON), practice labels, and meaning when the model is ready. The search box has an × that clears the text. Labels exist only on this PC.
The archive is **only on this PC**. File → Open archive folder / Export archive copies `mail.db` and the `.eml` files. Paths:
The archive is **only on this PC**. File -> Open archive folder / Export archive copies `mail.db` and the `.eml` files. Paths:
| OS | Config | Data |
|--|--|--|
@ -197,27 +198,27 @@ IMAP **QUOTA** from the gestore (when the server supports it) is a thin bar on t
| New store | File menu: empty bucket (`Inbox`, `Drafts`, `Sent Items`, `Deleted Items`) with `postclient.store.json` and `mail.db`. |
| Attach store | File menu: pick a folder that already has `postclient.store.json`. Recovers mail + search after an AppData wipe. |
| Move / Detach store | Copy the whole directory (or unregister it). The folder stays on disk when detached. |
| Retention | **Settings → Retention…**: days per folder (`0` = forever). Expired folder mail is moved to Trash after Get Messages. On Trash, `0` keeps it forever and a number of days permanently deletes older Trash. |
| Background sync | **Settings → Background sync**: a boot-time service (Windows service, Linux system systemd unit). It syncs every saved mailbox with nobody signed in. **Share this account** only lets other people on this PC open that mailbox. |
| Shared account | **Account settings → Share this account with other users on this PC**: that mailbox moves to the machine folder (`ProgramData\MaksIT\Postclient`, or `Public` when ProgramData is not writable; `/var/lib/maksit/postclient` on Linux; `/Users/Shared/MaksIT/Postclient` on macOS). Other users see it when they open Postclient. Passwords are not copied. |
| Easy Migration | **File → Easy Migration → Create bundle…** asks for a passphrase twice in one window, then Save. The `.postbundle` holds mail, accounts, secrets, and the shared model folder. **Open bundle…** restores it into this PC’s Postclient folders and rewrites store paths. Background sync stays off until you turn it on here. |
| Spam marks | **Mark as spam** remembers that message on this PC and teaches every mailbox, even after the message or mailbox is gone. Similar mail is only a hint. **Not spam** drops that memory and returns the message to Inbox. Moving mail into Junk does not teach the filter. **Settings → Indices… → Spam marks…** lists what was learned. |
| Updates and logs | **Help → Check for updates…** asks GitHub for the latest release and can download the package for this system. **Help → Logs** opens a copyable window over the app log folder. **Help → About** lists Info, Privacy, Security, and Support at maks-it.com. |
| Rules | **Settings → Rules**: **Import rules…** reads this app’s JSON export or a legacy `.rwz` file. **Export rules…** writes JSON. Each rule is bound to an **account**; a move folder can live on another mailbox (including a store). They run on Get Messages, on import, and from **Run all rules**. |
| Retention | **Settings -> Retention...**: days per folder (`0` = forever). Expired folder mail is moved to Trash after Get Messages. On Trash, `0` keeps it forever and a number of days permanently deletes older Trash. |
| Background sync | **Settings -> Background sync**: a boot-time service (Windows service, Linux system systemd unit). It syncs every saved mailbox with nobody signed in. **Share this account** only lets other people on this PC open that mailbox. |
| Shared account | **Account settings -> Share this account with other users on this PC**: that mailbox moves to the machine folder (`ProgramData\MaksIT\Postclient`, or `Public` when ProgramData is not writable; `/var/lib/maksit/postclient` on Linux; `/Users/Shared/MaksIT/Postclient` on macOS). Other users see it when they open Postclient. Passwords are not copied. |
| Easy Migration | **File -> Easy Migration -> Create bundle...** asks for a passphrase twice in one window, then Save. The `.postbundle` holds mail, accounts, secrets, and the shared model folder. **Open bundle...** restores it into this PC's Postclient folders and rewrites store paths. Background sync stays off until you turn it on here. |
| Spam marks | **Mark as spam** remembers that message on this PC and teaches every mailbox, even after the message or mailbox is gone. Similar mail is only a hint. **Not spam** drops that memory and returns the message to Inbox. Moving mail into Junk does not teach the filter. **Settings -> Indices... -> Spam marks...** lists what was learned. |
| Updates and logs | **Help -> Check for updates...** asks GitHub for the latest release and can download the package for this system. **Help -> Logs** opens a copyable window over the app log folder. **Help -> About** lists Info, Privacy, Security, and Support at maks-it.com. |
| Rules | **Settings -> Rules**: **Import rules...** reads this app's JSON export or a legacy `.rwz` file. **Export rules...** writes JSON. Each rule is bound to an **account**; a move folder can live on another mailbox (including a store). They run on Get Messages, on import, and from **Run all rules**. |
| Print | Readable HTML of the (optionally unwrapped) message |
| Save PDF | Same content as a PDF |
| Save attachments ZIP | All attachments of the open message |
| Export selected EML (fascicolo) | The original `.eml` files — what a notaio or PA can keep as the message |
| Export selected EML (fascicolo) | The original `.eml` files - what a notaio or PA can keep as the message |
Opened messages are also stored as `.eml` under the data folder. The `.eml` files are the originals; PDF/print are a reading copy.
## Interface language
View → Language: **English**, **Italiano**, **Français**, **Deutsch**, or **Español**. Folder names from the server (Posta in arrivo, Gesendet, Messages envoyés, Enviados, …) are mapped to Inbox/Sent/… in the UI language.
View -> Language: **English**, **Italiano**, **Français**, **Deutsch**, or **Español**. Folder names from the server (Posta in arrivo, Gesendet, Messages envoyés, Enviados, ...) are mapped to Inbox/Sent/... in the UI language.
## Country packs
**Settings → Features** is a checkbox matrix of **certified-mail** UI only. Ordinary IMAP/POP3, search, practice labels, mbox/`.pst` import, and attached data files stay on. Rows are certified-mail features; columns are **Italy**, **Europe**, **France**, **Germany**, **Spain**, **Switzerland**. The header checkbox turns every feature in that column on or off.
**Settings -> Features** is a checkbox matrix of **certified-mail** UI only. Ordinary IMAP/POP3, search, practice labels, mbox/`.pst` import, and attached data files stay on. Rows are certified-mail features; columns are **Italy**, **Europe**, **France**, **Germany**, **Spain**, **Switzerland**. The header checkbox turns every feature in that column on or off.
| Pack | What it unlocks | Default |
|--|--|--|
@ -237,7 +238,7 @@ Turn off a pack to hide its menus, columns, and account presets. Existing mailbo
## Getting started
Install a build from [Releases](https://github.com/MAKS-IT-COM/maksit-postclient/releases): Windows portable zip and setup (`postclient-{version}.exe`, self-contained — no .NET SDK on the PC; default path `C:\Program Files\MaksIT\Postclient`), Linux Flatpak, macOS DMG (`osx-arm64` and `osx-x64`). The Microsoft Store listing will be published soon. WinGet listing comes later from the same setup exe.
Install a build from [Releases](https://github.com/MAKS-IT-COM/maksit-postclient/releases): Windows portable zip and setup (`postclient-{version}.exe`, self-contained - no .NET SDK on the PC; default path `C:\Program Files\MaksIT\Postclient`), Linux Flatpak, macOS DMG (`osx-arm64` and `osx-x64`). Microsoft Store product ID `9N7F29XPK5W4` (listing after first submission). WinGet listing comes later from the same setup exe.
Or from `src/`:
@ -285,7 +286,7 @@ The local archive (`mail.db` and `.eml`) stays on this computer. IMAP and POP3 s
Next to settings (`DPAPI` on Windows, file mode `600` on Linux and macOS), never in `settings.json`. Gmail and Microsoft 365 sign in through Identity Hub. The Hub tokens stay in `secrets.bin`.
**Where are the installers?**
[GitHub Releases](https://github.com/MAKS-IT-COM/maksit-postclient/releases). The Microsoft Store listing will be published soon.
[GitHub Releases](https://github.com/MAKS-IT-COM/maksit-postclient/releases). [Microsoft Store](https://apps.microsoft.com/store/detail/9N7F29XPK5W4?cid=DevShareMCLPCB) (product reserved; public page after submission).
## Tests
@ -309,7 +310,7 @@ Desktop mail client for PEC, REM, and IMAP. Not a QTSP, a new PEC or REM address
## License
Apache 2.0 — see [LICENSE.md](LICENSE.md).
Apache 2.0 - see [LICENSE.md](LICENSE.md).
© Maksym Sadovnychyy (MAKS-IT)

View File

@ -87,6 +87,11 @@
<display_length compare="ge">768</display_length>
</requires>
<releases>
<release version="0.4.4" date="2026-10-29">
<description>
<p>HTML mail blocks remote images by default (Show images for one message). Windows setup upgrades keep sibling assemblies (Identity Hub Contracts). Release also builds a Store MSIX.</p>
</description>
</release>
<release version="0.4.3" date="2026-10-29">
<description>
<p>Gmail and Microsoft sign-in uses Identity Hub IdP-only; mailbox tokens renew automatically while Hub keeps the IdP refresh. What’s New appears after upgrade. Store and Flathub packaging notes are included.</p>

View File

@ -2,7 +2,7 @@
The app UI is English, Italian, French, German, or Spanish. Partner Center needs a Store listing for each of those languages. Paste only the fenced text. The MSIX package language stays `en-us`: the app chooses the UI language at runtime, so the package does not ship satellite resources.
The listing is not in the Store yet. Fill the Store ID in [CONTRIBUTING.md](../../CONTRIBUTING.md) when Partner Center reserves the product.
Partner Center Store ID is `9N7F29XPK5W4` (see [CONTRIBUTING.md](../../CONTRIBUTING.md)). The Store deep link and Web Store URL appear after the product is live.
| Partner Center language | Code | Listing |
|-------------------------|------|---------|

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.4.3</Version>
<Version>0.4.9</Version>
<Product>Postclient</Product>
<AssemblyTitle>Postclient</AssemblyTitle>
<Authors>Maksym Sadovnychyy</Authors>

View File

@ -12,7 +12,9 @@ public static class ServiceCollectionExtensions {
public static IServiceCollection AddPostClient(this IServiceCollection services) {
ArgumentNullException.ThrowIfNull(services);
services.AddIdentityHubIdpClient(IdentityHubAddress.Origin());
services.AddSingleton<ISecretStore, FileSecretStore>();
services.AddSingleton<ISecretStore>(_ => new LayeredSecretStore(
new FileSecretStore(),
new SharedSecretStore()));
services.AddSingleton<ISentReceiptStore, FileSentReceiptStore>();
services.AddSingleton<IMailAuthService, MailAuthService>();
services.AddSingleton<IMailSessionFactory, MailSessionFactory>();

View File

@ -19,7 +19,8 @@ public sealed class ImapMailSession : IMailSession {
private MailAuthMaterial? _material;
private CancellationTokenSource? _alive;
private readonly Dictionary<string, int> _listOffset = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, HashSet<uint>> _listedUids = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, int> _listMisses = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, List<UniqueId>> _listUids = new(StringComparer.OrdinalIgnoreCase);
private const MessageSummaryItems FlagItems =
MessageSummaryItems.UniqueId | MessageSummaryItems.Flags;
@ -69,6 +70,10 @@ public sealed class ImapMailSession : IMailSession {
cancellationToken).ConfigureAwait(false);
await MailKitAuth.AuthenticateAsync(client, account, resolved.Value, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) {
client.Dispose();
throw;
}
catch (Exception ex) {
client.Dispose();
return Result.UnprocessableEntity(MailAuthHint.Incoming(account, ex.Message));
@ -117,6 +122,9 @@ public sealed class ImapMailSession : IMailSession {
return Result<IReadOnlyList<MailFolderInfo>>.Ok(MailFolderCatalog.Normalize(folders, layout));
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result<IReadOnlyList<MailFolderInfo>>.UnprocessableEntity(null, ex.Message);
}
@ -202,53 +210,121 @@ public sealed class ImapMailSession : IMailSession {
});
}
int offset;
HashSet<uint> listed;
lock (_gate) {
_listOffset.TryGetValue(folder, out offset);
if (!_listedUids.TryGetValue(folder, out listed!)) {
listed = [];
_listedUids[folder] = listed;
}
}
var range = MailListBudget.NewestRange(imapFolder.Count, offset, itemBudget);
if (range.Take <= 0) {
ClearListCursor(folder);
return Result<MailFolderSync>.Ok(new MailFolderSync { Present = listed.ToList() });
}
var chunkRows = await FetchRangeAsync(
imapFolder,
range.Start,
range.End,
ListItems,
extraHeaders,
cancellationToken).ConfigureAwait(false);
foreach (var id in IdsOf(chunkRows))
listed.Add(id);
lock (_gate) {
if (range.Incomplete)
_listOffset[folder] = range.NextOffset;
else
ClearListCursor(folder);
}
return Result<MailFolderSync>.Ok(new MailFolderSync {
Headers = HeadersOf(folder, chunkRows),
Present = range.Incomplete ? null : listed.ToList(),
Incomplete = range.Incomplete
});
return await ListMessagesPagedAsync(
folder, imapFolder, extraHeaders, itemBudget, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) {
// Keep the list cursor so the next page can resume after Interrupt / soft cancel.
throw;
}
catch (Exception ex) {
// Keep the list cursor across transient IMAP errors so large folders can finish.
return Result<MailFolderSync>.UnprocessableEntity(null, ex.Message);
}
}
private async Task<Result<MailFolderSync>> ListMessagesPagedAsync(
string folder,
IMailFolder imapFolder,
string[]? extraHeaders,
int itemBudget,
CancellationToken cancellationToken) {
List<UniqueId>? plan;
int offset;
int misses;
lock (_gate) {
_listUids.TryGetValue(folder, out plan);
_listOffset.TryGetValue(folder, out offset);
_listMisses.TryGetValue(folder, out misses);
}
if (plan is null) {
plan = await BuildUidPlanAsync(imapFolder, cancellationToken).ConfigureAwait(false);
lock (_gate) {
_listUids[folder] = plan;
_listOffset[folder] = 0;
_listMisses[folder] = 0;
offset = 0;
misses = 0;
}
}
var range = MailListBudget.UidPage(plan.Count, offset, itemBudget);
if (range.Take <= 0) {
var present = plan.Select(uid => uid.Id).ToList();
ClearListCursor(folder);
return Result<MailFolderSync>.Ok(new MailFolderSync { Present = present });
}
var page = plan.GetRange(range.Start, range.Take);
var chunkRows = await FetchByUidsAsync(imapFolder, page, extraHeaders, cancellationToken)
.ConfigureAwait(false);
var fetched = IdsOf(chunkRows).ToHashSet();
// Count only UIDs from this page so DistinctUids cannot inflate progress.
var fetchedInPage = page.Count(uid => fetched.Contains(uid.Id));
var (nextOffset, nextMisses, incomplete) = MailListBudget.AdvanceUidPage(
plan.Count,
offset,
range.Take,
fetchedInPage,
misses);
lock (_gate) {
if (incomplete) {
_listOffset[folder] = nextOffset;
_listMisses[folder] = nextMisses;
}
else
ClearListCursor(folder);
}
return Result<MailFolderSync>.Ok(new MailFolderSync {
Headers = HeadersOf(folder, chunkRows),
Present = incomplete ? null : plan.Select(uid => uid.Id).ToList(),
Incomplete = incomplete
});
}
private static async Task<List<UniqueId>> BuildUidPlanAsync(
IMailFolder imapFolder,
CancellationToken cancellationToken) {
var searched = await imapFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
var plan = searched.OrderByDescending(uid => uid.Id).ToList();
// Gmail labels sometimes report EXISTS higher than SEARCH ALL. Fill gaps from sequence UIDs.
if (plan.Count >= imapFolder.Count || imapFolder.Count <= 0)
return plan;
try {
var rows = await FetchRangeAsync(
imapFolder,
0,
-1,
MessageSummaryItems.UniqueId,
extraHeaders: null,
cancellationToken).ConfigureAwait(false);
var seen = plan.Select(uid => uid.Id).ToHashSet();
foreach (var row in rows) {
if (!row.UniqueId.IsValid || !seen.Add(row.UniqueId.Id))
continue;
plan.Add(row.UniqueId);
}
plan = plan.OrderByDescending(uid => uid.Id).ToList();
}
catch (OperationCanceledException) {
throw;
}
catch {
}
return plan;
}
private void ClearListCursor(string folder) {
lock (_gate) {
_listOffset.Remove(folder);
_listedUids.Remove(folder);
_listMisses.Remove(folder);
_listUids.Remove(folder);
}
}
@ -358,6 +434,9 @@ public sealed class ImapMailSession : IMailSession {
var uid = await imapFolder.AppendAsync(new AppendRequest(mime), cancellationToken).ConfigureAwait(false);
return Result<uint?>.Ok(uid?.Id);
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result<uint?>.UnprocessableEntity(null, ex.Message);
}
@ -414,17 +493,42 @@ public sealed class ImapMailSession : IMailSession {
else
await CopyThenDeleteAsync(source, dest, uids, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) {
throw;
}
catch (Exception) when (client.Capabilities.HasFlag(ImapCapabilities.Move)) {
await CopyThenDeleteAsync(source, dest, uids, cancellationToken).ConfigureAwait(false);
var remaining = await RemainingUidsAsync(source, uids, cancellationToken).ConfigureAwait(false);
if (remaining.Count > 0)
await CopyThenDeleteAsync(source, dest, remaining, cancellationToken).ConfigureAwait(false);
}
return Result.Ok();
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message);
}
}
private static async Task<IList<UniqueId>> RemainingUidsAsync(
IMailFolder source,
IList<UniqueId> uids,
CancellationToken cancellationToken) {
if (uids.Count == 0)
return uids;
var set = new UniqueIdSet();
foreach (var uid in uids)
set.Add(uid);
var found = await source.SearchAsync(SearchQuery.Uids(set), cancellationToken).ConfigureAwait(false);
if (found.Count == 0)
return Array.Empty<UniqueId>();
if (found.Count == uids.Count)
return uids;
return found.ToList();
}
private static async Task CopyThenDeleteAsync(
IMailFolder source,
IMailFolder dest,
@ -481,6 +585,9 @@ public sealed class ImapMailSession : IMailSession {
catch (Exception ex) when (attempt == 0 && IsBrokenConnection(ex)) {
DropSocket();
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message);
}
@ -518,6 +625,9 @@ public sealed class ImapMailSession : IMailSession {
await parent.CreateAsync(leaf, true, cancellationToken).ConfigureAwait(false);
return Result.Ok();
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message);
}
@ -561,6 +671,9 @@ public sealed class ImapMailSession : IMailSession {
await imapFolder.RenameAsync(parent, leaf, cancellationToken).ConfigureAwait(false);
return Result.Ok();
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message);
}
@ -614,6 +727,9 @@ public sealed class ImapMailSession : IMailSession {
return Result.Ok();
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message);
}
@ -672,6 +788,9 @@ public sealed class ImapMailSession : IMailSession {
return Result.Ok();
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message);
}
@ -707,6 +826,9 @@ public sealed class ImapMailSession : IMailSession {
cancellationToken).ConfigureAwait(false);
return Result.Ok();
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message);
}
@ -839,6 +961,9 @@ public sealed class ImapMailSession : IMailSession {
lock (_gate) {
client = _imap;
_imap = null;
_listOffset.Clear();
_listMisses.Clear();
_listUids.Clear();
}
if (client is null)
@ -1020,6 +1145,9 @@ public sealed class ImapMailSession : IMailSession {
if (rows.Count > 0 || folder.Count == 0)
return rows;
}
catch (OperationCanceledException) {
throw;
}
catch {
}

View File

@ -200,12 +200,12 @@ public sealed class MailSyncEngine {
var page = await session.ListMessagesAsync(folder, known, cancellationToken, MailPipeline.HeaderPage)
.ConfigureAwait(false);
if (!page.IsSuccess)
return true;
return false;
var sync = page.Value ?? new MailFolderSync();
_archive.SetKeywordIndexEnabled(box.Id, box.InitialSyncCompleted);
if (sync.Flags.Count > 0)
_archive.UpdateFlags(box.Id, folder, sync.Flags.Select(f => (f.Id, f.IsSeen, f.IsFlagged)));
_archive.UpsertHeaders(box.Id, sync.Headers.Select(MailArchiveMap.FromHeader));
_archive.UpsertHeaders(box.Id, sync.Headers.Select(MailArchiveMap.FromHeader), updateFlags: false);
if (sync.Present is not null) {
var keep = sync.Present.ToHashSet();
var gone = _archive.Uids(box.Id, folder).Where(uid => !keep.Contains(uid)).ToList();
@ -219,8 +219,9 @@ public sealed class MailSyncEngine {
await ApplyRulesAsync(configuration, box, session, folder, slice, names, cancellationToken)
.ConfigureAwait(false);
}
if (sync.Incomplete && fresh.Count == 0)
return true;
// Newest-first pages often have no fresh UIDs once the top of the folder is archived.
// Incomplete still means older pages remain - keep walking instead of marking the folder done.
return !sync.Incomplete;
}
@ -343,18 +344,30 @@ public sealed class MailSyncEngine {
}
if (read.Count > 0) {
await session.SetMessageFlagsAsync(folder, read, new MailFlagUpdate { Seen = true }, cancellationToken)
var stored = await session
.SetMessageFlagsAsync(folder, read, new MailFlagUpdate { Seen = true }, cancellationToken)
.ConfigureAwait(false);
_archive.UpdateFlags(
box.Id,
folder,
headers.Where(header => read.Contains(header.Id))
.Select(header => (header.Id, true, header.IsFlagged || flagged.Contains(header.Id))));
if (stored.IsSuccess)
_archive.UpdateFlags(
box.Id,
folder,
headers.Where(header => read.Contains(header.Id))
.Select(header => (header.Id, true, header.IsFlagged || flagged.Contains(header.Id))),
respectPending: true);
}
if (flagged.Count > 0)
await session.SetMessageFlagsAsync(folder, flagged, new MailFlagUpdate { Flagged = true }, cancellationToken)
if (flagged.Count > 0) {
var stored = await session
.SetMessageFlagsAsync(folder, flagged, new MailFlagUpdate { Flagged = true }, cancellationToken)
.ConfigureAwait(false);
if (stored.IsSuccess)
_archive.UpdateFlags(
box.Id,
folder,
headers.Where(header => flagged.Contains(header.Id))
.Select(header => (header.Id, header.IsSeen || read.Contains(header.Id), true)),
respectPending: true);
}
}
private async Task RetentionAsync(

View File

@ -55,6 +55,10 @@ public sealed class Pop3MailSession : IMailSession {
cancellationToken).ConfigureAwait(false);
await MailKitAuth.AuthenticateAsync(client, account, resolved.Value, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) {
client.Dispose();
throw;
}
catch (Exception ex) {
client.Dispose();
return Result.UnprocessableEntity(MailAuthHint.Incoming(account, ex.Message));
@ -159,6 +163,9 @@ public sealed class Pop3MailSession : IMailSession {
Incomplete = true
});
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result<MailFolderSync>.UnprocessableEntity(null, ex.Message);
}
@ -180,10 +187,11 @@ public sealed class Pop3MailSession : IMailSession {
if (client is null)
return Result<MailMessageBody>.UnprocessableEntity(null, "Not connected.");
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
using var abort = linked.Token.Register(DropSocket);
if (!interactive)
linked.CancelAfter(MailFetch.BackfillTimeout);
using var timeout = interactive ? null : new CancellationTokenSource(MailFetch.BackfillTimeout);
using var linked = timeout is null
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
using var abort = timeout is null ? default : timeout.Token.Register(DropSocket);
MimeMessage mime;
try {
var index = (int)id;

View File

@ -73,6 +73,9 @@ public sealed class PstMailSession : IMailSession {
OpenStore(path);
return Task.FromResult(Result.Ok());
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Task.FromResult(Result.UnprocessableEntity(WriteError(ex)));
}
@ -104,6 +107,9 @@ public sealed class PstMailSession : IMailSession {
return Task.FromResult(Result<IReadOnlyList<MailFolderInfo>>.Ok(
MailFolderCatalog.Normalize(folders, MailFolderLayout.Store)));
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Task.FromResult(Result<IReadOnlyList<MailFolderInfo>>.UnprocessableEntity(null, ex.Message));
}
@ -279,6 +285,9 @@ public sealed class PstMailSession : IMailSession {
Incomplete = incomplete
});
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result<MailFolderSync>.UnprocessableEntity(null, ex.Message);
}
@ -329,6 +338,9 @@ public sealed class PstMailSession : IMailSession {
using var stream = new MemoryStream(eml, writable: false);
mime = await MimeMessage.LoadAsync(stream, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result<uint?>.UnprocessableEntity(null, ex.Message);
}
@ -603,6 +615,15 @@ public sealed class PstMailSession : IMailSession {
if (!copy.IsSuccess)
return copy;
}
catch (OperationCanceledException) {
try {
OpenStore(path);
}
catch {
}
throw;
}
catch (Exception ex) {
try {
OpenStore(path);

View File

@ -12,11 +12,11 @@
<ItemGroup>
<PackageReference Include="MailKit" Version="4.18.2" />
<PackageReference Include="MaksIT.Core" Version="1.6.14" />
<PackageReference Include="MaksIT.IdentityHub.Client" Version="3.1.3" />
<PackageReference Include="MaksIT.IdentityHub.Client" Version="4.0.0" />
<PackageReference Include="MaksIT.Results" Version="2.1.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.14" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.14" />
<PackageReference Include="OfficeIMO.Email" Version="3.5.2" />
<PackageReference Include="OfficeIMO.Email" Version="3.5.3" />
<PackageReference Include="PdfPig" Version="0.1.16" />
<PackageReference Include="SharpZipLib" Version="1.4.2" />
<PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.1" />

View File

@ -118,8 +118,11 @@ public sealed class MailArchiveCatalog : IDisposable {
return store;
}
public void UpsertHeaders(string mailboxId, IEnumerable<MailArchiveHeader> headers) =>
OffUi(() => Use(mailboxId, store => store.UpsertHeaders(mailboxId, headers)));
public void UpsertHeaders(
string mailboxId,
IEnumerable<MailArchiveHeader> headers,
bool updateFlags = false) =>
OffUi(() => Use(mailboxId, store => store.UpsertHeaders(mailboxId, headers, updateFlags)));
public void SetKeywordIndexEnabled(string mailboxId, bool enabled) =>
OffUi(() => {
@ -146,8 +149,27 @@ public sealed class MailArchiveCatalog : IDisposable {
public void UpdateFlags(
string mailboxId,
string folder,
IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags) =>
OffUi(() => Use(mailboxId, store => store.UpdateFlags(mailboxId, folder, flags)));
IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags,
bool respectPending = true) =>
OffUi(() => Use(mailboxId, store => store.UpdateFlags(mailboxId, folder, flags, respectPending)));
public void SetLocalFlags(
string mailboxId,
string folder,
IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags,
bool pending = true) =>
OffUi(() => Use(mailboxId, store => store.SetLocalFlags(mailboxId, folder, flags, pending)));
public void ClearPendingFlags(
string mailboxId,
string folder,
IEnumerable<uint> uids) =>
OffUi(() => Use(mailboxId, store => store.ClearPendingFlags(mailboxId, folder, uids)));
public IReadOnlyList<(string Folder, uint Uid, bool IsSeen, bool IsFlagged)> ListAllPendingFlags(
string mailboxId) =>
OffUi(() => TryGet(mailboxId)?.ListAllPendingFlags(mailboxId)
?? (IReadOnlyList<(string Folder, uint Uid, bool IsSeen, bool IsFlagged)>)[]);
public void UpsertBody(
string mailboxId,

View File

@ -23,12 +23,25 @@ public sealed partial class MailArchiveStore : IDisposable {
EnsureSchema();
}
public void UpsertHeaders(string mailboxId, IEnumerable<MailArchiveHeader> headers) {
public void UpsertHeaders(
string mailboxId,
IEnumerable<MailArchiveHeader> headers,
bool updateFlags = false) {
ArgumentNullException.ThrowIfNull(headers);
lock (_gate) {
using var tx = _db.BeginTransaction();
foreach (var header in headers)
UpsertHeader(tx, mailboxId, header, updateFlags: true);
foreach (var header in headers) {
var applyFlags = updateFlags;
if (applyFlags && TryReadPending(tx, mailboxId, header.Folder, header.Uid, out var pending)) {
if (pending.IsSeen == header.IsSeen && pending.IsFlagged == header.IsFlagged)
ClearPending(tx, mailboxId, header.Folder, header.Uid);
else
applyFlags = false;
}
UpsertHeader(tx, mailboxId, header, updateFlags: applyFlags);
}
tx.Commit();
}
}
@ -36,11 +49,19 @@ public sealed partial class MailArchiveStore : IDisposable {
public void UpdateFlags(
string mailboxId,
string folder,
IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags) {
IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags,
bool respectPending = true) {
ArgumentNullException.ThrowIfNull(flags);
lock (_gate) {
using var tx = _db.BeginTransaction();
foreach (var row in flags) {
if (respectPending && TryReadPending(tx, mailboxId, folder, row.Uid, out var pending)) {
if (pending.IsSeen == row.IsSeen && pending.IsFlagged == row.IsFlagged)
ClearPending(tx, mailboxId, folder, row.Uid);
else
continue;
}
using var cmd = _db.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = """
@ -54,12 +75,83 @@ public sealed partial class MailArchiveStore : IDisposable {
cmd.Parameters.AddWithValue("$f", folder);
cmd.Parameters.AddWithValue("$u", row.Uid);
cmd.ExecuteNonQuery();
if (!respectPending)
ClearPending(tx, mailboxId, folder, row.Uid);
}
tx.Commit();
}
}
public void SetLocalFlags(
string mailboxId,
string folder,
IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags,
bool pending = true) {
ArgumentNullException.ThrowIfNull(flags);
lock (_gate) {
using var tx = _db.BeginTransaction();
foreach (var row in flags) {
using (var cmd = _db.CreateCommand()) {
cmd.Transaction = tx;
cmd.CommandText = """
UPDATE messages
SET is_seen = $seen, is_flagged = $flag
WHERE mailbox_id = $m AND folder = $f AND uid = $u;
""";
cmd.Parameters.AddWithValue("$seen", row.IsSeen ? 1 : 0);
cmd.Parameters.AddWithValue("$flag", row.IsFlagged ? 1 : 0);
cmd.Parameters.AddWithValue("$m", mailboxId);
cmd.Parameters.AddWithValue("$f", folder);
cmd.Parameters.AddWithValue("$u", row.Uid);
cmd.ExecuteNonQuery();
}
if (pending)
UpsertPending(tx, mailboxId, folder, row.Uid, row.IsSeen, row.IsFlagged);
else
ClearPending(tx, mailboxId, folder, row.Uid);
}
tx.Commit();
}
}
public void ClearPendingFlags(
string mailboxId,
string folder,
IEnumerable<uint> uids) {
ArgumentNullException.ThrowIfNull(uids);
lock (_gate) {
using var tx = _db.BeginTransaction();
foreach (var uid in uids)
ClearPending(tx, mailboxId, folder, uid);
tx.Commit();
}
}
public IReadOnlyList<(string Folder, uint Uid, bool IsSeen, bool IsFlagged)> ListAllPendingFlags(
string mailboxId) {
lock (_gate) {
using var cmd = _db.CreateCommand();
cmd.CommandText = """
SELECT folder, uid, is_seen, is_flagged
FROM pending_flags
WHERE mailbox_id = $m;
""";
cmd.Parameters.AddWithValue("$m", mailboxId);
var rows = new List<(string Folder, uint Uid, bool IsSeen, bool IsFlagged)>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
rows.Add((
reader.GetString(0),
(uint)reader.GetInt64(1),
reader.GetInt64(2) != 0,
reader.GetInt64(3) != 0));
return rows;
}
}
public void UpsertBody(
string mailboxId,
MailArchiveHeader header,
@ -1297,6 +1389,14 @@ public sealed partial class MailArchiveStore : IDisposable {
fingerprint TEXT PRIMARY KEY,
dismissed_utc TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS pending_flags (
mailbox_id TEXT NOT NULL,
folder TEXT NOT NULL,
uid INTEGER NOT NULL,
is_seen INTEGER NOT NULL,
is_flagged INTEGER NOT NULL,
PRIMARY KEY (mailbox_id, folder, uid)
);
""";
cmd.ExecuteNonQuery();
EnsureColumn("messages", "trashed_utc", "TEXT NOT NULL DEFAULT ''");
@ -1304,6 +1404,73 @@ public sealed partial class MailArchiveStore : IDisposable {
EnsureColumn("messages", "spam_score", "REAL");
}
private static bool TryReadPending(
SqliteTransaction tx,
string mailboxId,
string folder,
uint uid,
out (bool IsSeen, bool IsFlagged) pending) {
using var cmd = tx.Connection!.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = """
SELECT is_seen, is_flagged
FROM pending_flags
WHERE mailbox_id = $m AND folder = $f AND uid = $u;
""";
cmd.Parameters.AddWithValue("$m", mailboxId);
cmd.Parameters.AddWithValue("$f", folder);
cmd.Parameters.AddWithValue("$u", uid);
using var reader = cmd.ExecuteReader();
if (!reader.Read()) {
pending = default;
return false;
}
pending = (reader.GetInt64(0) != 0, reader.GetInt64(1) != 0);
return true;
}
private static void UpsertPending(
SqliteTransaction tx,
string mailboxId,
string folder,
uint uid,
bool isSeen,
bool isFlagged) {
using var cmd = tx.Connection!.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = """
INSERT INTO pending_flags (mailbox_id, folder, uid, is_seen, is_flagged)
VALUES ($m, $f, $u, $seen, $flag)
ON CONFLICT(mailbox_id, folder, uid) DO UPDATE SET
is_seen = excluded.is_seen,
is_flagged = excluded.is_flagged;
""";
cmd.Parameters.AddWithValue("$m", mailboxId);
cmd.Parameters.AddWithValue("$f", folder);
cmd.Parameters.AddWithValue("$u", uid);
cmd.Parameters.AddWithValue("$seen", isSeen ? 1 : 0);
cmd.Parameters.AddWithValue("$flag", isFlagged ? 1 : 0);
cmd.ExecuteNonQuery();
}
private static void ClearPending(
SqliteTransaction tx,
string mailboxId,
string folder,
uint uid) {
using var cmd = tx.Connection!.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = """
DELETE FROM pending_flags
WHERE mailbox_id = $m AND folder = $f AND uid = $u;
""";
cmd.Parameters.AddWithValue("$m", mailboxId);
cmd.Parameters.AddWithValue("$f", folder);
cmd.Parameters.AddWithValue("$u", uid);
cmd.ExecuteNonQuery();
}
private void EnsureColumn(string table, string column, string definition) {
using var info = _db.CreateCommand();
info.CommandText = "PRAGMA table_info(" + table + ");";

View File

@ -6,8 +6,9 @@ namespace MaksIT.PostClient.Shared.Archive;
/// <summary>
/// Moves one opted-in mailbox between the user profile and the shared folder.
/// The account file has no password. Each user keeps their own secret.
/// Moves one mailbox between the user profile and the shared folder when Shared toggles.
/// Shared=true publishes under ProgramData; Shared=false keeps mail under the user profile.
/// Mailbox password and OAuth tokens move with the share flag via SharedSecretStore.
/// </summary>
public static class SharedMailboxStore {
private static readonly JsonSerializerOptions Json = new() {
@ -56,16 +57,33 @@ public static class SharedMailboxStore {
ArgumentNullException.ThrowIfNull(account);
if (account.IsLocalStore)
return Result.BadRequest("A local store keeps the folder you chose.");
if (string.IsNullOrWhiteSpace(privateRoot))
return Result.BadRequest("A private account folder is required.");
account.Shared = shared;
account.ArchiveStoreId = "";
var central = SharedMailPaths.AccountDirectory(account.Id);
if (!privateRoot.Equals(central, StringComparison.OrdinalIgnoreCase)) {
var moved = Move(privateRoot, central);
if (shared) {
if (!privateRoot.Equals(central, StringComparison.OrdinalIgnoreCase)) {
var moved = Move(privateRoot, central, replaceDest: true);
if (!moved.IsSuccess)
return moved;
}
return Publish(account);
}
if (!central.Equals(privateRoot, StringComparison.OrdinalIgnoreCase)
&& Directory.Exists(central)
&& Directory.GetFileSystemEntries(central).Length > 0) {
var moved = Move(central, privateRoot, replaceDest: true);
if (!moved.IsSuccess)
return moved;
}
account.ArchiveStoreId = "";
return Publish(account);
DropLocalAccountFile(privateRoot);
RemovePublished(account.Id);
return Result.Ok();
}
public static Result Publish(MailboxAccount account) {
@ -90,6 +108,16 @@ public static class SharedMailboxStore {
}
}
private static void DropLocalAccountFile(string privateRoot) {
try {
var file = Path.Combine(privateRoot, "account.json");
if (File.Exists(file))
File.Delete(file);
}
catch {
}
}
private static void RemovePublished(string mailboxId) {
var file = SharedMailPaths.AccountFile(mailboxId);
if (File.Exists(file))
@ -99,23 +127,31 @@ public static class SharedMailboxStore {
Directory.Delete(dir);
}
private static Result Move(string source, string dest) {
private static Result Move(string source, string dest, bool replaceDest) {
try {
if (source.Equals(dest, StringComparison.OrdinalIgnoreCase))
return Result.Ok();
if (!Directory.Exists(source)) {
Directory.CreateDirectory(dest);
ShareWithOtherUsers(dest);
if (IsSharedRoot(dest))
ShareWithOtherUsers(dest);
return Result.Ok();
}
if (Directory.Exists(dest) && Directory.GetFileSystemEntries(dest).Length > 0)
return Result.BadRequest("The shared folder already has mail for this account.");
if (Directory.Exists(dest) && Directory.GetFileSystemEntries(dest).Length > 0) {
if (!replaceDest)
return Result.BadRequest("The destination folder already has mail for this account.");
Directory.Delete(dest, recursive: true);
}
var parent = Path.GetDirectoryName(dest);
if (!string.IsNullOrWhiteSpace(parent))
Directory.CreateDirectory(parent);
if (Directory.Exists(dest))
Directory.Delete(dest);
Directory.Move(source, dest);
ShareWithOtherUsers(dest);
if (IsSharedRoot(dest))
ShareWithOtherUsers(dest);
return Result.Ok();
}
catch (Exception ex) {
@ -123,6 +159,11 @@ public static class SharedMailboxStore {
}
}
private static bool IsSharedRoot(string directory) {
var root = SharedMailPaths.Root();
return directory.StartsWith(root, StringComparison.OrdinalIgnoreCase);
}
private static void ShareWithOtherUsers(string directory) {
if (!Directory.Exists(directory))
return;

View File

@ -4,6 +4,7 @@ namespace MaksIT.PostClient.Shared.Auth;
public static class IdentityHubAddress {
public const string Env = "POSTCLIENT_IDENTITY_HUB";
public const string Production = "https://identity.maks-it.com";
public const string CoveredApplicationId = "MaksIT.PostClient";
public static string Origin() {
var fromEnv = Environment.GetEnvironmentVariable(Env);
@ -16,7 +17,9 @@ public static class IdentityHubAddress {
var provider = MailAuthKind.Normalize(authKind) == MailAuthKind.Microsoft
? "Microsoft"
: "Google";
return Origin() + "/desktop-login?provider=" + provider;
return Origin()
+ "/desktop-login?provider=" + Uri.EscapeDataString(provider)
+ "&app=" + Uri.EscapeDataString(CoveredApplicationId);
}
public static bool IsHubUrl(Uri? uri) {

View File

@ -0,0 +1,50 @@
using MaksIT.Results;
namespace MaksIT.PostClient.Shared.Auth;
/// <summary>
/// User profile secrets first; falls back to the machine shared store for published mailboxes.
/// Writes go to the user store; when a key is already published, the shared copy is refreshed too.
/// </summary>
public sealed class LayeredSecretStore : ISecretStore {
private readonly ISecretStore _user;
private readonly ISecretStore _shared;
public LayeredSecretStore(ISecretStore user, ISecretStore shared) {
_user = user ?? throw new ArgumentNullException(nameof(user));
_shared = shared ?? throw new ArgumentNullException(nameof(shared));
}
public Result Put(string key, string secret) {
var saved = _user.Put(key, secret);
if (!saved.IsSuccess)
return saved;
var published = _shared.Get(key);
if (published.IsSuccess && !string.IsNullOrWhiteSpace(published.Value))
return _shared.Put(key, secret);
return saved;
}
public Result<string?> Get(string key) {
var user = _user.Get(key);
if (user.IsSuccess && !string.IsNullOrWhiteSpace(user.Value))
return user;
return _shared.Get(key);
}
public Result Delete(string key) {
_user.Delete(key);
_shared.Delete(key);
return Result.Ok();
}
/// <summary>Moves mailbox password and OAuth tokens into the shared store.</summary>
public Result PublishMailbox(string mailboxId) =>
SharedSecretStore.PublishMailbox(_user, mailboxId);
/// <summary>Moves mailbox password and OAuth tokens back into the user store.</summary>
public Result WithdrawMailbox(string mailboxId) =>
SharedSecretStore.WithdrawMailbox(_user, mailboxId);
}

View File

@ -8,9 +8,8 @@ namespace MaksIT.PostClient.Shared.Auth;
/// <summary>
/// Secrets for mailboxes the machine service syncs.
/// Windows protects them with the machine key so LocalService can read them after logoff.
/// The file is not granted to every user.
/// Machine-scoped secrets for shared mailboxes and the sync service.
/// Windows protects them with the machine key so LocalService and other users on this PC can read them.
/// </summary>
public sealed class SharedSecretStore : ISecretStore {
public static string FilePath() =>
@ -31,9 +30,57 @@ public sealed class SharedSecretStore : ISecretStore {
public Result Delete(string key) =>
_inner.Delete(key);
/// <summary>
/// Moves mailbox password and OAuth tokens from the user store into the shared store.
/// </summary>
public static Result PublishMailbox(ISecretStore user, string mailboxId) {
ArgumentNullException.ThrowIfNull(user);
if (string.IsNullOrWhiteSpace(mailboxId))
return Result.BadRequest("Mailbox id is required.");
var shared = new SharedSecretStore();
foreach (var key in MailboxKeys(mailboxId)) {
var stored = user.Get(key);
if (!stored.IsSuccess || string.IsNullOrWhiteSpace(stored.Value))
continue;
var saved = shared.Put(key, stored.Value);
if (!saved.IsSuccess)
return saved;
user.Delete(key);
}
return Result.Ok();
}
/// <summary>
/// Moves mailbox password and OAuth tokens from the shared store back into the user store.
/// </summary>
public static Result WithdrawMailbox(ISecretStore user, string mailboxId) {
ArgumentNullException.ThrowIfNull(user);
if (string.IsNullOrWhiteSpace(mailboxId))
return Result.BadRequest("Mailbox id is required.");
var shared = new SharedSecretStore();
foreach (var key in MailboxKeys(mailboxId)) {
var stored = shared.Get(key);
if (stored.IsSuccess && !string.IsNullOrWhiteSpace(stored.Value)) {
var saved = user.Put(key, stored.Value);
if (!saved.IsSuccess)
return saved;
}
shared.Delete(key);
}
return Result.Ok();
}
public static void Protect(string path) =>
Harden(path);
private static IEnumerable<string> MailboxKeys(string mailboxId) {
yield return FileSecretStore.MailboxKey(mailboxId);
yield return FileSecretStore.OAuthKey(mailboxId);
}
private static void Harden(string path) {
try {
if (!File.Exists(path))
@ -48,6 +95,7 @@ public sealed class SharedSecretStore : ISecretStore {
var info = new FileInfo(path);
var security = info.GetAccessControl();
security.AddAccessRule(ServiceRule(FileSystemRights.Modify));
security.AddAccessRule(UsersRule(FileSystemRights.ReadAndExecute));
info.SetAccessControl(security);
}
catch {
@ -61,4 +109,12 @@ public sealed class SharedSecretStore : ISecretStore {
InheritanceFlags.None,
PropagationFlags.None,
AccessControlType.Allow);
private static FileSystemAccessRule UsersRule(FileSystemRights rights) =>
new(
new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null),
rights,
InheritanceFlags.None,
PropagationFlags.None,
AccessControlType.Allow);
}

View File

@ -21,6 +21,8 @@ public sealed class Configuration {
public bool GroupConversations { get; set; } = true;
public bool BlockRemoteContent { get; set; } = true;
public string Language { get; set; } = "";
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]

View File

@ -16,25 +16,31 @@ public static class MessageHtml {
private static readonly Regex Head = new(
@"<head[^>]*>",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private const string Csp =
private static readonly Regex RemoteRef = new(
@"\b(?:src|srcset|poster|background)\s*=\s*[""']?\s*(?:https?:|//)|url\(\s*['""]?\s*(?:https?:|//)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private const string CspAllow =
"""<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: https: http:; style-src 'unsafe-inline'; font-src data: https:; media-src data:">""";
private const string CspBlock =
"""<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; media-src data:">""";
private const string Style =
"<style>html{color-scheme:light;background:#fff;} img{max-width:100%;height:auto;} a{color:#1565c0;}</style>";
public static string Document(string? html) {
public static string Document(string? html, bool allowRemoteContent = true) {
var body = (html ?? "").Trim();
var csp = allowRemoteContent ? CspAllow : CspBlock;
string document;
if (LooksLikeDocument(body)) {
document = Head.IsMatch(body)
? Head.Replace(body, m => m.Value + Csp + Style, 1)
: Csp + Style + body;
? Head.Replace(body, m => m.Value + csp + Style, 1)
: csp + Style + body;
}
else {
document = """
<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
""" + Csp + Style + """
""" + csp + Style + """
</head>
<body style="margin:12px;background:#fff;color:#222;font:14px/1.45 Segoe UI, Ubuntu, sans-serif;">
""" + body + "</body></html>";
@ -62,6 +68,12 @@ public static class MessageHtml {
return LooksLikeDocument(html) || Markup.IsMatch(html);
}
public static bool HasRemoteContent(string? html) {
if (string.IsNullOrWhiteSpace(html))
return false;
return RemoteRef.IsMatch(html);
}
public static string FromPlain(string? text) {
if (string.IsNullOrWhiteSpace(text))
return "";

View File

@ -278,6 +278,14 @@ public sealed partial class UiCopy {
public string GroupConversationsTip { get; init; } = "";
public string BlockRemoteContent { get; init; } = "";
public string BlockRemoteContentTip { get; init; } = "";
public string ShowRemoteImages { get; init; } = "";
public string RemoteContentBlocked { get; init; } = "";
public string ListAboveReading { get; init; } = "";
public string ThreeColumns { get; init; } = "";
@ -324,6 +332,10 @@ public sealed partial class UiCopy {
public string Folders { get; init; } = "";
public string AllInboxes { get; init; } = "";
public string AllInboxesHint { get; init; } = "";
public string SearchPlaceholder { get; init; } = "";
public string ClearSearch { get; init; } = "";
@ -809,6 +821,10 @@ public sealed partial class UiCopy {
UnwrapEnvelope = "Unwrap PEC/REM envelope",
GroupConversations = "Group conversations",
GroupConversationsTip = "Indent replies in this folder. Message-ID / In-Reply-To / References — not a conversation pane.",
BlockRemoteContent = "Block remote content",
BlockRemoteContentTip = "Do not load images or tracking pixels from the internet. Inline attachments still show.",
ShowRemoteImages = "Show images",
RemoteContentBlocked = "Remote images are blocked so open tracking cannot run.",
ListAboveReading = "List above reading",
ThreeColumns = "Three columns",
ThreeColumnsTip = "Folders, list, and reading side by side",
@ -832,6 +848,8 @@ public sealed partial class UiCopy {
Delete = "Delete",
MoveTo = "Move To",
Folders = "Folders",
AllInboxes = "All inboxes",
AllInboxesHint = "Only each account's Inbox - not labels or other folders - so this list stays about what needs attention across mailboxes.",
SearchPlaceholder = "Search subject, body, attachments, labels, meaning",
ClearSearch = "Clear search",
Label = "Label",
@ -871,7 +889,7 @@ public sealed partial class UiCopy {
RemoveAccount = "Remove Account",
AccountHint = "Gmail and Outlook use OAuth when you Sign in. Italian PEC and EU QTSP mailboxes fill IMAP/SMTP. Other accounts use IMAP/POP3 and a password (DPAPI on Windows, mode 600 on Linux).",
ShareAccount = "Share this account with other users on this PC",
ShareAccountHint = "Other people on this PC can open this mailbox. The sync service already keeps every mailbox in {0}, with or without this box checked.",
ShareAccountHint = "Mail and sign-in secrets move to {0}. Other people on this PC can open this mailbox without signing in again.",
DisplayName = "Display name",
Address = "Address",
Username = "Username (if different)",
@ -1111,6 +1129,10 @@ public sealed partial class UiCopy {
UnwrapEnvelope = "Apri busta PEC/REM",
GroupConversations = "Raggruppa conversazioni",
GroupConversationsTip = "Indenta le risposte in questa cartella. Message-ID / In-Reply-To / References — non un riquadro conversazioni.",
BlockRemoteContent = "Blocca contenuto remoto",
BlockRemoteContentTip = "Non carica immagini o pixel di tracking da Internet. Gli allegati incorporati restano visibili.",
ShowRemoteImages = "Mostra immagini",
RemoteContentBlocked = "Le immagini remote sono bloccate per evitare il tracking di apertura.",
ListAboveReading = "Elenco sopra la lettura",
ThreeColumns = "Tre colonne",
ThreeColumnsTip = "Cartelle, elenco e lettura affiancati",
@ -1134,6 +1156,8 @@ public sealed partial class UiCopy {
Delete = "Elimina",
MoveTo = "Sposta in",
Folders = "Cartelle",
AllInboxes = "Tutte le caselle",
AllInboxesHint = "Solo la Posta in arrivo di ogni account - non etichette o altre cartelle - cosi l'elenco resta su cio che richiede attenzione tra le caselle.",
SearchPlaceholder = "Cerca oggetto, corpo, allegati, etichette, significato",
ClearSearch = "Cancella ricerca",
Label = "Etichetta",
@ -1173,7 +1197,7 @@ public sealed partial class UiCopy {
RemoveAccount = "Rimuovi account",
AccountHint = "Gmail e Outlook usano OAuth con Accedi. PEC italiane e caselle QTSP UE compilano IMAP/SMTP. Gli altri account usano IMAP/POP3 e una password (DPAPI su Windows, mode 600 su Linux).",
ShareAccount = "Condividi questo account con gli altri utenti di questo PC",
ShareAccountHint = "Le altre persone su questo PC possono aprire questa casella. Il servizio di sync tiene già ogni casella in {0}, con o senza questa opzione.",
ShareAccountHint = "La posta e i segreti di accesso si spostano in {0}. Le altre persone su questo PC possono aprire questa casella senza accedere di nuovo.",
DisplayName = "Nome visualizzato",
Address = "Indirizzo",
Username = "Nome utente (se diverso)",

View File

@ -142,6 +142,11 @@ public sealed partial class UiCopy {
UnwrapEnvelope = "PEC-/REM-Umschlag öffnen",
GroupConversations = "Unterhaltungen gruppieren",
GroupConversationsTip = "Rückt Antworten in diesem Ordner ein. Message-ID / In-Reply-To / References — kein Unterhaltungsfenster.",
BlockRemoteContent = "Externe Inhalte blockieren",
BlockRemoteContentTip = "Lädt keine Bilder oder Tracking-Pixel aus dem Internet. Eingebettete Anhänge bleiben sichtbar.",
ShowRemoteImages = "Bilder anzeigen",
RemoteContentBlocked = "Externe Bilder sind blockiert, damit Öffnungs-Tracking nicht läuft.",
ListAboveReading = "Liste über dem Lesen",
ThreeColumns = "Drei Spalten",
ThreeColumnsTip = "Ordner, Liste und Lesen nebeneinander",
@ -165,6 +170,8 @@ public sealed partial class UiCopy {
Delete = "Löschen",
MoveTo = "Verschieben nach",
Folders = "Ordner",
AllInboxes = "Alle Posteingange",
AllInboxesHint = "Nur der Posteingang jedes Kontos - keine Labels oder anderen Ordner - damit die Liste bei dem bleibt, was uber Konten hinweg Aufmerksamkeit braucht.",
SearchPlaceholder = "Betreff, Text, Anlagen, Kennzeichen, Bedeutung suchen",
ClearSearch = "Suche leeren",
Label = "Kennzeichen",
@ -204,7 +211,7 @@ public sealed partial class UiCopy {
RemoveAccount = "Konto entfernen",
AccountHint = "Gmail und Microsoft 365 nutzen OAuth bei der Anmeldung. Italienische PEC- und EU-QTSP-Postfächer füllen IMAP/SMTP. Andere Konten nutzen IMAP/POP3 und ein Passwort (DPAPI unter Windows, Modus 600 unter Linux).",
ShareAccount = "Dieses Konto mit anderen Benutzern dieses PCs teilen",
ShareAccountHint = "Die Post zieht nach {0}. Andere Konten auf diesem PC können sie öffnen. Das Passwort bleibt in Ihrer eigenen Geheimdatei; jede Person meldet sich selbst an.",
ShareAccountHint = "Post und Anmeldegeheimnisse ziehen nach {0}. Andere Konten auf diesem PC können das Postfach ohne erneute Anmeldung öffnen.",
DisplayName = "Anzeigename",
Address = "Adresse",
Username = "Benutzername (falls abweichend)",

View File

@ -142,6 +142,11 @@ public sealed partial class UiCopy {
UnwrapEnvelope = "Abrir sobre PEC/REM",
GroupConversations = "Agrupar conversaciones",
GroupConversationsTip = "Sangra las respuestas en esta carpeta. Message-ID / In-Reply-To / References — no un panel de conversación.",
BlockRemoteContent = "Bloquear contenido remoto",
BlockRemoteContentTip = "No carga imágenes ni píxeles de seguimiento de Internet. Los adjuntos incrustados siguen visibles.",
ShowRemoteImages = "Mostrar imágenes",
RemoteContentBlocked = "Las imágenes remotas están bloqueadas para evitar el seguimiento de apertura.",
ListAboveReading = "Lista encima de la lectura",
ThreeColumns = "Tres columnas",
ThreeColumnsTip = "Carpetas, lista y lectura en paralelo",
@ -165,6 +170,8 @@ public sealed partial class UiCopy {
Delete = "Eliminar",
MoveTo = "Mover a",
Folders = "Carpetas",
AllInboxes = "Todas las bandejas",
AllInboxesHint = "Solo la bandeja de entrada de cada cuenta - no etiquetas u otras carpetas - para que la lista se centre en lo que requiere atencion entre cuentas.",
SearchPlaceholder = "Buscar asunto, cuerpo, adjuntos, etiquetas, significado",
ClearSearch = "Borrar búsqueda",
Label = "Etiqueta",
@ -204,7 +211,7 @@ public sealed partial class UiCopy {
RemoveAccount = "Quitar cuenta",
AccountHint = "Gmail y Microsoft 365 usan OAuth al iniciar sesión. Los buzones PEC italianos y QTSP de la UE rellenan IMAP/SMTP. Las demás cuentas usan IMAP/POP3 y una contraseña (DPAPI en Windows, modo 600 en Linux).",
ShareAccount = "Compartir esta cuenta con los demás usuarios de este PC",
ShareAccountHint = "El correo se mueve a {0}. Las demás cuentas de este PC pueden abrirlo. La contraseña sigue en su archivo secreto; cada persona inicia sesión.",
ShareAccountHint = "El correo y los secretos de acceso se mueven a {0}. Las demás cuentas de este PC pueden abrir el buzón sin volver a iniciar sesión.",
DisplayName = "Nombre para mostrar",
Address = "Dirección",
Username = "Nombre de usuario (si es distinto)",

View File

@ -142,6 +142,11 @@ public sealed partial class UiCopy {
UnwrapEnvelope = "Ouvrir l’enveloppe PEC/REM",
GroupConversations = "Grouper les conversations",
GroupConversationsTip = "Indente les réponses dans ce dossier. Message-ID / In-Reply-To / References — pas un volet conversation.",
BlockRemoteContent = "Bloquer le contenu distant",
BlockRemoteContentTip = "Ne charge pas les images ni les pixels de suivi depuis Internet. Les pièces jointes intégrées restent visibles.",
ShowRemoteImages = "Afficher les images",
RemoteContentBlocked = "Les images distantes sont bloquées pour empêcher le suivi d’ouverture.",
ListAboveReading = "Liste au-dessus de la lecture",
ThreeColumns = "Trois colonnes",
ThreeColumnsTip = "Dossiers, liste et lecture côte à côte",
@ -165,6 +170,8 @@ public sealed partial class UiCopy {
Delete = "Supprimer",
MoveTo = "Déplacer vers",
Folders = "Dossiers",
AllInboxes = "Toutes les boites",
AllInboxesHint = "Uniquement la boite de reception de chaque compte - pas les libelles ni les autres dossiers - pour garder la liste sur ce qui demande de l'attention.",
SearchPlaceholder = "Rechercher objet, corps, pièces, étiquettes, sens",
ClearSearch = "Effacer la recherche",
Label = "Étiquette",
@ -204,7 +211,7 @@ public sealed partial class UiCopy {
RemoveAccount = "Supprimer le compte",
AccountHint = "Gmail et Microsoft 365 utilisent OAuth lorsque vous vous connectez. Les boîtes PEC italiennes et QTSP UE remplissent IMAP/SMTP. Les autres comptes utilisent IMAP/POP3 et un mot de passe (DPAPI sous Windows, mode 600 sous Linux).",
ShareAccount = "Partager ce compte avec les autres utilisateurs de ce PC",
ShareAccountHint = "Le courrier est déplacé vers {0}. Les autres comptes de ce PC peuvent l’ouvrir. Le mot de passe reste dans votre fichier secret ; chacun se connecte.",
ShareAccountHint = "Le courrier et les secrets de connexion sont déplacés vers {0}. Les autres comptes de ce PC peuvent ouvrir la boîte sans se reconnecter.",
DisplayName = "Nom affiché",
Address = "Adresse",
Username = "Nom d’utilisateur (s’il est différent)",

View File

@ -4,6 +4,7 @@ namespace MaksIT.PostClient.Shared.Mail;
public static class MailListBudget {
public readonly record struct Slice(int Start, int End, int Take, int NextOffset, bool Incomplete);
/// <summary>Newest-first window over a stable sequence index space (POP3 / PST).</summary>
public static Slice NewestRange(int count, int offset, int budget) {
if (count <= 0 || offset >= count || budget <= 0)
return new Slice(0, -1, 0, Math.Max(0, offset), false);
@ -15,4 +16,42 @@ public static class MailListBudget {
var next = offset + take;
return new Slice(start, end, take, next, next < count);
}
/// <summary>Newest-first window over a pre-ordered UID / id list (IMAP SEARCH).</summary>
public static Slice UidPage(int count, int offset, int budget) {
if (count <= 0 || offset >= count || budget <= 0)
return new Slice(0, -1, 0, Math.Max(0, offset), false);
var take = Math.Min(budget, count - offset);
var next = offset + take;
return new Slice(offset, offset + take - 1, take, next, next < count);
}
/// <summary>
/// Advance a UID page after FETCH. Partial or empty pages stay on the same offset until
/// <paramref name="maxMisses"/>, then the page is skipped so sync cannot stick forever.
/// </summary>
public static (int NextOffset, int NextMissStreak, bool Incomplete) AdvanceUidPage(
int planCount,
int offset,
int take,
int fetchedInPage,
int missStreak,
int maxMisses = 3) {
if (take <= 0 || offset >= planCount)
return (Math.Min(offset, Math.Max(0, planCount)), 0, false);
if (fetchedInPage >= take) {
var next = offset + take;
return (next, 0, next < planCount);
}
var streak = missStreak + 1;
if (streak >= Math.Max(1, maxMisses)) {
var next = offset + take;
return (next, 0, next < planCount);
}
return (offset, streak, true);
}
}

View File

@ -11,9 +11,14 @@ public class SharedMailboxTests {
try {
var box = new MailboxAccount { Id = "box1", Address = "shared@example.com" };
Assert.StartsWith(root, SharedMailPaths.AccountDirectory(box.Id), StringComparison.OrdinalIgnoreCase);
var placed = SharedMailboxStore.Place(box, shared: true, privateRoot: Path.Combine(root, "unused"));
var privateRoot = Path.Combine(AppPaths.AccountsDirectory(), box.Id);
Directory.CreateDirectory(privateRoot);
File.WriteAllText(Path.Combine(privateRoot, "mail.db"), "x");
var placed = SharedMailboxStore.Place(box, shared: true, privateRoot);
Assert.True(placed.IsSuccess);
Assert.True(File.Exists(SharedMailPaths.AccountFile(box.Id)));
Assert.True(File.Exists(Path.Combine(SharedMailPaths.AccountDirectory(box.Id), "mail.db")));
Assert.False(Directory.Exists(privateRoot));
var configuration = new Configuration();
Assert.True(SharedMailboxStore.MergeMissing(configuration));
Assert.Equal("box1", configuration.Mailboxes[0].Id);
@ -39,17 +44,24 @@ public class SharedMailboxTests {
}
[Fact]
public void EveryEnrolledAccountIsListed_OnlySharedOnesAreImported() {
public void PrivatePlaceDoesNotPublish_OnlySharedOnesAreImported() {
var previous = Environment.GetEnvironmentVariable(SharedMailPaths.Env);
var root = Path.Combine(Path.GetTempPath(), "postclient-central-" + Guid.NewGuid().ToString("N"));
Environment.SetEnvironmentVariable(SharedMailPaths.Env, root);
try {
var privateBox = new MailboxAccount { Id = "mine", Address = "mine@example.com" };
var sharedBox = new MailboxAccount { Id = "ours", Address = "ours@example.com" };
Assert.True(SharedMailboxStore.Place(privateBox, shared: false, Path.Combine(root, "a")).IsSuccess);
Assert.True(SharedMailboxStore.Place(sharedBox, shared: true, Path.Combine(root, "b")).IsSuccess);
var privateRoot = Path.Combine(AppPaths.AccountsDirectory(), privateBox.Id);
Directory.CreateDirectory(privateRoot);
Assert.True(SharedMailboxStore.Place(privateBox, shared: false, privateRoot).IsSuccess);
Assert.False(File.Exists(SharedMailPaths.AccountFile(privateBox.Id)));
Assert.True(SharedMailboxStore.Place(
sharedBox,
shared: true,
Path.Combine(AppPaths.AccountsDirectory(), sharedBox.Id)).IsSuccess);
var listed = SharedMailboxStore.List();
Assert.Equal(2, listed.Count);
Assert.Single(listed);
Assert.Equal("ours", listed[0].Id);
var configuration = new Configuration();
Assert.True(SharedMailboxStore.MergeMissing(configuration));
Assert.Single(configuration.Mailboxes);
@ -61,4 +73,35 @@ public class SharedMailboxTests {
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void TogglingSharedMovesTheArchive() {
var previous = Environment.GetEnvironmentVariable(SharedMailPaths.Env);
var root = Path.Combine(Path.GetTempPath(), "postclient-toggle-" + Guid.NewGuid().ToString("N"));
Environment.SetEnvironmentVariable(SharedMailPaths.Env, root);
var box = new MailboxAccount { Id = "toggle", Address = "toggle@example.com" };
var privateRoot = Path.Combine(AppPaths.AccountsDirectory(), box.Id);
try {
if (Directory.Exists(privateRoot))
Directory.Delete(privateRoot, recursive: true);
Directory.CreateDirectory(privateRoot);
File.WriteAllText(Path.Combine(privateRoot, "mail.db"), "live");
Assert.True(SharedMailboxStore.Place(box, shared: true, privateRoot).IsSuccess);
var sharedDb = Path.Combine(SharedMailPaths.AccountDirectory(box.Id), "mail.db");
Assert.Equal("live", File.ReadAllText(sharedDb));
Assert.False(Directory.Exists(privateRoot));
Assert.True(SharedMailboxStore.Place(box, shared: false, privateRoot).IsSuccess);
Assert.False(File.Exists(SharedMailPaths.AccountFile(box.Id)));
Assert.Equal("live", File.ReadAllText(Path.Combine(privateRoot, "mail.db")));
Assert.Equal(privateRoot, MailArchiveLayout.MailRoot(box, [box]));
}
finally {
Environment.SetEnvironmentVariable(SharedMailPaths.Env, previous);
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
if (Directory.Exists(privateRoot))
Directory.Delete(privateRoot, recursive: true);
}
}
}

View File

@ -215,9 +215,26 @@ public class MessageHtmlTests {
var html = MessageHtml.Document("<p>Hello</p>");
Assert.Contains("<p>Hello</p>", html);
Assert.Contains("Content-Security-Policy", html);
Assert.Contains("img-src data: https: http:", html);
Assert.Contains("<html>", html);
}
[Fact]
public void Document_BlocksRemoteImagesWhenAsked() {
var html = MessageHtml.Document("<img src=\"https://track.example/pixel.gif\">", allowRemoteContent: false);
Assert.Contains("img-src data:", html);
Assert.DoesNotContain("img-src data: https:", html);
}
[Fact]
public void HasRemoteContent_DetectsHttpImagesNotCid() {
Assert.True(MessageHtml.HasRemoteContent("<img src=\"https://track.example/x.gif\">"));
Assert.True(MessageHtml.HasRemoteContent("style=\"background:url(http://x/y.png)\""));
Assert.False(MessageHtml.HasRemoteContent("<img src=\"cid:pic@mail\">"));
Assert.False(MessageHtml.HasRemoteContent("<img src=\"data:image/png;base64,xx\">"));
Assert.False(MessageHtml.HasRemoteContent("<a href=\"https://example.com\">link</a>"));
}
[Fact]
public void Document_KeepsFullHtml() {
var html = MessageHtml.Document("<html><head></head><body>Hi</body></html>");
@ -568,6 +585,37 @@ public class MailListBudgetTests {
Assert.Equal(199, last.End);
Assert.False(last.Incomplete);
}
[Fact]
public void UidPage_WalksNewestFirstList() {
var first = MailListBudget.UidPage(1000, 0, 200);
Assert.Equal(0, first.Start);
Assert.Equal(199, first.End);
Assert.Equal(200, first.Take);
Assert.True(first.Incomplete);
var last = MailListBudget.UidPage(1000, 800, 200);
Assert.Equal(800, last.Start);
Assert.Equal(999, last.End);
Assert.False(last.Incomplete);
}
[Fact]
public void AdvanceUidPage_RetriesPartialFetchUntilMissLimit() {
var retry = MailListBudget.AdvanceUidPage(29, 0, 29, fetchedInPage: 15, missStreak: 0);
Assert.Equal(0, retry.NextOffset);
Assert.Equal(1, retry.NextMissStreak);
Assert.True(retry.Incomplete);
var skip = MailListBudget.AdvanceUidPage(29, 0, 29, fetchedInPage: 15, missStreak: 2);
Assert.Equal(29, skip.NextOffset);
Assert.Equal(0, skip.NextMissStreak);
Assert.False(skip.Incomplete);
var full = MailListBudget.AdvanceUidPage(29, 0, 29, fetchedInPage: 29, missStreak: 1);
Assert.Equal(29, full.NextOffset);
Assert.Equal(0, full.NextMissStreak);
Assert.False(full.Incomplete);
}
}
@ -849,6 +897,63 @@ public class MailPriorityTests {
}
[Collection(ProcessEnvironment.Name)]
public class LayeredSecretStoreTests {
[Fact]
public void GetFallsBackToShared_AndPublishMovesMailboxKeys() {
var previous = Environment.GetEnvironmentVariable(SharedMailPaths.Env);
var root = Path.Combine(Path.GetTempPath(), "postclient-layered-" + Guid.NewGuid().ToString("N"));
Environment.SetEnvironmentVariable(SharedMailPaths.Env, root);
var userPath = Path.Combine(Path.GetTempPath(), "postclient-user-secrets-" + Guid.NewGuid().ToString("N") + ".bin");
try {
var user = new FileSecretStore(userPath);
var shared = new SharedSecretStore();
var layered = new LayeredSecretStore(user, shared);
Assert.True(user.Put(FileSecretStore.MailboxKey("box"), "pw").IsSuccess);
Assert.True(user.Put(FileSecretStore.OAuthKey("box"), "tok").IsSuccess);
Assert.True(layered.PublishMailbox("box").IsSuccess);
Assert.Null(user.Get(FileSecretStore.MailboxKey("box")).Value);
Assert.Equal("pw", shared.Get(FileSecretStore.MailboxKey("box")).Value);
Assert.Equal("pw", layered.Get(FileSecretStore.MailboxKey("box")).Value);
Assert.True(layered.WithdrawMailbox("box").IsSuccess);
Assert.Equal("pw", user.Get(FileSecretStore.MailboxKey("box")).Value);
Assert.Null(shared.Get(FileSecretStore.MailboxKey("box")).Value);
}
finally {
Environment.SetEnvironmentVariable(SharedMailPaths.Env, previous);
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
if (File.Exists(userPath))
File.Delete(userPath);
}
}
[Fact]
public void PutRefreshesPublishedSharedCopy() {
var previous = Environment.GetEnvironmentVariable(SharedMailPaths.Env);
var root = Path.Combine(Path.GetTempPath(), "postclient-layered-put-" + Guid.NewGuid().ToString("N"));
Environment.SetEnvironmentVariable(SharedMailPaths.Env, root);
var userPath = Path.Combine(Path.GetTempPath(), "postclient-user-secrets-put-" + Guid.NewGuid().ToString("N") + ".bin");
try {
var user = new FileSecretStore(userPath);
var shared = new SharedSecretStore();
var layered = new LayeredSecretStore(user, shared);
Assert.True(shared.Put(FileSecretStore.MailboxKey("box"), "old").IsSuccess);
Assert.True(layered.Put(FileSecretStore.MailboxKey("box"), "new").IsSuccess);
Assert.Equal("new", shared.Get(FileSecretStore.MailboxKey("box")).Value);
Assert.Equal("new", user.Get(FileSecretStore.MailboxKey("box")).Value);
}
finally {
Environment.SetEnvironmentVariable(SharedMailPaths.Env, previous);
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
if (File.Exists(userPath))
File.Delete(userPath);
}
}
}
public class FileSecretStoreTests {
[Fact]
public void PutGetRoundTrip() {

View File

@ -191,6 +191,96 @@ public class MailArchiveStoreTests {
}
}
[Fact]
public void PendingFlags_BlockStaleServerSeenUntilAck() {
var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db");
try {
using var store = new MailArchiveStore(path);
store.UpsertHeaders("box", [
new MailArchiveHeader {
Uid = 9,
Folder = "INBOX",
Subject = "Unread",
From = "a@b.c",
Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"),
IsSeen = false
}
]);
store.SetLocalFlags("box", "INBOX", [(9u, true, false)], pending: true);
Assert.True(Assert.Single(store.ListFolder("box", "INBOX")).IsSeen);
store.UpdateFlags("box", "INBOX", [(9u, false, false)]);
Assert.True(Assert.Single(store.ListFolder("box", "INBOX")).IsSeen);
store.UpdateFlags("box", "INBOX", [(9u, true, false)]);
Assert.True(Assert.Single(store.ListFolder("box", "INBOX")).IsSeen);
Assert.Empty(store.ListAllPendingFlags("box"));
}
finally {
DeleteArchive(path);
}
}
[Fact]
public void UpsertHeaders_DoesNotClobberPendingSeen() {
var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db");
try {
using var store = new MailArchiveStore(path);
store.UpsertHeaders("box", [
new MailArchiveHeader {
Uid = 11,
Folder = "INBOX",
Subject = "A",
From = "a@b.c",
Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"),
IsSeen = false
}
]);
store.SetLocalFlags("box", "INBOX", [(11u, true, false)], pending: true);
store.UpsertHeaders(
"box",
[
new MailArchiveHeader {
Uid = 11,
Folder = "INBOX",
Subject = "A renamed",
From = "a@b.c",
Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"),
IsSeen = false
}
],
updateFlags: true);
var row = Assert.Single(store.ListFolder("box", "INBOX"));
Assert.Equal("A renamed", row.Subject);
Assert.True(row.IsSeen);
}
finally {
DeleteArchive(path);
}
}
[Fact]
public void SetLocalFlags_WithoutPending_AllowsServerOverwrite() {
var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db");
try {
using var store = new MailArchiveStore(path);
store.UpsertHeaders("box", [
new MailArchiveHeader {
Uid = 12,
Folder = "INBOX",
Subject = "Local",
From = "a@b.c",
Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"),
IsSeen = false
}
]);
store.SetLocalFlags("box", "INBOX", [(12u, true, false)], pending: false);
store.UpdateFlags("box", "INBOX", [(12u, false, false)]);
Assert.False(Assert.Single(store.ListFolder("box", "INBOX")).IsSeen);
}
finally {
DeleteArchive(path);
}
}
[Fact]
public void UpsertHeaders_KeepsPreviousSizeWhenIncomingIsZero() {
var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db");

View File

@ -11,17 +11,20 @@ namespace MaksIT.PostClient.Tests.Archive;
public class IdentityHubAddressTests {
[Fact]
public void DesktopLoginUrl_UsesOfficialPathAndProvider() {
public void DesktopLoginUrl_UsesOfficialPathProviderAndCoveredApp() {
var previous = Environment.GetEnvironmentVariable(IdentityHubAddress.Env);
try {
Environment.SetEnvironmentVariable(IdentityHubAddress.Env, null);
var google = IdentityHubAddress.DesktopLoginUrl(MailAuthKind.Google);
Assert.StartsWith(IdentityHubAddress.Production, google);
Assert.Contains("/desktop-login?provider=Google", google);
Assert.Contains("&app=" + IdentityHubAddress.CoveredApplicationId, google);
Assert.Equal("MaksIT.PostClient", IdentityHubAddress.CoveredApplicationId);
Assert.DoesNotContain("127.0.0.1", google);
Assert.DoesNotContain("purpose=", google);
var microsoft = IdentityHubAddress.DesktopLoginUrl(MailAuthKind.Microsoft);
Assert.Contains("/desktop-login?provider=Microsoft", microsoft);
Assert.Contains("&app=" + IdentityHubAddress.CoveredApplicationId, microsoft);
}
finally {
Environment.SetEnvironmentVariable(IdentityHubAddress.Env, previous);

View File

@ -11,7 +11,7 @@
<ItemGroup>
<PackageReference Include="coverlet.MTP" Version="10.1.0" />
<PackageReference Include="MaksIT.IdentityHub.Client" Version="3.1.3" />
<PackageReference Include="MaksIT.IdentityHub.Client" Version="4.0.0" />
<PackageReference Include="SharpZipLib" Version="1.4.2" />
<PackageReference Include="xunit.v3" Version="4.0.2" />
</ItemGroup>

View File

@ -67,6 +67,10 @@
ToggleType="CheckBox"
IsChecked="{Binding GroupConversations}"
ToolTip.Tip="{Binding Copy.GroupConversationsTip}" />
<MenuItem Header="{Binding Copy.BlockRemoteContent}"
ToggleType="CheckBox"
IsChecked="{Binding BlockRemoteContent}"
ToolTip.Tip="{Binding Copy.BlockRemoteContentTip}" />
<Separator />
<MenuItem Header="{Binding Copy.ListAboveReading}"
ToggleType="Radio"

View File

@ -281,7 +281,7 @@ public partial class MainWindow : Window {
};
var json = await window.ShowDialog<string?>(_accountWindow ?? this);
if (string.IsNullOrWhiteSpace(json))
vm.CancelIdentityHubSignIn("Identity Hub sign-in cancelled.");
vm.CancelIdentityHubSignIn("Sign-in cancelled.");
else
await vm.CompleteIdentityHubSignIn(kind, json);
}

View File

@ -12,10 +12,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.4" />
<PackageReference Include="Avalonia" Version="12.1.5" />
<PackageReference Include="Avalonia.Controls.WebView" Version="12.1.1" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.4" />
<PackageReference Include="Avalonia.Themes.Simple" Version="12.1.4" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.5" />
<PackageReference Include="Avalonia.Themes.Simple" Version="12.1.5" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="MaksIT.Core" Version="1.6.14" />

View File

@ -228,6 +228,12 @@
<Setter Property="BorderBrush" Value="#2a2e33" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
</Style>
<Style Selector="Border.remote-content-banner">
<Setter Property="Background" Value="#1e262e" />
<Setter Property="BorderBrush" Value="#33404c" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
</Style>
<Style Selector="TextBlock.header-label">
<Setter Property="Foreground" Value="#9aa0a6" />
<Setter Property="FontSize" Value="12" />

View File

@ -94,28 +94,33 @@ public partial class MainViewModel {
.ListMessagesAsync(slot.Folder, known, token, MailPipeline.HeaderPage)
.ConfigureAwait(false);
if (!listed.IsSuccess) {
_pipelineSticky = null;
// Keep sticky so a transient list error retries the same folder instead of restarting
// every label from the newest page and never reaching older mail.
_pipelineSticky = key;
if (!token.IsCancellationRequested)
_folderFailUtc[key] = DateTimeOffset.UtcNow;
return true;
}
if (token.IsCancellationRequested || Volatile.Read(ref _mailEpoch) != epoch)
if (token.IsCancellationRequested)
return true;
var applyFlags = Volatile.Read(ref _mailEpoch) == epoch;
_folderFailUtc.Remove(key);
var sync = listed.Value ?? new MailFolderSync();
ApplyFolderSync(slot.MailboxId, slot.Folder, sync);
ApplyFolderSync(slot.MailboxId, slot.Folder, sync, applyFlags);
var fresh = sync.Headers.Where(header => !known.Contains(header.Id)).ToList();
if (fresh.Count > 0) {
var openFolder = false;
await UiAsync(() => {
openFolder = SelectedMailbox?.Id.Equals(slot.MailboxId, StringComparison.OrdinalIgnoreCase) == true
&& SelectedFolder?.FullName.Equals(slot.Folder, StringComparison.OrdinalIgnoreCase) == true;
if (fresh.Count > 0 && openFolder)
NoticeNewMail(fresh);
}).ConfigureAwait(false);
if (fresh.Count > 0)
EnqueueFreshRules(slot.MailboxId, slot.Folder, fresh);
await UiAsync(() => {
if (SelectedMailbox?.Id.Equals(slot.MailboxId, StringComparison.OrdinalIgnoreCase) == true
&& SelectedFolder?.FullName.Equals(slot.Folder, StringComparison.OrdinalIgnoreCase) == true)
NoticeNewMail(fresh);
}).ConfigureAwait(false);
if (openFolder && (fresh.Count > 0 || (applyFlags && sync.Flags.Count > 0)))
await ShowArchiveAsync(slot.MailboxId, slot.Folder, token).ConfigureAwait(false);
}
await SetIndexLineAsync(GlobalIndexLine(), token).ConfigureAwait(false);
var counts = _archive.FolderCounts(slot.MailboxId, slot.Folder);

File diff suppressed because it is too large Load Diff

View File

@ -12,8 +12,11 @@ public partial class MessageWindowViewModel : ObservableObject {
private readonly ComposeIdentity _from;
private readonly Func<ComposeIdentity, CancellationToken, Task<IMailSession?>>? _ensureFrom;
private readonly string _preference;
private readonly bool _blockRemoteContent;
private bool _syncingKind;
private bool _htmlReady;
private bool _allowRemoteForMessage;
private bool _readingHasRemoteContent;
[ObservableProperty]
private string readingSubject = "";
@ -100,6 +103,12 @@ public partial class MessageWindowViewModel : ObservableObject {
public bool ShowFatturaPaTools =>
HasReadingFattura && UseFatturaPa;
public bool ShowRemoteContentBanner =>
ShowHtmlBody
&& _blockRemoteContent
&& !_allowRemoteForMessage
&& _readingHasRemoteContent;
public bool HasReadingFattura { get; private set; }
public string ReadingFattura { get; private set; } = "";
@ -117,6 +126,7 @@ public partial class MessageWindowViewModel : ObservableObject {
bool unwrap,
string preference,
bool htmlReady,
bool blockRemoteContent = true,
Func<ComposeIdentity, CancellationToken, Task<IMailSession?>>? ensureFrom = null) {
_body = body;
_fromAccounts = fromAccounts;
@ -124,6 +134,7 @@ public partial class MessageWindowViewModel : ObservableObject {
_ensureFrom = ensureFrom;
_preference = MailBodyKind.Preference(preference);
_htmlReady = htmlReady;
_blockRemoteContent = blockRemoteContent;
unwrapEnvelope = unwrap;
readingBodyKind = MailBodyKind.DefaultView(
unwrap && body.Envelope.HasInnerMessage ? body.InnerHtml : body.Html,
@ -162,6 +173,18 @@ public partial class MessageWindowViewModel : ObservableObject {
SetReadingKind(MailBodyKind.Fattura);
}
[RelayCommand(CanExecute = nameof(CanShowRemoteImages))]
private void ShowRemoteImages() {
if (!CanShowRemoteImages())
return;
_allowRemoteForMessage = true;
ApplyReading(true);
NotifyRemoteContent();
}
private bool CanShowRemoteImages() =>
ShowRemoteContentBanner;
[RelayCommand]
private void Reply() =>
OpenCompose("Reply", Recipients(ReadingFrom), null, ReplySubject(), QuotedBody());
@ -234,12 +257,17 @@ public partial class MessageWindowViewModel : ObservableObject {
var markup = MessageHtml.HasMarkup(html);
if (string.IsNullOrWhiteSpace(text))
text = MessageText.StripHtml(html);
if (markup) {
var cid = MessageHtml.DataUris(files.Select(f => (f.ContentId, f.ContentType, f.Bytes)));
ReadingHtmlDocument = MessageHtml.Document(MessageHtml.InlineCid(html, cid));
}
var inlined = markup
? MessageHtml.InlineCid(html, MessageHtml.DataUris(files.Select(f => (f.ContentId, f.ContentType, f.Bytes))))
: "";
_readingHasRemoteContent = MessageHtml.HasRemoteContent(inlined.Length > 0 ? inlined : html);
if (!keepKind)
_allowRemoteForMessage = false;
var allowRemote = !_blockRemoteContent || _allowRemoteForMessage;
if (markup)
ReadingHtmlDocument = MessageHtml.Document(inlined, allowRemote);
else if (keepKind && MailBodyKind.IsHtml(ReadingBodyKind))
ReadingHtmlDocument = MessageHtml.Document(MessageHtml.FromPlain(text));
ReadingHtmlDocument = MessageHtml.Document(MessageHtml.FromPlain(text), allowRemote);
else
ReadingHtmlDocument = "";
ReadingBody = text ?? "";
@ -272,11 +300,17 @@ public partial class MessageWindowViewModel : ObservableObject {
NotifyBodyKind();
}
private void NotifyRemoteContent() {
OnPropertyChanged(nameof(ShowRemoteContentBanner));
ShowRemoteImagesCommand.NotifyCanExecuteChanged();
}
private void NotifyBodyKind() {
OnPropertyChanged(nameof(ShowHtmlBody));
OnPropertyChanged(nameof(ShowTextBody));
OnPropertyChanged(nameof(ShowSourceBody));
OnPropertyChanged(nameof(ShowFatturaBody));
NotifyRemoteContent();
OnPropertyChanged(nameof(IsHtmlKind));
OnPropertyChanged(nameof(IsTextKind));
OnPropertyChanged(nameof(IsSourceKind));

View File

@ -114,8 +114,12 @@ public sealed partial class FolderRowViewModel : ObservableObject {
public sealed partial class FolderNodeViewModel : ObservableObject {
public const string AllEmailsId = "__all_emails__";
public bool IsAccount { get; init; }
public bool IsAllEmails { get; init; }
public MailboxAccount? Mailbox { get; init; }
public FolderRowViewModel? Folder { get; init; }
@ -126,13 +130,30 @@ public sealed partial class FolderNodeViewModel : ObservableObject {
public ObservableCollection<FolderNodeViewModel> Children { get; } = [];
public string Title =>
IsAccount ? Mailbox?.Label ?? UiLocale.Copy.Mailbox : Folder?.Name ?? "";
IsAllEmails
? UiLocale.Copy.AllInboxes
: IsAccount
? Mailbox?.Label ?? UiLocale.Copy.Mailbox
: Folder?.Name ?? "";
public string Tip =>
IsAllEmails
? UiLocale.Copy.AllInboxesHint
: QuotaTip;
public string Glyph =>
IsAccount ? "✉" : MailFolderRole.Glyph(Folder?.Name, Folder?.FullName);
IsAllEmails
? "📥"
: IsAccount
? "✉"
: MailFolderRole.Glyph(Folder?.Name, Folder?.FullName);
public int Unread =>
IsAccount ? ChildUnread() : Folder?.Unread ?? 0;
IsAllEmails
? Folder?.Unread ?? 0
: IsAccount
? ChildUnread()
: Folder?.Unread ?? 0;
private int ChildUnread() {
var n = 0;
@ -177,6 +198,7 @@ public sealed partial class FolderNodeViewModel : ObservableObject {
public void NotifyCounts() {
OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(Tip));
OnPropertyChanged(nameof(Unread));
OnPropertyChanged(nameof(UnreadLabel));
OnPropertyChanged(nameof(HasUnread));
@ -192,6 +214,13 @@ public sealed partial class FolderNodeViewModel : ObservableObject {
OnPropertyChanged(nameof(QuotaTip));
}
public static FolderNodeViewModel AllEmails(FolderRowViewModel folder) =>
new() {
IsAllEmails = true,
Folder = folder,
IsExpanded = false
};
public static FolderNodeViewModel Account(MailboxAccount box, bool expanded = true) =>
new() {
IsAccount = true,

View File

@ -72,7 +72,7 @@
<TextBlock Text="{Binding Title}"
FontWeight="{Binding Weight}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding QuotaTip}" />
ToolTip.Tip="{Binding Tip}" />
<ProgressBar Height="3"
Minimum="0"
Maximum="100"

View File

@ -53,6 +53,21 @@
<CheckBox Content="{Binding Copy.UnwrapEnvelope}"
IsChecked="{Binding UnwrapEnvelope}"
IsVisible="{Binding UseEnvelopeTools}" />
<Border Classes="remote-content-banner"
IsVisible="{Binding ShowRemoteContentBanner}"
Padding="8,6"
Margin="0,2,0,0">
<DockPanel>
<Button DockPanel.Dock="Right"
Content="{Binding Copy.ShowRemoteImages}"
Classes="mail-tool"
Command="{Binding ShowRemoteImagesCommand}"
Margin="8,0,0,0" />
<TextBlock Text="{Binding Copy.RemoteContentBlocked}"
TextWrapping="Wrap"
VerticalAlignment="Center" />
</DockPanel>
</Border>
<StackPanel Orientation="Horizontal" Spacing="4">
<Button Content="{Binding Copy.Html}"
Classes="body-kind"

View File

@ -1,7 +1,7 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="MaksIT.PostClient.UI.Windows.IdentityHubLoginWindow"
Title="Identity Hub"
Title="MaksIT Postclient"
Icon="/Assets/icon.ico"
Width="920"
Height="720"
@ -9,12 +9,21 @@
MinHeight="480"
WindowStartupLocation="CenterOwner">
<DockPanel>
<TextBlock x:Name="StatusText"
DockPanel.Dock="Top"
Classes="hint"
Margin="12,8"
TextWrapping="Wrap"
Text="Waiting for Identity Hub… keep this window open until it closes itself." />
<DockPanel DockPanel.Dock="Top" Margin="12,8">
<Button x:Name="BackButton"
DockPanel.Dock="Left"
Content="Back"
Classes="quiet"
Margin="0,0,12,0"
VerticalAlignment="Top"
IsEnabled="False"
Click="OnBackClick" />
<TextBlock x:Name="StatusText"
Classes="hint"
TextWrapping="Wrap"
VerticalAlignment="Center"
Text="Waiting for sign-in… keep this window open until it closes itself." />
</DockPanel>
<NativeWebView x:Name="HubWeb" />
</DockPanel>
</Window>

View File

@ -1,4 +1,5 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
@ -51,7 +52,7 @@ public partial class IdentityHubLoginWindow : Window {
_poll.Tick += (_, _) => _ = TryCompleteFromWebViewAsync();
HubWeb.EnvironmentRequested += (_, args) => WebViewSetup.Apply(args, identityHub: true);
HubWeb.AdapterCreated += (_, _) => LoadHub(force: false);
HubWeb.NavigationCompleted += (_, _) => _ = TryCompleteFromWebViewAsync();
HubWeb.NavigationCompleted += (_, _) => OnNavigationCompleted();
HubWeb.WebMessageReceived += OnWebMessage;
HubWeb.NewWindowRequested += OnNewWindow;
Opened += OnOpened;
@ -61,15 +62,55 @@ public partial class IdentityHubLoginWindow : Window {
private void OnOpened(object? sender, EventArgs e) {
LoadHub(force: false);
StatusText.Text = "Waiting for Identity Hub… keep this window open until it closes itself.";
StatusText.Text = "Waiting for sign-in… keep this window open until it closes itself.";
UpdateBackButton();
_poll.Start();
}
private void OnNavigationCompleted() {
UpdateBackButton();
_ = TryCompleteFromWebViewAsync();
}
private void UpdateBackButton() {
try {
BackButton.IsEnabled = !_done && (HubWeb.CanGoBack || IsAwayFromSignIn());
}
catch {
BackButton.IsEnabled = !_done && IsAwayFromSignIn();
}
}
private bool IsAwayFromSignIn() {
if (HubWeb.Source is not { } current || IsEmptyDocument(current))
return false;
if (!Uri.TryCreate(_url, UriKind.Absolute, out var start))
return false;
return !string.Equals(current.GetLeftPart(UriPartial.Path), start.GetLeftPart(UriPartial.Path), StringComparison.OrdinalIgnoreCase);
}
private void OnBackClick(object? sender, RoutedEventArgs e) {
if (_done)
return;
try {
if (HubWeb.CanGoBack) {
HubWeb.GoBack();
return;
}
}
catch {
// Fall through to reload the sign-in URL.
}
LoadHub(force: true);
UpdateBackButton();
}
private void LoadHub(bool force) {
if (_done)
return;
if (!Uri.TryCreate(_url, UriKind.Absolute, out var target)) {
StatusText.Text = "Unable to open Identity Hub.";
StatusText.Text = "Unable to open sign-in.";
return;
}
@ -78,10 +119,11 @@ public partial class IdentityHubLoginWindow : Window {
try {
HubWeb.Source = target;
_started = true;
UpdateBackButton();
}
catch {
if (!_started)
StatusText.Text = "Unable to open Identity Hub.";
StatusText.Text = "Unable to open sign-in.";
}
}
@ -121,6 +163,7 @@ public partial class IdentityHubLoginWindow : Window {
return;
_done = true;
_poll.Stop();
BackButton.IsEnabled = false;
Close(json);
}
}

View File

@ -53,6 +53,21 @@
<CheckBox Content="{Binding Copy.UnwrapEnvelope}"
IsChecked="{Binding UnwrapEnvelope}"
IsVisible="{Binding UseEnvelopeTools}" />
<Border Classes="remote-content-banner"
IsVisible="{Binding ShowRemoteContentBanner}"
Padding="8,6"
Margin="0,2,0,0">
<DockPanel>
<Button DockPanel.Dock="Right"
Content="{Binding Copy.ShowRemoteImages}"
Classes="mail-tool"
Command="{Binding ShowRemoteImagesCommand}"
Margin="8,0,0,0" />
<TextBlock Text="{Binding Copy.RemoteContentBlocked}"
TextWrapping="Wrap"
VerticalAlignment="Center" />
</DockPanel>
</Border>
<StackPanel Orientation="Horizontal" Spacing="4">
<Button Content="{Binding Copy.Html}"
Classes="body-kind"

View File

@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Postclient Release Script Settings",
"description": "Community desktop pipeline (same shape as maksit-wvc210). GitHub assets are siblings: portable zip, Windows setup exe, Flatpak. The zip never contains the installer or Flatpak. Self-contained publish — no .NET SDK on the user PC. Default Windows install is C:\\Program Files\\MaksIT\\Postclient.",
"description": "Community desktop pipeline. GitHub assets are siblings: portable zip, Windows setup exe, Flatpak. MsixPack writes a Store .msix (kept in releases/, not a GitHub asset). The zip never contains the installer, Flatpak, or MSIX. Self-contained publish — no .NET SDK on the user PC. Default Windows install is C:\\Program Files\\MaksIT\\Postclient.",
"plugins": [
{
"name": "DotNetReleaseVersion",
@ -62,20 +62,31 @@
"runtimeIdentifier": "win-x64",
"installScope": "perMachine",
"iconPath": "..\\..\\..\\src\\MaksIT.PostClient.UI\\Assets\\icon.ico",
"exeNamePattern": "postclient-{version}.exe"
"exeNamePattern": "postclient-{version}.exe",
"requiredPublishFiles": [
"MaksIT.PostClient.Client.dll",
"MaksIT.IdentityHub.Client.dll",
"MaksIT.IdentityHub.Contracts.dll"
]
},
{
"name": "MsixPack",
"stageLabel": "build",
"enabled": false,
"enabled": true,
"packageName": "MAKS-IT.Postclient",
"publisher": "CN=PartnerCenter",
"publisher": "CN=FCC8C0E7-6D5F-4028-B8EE-903B88C0C8F9",
"publisherDisplayName": "MaksIT",
"displayName": "Postclient",
"description": "Desktop mail client for the mailbox you already have",
"executableName": "MaksIT.PostClient.UI.exe",
"runtimeIdentifier": "win-x64",
"iconPath": "..\\..\\..\\src\\MaksIT.PostClient.UI\\Assets\\icon.png",
"msixNamePattern": "postclient-{version}.msix"
"msixNamePattern": "postclient-{version}.msix",
"requiredPublishFiles": [
"MaksIT.PostClient.Client.dll",
"MaksIT.IdentityHub.Client.dll",
"MaksIT.IdentityHub.Contracts.dll"
]
},
{
"name": "FlatpakPack",

View File

@ -301,8 +301,13 @@ function New-WixPackageXml {
$null = $package.SetAttribute('Scope', $scope)
$null = $wix.AppendChild($package)
# Install the new payload first, then remove the previous product. Early
# RemoveExistingProducts can leave a half-empty folder when a Burn/MSI run
# aborts after uninstalling the old product (sibling DLLs appear "missing").
$major = $xml.CreateElement('MajorUpgrade', $ns)
$null = $major.SetAttribute('DowngradeErrorMessage', 'A newer version is already installed.')
$null = $major.SetAttribute('Schedule', 'afterInstallExecute')
$null = $major.SetAttribute('AllowSameVersionUpgrades', 'yes')
$null = $package.AppendChild($major)
$media = $xml.CreateElement('MediaTemplate', $ns)
@ -431,7 +436,11 @@ function New-WixPackageXml {
$component = $xml.CreateElement('Component', $ns)
$componentId = ConvertTo-WixIdentifier -Value $relative -Prefix 'c'
$null = $component.SetAttribute('Id', $componentId)
$null = $component.SetAttribute('Guid', '*')
# Stable GUIDs across builds so upgrades/repairs replace the same
# logical file instead of orphaning Guid='*' components.
$null = $component.SetAttribute(
'Guid',
(Get-WixFileComponentGuid -UpgradeCode $UpgradeCode -RelativePath $relative).ToString('D'))
$componentIds.Add($componentId)
$fileNode = $xml.CreateElement('File', $ns)
$null = $fileNode.SetAttribute('Id', (ConvertTo-WixIdentifier -Value $relative -Prefix 'f'))
@ -941,6 +950,71 @@ function Get-DesktopShortcutComponentGuid {
return [guid]::new($hash)
}
function Get-WixFileComponentGuid {
param(
[Parameter(Mandatory = $true)]
[guid]$UpgradeCode,
[Parameter(Mandatory = $true)]
[string]$RelativePath
)
$normalized = ($RelativePath -replace '\\', '/').Trim().ToLowerInvariant()
$identity = "maksit-wix-file|$($UpgradeCode.ToString('D'))|$normalized"
$md5 = [System.Security.Cryptography.MD5]::Create()
try {
$hash = $md5.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($identity))
}
finally {
$md5.Dispose()
}
return [guid]::new($hash)
}
function Assert-DesktopPublishFiles {
param(
[Parameter(Mandatory = $true)]
[string]$PublishDirectory,
[Parameter(Mandatory = $false)]
[string[]]$RequiredFileNames = @()
)
if (-not (Test-Path -LiteralPath $PublishDirectory -PathType Container)) {
throw "Desktop publish directory not found: $PublishDirectory"
}
$names = @(
$RequiredFileNames |
Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } |
ForEach-Object { [string]$_.Trim() } |
Select-Object -Unique
)
if ($names.Count -eq 0) {
return
}
$missing = [System.Collections.Generic.List[string]]::new()
foreach ($name in $names) {
$direct = Join-Path $PublishDirectory $name
if (Test-Path -LiteralPath $direct -PathType Leaf) {
continue
}
$found = @(
Get-ChildItem -LiteralPath $PublishDirectory -Recurse -File -Filter $name -ErrorAction SilentlyContinue
)
if ($found.Count -eq 0) {
$missing.Add($name)
}
}
if ($missing.Count -gt 0) {
throw "Desktop publish folder is missing required file(s) for packaging: $($missing -join ', '). Publish directory: $PublishDirectory"
}
}
function Get-DerivedBundleUpgradeCode {
param(
[Parameter(Mandatory = $true)]
@ -1477,6 +1551,8 @@ Export-ModuleMember -Function `
Get-PluginPropertyValue, `
Resolve-DesktopPublishDirectory, `
Resolve-DesktopExecutablePath, `
Get-WixFileComponentGuid, `
Assert-DesktopPublishFiles, `
New-WixPackageXml, `
ConvertTo-MsixPackageVersion, `
Assert-MsixPackageName, `

View File

@ -131,6 +131,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
Import-PluginDependency -ModuleName "ExternalCommandSupport" -RequiredCommand "Invoke-ExternalCommand"
Import-PluginDependency -ModuleName "DesktopPackSupport" -RequiredCommand "New-MsixManifestXml"
Import-PluginDependency -ModuleName "DesktopPackSupport" -RequiredCommand "Assert-DesktopPublishFiles"
if (-not $IsWindows -and -not (Test-ExternalCommandTestHandler)) {
throw "MsixPack requires Windows (Windows SDK makeappx.exe and signtool.exe)."
@ -191,6 +192,18 @@ function Invoke-Plugin {
-PublishDir $publishDirSetting `
-ScriptDir $scriptDir
$requiredPublishFiles = @(Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'requiredPublishFiles' -Default @())
$requiredPublishFiles = @(
$requiredPublishFiles |
ForEach-Object { [string]$_ } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
if (-not [string]::IsNullOrWhiteSpace($executableName) -and ($requiredPublishFiles -notcontains $executableName)) {
$requiredPublishFiles = @($executableName) + $requiredPublishFiles
}
Assert-DesktopPublishFiles -PublishDirectory $publishDirectory -RequiredFileNames $requiredPublishFiles
$null = Resolve-DesktopExecutablePath `
-PublishDirectory $publishDirectory `
-ExecutableName $executableName `

View File

@ -85,6 +85,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-ReleaseAssetPaths"
Import-PluginDependency -ModuleName "ExternalCommandSupport" -RequiredCommand "Invoke-ExternalCommand"
Import-PluginDependency -ModuleName "DesktopPackSupport" -RequiredCommand "New-WixPackageXml"
Import-PluginDependency -ModuleName "DesktopPackSupport" -RequiredCommand "Assert-DesktopPublishFiles"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
@ -130,6 +131,18 @@ function Invoke-Plugin {
-PublishDir $publishDirSetting `
-ScriptDir $scriptDir
$requiredPublishFiles = @(Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'requiredPublishFiles' -Default @())
$requiredPublishFiles = @(
$requiredPublishFiles |
ForEach-Object { [string]$_ } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
if (-not [string]::IsNullOrWhiteSpace($executableName) -and ($requiredPublishFiles -notcontains $executableName)) {
$requiredPublishFiles = @($executableName) + $requiredPublishFiles
}
Assert-DesktopPublishFiles -PublishDirectory $publishDirectory -RequiredFileNames $requiredPublishFiles
$executablePath = Resolve-DesktopExecutablePath `
-PublishDirectory $publishDirectory `
-ExecutableName $executableName `