From 4f8665ab90b6336a7eab41dc262aa65878ff1f36 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Mon, 21 Sep 2026 20:14:20 +0200 Subject: [PATCH] (feature): show message size and keep html mail and attachments reliable --- CHANGELOG.md | 19 ++ data/eu.postclient.desktop.metainfo.xml | 5 + src/Directory.Build.props | 2 +- .../{ => Auth}/IMailAuthService.cs | 3 +- .../{ => Auth}/MailAuthHint.cs | 3 +- .../{ => Auth}/MailAuthService.cs | 3 +- .../{ => Auth}/MailKitAuth.cs | 3 +- .../{ => Auth}/OAuthLoopback.cs | 2 +- .../{ => Auth}/OAuthPkce.cs | 2 +- .../{ => Auth}/OAuthProviderProfile.cs | 3 +- .../{ => Auth}/OAuthTokenClient.cs | 3 +- .../{ => Auth}/OAuthTrace.cs | 3 +- .../Extensions/ServiceCollectionExtensions.cs | 3 +- src/MaksIT.PostClient.Client/GlobalUsings.cs | 15 ++ .../{ => Import}/LocalMailImport.cs | 3 +- .../{ => Import}/MapiRestriction.cs | 3 +- .../{ => Import}/MboxReader.cs | 2 +- .../{ => Import}/OleCompound.cs | 2 +- .../{ => Import}/OutlookPst.cs | 2 +- .../{ => Import}/OutlookRules.cs | 3 +- .../{ => Import}/OutlookRwz.cs | 3 +- .../{ => Import}/PstFile.cs | 2 +- .../{ => Import}/PstImport.cs | 3 +- .../{ => Import}/PstMime.cs | 2 +- .../{ => Import}/PstStoreMigrator.cs | 3 +- .../{ => Import}/PstSubject.cs | 2 +- .../{ => Import}/ThunderbirdProfiles.cs | 2 +- .../{ => Mail}/IMailSession.cs | 6 +- .../{ => Mail}/ImapFetchPolicy.cs | 3 +- .../{ => Mail}/ImapMailSession.cs | 105 +++++++++-- .../{ => Mail}/LocalStoreSession.cs | 8 +- .../{ => Mail}/MailArchiveMap.cs | 9 +- .../Mail/MailFetch.cs | 29 +++ .../{ => Mail}/MailFolderCatalog.cs | 3 +- .../{ => Mail}/MailModels.cs | 9 +- .../{ => Mail}/MailSessionFactory.cs | 3 +- .../{ => Mail}/MailSessionGate.cs | 2 +- .../{ => Mail}/MailSocket.cs | 3 +- .../{ => Mail}/MailWorkerHost.cs | 3 +- .../{ => Mail}/Pop3MailSession.cs | 110 +++++++++-- .../{ => Mail}/PstMailSession.cs | 11 +- .../{ => Mail}/SmtpSender.cs | 3 +- .../{ => Mime}/AttachmentText.cs | 2 +- .../{ => Mime}/AttachmentZip.cs | 2 +- .../{ => Mime}/EnvelopeUnwrapper.cs | 3 +- .../{ => Mime}/FatturaPa.cs | 2 +- .../{ => Mime}/MessagePrint.cs | 3 +- .../{ => Mime}/MimeAttachments.cs | 83 +++++++- .../{ => Mime}/MimeBody.cs | 27 ++- .../{ => Mime}/RecipientParser.cs | 2 +- .../{ => Search}/EmbeddingModelDownloader.cs | 3 +- .../{ => Search}/GpuProbe.cs | 3 +- .../{ => Search}/ISemanticSearchService.cs | 3 +- .../{ => Search}/ITextEmbedder.cs | 2 +- .../{ => Search}/OnnxGemmaEmbedder.cs | 3 +- .../{ => Search}/SemanticSearchService.cs | 3 +- .../{ => Update}/AppUpdateService.cs | 3 +- .../{ => App}/AppInfo.cs | 2 +- .../{ => App}/AppInstall.cs | 2 +- .../{ => App}/AppLog.cs | 2 +- .../{ => App}/AppPaths.cs | 34 +++- .../{ => App}/AppVersion.cs | 2 +- .../{ => App}/ErrorReport.cs | 2 +- .../{ => App}/GitHubRelease.cs | 2 +- .../{ => Archive}/ArchiveFiles.cs | 2 +- .../{ => Archive}/FileSentReceiptStore.cs | 2 +- .../{ => Archive}/ISentReceiptStore.cs | 2 +- .../{ => Archive}/LocalStoreSidecar.cs | 2 +- .../{ => Archive}/MailArchiveCatalog.cs | 2 +- .../{ => Archive}/MailArchiveCopy.cs | 2 +- .../{ => Archive}/MailArchiveLayout.cs | 2 +- .../{ => Archive}/MailArchiveStore.cs | 79 +++++--- .../{ => Auth}/FileSecretStore.cs | 2 +- .../{ => Auth}/HubDesktopSession.cs | 2 +- .../{ => Auth}/ISecretStore.cs | 2 +- .../{ => Auth}/IdentityHubAddress.cs | 2 +- .../{ => Auth}/MailAuthKind.cs | 2 +- .../{ => Auth}/OAuthClientId.cs | 2 +- .../{ => Auth}/OAuthClientSecret.cs | 2 +- .../{ => Auth}/OAuthLoginHint.cs | 2 +- .../{ => Auth}/OAuthMailScope.cs | 2 +- .../{ => Auth}/OAuthTokenSet.cs | 2 +- .../{ => Config}/Configuration.cs | 2 +- .../{ => Config}/ConfigurationFileService.cs | 2 +- .../{ => Config}/FeatureCatalog.cs | 2 +- .../{ => Config}/FeatureSettings.cs | 2 +- .../{ => Content}/MessageHtml.cs | 24 ++- .../{ => Content}/MessageText.cs | 2 +- .../{ => Content}/SimplePdf.cs | 2 +- .../{ => Envelope}/DaticertParser.cs | 2 +- .../{ => Envelope}/EnvelopeClassifier.cs | 2 +- .../{ => Envelope}/EnvelopeInfo.cs | 2 +- .../{ => Envelope}/EnvelopeKind.cs | 2 +- .../{ => Envelope}/ReceiptStatus.cs | 2 +- .../{ => Envelope}/RemEvidenceParser.cs | 2 +- src/MaksIT.PostClient.Shared/GlobalUsings.cs | 9 + .../{ => Localization}/UiCopy.cs | 6 +- .../{ => Localization}/UiCopy.de.cs | 3 +- .../{ => Localization}/UiCopy.es.cs | 3 +- .../{ => Localization}/UiCopy.fr.cs | 3 +- .../{ => Localization}/UiLanguage.cs | 2 +- .../{ => Localization}/UiLocale.cs | 2 +- .../{ => Mail}/FolderRetention.cs | 2 +- .../{ => Mail}/LayoutSettings.cs | 2 +- .../{ => Mail}/MailBodyKind.cs | 2 +- .../{ => Mail}/MailCertifiedKind.cs | 2 +- .../{ => Mail}/MailChain.cs | 2 +- .../{ => Mail}/MailDragPayload.cs | 2 +- .../{ => Mail}/MailDrop.cs | 2 +- .../{ => Mail}/MailFolderLayout.cs | 2 +- .../{ => Mail}/MailFolderPath.cs | 2 +- .../{ => Mail}/MailFolderRole.cs | 2 +- .../{ => Mail}/MailFolderStats.cs | 2 +- .../{ => Mail}/MailId.cs | 2 +- .../{ => Mail}/MailIndexProgress.cs | 4 +- .../{ => Mail}/MailLayout.cs | 2 +- .../{ => Mail}/MailListBudget.cs | 2 +- .../{ => Mail}/MailListOrder.cs | 2 +- .../{ => Mail}/MailMessageKey.cs | 2 +- .../{ => Mail}/MailMessageList.cs | 21 ++- .../{ => Mail}/MailPriority.cs | 2 +- .../{ => Mail}/MailProtocol.cs | 2 +- .../{ => Mail}/MailProvider.cs | 2 +- .../{ => Mail}/MailRetention.cs | 2 +- .../{ => Mail}/MailRule.cs | 2 +- .../{ => Mail}/MailSecurity.cs | 2 +- .../{ => Mail}/MailSelection.cs | 2 +- src/MaksIT.PostClient.Shared/Mail/MailSize.cs | 19 ++ .../{ => Mail}/MailWhen.cs | 2 +- .../{ => Mail}/MailboxAccount.cs | 2 +- .../{ => Mail}/MailboxQuota.cs | 2 +- .../{ => Search}/EmbeddingModelSpec.cs | 2 +- .../{ => Search}/EmbeddingPrompt.cs | 2 +- .../{ => Search}/EmbeddingVector.cs | 2 +- .../{ => Search}/SemanticDevice.cs | 2 +- .../{ => Search}/SemanticSearchSettings.cs | 2 +- .../{ => App}/SharedTests.cs | 96 +++++++++- .../{ => Archive}/ArchiveTests.cs | 112 ++++++++++- .../{ => Archive}/StoreAndHubTests.cs | 4 +- .../{ => Auth}/OAuthTests.cs | 4 +- .../{ => Envelope}/EnvelopeTests.cs | 43 ++++- src/MaksIT.PostClient.Tests/GlobalUsings.cs | 15 ++ .../{ => Import}/OutlookRulesTests.cs | 4 +- .../{ => Mail}/FolderDropTests.cs | 3 +- .../{ => Mail}/ProtocolTests.cs | 19 +- .../Mime/MimeAttachmentTests.cs | 55 ++++++ .../{ => Mime}/RecipientTests.cs | 3 +- .../{ => Search}/SemanticSearchTests.cs | 4 +- src/MaksIT.PostClient.UI/App.axaml.cs | 2 - .../Controls/RecipientBox.axaml.cs | 1 - .../{ => Dialogs}/DesktopNotice.cs | 2 +- .../{ => Dialogs}/ErrorDialog.cs | 4 +- src/MaksIT.PostClient.UI/GlobalUsings.cs | 19 ++ .../{ => Layout}/LayoutPersistence.cs | 3 +- src/MaksIT.PostClient.UI/MainWindow.axaml | 11 ++ src/MaksIT.PostClient.UI/MainWindow.axaml.cs | 48 +++-- src/MaksIT.PostClient.UI/Program.cs | 12 +- .../ViewModels/AboutViewModel.cs | 1 - .../ViewModels/ComposeViewModel.cs | 2 - .../ViewModels/ErrorReportViewModel.cs | 1 - .../ViewModels/FeatureMatrixViewModel.cs | 1 - .../ViewModels/LogViewModel.cs | 1 - .../ViewModels/MainViewModel.cs | 178 ++++++++++++++---- .../ViewModels/MessageWindowViewModel.cs | 2 - .../ViewModels/RecipientFieldViewModel.cs | 1 - .../ViewModels/RetentionViewModel.cs | 1 - .../ViewModels/RowViewModels.cs | 8 +- .../ViewModels/RulesViewModel.cs | 2 - .../ViewModels/SemanticSearchViewModel.cs | 2 - .../{ => Web}/WebViewSetup.cs | 43 +++-- .../Windows/AboutWindow.axaml.cs | 1 - .../Windows/IdentityHubLoginWindow.axaml.cs | 1 - .../Windows/MessageWindow.axaml.cs | 2 - .../Windows/ZipSendWindow.axaml.cs | 1 - 174 files changed, 1246 insertions(+), 359 deletions(-) rename src/MaksIT.PostClient.Client/{ => Auth}/IMailAuthService.cs (93%) rename src/MaksIT.PostClient.Client/{ => Auth}/MailAuthHint.cs (93%) rename src/MaksIT.PostClient.Client/{ => Auth}/MailAuthService.cs (99%) rename src/MaksIT.PostClient.Client/{ => Auth}/MailKitAuth.cs (90%) rename src/MaksIT.PostClient.Client/{ => Auth}/OAuthLoopback.cs (99%) rename src/MaksIT.PostClient.Client/{ => Auth}/OAuthPkce.cs (97%) rename src/MaksIT.PostClient.Client/{ => Auth}/OAuthProviderProfile.cs (94%) rename src/MaksIT.PostClient.Client/{ => Auth}/OAuthTokenClient.cs (98%) rename src/MaksIT.PostClient.Client/{ => Auth}/OAuthTrace.cs (82%) create mode 100644 src/MaksIT.PostClient.Client/GlobalUsings.cs rename src/MaksIT.PostClient.Client/{ => Import}/LocalMailImport.cs (98%) rename src/MaksIT.PostClient.Client/{ => Import}/MapiRestriction.cs (99%) rename src/MaksIT.PostClient.Client/{ => Import}/MboxReader.cs (96%) rename src/MaksIT.PostClient.Client/{ => Import}/OleCompound.cs (98%) rename src/MaksIT.PostClient.Client/{ => Import}/OutlookPst.cs (98%) rename src/MaksIT.PostClient.Client/{ => Import}/OutlookRules.cs (98%) rename src/MaksIT.PostClient.Client/{ => Import}/OutlookRwz.cs (98%) rename src/MaksIT.PostClient.Client/{ => Import}/PstFile.cs (94%) rename src/MaksIT.PostClient.Client/{ => Import}/PstImport.cs (97%) rename src/MaksIT.PostClient.Client/{ => Import}/PstMime.cs (98%) rename src/MaksIT.PostClient.Client/{ => Import}/PstStoreMigrator.cs (97%) rename src/MaksIT.PostClient.Client/{ => Import}/PstSubject.cs (95%) rename src/MaksIT.PostClient.Client/{ => Import}/ThunderbirdProfiles.cs (97%) rename src/MaksIT.PostClient.Client/{ => Mail}/IMailSession.cs (94%) rename src/MaksIT.PostClient.Client/{ => Mail}/ImapFetchPolicy.cs (93%) rename src/MaksIT.PostClient.Client/{ => Mail}/ImapMailSession.cs (93%) rename src/MaksIT.PostClient.Client/{ => Mail}/LocalStoreSession.cs (98%) rename src/MaksIT.PostClient.Client/{ => Mail}/MailArchiveMap.cs (90%) create mode 100644 src/MaksIT.PostClient.Client/Mail/MailFetch.cs rename src/MaksIT.PostClient.Client/{ => Mail}/MailFolderCatalog.cs (97%) rename src/MaksIT.PostClient.Client/{ => Mail}/MailModels.cs (97%) rename src/MaksIT.PostClient.Client/{ => Mail}/MailSessionFactory.cs (91%) rename src/MaksIT.PostClient.Client/{ => Mail}/MailSessionGate.cs (97%) rename src/MaksIT.PostClient.Client/{ => Mail}/MailSocket.cs (93%) rename src/MaksIT.PostClient.Client/{ => Mail}/MailWorkerHost.cs (99%) rename src/MaksIT.PostClient.Client/{ => Mail}/Pop3MailSession.cs (79%) rename src/MaksIT.PostClient.Client/{ => Mail}/PstMailSession.cs (99%) rename src/MaksIT.PostClient.Client/{ => Mail}/SmtpSender.cs (97%) rename src/MaksIT.PostClient.Client/{ => Mime}/AttachmentText.cs (98%) rename src/MaksIT.PostClient.Client/{ => Mime}/AttachmentZip.cs (98%) rename src/MaksIT.PostClient.Client/{ => Mime}/EnvelopeUnwrapper.cs (97%) rename src/MaksIT.PostClient.Client/{ => Mime}/FatturaPa.cs (99%) rename src/MaksIT.PostClient.Client/{ => Mime}/MessagePrint.cs (97%) rename src/MaksIT.PostClient.Client/{ => Mime}/MimeAttachments.cs (52%) rename src/MaksIT.PostClient.Client/{ => Mime}/MimeBody.cs (86%) rename src/MaksIT.PostClient.Client/{ => Mime}/RecipientParser.cs (97%) rename src/MaksIT.PostClient.Client/{ => Search}/EmbeddingModelDownloader.cs (98%) rename src/MaksIT.PostClient.Client/{ => Search}/GpuProbe.cs (98%) rename src/MaksIT.PostClient.Client/{ => Search}/ISemanticSearchService.cs (86%) rename src/MaksIT.PostClient.Client/{ => Search}/ITextEmbedder.cs (78%) rename src/MaksIT.PostClient.Client/{ => Search}/OnnxGemmaEmbedder.cs (98%) rename src/MaksIT.PostClient.Client/{ => Search}/SemanticSearchService.cs (99%) rename src/MaksIT.PostClient.Client/{ => Update}/AppUpdateService.cs (98%) rename src/MaksIT.PostClient.Shared/{ => App}/AppInfo.cs (94%) rename src/MaksIT.PostClient.Shared/{ => App}/AppInstall.cs (98%) rename src/MaksIT.PostClient.Shared/{ => App}/AppLog.cs (93%) rename src/MaksIT.PostClient.Shared/{ => App}/AppPaths.cs (91%) rename src/MaksIT.PostClient.Shared/{ => App}/AppVersion.cs (97%) rename src/MaksIT.PostClient.Shared/{ => App}/ErrorReport.cs (98%) rename src/MaksIT.PostClient.Shared/{ => App}/GitHubRelease.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Archive}/ArchiveFiles.cs (97%) rename src/MaksIT.PostClient.Shared/{ => Archive}/FileSentReceiptStore.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Archive}/ISentReceiptStore.cs (94%) rename src/MaksIT.PostClient.Shared/{ => Archive}/LocalStoreSidecar.cs (95%) rename src/MaksIT.PostClient.Shared/{ => Archive}/MailArchiveCatalog.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Archive}/MailArchiveCopy.cs (88%) rename src/MaksIT.PostClient.Shared/{ => Archive}/MailArchiveLayout.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Archive}/MailArchiveStore.cs (94%) rename src/MaksIT.PostClient.Shared/{ => Auth}/FileSecretStore.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Auth}/HubDesktopSession.cs (97%) rename src/MaksIT.PostClient.Shared/{ => Auth}/ISecretStore.cs (80%) rename src/MaksIT.PostClient.Shared/{ => Auth}/IdentityHubAddress.cs (95%) rename src/MaksIT.PostClient.Shared/{ => Auth}/MailAuthKind.cs (93%) rename src/MaksIT.PostClient.Shared/{ => Auth}/OAuthClientId.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Auth}/OAuthClientSecret.cs (96%) rename src/MaksIT.PostClient.Shared/{ => Auth}/OAuthLoginHint.cs (93%) rename src/MaksIT.PostClient.Shared/{ => Auth}/OAuthMailScope.cs (97%) rename src/MaksIT.PostClient.Shared/{ => Auth}/OAuthTokenSet.cs (92%) rename src/MaksIT.PostClient.Shared/{ => Config}/Configuration.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Config}/ConfigurationFileService.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Config}/FeatureCatalog.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Config}/FeatureSettings.cs (96%) rename src/MaksIT.PostClient.Shared/{ => Content}/MessageHtml.cs (84%) rename src/MaksIT.PostClient.Shared/{ => Content}/MessageText.cs (87%) rename src/MaksIT.PostClient.Shared/{ => Content}/SimplePdf.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Envelope}/DaticertParser.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Envelope}/EnvelopeClassifier.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Envelope}/EnvelopeInfo.cs (97%) rename src/MaksIT.PostClient.Shared/{ => Envelope}/EnvelopeKind.cs (94%) rename src/MaksIT.PostClient.Shared/{ => Envelope}/ReceiptStatus.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Envelope}/RemEvidenceParser.cs (97%) create mode 100644 src/MaksIT.PostClient.Shared/GlobalUsings.cs rename src/MaksIT.PostClient.Shared/{ => Localization}/UiCopy.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Localization}/UiCopy.de.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Localization}/UiCopy.es.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Localization}/UiCopy.fr.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Localization}/UiLanguage.cs (95%) rename src/MaksIT.PostClient.Shared/{ => Localization}/UiLocale.cs (94%) rename src/MaksIT.PostClient.Shared/{ => Mail}/FolderRetention.cs (80%) rename src/MaksIT.PostClient.Shared/{ => Mail}/LayoutSettings.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailBodyKind.cs (96%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailCertifiedKind.cs (96%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailChain.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailDragPayload.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailDrop.cs (97%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailFolderLayout.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailFolderPath.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailFolderRole.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailFolderStats.cs (87%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailId.cs (96%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailIndexProgress.cs (93%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailLayout.cs (92%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailListBudget.cs (93%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailListOrder.cs (97%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailMessageKey.cs (96%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailMessageList.cs (79%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailPriority.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailProtocol.cs (96%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailProvider.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailRetention.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailRule.cs (99%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailSecurity.cs (97%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailSelection.cs (95%) create mode 100644 src/MaksIT.PostClient.Shared/Mail/MailSize.cs rename src/MaksIT.PostClient.Shared/{ => Mail}/MailWhen.cs (88%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailboxAccount.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Mail}/MailboxQuota.cs (96%) rename src/MaksIT.PostClient.Shared/{ => Search}/EmbeddingModelSpec.cs (98%) rename src/MaksIT.PostClient.Shared/{ => Search}/EmbeddingPrompt.cs (95%) rename src/MaksIT.PostClient.Shared/{ => Search}/EmbeddingVector.cs (97%) rename src/MaksIT.PostClient.Shared/{ => Search}/SemanticDevice.cs (93%) rename src/MaksIT.PostClient.Shared/{ => Search}/SemanticSearchSettings.cs (84%) rename src/MaksIT.PostClient.Tests/{ => App}/SharedTests.cs (92%) rename src/MaksIT.PostClient.Tests/{ => Archive}/ArchiveTests.cs (87%) rename src/MaksIT.PostClient.Tests/{ => Archive}/StoreAndHubTests.cs (99%) rename src/MaksIT.PostClient.Tests/{ => Auth}/OAuthTests.cs (98%) rename src/MaksIT.PostClient.Tests/{ => Envelope}/EnvelopeTests.cs (88%) create mode 100644 src/MaksIT.PostClient.Tests/GlobalUsings.cs rename src/MaksIT.PostClient.Tests/{ => Import}/OutlookRulesTests.cs (99%) rename src/MaksIT.PostClient.Tests/{ => Mail}/FolderDropTests.cs (98%) rename src/MaksIT.PostClient.Tests/{ => Mail}/ProtocolTests.cs (97%) create mode 100644 src/MaksIT.PostClient.Tests/Mime/MimeAttachmentTests.cs rename src/MaksIT.PostClient.Tests/{ => Mime}/RecipientTests.cs (92%) rename src/MaksIT.PostClient.Tests/{ => Search}/SemanticSearchTests.cs (98%) rename src/MaksIT.PostClient.UI/{ => Dialogs}/DesktopNotice.cs (98%) rename src/MaksIT.PostClient.UI/{ => Dialogs}/ErrorDialog.cs (97%) create mode 100644 src/MaksIT.PostClient.UI/GlobalUsings.cs rename src/MaksIT.PostClient.UI/{ => Layout}/LayoutPersistence.cs (99%) rename src/MaksIT.PostClient.UI/{ => Web}/WebViewSetup.cs (68%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7071052..fe6e1dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic ## [Unreleased] +## [0.3.8] - 2026-09-21 + +HTML mail opens without a WebView2 crash dialog. The message list shows each item's size. + +### Added + +- Message list **Size** column (sortable) so heavy mail is easy to find and delete. IMAP uses RFC822.SIZE, POP3 uses LIST sizes, and downloaded `.eml` files fill in the rest. + +### Changed + +- Client, Shared, UI, and Tests types live in feature folders with matching namespaces (`Mail`, `Auth`, `Archive`, and the rest). + +### Fixed + +- Opening HTML mail no longer crashes WebView2 with `NavigateToString` (large inlined images, or the engine still starting). The reading pane loads from a local file, and WebView failures fall back to text without a crash window. +- Message list paperclip shows for mail whose attachments are not `Content-Disposition: attachment` (Outlook inline files, named parts, nested `.eml`). IMAP now reads BODYSTRUCTURE, opening or indexing a message keeps the flag, and a later folder refresh no longer clears it. +- Body indexing no longer stalls on a few messages that fail or take too long: those are skipped (or dropped if they are gone from the server) so the rest of the archive can finish. Hung IMAP/POP3 downloads are aborted after 25 seconds and the socket is dropped so the next message can start. Messages larger than 8 MB wait until you open them. When every body is done, the indexing line hides like the meaning index. +- Search results keep Reply / Move / Delete on the context menu: right-click selects the mail under the pointer, and a folder refresh no longer treats hits from other folders as an empty selection. After delete, the next selection is tracked instead of a leftover or already-removed row. + ## [0.3.7] - 2026-09-20 Selected mail keeps its preview while the folder list refreshes. diff --git a/data/eu.postclient.desktop.metainfo.xml b/data/eu.postclient.desktop.metainfo.xml index 0ad6a78..ff09148 100644 --- a/data/eu.postclient.desktop.metainfo.xml +++ b/data/eu.postclient.desktop.metainfo.xml @@ -30,6 +30,11 @@ 768 + + +

HTML mail no longer crashes WebView2 on Windows. Large messages with inline images load from a local file, and WebView failures fall back to text. The message list shows each item's size so heavy mail is easy to sort and delete, and the paperclip stays on mail whose files are not marked as attachments. Search results keep the message context menu after you select mail.

+
+

Selected mail keeps its preview while the folder list refreshes. Delete and move use the row's folder, and opening a message is not stuck behind a catalog scan.

diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 11e0238..edfa7f3 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.3.7 + 0.3.8 Postclient Postclient Maksym Sadovnychyy diff --git a/src/MaksIT.PostClient.Client/IMailAuthService.cs b/src/MaksIT.PostClient.Client/Auth/IMailAuthService.cs similarity index 93% rename from src/MaksIT.PostClient.Client/IMailAuthService.cs rename to src/MaksIT.PostClient.Client/Auth/IMailAuthService.cs index ceb3af1..3d33bf9 100644 --- a/src/MaksIT.PostClient.Client/IMailAuthService.cs +++ b/src/MaksIT.PostClient.Client/Auth/IMailAuthService.cs @@ -1,8 +1,7 @@ using MaksIT.Results; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Auth; public sealed class MailAuthMaterial { diff --git a/src/MaksIT.PostClient.Client/MailAuthHint.cs b/src/MaksIT.PostClient.Client/Auth/MailAuthHint.cs similarity index 93% rename from src/MaksIT.PostClient.Client/MailAuthHint.cs rename to src/MaksIT.PostClient.Client/Auth/MailAuthHint.cs index 5cad71b..7a3bd18 100644 --- a/src/MaksIT.PostClient.Client/MailAuthHint.cs +++ b/src/MaksIT.PostClient.Client/Auth/MailAuthHint.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Auth; internal static class MailAuthHint { diff --git a/src/MaksIT.PostClient.Client/MailAuthService.cs b/src/MaksIT.PostClient.Client/Auth/MailAuthService.cs similarity index 99% rename from src/MaksIT.PostClient.Client/MailAuthService.cs rename to src/MaksIT.PostClient.Client/Auth/MailAuthService.cs index 3bfb480..78644ff 100644 --- a/src/MaksIT.PostClient.Client/MailAuthService.cs +++ b/src/MaksIT.PostClient.Client/Auth/MailAuthService.cs @@ -3,10 +3,9 @@ using System.Text.Json; using MaksIT.IdentityHub.Client; using MaksIT.IdentityHub.Contracts.Identity; using MaksIT.Results; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Auth; public sealed class MailAuthService : IMailAuthService { diff --git a/src/MaksIT.PostClient.Client/MailKitAuth.cs b/src/MaksIT.PostClient.Client/Auth/MailKitAuth.cs similarity index 90% rename from src/MaksIT.PostClient.Client/MailKitAuth.cs rename to src/MaksIT.PostClient.Client/Auth/MailKitAuth.cs index 87b3bfa..ac8f42d 100644 --- a/src/MaksIT.PostClient.Client/MailKitAuth.cs +++ b/src/MaksIT.PostClient.Client/Auth/MailKitAuth.cs @@ -1,9 +1,8 @@ using MailKit; using MailKit.Security; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Auth; internal static class MailKitAuth { diff --git a/src/MaksIT.PostClient.Client/OAuthLoopback.cs b/src/MaksIT.PostClient.Client/Auth/OAuthLoopback.cs similarity index 99% rename from src/MaksIT.PostClient.Client/OAuthLoopback.cs rename to src/MaksIT.PostClient.Client/Auth/OAuthLoopback.cs index a4aa6c1..417721f 100644 --- a/src/MaksIT.PostClient.Client/OAuthLoopback.cs +++ b/src/MaksIT.PostClient.Client/Auth/OAuthLoopback.cs @@ -4,7 +4,7 @@ using System.Text; using MaksIT.Results; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Auth; internal sealed class OAuthLoopback : IDisposable { diff --git a/src/MaksIT.PostClient.Client/OAuthPkce.cs b/src/MaksIT.PostClient.Client/Auth/OAuthPkce.cs similarity index 97% rename from src/MaksIT.PostClient.Client/OAuthPkce.cs rename to src/MaksIT.PostClient.Client/Auth/OAuthPkce.cs index a7876f8..ecd27cf 100644 --- a/src/MaksIT.PostClient.Client/OAuthPkce.cs +++ b/src/MaksIT.PostClient.Client/Auth/OAuthPkce.cs @@ -3,7 +3,7 @@ using System.Text; using System.Text.Json; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Auth; public static class OAuthPkce { diff --git a/src/MaksIT.PostClient.Client/OAuthProviderProfile.cs b/src/MaksIT.PostClient.Client/Auth/OAuthProviderProfile.cs similarity index 94% rename from src/MaksIT.PostClient.Client/OAuthProviderProfile.cs rename to src/MaksIT.PostClient.Client/Auth/OAuthProviderProfile.cs index aed23d1..d4e7d67 100644 --- a/src/MaksIT.PostClient.Client/OAuthProviderProfile.cs +++ b/src/MaksIT.PostClient.Client/Auth/OAuthProviderProfile.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Auth; internal sealed class OAuthProviderProfile { diff --git a/src/MaksIT.PostClient.Client/OAuthTokenClient.cs b/src/MaksIT.PostClient.Client/Auth/OAuthTokenClient.cs similarity index 98% rename from src/MaksIT.PostClient.Client/OAuthTokenClient.cs rename to src/MaksIT.PostClient.Client/Auth/OAuthTokenClient.cs index 6a46028..a26852d 100644 --- a/src/MaksIT.PostClient.Client/OAuthTokenClient.cs +++ b/src/MaksIT.PostClient.Client/Auth/OAuthTokenClient.cs @@ -1,10 +1,9 @@ using System.Net.Http; using System.Text.Json; using MaksIT.Results; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Auth; internal static class OAuthTokenClient { diff --git a/src/MaksIT.PostClient.Client/OAuthTrace.cs b/src/MaksIT.PostClient.Client/Auth/OAuthTrace.cs similarity index 82% rename from src/MaksIT.PostClient.Client/OAuthTrace.cs rename to src/MaksIT.PostClient.Client/Auth/OAuthTrace.cs index 2dc5877..c9458a1 100644 --- a/src/MaksIT.PostClient.Client/OAuthTrace.cs +++ b/src/MaksIT.PostClient.Client/Auth/OAuthTrace.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Auth; internal static class OAuthTrace { diff --git a/src/MaksIT.PostClient.Client/Extensions/ServiceCollectionExtensions.cs b/src/MaksIT.PostClient.Client/Extensions/ServiceCollectionExtensions.cs index fab7d65..bda09d6 100644 --- a/src/MaksIT.PostClient.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/MaksIT.PostClient.Client/Extensions/ServiceCollectionExtensions.cs @@ -1,9 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using MaksIT.IdentityHub.Client; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Extensions; public static class ServiceCollectionExtensions { diff --git a/src/MaksIT.PostClient.Client/GlobalUsings.cs b/src/MaksIT.PostClient.Client/GlobalUsings.cs new file mode 100644 index 0000000..3968f06 --- /dev/null +++ b/src/MaksIT.PostClient.Client/GlobalUsings.cs @@ -0,0 +1,15 @@ +global using MaksIT.PostClient.Client.Auth; +global using MaksIT.PostClient.Client.Import; +global using MaksIT.PostClient.Client.Mail; +global using MaksIT.PostClient.Client.Mime; +global using MaksIT.PostClient.Client.Search; +global using MaksIT.PostClient.Client.Update; +global using MaksIT.PostClient.Shared.App; +global using MaksIT.PostClient.Shared.Archive; +global using MaksIT.PostClient.Shared.Auth; +global using MaksIT.PostClient.Shared.Config; +global using MaksIT.PostClient.Shared.Content; +global using MaksIT.PostClient.Shared.Envelope; +global using MaksIT.PostClient.Shared.Localization; +global using MaksIT.PostClient.Shared.Mail; +global using MaksIT.PostClient.Shared.Search; diff --git a/src/MaksIT.PostClient.Client/LocalMailImport.cs b/src/MaksIT.PostClient.Client/Import/LocalMailImport.cs similarity index 98% rename from src/MaksIT.PostClient.Client/LocalMailImport.cs rename to src/MaksIT.PostClient.Client/Import/LocalMailImport.cs index 2bb1549..377ad8c 100644 --- a/src/MaksIT.PostClient.Client/LocalMailImport.cs +++ b/src/MaksIT.PostClient.Client/Import/LocalMailImport.cs @@ -1,9 +1,8 @@ using MimeKit; -using MaksIT.PostClient.Shared; using MaksIT.Results; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Import; public sealed class LocalMailImport { diff --git a/src/MaksIT.PostClient.Client/MapiRestriction.cs b/src/MaksIT.PostClient.Client/Import/MapiRestriction.cs similarity index 99% rename from src/MaksIT.PostClient.Client/MapiRestriction.cs rename to src/MaksIT.PostClient.Client/Import/MapiRestriction.cs index d0c0e75..7f57ee7 100644 --- a/src/MaksIT.PostClient.Client/MapiRestriction.cs +++ b/src/MaksIT.PostClient.Client/Import/MapiRestriction.cs @@ -1,8 +1,7 @@ using System.Text; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Import; internal static class MapiRestriction { diff --git a/src/MaksIT.PostClient.Client/MboxReader.cs b/src/MaksIT.PostClient.Client/Import/MboxReader.cs similarity index 96% rename from src/MaksIT.PostClient.Client/MboxReader.cs rename to src/MaksIT.PostClient.Client/Import/MboxReader.cs index ca73381..9abf548 100644 --- a/src/MaksIT.PostClient.Client/MboxReader.cs +++ b/src/MaksIT.PostClient.Client/Import/MboxReader.cs @@ -1,7 +1,7 @@ using System.Text; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Import; public static class MboxReader { diff --git a/src/MaksIT.PostClient.Client/OleCompound.cs b/src/MaksIT.PostClient.Client/Import/OleCompound.cs similarity index 98% rename from src/MaksIT.PostClient.Client/OleCompound.cs rename to src/MaksIT.PostClient.Client/Import/OleCompound.cs index 1a22c9b..8d21bca 100644 --- a/src/MaksIT.PostClient.Client/OleCompound.cs +++ b/src/MaksIT.PostClient.Client/Import/OleCompound.cs @@ -1,7 +1,7 @@ using System.Text; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Import; internal static class OleCompound { diff --git a/src/MaksIT.PostClient.Client/OutlookPst.cs b/src/MaksIT.PostClient.Client/Import/OutlookPst.cs similarity index 98% rename from src/MaksIT.PostClient.Client/OutlookPst.cs rename to src/MaksIT.PostClient.Client/Import/OutlookPst.cs index 9c3b91f..fdc48f0 100644 --- a/src/MaksIT.PostClient.Client/OutlookPst.cs +++ b/src/MaksIT.PostClient.Client/Import/OutlookPst.cs @@ -1,7 +1,7 @@ using OfficeIMO.Email.Store; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Import; public static class OutlookPst { diff --git a/src/MaksIT.PostClient.Client/OutlookRules.cs b/src/MaksIT.PostClient.Client/Import/OutlookRules.cs similarity index 98% rename from src/MaksIT.PostClient.Client/OutlookRules.cs rename to src/MaksIT.PostClient.Client/Import/OutlookRules.cs index aadcb45..1c5f829 100644 --- a/src/MaksIT.PostClient.Client/OutlookRules.cs +++ b/src/MaksIT.PostClient.Client/Import/OutlookRules.cs @@ -1,10 +1,9 @@ using System.Text.Json; using XstReader; using XstReader.ElementProperties; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Import; public static class OutlookRules { diff --git a/src/MaksIT.PostClient.Client/OutlookRwz.cs b/src/MaksIT.PostClient.Client/Import/OutlookRwz.cs similarity index 98% rename from src/MaksIT.PostClient.Client/OutlookRwz.cs rename to src/MaksIT.PostClient.Client/Import/OutlookRwz.cs index e7fd1d3..c896b77 100644 --- a/src/MaksIT.PostClient.Client/OutlookRwz.cs +++ b/src/MaksIT.PostClient.Client/Import/OutlookRwz.cs @@ -1,9 +1,8 @@ using System.Text; using System.Text.RegularExpressions; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Import; public static class OutlookRwz { diff --git a/src/MaksIT.PostClient.Client/PstFile.cs b/src/MaksIT.PostClient.Client/Import/PstFile.cs similarity index 94% rename from src/MaksIT.PostClient.Client/PstFile.cs rename to src/MaksIT.PostClient.Client/Import/PstFile.cs index c195ed1..7a79d92 100644 --- a/src/MaksIT.PostClient.Client/PstFile.cs +++ b/src/MaksIT.PostClient.Client/Import/PstFile.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Import; public static class PstFile { diff --git a/src/MaksIT.PostClient.Client/PstImport.cs b/src/MaksIT.PostClient.Client/Import/PstImport.cs similarity index 97% rename from src/MaksIT.PostClient.Client/PstImport.cs rename to src/MaksIT.PostClient.Client/Import/PstImport.cs index dfd542f..c084311 100644 --- a/src/MaksIT.PostClient.Client/PstImport.cs +++ b/src/MaksIT.PostClient.Client/Import/PstImport.cs @@ -1,8 +1,7 @@ using XstReader; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Import; public static class PstImport { diff --git a/src/MaksIT.PostClient.Client/PstMime.cs b/src/MaksIT.PostClient.Client/Import/PstMime.cs similarity index 98% rename from src/MaksIT.PostClient.Client/PstMime.cs rename to src/MaksIT.PostClient.Client/Import/PstMime.cs index 0deeb54..fe37cea 100644 --- a/src/MaksIT.PostClient.Client/PstMime.cs +++ b/src/MaksIT.PostClient.Client/Import/PstMime.cs @@ -2,7 +2,7 @@ using MimeKit; using XstReader; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Import; public static class PstMime { diff --git a/src/MaksIT.PostClient.Client/PstStoreMigrator.cs b/src/MaksIT.PostClient.Client/Import/PstStoreMigrator.cs similarity index 97% rename from src/MaksIT.PostClient.Client/PstStoreMigrator.cs rename to src/MaksIT.PostClient.Client/Import/PstStoreMigrator.cs index 75a2110..648247c 100644 --- a/src/MaksIT.PostClient.Client/PstStoreMigrator.cs +++ b/src/MaksIT.PostClient.Client/Import/PstStoreMigrator.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Import; public static class PstStoreMigrator { diff --git a/src/MaksIT.PostClient.Client/PstSubject.cs b/src/MaksIT.PostClient.Client/Import/PstSubject.cs similarity index 95% rename from src/MaksIT.PostClient.Client/PstSubject.cs rename to src/MaksIT.PostClient.Client/Import/PstSubject.cs index 2ce1723..1753818 100644 --- a/src/MaksIT.PostClient.Client/PstSubject.cs +++ b/src/MaksIT.PostClient.Client/Import/PstSubject.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Import; public static class PstSubject { diff --git a/src/MaksIT.PostClient.Client/ThunderbirdProfiles.cs b/src/MaksIT.PostClient.Client/Import/ThunderbirdProfiles.cs similarity index 97% rename from src/MaksIT.PostClient.Client/ThunderbirdProfiles.cs rename to src/MaksIT.PostClient.Client/Import/ThunderbirdProfiles.cs index 9dd0738..ada412f 100644 --- a/src/MaksIT.PostClient.Client/ThunderbirdProfiles.cs +++ b/src/MaksIT.PostClient.Client/Import/ThunderbirdProfiles.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Import; public static class ThunderbirdProfiles { diff --git a/src/MaksIT.PostClient.Client/IMailSession.cs b/src/MaksIT.PostClient.Client/Mail/IMailSession.cs similarity index 94% rename from src/MaksIT.PostClient.Client/IMailSession.cs rename to src/MaksIT.PostClient.Client/Mail/IMailSession.cs index 0f88f5d..bdbdd21 100644 --- a/src/MaksIT.PostClient.Client/IMailSession.cs +++ b/src/MaksIT.PostClient.Client/Mail/IMailSession.cs @@ -1,8 +1,7 @@ -using MaksIT.PostClient.Shared; using MaksIT.Results; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; public interface IMailSession : IAsyncDisposable { @@ -23,7 +22,8 @@ public interface IMailSession : IAsyncDisposable { Task> GetMessageAsync( string folder, uint id, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + bool interactive = true); Task> GetQuotaAsync(CancellationToken cancellationToken = default); diff --git a/src/MaksIT.PostClient.Client/ImapFetchPolicy.cs b/src/MaksIT.PostClient.Client/Mail/ImapFetchPolicy.cs similarity index 93% rename from src/MaksIT.PostClient.Client/ImapFetchPolicy.cs rename to src/MaksIT.PostClient.Client/Mail/ImapFetchPolicy.cs index 84a6e65..53919a3 100644 --- a/src/MaksIT.PostClient.Client/ImapFetchPolicy.cs +++ b/src/MaksIT.PostClient.Client/Mail/ImapFetchPolicy.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; internal static class ImapFetchPolicy { diff --git a/src/MaksIT.PostClient.Client/ImapMailSession.cs b/src/MaksIT.PostClient.Client/Mail/ImapMailSession.cs similarity index 93% rename from src/MaksIT.PostClient.Client/ImapMailSession.cs rename to src/MaksIT.PostClient.Client/Mail/ImapMailSession.cs index 7611f91..e56e299 100644 --- a/src/MaksIT.PostClient.Client/ImapMailSession.cs +++ b/src/MaksIT.PostClient.Client/Mail/ImapMailSession.cs @@ -3,10 +3,9 @@ using MailKit.Net.Imap; using MailKit.Search; using MimeKit; using MaksIT.Results; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; public sealed class ImapMailSession : IMailSession { @@ -26,7 +25,9 @@ public sealed class ImapMailSession : IMailSession { MessageSummaryItems.UniqueId | MessageSummaryItems.Envelope | MessageSummaryItems.Flags - | MessageSummaryItems.InternalDate; + | MessageSummaryItems.InternalDate + | MessageSummaryItems.Size + | MessageSummaryItems.BodyStructure; public ImapMailSession(IMailAuthService auth) { _auth = auth; @@ -117,7 +118,7 @@ public sealed class ImapMailSession : IMailSession { IReadOnlySet? knownIds, CancellationToken cancellationToken, int itemBudget) { - var client = RequireImap(); + var client = await EnsureImapAsync(cancellationToken).ConfigureAwait(false); if (client is null) return Result.UnprocessableEntity(null, "Not connected."); if (string.IsNullOrWhiteSpace(folder)) @@ -238,26 +239,41 @@ public sealed class ImapMailSession : IMailSession { public Task> GetMessageAsync( string folder, uint id, - CancellationToken cancellationToken = default) => - _io.RunAsync(() => GetMessageCoreAsync(folder, id, cancellationToken), cancellationToken, interactive: true); + CancellationToken cancellationToken = default, + bool interactive = true) => + _io.RunAsync(() => GetMessageCoreAsync(folder, id, cancellationToken, interactive), cancellationToken, interactive); private async Task> GetMessageCoreAsync( string folder, uint id, - CancellationToken cancellationToken) { - var client = RequireImap(); + CancellationToken cancellationToken, + bool interactive) { + var client = await EnsureImapAsync(cancellationToken).ConfigureAwait(false); if (client is null) return Result.UnprocessableEntity(null, "Not connected."); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var abort = linked.Token.Register(DropSocket); + if (!interactive) + linked.CancelAfter(MailFetch.BackfillTimeout); + MimeMessage mime; try { - var imapFolder = await GetMailFolderAsync(client, folder, cancellationToken).ConfigureAwait(false); - await OpenReadOnlyAsync(imapFolder, cancellationToken).ConfigureAwait(false); - var mime = await imapFolder.GetMessageAsync(new UniqueId(id), cancellationToken).ConfigureAwait(false); - return Result.Ok(await MimeBody.FromMimeAsync(folder, id, mime, cancellationToken).ConfigureAwait(false)); + var imapFolder = await GetMailFolderAsync(client, folder, linked.Token).ConfigureAwait(false); + await OpenReadOnlyAsync(imapFolder, linked.Token).ConfigureAwait(false); + mime = await imapFolder.GetMessageAsync(new UniqueId(id), linked.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return Result.UnprocessableEntity(null, "Timed out."); + } + catch (OperationCanceledException) { + throw; } catch (Exception ex) { return Result.UnprocessableEntity(null, ex.Message); } + + abort.Unregister(); + return Result.Ok(await MimeBody.FromMimeAsync(folder, id, mime, cancellationToken).ConfigureAwait(false)); } public Task> GetQuotaAsync(CancellationToken cancellationToken = default) => @@ -685,6 +701,61 @@ public sealed class ImapMailSession : IMailSession { return _imap is { IsConnected: true, IsAuthenticated: true } ? _imap : null; } + private async Task EnsureImapAsync(CancellationToken cancellationToken) { + var live = RequireImap(); + if (live is not null) + return live; + MailboxAccount? account; + MailAuthMaterial? material; + lock (_gate) { + account = _account; + material = _material; + } + + if (account is null || material is null || string.IsNullOrWhiteSpace(account.ImapHost)) + return null; + var client = new ImapClient(); + try { + await client.ConnectAsync( + account.ImapHost.Trim(), + account.ImapPort, + MailSocket.Incoming(account), + cancellationToken).ConfigureAwait(false); + await MailKitAuth.AuthenticateAsync(client, account, material, cancellationToken).ConfigureAwait(false); + } + catch { + client.Dispose(); + return null; + } + + lock (_gate) { + if (_imap is { IsConnected: true, IsAuthenticated: true } current) { + client.Dispose(); + return current; + } + + _imap = client; + } + + return client; + } + + private void DropSocket() { + ImapClient? client; + lock (_gate) { + client = _imap; + _imap = null; + } + + if (client is null) + return; + try { + client.Dispose(); + } + catch { + } + } + private string? AccountProvider() { lock (_gate) return _account?.Provider; @@ -1007,7 +1078,7 @@ public sealed class ImapMailSession : IMailSession { envelope?.Date ?? summary.InternalDate ?? DateTimeOffset.MinValue, summary.Flags?.HasFlag(MessageFlags.Seen) == true, summary.Flags?.HasFlag(MessageFlags.Flagged) == true, - summary.Attachments.Any() || names.Any(EnvelopeClassifier.IsPostacertName), + summary.Attachments.Any() || MimeAttachments.HasFromStructure(summary.Body), info, MailPriority.Resolve(keywords, xPriority, importance, priorityHeader), envelope?.MessageId, @@ -1016,7 +1087,8 @@ public sealed class ImapMailSession : IMailSession { HeaderValue(summary, "X-Riferimento-Message-ID"), envelope?.InReplyTo, HeaderValue(summary, "In-Reply-To")), - HeaderValue(summary, "References"))); + HeaderValue(summary, "References")), + summary.Size ?? 0); } private static string FirstNonEmpty(params string?[] values) { @@ -1071,6 +1143,11 @@ public sealed class ImapMailSession : IMailSession { return; if (part is BodyPartBasic basic && !string.IsNullOrWhiteSpace(basic.FileName)) names.Add(basic.FileName); + if (part is BodyPartMessage rfc) { + CollectPartNames(rfc.Body, names); + return; + } + if (part is BodyPartMultipart multi) { foreach (var child in multi.BodyParts) CollectPartNames(child, names); diff --git a/src/MaksIT.PostClient.Client/LocalStoreSession.cs b/src/MaksIT.PostClient.Client/Mail/LocalStoreSession.cs similarity index 98% rename from src/MaksIT.PostClient.Client/LocalStoreSession.cs rename to src/MaksIT.PostClient.Client/Mail/LocalStoreSession.cs index c5fcaa5..e466bd1 100644 --- a/src/MaksIT.PostClient.Client/LocalStoreSession.cs +++ b/src/MaksIT.PostClient.Client/Mail/LocalStoreSession.cs @@ -1,9 +1,8 @@ using MimeKit; using MaksIT.Results; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; public interface IFileMailStore { @@ -137,14 +136,15 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore { public Task> GetMessageAsync( string folder, uint id, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken = default, + bool interactive = true) => _io.RunAsync(async () => { var path = MessagePath(folder, id); var body = await MimeBody.TryFromEmlAsync(folder, id, path, cancellationToken).ConfigureAwait(false); if (body is null) return Result.NotFound(null, "Message file is missing."); return Result.Ok(body); - }, cancellationToken, interactive: true); + }, cancellationToken, interactive); public Task> GetQuotaAsync(CancellationToken cancellationToken = default) { _ = cancellationToken; diff --git a/src/MaksIT.PostClient.Client/MailArchiveMap.cs b/src/MaksIT.PostClient.Client/Mail/MailArchiveMap.cs similarity index 90% rename from src/MaksIT.PostClient.Client/MailArchiveMap.cs rename to src/MaksIT.PostClient.Client/Mail/MailArchiveMap.cs index a9039e7..2be6c2e 100644 --- a/src/MaksIT.PostClient.Client/MailArchiveMap.cs +++ b/src/MaksIT.PostClient.Client/Mail/MailArchiveMap.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; public static class MailArchiveMap { @@ -21,7 +20,8 @@ public static class MailArchiveMap { EnvelopeTipo = header.EnvelopeTipo, MessageId = header.MessageId, InReplyTo = header.InReplyTo, - Labels = header.Labels + Labels = header.Labels, + Size = header.Size }; public static MailMessageHeader ToHeader(MailArchiveHeader row) => @@ -41,7 +41,8 @@ public static class MailArchiveMap { MessageId = row.MessageId, InReplyTo = row.InReplyTo, Labels = row.Labels, - DeliveryStatus = ReceiptStatus.FromTipo(row.EnvelopeTipo) + DeliveryStatus = ReceiptStatus.FromTipo(row.EnvelopeTipo), + Size = row.Size }; public static string BodyText(MailMessageBody body, bool unwrap) { diff --git a/src/MaksIT.PostClient.Client/Mail/MailFetch.cs b/src/MaksIT.PostClient.Client/Mail/MailFetch.cs new file mode 100644 index 0000000..327ce70 --- /dev/null +++ b/src/MaksIT.PostClient.Client/Mail/MailFetch.cs @@ -0,0 +1,29 @@ +namespace MaksIT.PostClient.Client.Mail; + + +public static class MailFetch { + public static readonly TimeSpan BackfillTimeout = TimeSpan.FromSeconds(25); + + public const long MaxBackfillBytes = 8L * 1024 * 1024; + public static bool IsGone(IEnumerable? messages) { + if (messages is null) + return false; + foreach (var message in messages) { + if (IsGone(message)) + return true; + } + + return false; + } + + public static bool IsGone(string? message) { + if (string.IsNullOrWhiteSpace(message)) + return false; + return message.Contains("no such message", StringComparison.OrdinalIgnoreCase) + || message.Contains("message not found", StringComparison.OrdinalIgnoreCase) + || message.Contains("the unique id is invalid", StringComparison.OrdinalIgnoreCase) + || message.Contains("uid is invalid", StringComparison.OrdinalIgnoreCase) + || message.Contains("has been expunged", StringComparison.OrdinalIgnoreCase) + || message.Contains("could not be found", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/MaksIT.PostClient.Client/MailFolderCatalog.cs b/src/MaksIT.PostClient.Client/Mail/MailFolderCatalog.cs similarity index 97% rename from src/MaksIT.PostClient.Client/MailFolderCatalog.cs rename to src/MaksIT.PostClient.Client/Mail/MailFolderCatalog.cs index 9b1a73e..ad3b753 100644 --- a/src/MaksIT.PostClient.Client/MailFolderCatalog.cs +++ b/src/MaksIT.PostClient.Client/Mail/MailFolderCatalog.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; internal static class MailFolderCatalog { diff --git a/src/MaksIT.PostClient.Client/MailModels.cs b/src/MaksIT.PostClient.Client/Mail/MailModels.cs similarity index 97% rename from src/MaksIT.PostClient.Client/MailModels.cs rename to src/MaksIT.PostClient.Client/Mail/MailModels.cs index 633f28a..7922863 100644 --- a/src/MaksIT.PostClient.Client/MailModels.cs +++ b/src/MaksIT.PostClient.Client/Mail/MailModels.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; public sealed class MailFolderInfo { @@ -34,11 +33,11 @@ public sealed class MailMessageHeader { public bool IsFlagged { get; set; } - public bool HasAttachments { get; init; } + public bool HasAttachments { get; set; } public string Priority { get; set; } = MailPriority.Normal; - public string EnvelopeKind { get; init; } = MaksIT.PostClient.Shared.EnvelopeKind.Ordinary; + public string EnvelopeKind { get; init; } = MaksIT.PostClient.Shared.Envelope.EnvelopeKind.Ordinary; public string EnvelopeBadge { get; init; } = ""; @@ -51,6 +50,8 @@ public sealed class MailMessageHeader { public string DeliveryStatus { get; set; } = ""; public string Labels { get; set; } = ""; + + public long Size { get; init; } } diff --git a/src/MaksIT.PostClient.Client/MailSessionFactory.cs b/src/MaksIT.PostClient.Client/Mail/MailSessionFactory.cs similarity index 91% rename from src/MaksIT.PostClient.Client/MailSessionFactory.cs rename to src/MaksIT.PostClient.Client/Mail/MailSessionFactory.cs index 8691f6d..e0d6e37 100644 --- a/src/MaksIT.PostClient.Client/MailSessionFactory.cs +++ b/src/MaksIT.PostClient.Client/Mail/MailSessionFactory.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; public sealed class MailSessionFactory : IMailSessionFactory { diff --git a/src/MaksIT.PostClient.Client/MailSessionGate.cs b/src/MaksIT.PostClient.Client/Mail/MailSessionGate.cs similarity index 97% rename from src/MaksIT.PostClient.Client/MailSessionGate.cs rename to src/MaksIT.PostClient.Client/Mail/MailSessionGate.cs index 2ad30f4..7d28801 100644 --- a/src/MaksIT.PostClient.Client/MailSessionGate.cs +++ b/src/MaksIT.PostClient.Client/Mail/MailSessionGate.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; internal sealed class MailSessionGate : IDisposable { diff --git a/src/MaksIT.PostClient.Client/MailSocket.cs b/src/MaksIT.PostClient.Client/Mail/MailSocket.cs similarity index 93% rename from src/MaksIT.PostClient.Client/MailSocket.cs rename to src/MaksIT.PostClient.Client/Mail/MailSocket.cs index b972a6e..07773e2 100644 --- a/src/MaksIT.PostClient.Client/MailSocket.cs +++ b/src/MaksIT.PostClient.Client/Mail/MailSocket.cs @@ -1,8 +1,7 @@ using MailKit.Security; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; internal static class MailSocket { diff --git a/src/MaksIT.PostClient.Client/MailWorkerHost.cs b/src/MaksIT.PostClient.Client/Mail/MailWorkerHost.cs similarity index 99% rename from src/MaksIT.PostClient.Client/MailWorkerHost.cs rename to src/MaksIT.PostClient.Client/Mail/MailWorkerHost.cs index fd9136e..24396a1 100644 --- a/src/MaksIT.PostClient.Client/MailWorkerHost.cs +++ b/src/MaksIT.PostClient.Client/Mail/MailWorkerHost.cs @@ -1,10 +1,9 @@ using System.IO.Pipes; using System.Text; using System.Text.Json; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; public sealed class MailWorkerHost : IDisposable { diff --git a/src/MaksIT.PostClient.Client/Pop3MailSession.cs b/src/MaksIT.PostClient.Client/Mail/Pop3MailSession.cs similarity index 79% rename from src/MaksIT.PostClient.Client/Pop3MailSession.cs rename to src/MaksIT.PostClient.Client/Mail/Pop3MailSession.cs index a591892..814183e 100644 --- a/src/MaksIT.PostClient.Client/Pop3MailSession.cs +++ b/src/MaksIT.PostClient.Client/Mail/Pop3MailSession.cs @@ -2,10 +2,9 @@ using MailKit.Net.Pop3; using MimeKit; using MimeKit.Utils; using MaksIT.Results; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; public sealed class Pop3MailSession : IMailSession { @@ -94,7 +93,7 @@ public sealed class Pop3MailSession : IMailSession { IReadOnlySet? knownIds, CancellationToken cancellationToken, int itemBudget) { - var client = RequirePop(); + var client = await EnsurePopAsync(cancellationToken).ConfigureAwait(false); if (client is null) return Result.UnprocessableEntity(null, "Not connected."); _ = folder; @@ -109,12 +108,13 @@ public sealed class Pop3MailSession : IMailSession { var count = client.Count; if (count == 0) return Result.Ok(new MailFolderSync { Present = [] }); + var messageSizes = await MessageSizesAsync(client, cancellationToken).ConfigureAwait(false); if (!chunk) { var indexes = Enumerable.Range(0, count).ToList(); var headers = await client.GetMessageHeadersAsync(indexes, cancellationToken).ConfigureAwait(false); var rows = indexes - .Select((index, i) => ToHeader(index, i < headers.Count ? headers[i] : new HeaderList())) + .Select((index, i) => ToHeader(index, i < headers.Count ? headers[i] : new HeaderList(), SizeAt(messageSizes, index))) .OrderByDescending(h => h.Date) .ThenByDescending(h => h.Id) .ToList(); @@ -140,7 +140,7 @@ public sealed class Pop3MailSession : IMailSession { _listedIds.Add(id); if (known.Contains(id)) continue; - added.Add(ToHeader(slice[i], i < chunkHeaders.Count ? chunkHeaders[i] : new HeaderList())); + added.Add(ToHeader(slice[i], i < chunkHeaders.Count ? chunkHeaders[i] : new HeaderList(), SizeAt(messageSizes, slice[i]))); } _listOffset = range.NextOffset; @@ -167,25 +167,40 @@ public sealed class Pop3MailSession : IMailSession { public Task> GetMessageAsync( string folder, uint id, - CancellationToken cancellationToken = default) => - _io.RunAsync(() => GetMessageCoreAsync(folder, id, cancellationToken), cancellationToken, interactive: true); + CancellationToken cancellationToken = default, + bool interactive = true) => + _io.RunAsync(() => GetMessageCoreAsync(folder, id, cancellationToken, interactive), cancellationToken, interactive); private async Task> GetMessageCoreAsync( string folder, uint id, - CancellationToken cancellationToken) { - var client = RequirePop(); + CancellationToken cancellationToken, + bool interactive) { + var client = await EnsurePopAsync(cancellationToken).ConfigureAwait(false); if (client is null) return Result.UnprocessableEntity(null, "Not connected."); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var abort = linked.Token.Register(DropSocket); + if (!interactive) + linked.CancelAfter(MailFetch.BackfillTimeout); + MimeMessage mime; try { var index = (int)id; - var mime = await client.GetMessageAsync(index, cancellationToken).ConfigureAwait(false); - return Result.Ok(await MimeBody.FromMimeAsync(Inbox, id, mime, cancellationToken).ConfigureAwait(false)); + mime = await client.GetMessageAsync(index, linked.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return Result.UnprocessableEntity(null, "Timed out."); + } + catch (OperationCanceledException) { + throw; } catch (Exception ex) { return Result.UnprocessableEntity(null, ex.Message); } + + abort.Unregister(); + return Result.Ok(await MimeBody.FromMimeAsync(Inbox, id, mime, cancellationToken).ConfigureAwait(false)); } public Task> GetQuotaAsync(CancellationToken cancellationToken = default) => @@ -323,6 +338,61 @@ public sealed class Pop3MailSession : IMailSession { return _pop is { IsConnected: true, IsAuthenticated: true } ? _pop : null; } + private async Task EnsurePopAsync(CancellationToken cancellationToken) { + var live = RequirePop(); + if (live is not null) + return live; + MailboxAccount? account; + MailAuthMaterial? material; + lock (_gate) { + account = _account; + material = _material; + } + + if (account is null || material is null || string.IsNullOrWhiteSpace(account.ImapHost)) + return null; + var client = new Pop3Client(); + try { + await client.ConnectAsync( + account.ImapHost.Trim(), + account.ImapPort, + MailSocket.Incoming(account), + cancellationToken).ConfigureAwait(false); + await MailKitAuth.AuthenticateAsync(client, account, material, cancellationToken).ConfigureAwait(false); + } + catch { + client.Dispose(); + return null; + } + + lock (_gate) { + if (_pop is { IsConnected: true, IsAuthenticated: true } current) { + client.Dispose(); + return current; + } + + _pop = client; + } + + return client; + } + + private void DropSocket() { + Pop3Client? client; + lock (_gate) { + client = _pop; + _pop = null; + } + + if (client is null) + return; + try { + client.Dispose(); + } + catch { + } + } + private async Task DisposeClientAsync() { Pop3Client? client; lock (_gate) { @@ -342,7 +412,7 @@ public sealed class Pop3MailSession : IMailSession { client.Dispose(); } - private static MailMessageHeader ToHeader(int index, HeaderList headers) { + private static MailMessageHeader ToHeader(int index, HeaderList headers, long size) { var date = DateTimeOffset.MinValue; var rawDate = headers[HeaderId.Date]; if (!string.IsNullOrWhiteSpace(rawDate)) @@ -353,6 +423,7 @@ public sealed class Pop3MailSession : IMailSession { headers.Select(h => new KeyValuePair(h.Field, h.Value)), []); var hasAttachments = contentType.Contains("multipart/mixed", StringComparison.OrdinalIgnoreCase) + || contentType.Contains("name=", StringComparison.OrdinalIgnoreCase) || disposition.Contains("attachment", StringComparison.OrdinalIgnoreCase) || info.HasInnerMessage; return MimeBody.ToListHeader( @@ -372,9 +443,22 @@ public sealed class Pop3MailSession : IMailSession { headers[HeaderId.MessageId], MailId.Parent( FirstNonEmpty(headers["X-Riferimento-Message-ID"], headers[HeaderId.InReplyTo]), - headers[HeaderId.References])); + headers[HeaderId.References]), + size); } + private static async Task?> MessageSizesAsync(Pop3Client client, CancellationToken cancellationToken) { + try { + return await client.GetMessageSizesAsync(cancellationToken).ConfigureAwait(false); + } + catch { + return null; + } + } + + private static long SizeAt(IList? sizes, int index) => + sizes is not null && index >= 0 && index < sizes.Count ? sizes[index] : 0; + private static string FirstNonEmpty(params string?[] values) { foreach (var value in values) { if (!string.IsNullOrWhiteSpace(value)) diff --git a/src/MaksIT.PostClient.Client/PstMailSession.cs b/src/MaksIT.PostClient.Client/Mail/PstMailSession.cs similarity index 99% rename from src/MaksIT.PostClient.Client/PstMailSession.cs rename to src/MaksIT.PostClient.Client/Mail/PstMailSession.cs index 892643b..1bb200d 100644 --- a/src/MaksIT.PostClient.Client/PstMailSession.cs +++ b/src/MaksIT.PostClient.Client/Mail/PstMailSession.cs @@ -3,10 +3,9 @@ using MimeKit; using OfficeIMO.Email; using OfficeIMO.Email.Store; using MaksIT.Results; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; public sealed class PstMailSession : IMailSession { @@ -122,8 +121,9 @@ public sealed class PstMailSession : IMailSession { public Task> GetMessageAsync( string folder, uint id, - CancellationToken cancellationToken = default) => - _io.RunAsync(() => GetMessageCoreAsync(folder, id, cancellationToken), cancellationToken, interactive: true); + CancellationToken cancellationToken = default, + bool interactive = true) => + _io.RunAsync(() => GetMessageCoreAsync(folder, id, cancellationToken), cancellationToken, interactive); public Task> GetQuotaAsync(CancellationToken cancellationToken = default) { _ = cancellationToken; @@ -308,6 +308,9 @@ public sealed class PstMailSession : IMailSession { return Result.Ok( await MimeBody.FromMimeAsync(folder, id, mime, cancellationToken, seen, flagged).ConfigureAwait(false)); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Result.UnprocessableEntity(null, ex.Message); } diff --git a/src/MaksIT.PostClient.Client/SmtpSender.cs b/src/MaksIT.PostClient.Client/Mail/SmtpSender.cs similarity index 97% rename from src/MaksIT.PostClient.Client/SmtpSender.cs rename to src/MaksIT.PostClient.Client/Mail/SmtpSender.cs index 9f0ea9d..0871f95 100644 --- a/src/MaksIT.PostClient.Client/SmtpSender.cs +++ b/src/MaksIT.PostClient.Client/Mail/SmtpSender.cs @@ -1,10 +1,9 @@ using MailKit.Net.Smtp; using MimeKit; using MaksIT.Results; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mail; internal static class SmtpSender { diff --git a/src/MaksIT.PostClient.Client/AttachmentText.cs b/src/MaksIT.PostClient.Client/Mime/AttachmentText.cs similarity index 98% rename from src/MaksIT.PostClient.Client/AttachmentText.cs rename to src/MaksIT.PostClient.Client/Mime/AttachmentText.cs index 24b0d94..f0c73ab 100644 --- a/src/MaksIT.PostClient.Client/AttachmentText.cs +++ b/src/MaksIT.PostClient.Client/Mime/AttachmentText.cs @@ -2,7 +2,7 @@ using System.Text; using UglyToad.PdfPig; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mime; public static class AttachmentText { diff --git a/src/MaksIT.PostClient.Client/AttachmentZip.cs b/src/MaksIT.PostClient.Client/Mime/AttachmentZip.cs similarity index 98% rename from src/MaksIT.PostClient.Client/AttachmentZip.cs rename to src/MaksIT.PostClient.Client/Mime/AttachmentZip.cs index b67f833..889d096 100644 --- a/src/MaksIT.PostClient.Client/AttachmentZip.cs +++ b/src/MaksIT.PostClient.Client/Mime/AttachmentZip.cs @@ -2,7 +2,7 @@ using System.IO.Compression; using ICSharpCode.SharpZipLib.Zip; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mime; public static class AttachmentZip { diff --git a/src/MaksIT.PostClient.Client/EnvelopeUnwrapper.cs b/src/MaksIT.PostClient.Client/Mime/EnvelopeUnwrapper.cs similarity index 97% rename from src/MaksIT.PostClient.Client/EnvelopeUnwrapper.cs rename to src/MaksIT.PostClient.Client/Mime/EnvelopeUnwrapper.cs index 1e2a502..d14491b 100644 --- a/src/MaksIT.PostClient.Client/EnvelopeUnwrapper.cs +++ b/src/MaksIT.PostClient.Client/Mime/EnvelopeUnwrapper.cs @@ -1,9 +1,8 @@ using MimeKit; using MimeKit.Cryptography; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mime; public sealed class EnvelopeParse { diff --git a/src/MaksIT.PostClient.Client/FatturaPa.cs b/src/MaksIT.PostClient.Client/Mime/FatturaPa.cs similarity index 99% rename from src/MaksIT.PostClient.Client/FatturaPa.cs rename to src/MaksIT.PostClient.Client/Mime/FatturaPa.cs index 8c52f21..fb7a24a 100644 --- a/src/MaksIT.PostClient.Client/FatturaPa.cs +++ b/src/MaksIT.PostClient.Client/Mime/FatturaPa.cs @@ -2,7 +2,7 @@ using System.Text; using System.Xml.Linq; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mime; public sealed class FatturaPaDocument { diff --git a/src/MaksIT.PostClient.Client/MessagePrint.cs b/src/MaksIT.PostClient.Client/Mime/MessagePrint.cs similarity index 97% rename from src/MaksIT.PostClient.Client/MessagePrint.cs rename to src/MaksIT.PostClient.Client/Mime/MessagePrint.cs index 7ec0ffd..88c80d4 100644 --- a/src/MaksIT.PostClient.Client/MessagePrint.cs +++ b/src/MaksIT.PostClient.Client/Mime/MessagePrint.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mime; public static class MessagePrint { diff --git a/src/MaksIT.PostClient.Client/MimeAttachments.cs b/src/MaksIT.PostClient.Client/Mime/MimeAttachments.cs similarity index 52% rename from src/MaksIT.PostClient.Client/MimeAttachments.cs rename to src/MaksIT.PostClient.Client/Mime/MimeAttachments.cs index af33b76..48f7528 100644 --- a/src/MaksIT.PostClient.Client/MimeAttachments.cs +++ b/src/MaksIT.PostClient.Client/Mime/MimeAttachments.cs @@ -1,9 +1,9 @@ +using MailKit; using MimeKit; using MimeKit.Cryptography; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mime; internal static class MimeAttachments { @@ -17,6 +17,53 @@ internal static class MimeAttachments { public static IReadOnlyList Names(IEnumerable files) => files.Select(f => f.Name).ToList(); + public static bool HasVisible(IEnumerable files, string? html) { + foreach (var file in files) { + if (!IsInlineImage(file, html)) + return true; + } + + return false; + } + + public static bool IsInlineImage(MailFileAttachment file, string? html) { + if (string.IsNullOrWhiteSpace(file.ContentId)) + return false; + if (!file.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + return false; + if (string.IsNullOrWhiteSpace(html)) + return false; + return html.Contains("cid:" + file.ContentId, StringComparison.OrdinalIgnoreCase); + } + + public static bool HasFromStructure(BodyPart? part) => + HasFromStructure(part, skipEnvelope: false); + + private static bool HasFromStructure(BodyPart? part, bool skipEnvelope) { + if (part is null) + return false; + if (part is BodyPartMultipart multi) { + if (multi.ContentType.IsMimeType("multipart", "signed") && multi.BodyParts.Count > 0) + return HasFromStructure(multi.BodyParts[0], skipEnvelope); + foreach (var child in multi.BodyParts) { + if (HasFromStructure(child, skipEnvelope)) + return true; + } + + return false; + } + + if (part is BodyPartMessage rfc) { + if (!skipEnvelope) + return true; + return HasFromStructure(rfc.Body, skipEnvelope: true); + } + + if (part is BodyPartBasic basic) + return IsVisiblePart(basic, skipEnvelope); + return false; + } + private static void Walk(MimeEntity? entity, List list, bool skipEnvelope) { if (entity is null) return; @@ -77,12 +124,44 @@ internal static class MimeAttachments { }); } + private static bool IsVisiblePart(BodyPartBasic part, bool skipEnvelope) { + var name = part.FileName ?? ""; + if (skipEnvelope && IsEnvelopeName(name)) + return false; + if (IsBodyText(part, name)) + return false; + if (IsInlineImage(part)) + return false; + if (part.IsAttachment || !string.IsNullOrWhiteSpace(name)) + return true; + if (!string.IsNullOrWhiteSpace(part.ContentId)) + return true; + var media = part.ContentType.MediaType ?? ""; + if (media.Equals("text", StringComparison.OrdinalIgnoreCase) + || media.Equals("multipart", StringComparison.OrdinalIgnoreCase) + || media.Equals("message", StringComparison.OrdinalIgnoreCase)) + return false; + return part.Octets > 0; + } + private static bool IsBodyText(MimePart part, string name) { if (part is not TextPart || part.IsAttachment || !string.IsNullOrWhiteSpace(name)) return false; return part.ContentType.IsMimeType("text", "plain") || part.ContentType.IsMimeType("text", "html"); } + private static bool IsBodyText(BodyPartBasic part, string name) { + if (part.IsAttachment || !string.IsNullOrWhiteSpace(name)) + return false; + return part.ContentType.IsMimeType("text", "plain") || part.ContentType.IsMimeType("text", "html"); + } + + private static bool IsInlineImage(BodyPartBasic part) { + if (part.IsAttachment || string.IsNullOrWhiteSpace(part.ContentId)) + return false; + return string.Equals(part.ContentType.MediaType, "image", StringComparison.OrdinalIgnoreCase); + } + private static bool IsEnvelopeName(string name) => EnvelopeClassifier.IsDaticertName(name) || EnvelopeClassifier.IsPostacertName(name) diff --git a/src/MaksIT.PostClient.Client/MimeBody.cs b/src/MaksIT.PostClient.Client/Mime/MimeBody.cs similarity index 86% rename from src/MaksIT.PostClient.Client/MimeBody.cs rename to src/MaksIT.PostClient.Client/Mime/MimeBody.cs index f42e31c..c96f81d 100644 --- a/src/MaksIT.PostClient.Client/MimeBody.cs +++ b/src/MaksIT.PostClient.Client/Mime/MimeBody.cs @@ -1,8 +1,7 @@ using MimeKit; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mime; public static class MimeBody { @@ -15,7 +14,8 @@ public static class MimeBody { return null; await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); var mime = await MimeMessage.LoadAsync(stream, cancellationToken).ConfigureAwait(false); - return await FromMimeAsync(folder, id, mime, cancellationToken).ConfigureAwait(false); + return await FromMimeAsync(folder, id, mime, cancellationToken, size: new FileInfo(path).Length) + .ConfigureAwait(false); } public static async Task FromMimeAsync( @@ -24,7 +24,8 @@ public static class MimeBody { MimeMessage mime, CancellationToken cancellationToken, bool seen = false, - bool flagged = false) { + bool flagged = false, + long size = 0) { using var buffer = new MemoryStream(); await mime.WriteToAsync(buffer, cancellationToken).ConfigureAwait(false); var parsed = EnvelopeUnwrapper.Inspect(mime); @@ -36,9 +37,11 @@ public static class MimeBody { id, mime, parsed.Info, - outerFiles.Count > 0 || innerFiles.Count > 0, + MimeAttachments.HasVisible(outerFiles, mime.HtmlBody) + || MimeAttachments.HasVisible(innerFiles, inner?.HtmlBody), seen, - flagged); + flagged, + size > 0 ? size : buffer.Length); return new MailMessageBody { Header = header, Text = mime.TextBody ?? "", @@ -67,7 +70,8 @@ public static class MimeBody { EnvelopeInfo info, bool hasAttachments, bool seen = false, - bool flagged = false) => + bool flagged = false, + long size = 0) => new() { Id = id, Folder = folder, @@ -87,7 +91,8 @@ public static class MimeBody { EnvelopeTipo = info.Tipo, MessageId = mime.MessageId ?? "", InReplyTo = MailId.Parent(mime.InReplyTo, string.Join(" ", mime.References)), - DeliveryStatus = info.EventStatus + DeliveryStatus = info.EventStatus, + Size = size }; public static MailMessageHeader ToListHeader( @@ -102,7 +107,8 @@ public static class MimeBody { EnvelopeInfo info, string priority = MailPriority.Normal, string? messageId = null, - string? inReplyTo = null) => + string? inReplyTo = null, + long size = 0) => new() { Id = id, Folder = folder, @@ -118,6 +124,7 @@ public static class MimeBody { EnvelopeTipo = info.Tipo, MessageId = messageId ?? "", InReplyTo = inReplyTo ?? "", - DeliveryStatus = info.EventStatus + DeliveryStatus = info.EventStatus, + Size = size }; } diff --git a/src/MaksIT.PostClient.Client/RecipientParser.cs b/src/MaksIT.PostClient.Client/Mime/RecipientParser.cs similarity index 97% rename from src/MaksIT.PostClient.Client/RecipientParser.cs rename to src/MaksIT.PostClient.Client/Mime/RecipientParser.cs index c0bfc3f..01395d3 100644 --- a/src/MaksIT.PostClient.Client/RecipientParser.cs +++ b/src/MaksIT.PostClient.Client/Mime/RecipientParser.cs @@ -1,7 +1,7 @@ using MimeKit; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Mime; public sealed class MailRecipient { diff --git a/src/MaksIT.PostClient.Client/EmbeddingModelDownloader.cs b/src/MaksIT.PostClient.Client/Search/EmbeddingModelDownloader.cs similarity index 98% rename from src/MaksIT.PostClient.Client/EmbeddingModelDownloader.cs rename to src/MaksIT.PostClient.Client/Search/EmbeddingModelDownloader.cs index d616763..14aedfd 100644 --- a/src/MaksIT.PostClient.Client/EmbeddingModelDownloader.cs +++ b/src/MaksIT.PostClient.Client/Search/EmbeddingModelDownloader.cs @@ -1,10 +1,9 @@ using System.Diagnostics; using System.Net.Http.Headers; using MaksIT.Results; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Search; public sealed class EmbeddingModelDownloader : IDisposable { diff --git a/src/MaksIT.PostClient.Client/GpuProbe.cs b/src/MaksIT.PostClient.Client/Search/GpuProbe.cs similarity index 98% rename from src/MaksIT.PostClient.Client/GpuProbe.cs rename to src/MaksIT.PostClient.Client/Search/GpuProbe.cs index 34a41cb..bd7f037 100644 --- a/src/MaksIT.PostClient.Client/GpuProbe.cs +++ b/src/MaksIT.PostClient.Client/Search/GpuProbe.cs @@ -1,9 +1,8 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Search; public readonly record struct GpuAdapter( diff --git a/src/MaksIT.PostClient.Client/ISemanticSearchService.cs b/src/MaksIT.PostClient.Client/Search/ISemanticSearchService.cs similarity index 86% rename from src/MaksIT.PostClient.Client/ISemanticSearchService.cs rename to src/MaksIT.PostClient.Client/Search/ISemanticSearchService.cs index 5752707..c8c3047 100644 --- a/src/MaksIT.PostClient.Client/ISemanticSearchService.cs +++ b/src/MaksIT.PostClient.Client/Search/ISemanticSearchService.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Search; public interface ISemanticSearchService : IDisposable { diff --git a/src/MaksIT.PostClient.Client/ITextEmbedder.cs b/src/MaksIT.PostClient.Client/Search/ITextEmbedder.cs similarity index 78% rename from src/MaksIT.PostClient.Client/ITextEmbedder.cs rename to src/MaksIT.PostClient.Client/Search/ITextEmbedder.cs index 6cacff9..390ac6e 100644 --- a/src/MaksIT.PostClient.Client/ITextEmbedder.cs +++ b/src/MaksIT.PostClient.Client/Search/ITextEmbedder.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Search; public interface ITextEmbedder : IDisposable { diff --git a/src/MaksIT.PostClient.Client/OnnxGemmaEmbedder.cs b/src/MaksIT.PostClient.Client/Search/OnnxGemmaEmbedder.cs similarity index 98% rename from src/MaksIT.PostClient.Client/OnnxGemmaEmbedder.cs rename to src/MaksIT.PostClient.Client/Search/OnnxGemmaEmbedder.cs index 249d73f..9772840 100644 --- a/src/MaksIT.PostClient.Client/OnnxGemmaEmbedder.cs +++ b/src/MaksIT.PostClient.Client/Search/OnnxGemmaEmbedder.cs @@ -3,10 +3,9 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.ML.Tokenizers; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Search; public sealed class OnnxGemmaEmbedder : ITextEmbedder { diff --git a/src/MaksIT.PostClient.Client/SemanticSearchService.cs b/src/MaksIT.PostClient.Client/Search/SemanticSearchService.cs similarity index 99% rename from src/MaksIT.PostClient.Client/SemanticSearchService.cs rename to src/MaksIT.PostClient.Client/Search/SemanticSearchService.cs index 6a955cd..edbb823 100644 --- a/src/MaksIT.PostClient.Client/SemanticSearchService.cs +++ b/src/MaksIT.PostClient.Client/Search/SemanticSearchService.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Search; public sealed class SemanticSearchService : ISemanticSearchService { diff --git a/src/MaksIT.PostClient.Client/AppUpdateService.cs b/src/MaksIT.PostClient.Client/Update/AppUpdateService.cs similarity index 98% rename from src/MaksIT.PostClient.Client/AppUpdateService.cs rename to src/MaksIT.PostClient.Client/Update/AppUpdateService.cs index 1683067..208d346 100644 --- a/src/MaksIT.PostClient.Client/AppUpdateService.cs +++ b/src/MaksIT.PostClient.Client/Update/AppUpdateService.cs @@ -1,11 +1,10 @@ using System.Diagnostics; using System.Net.Http.Headers; using System.Runtime.InteropServices; -using MaksIT.PostClient.Shared; using MaksIT.Results; -namespace MaksIT.PostClient.Client; +namespace MaksIT.PostClient.Client.Update; public sealed class AppUpdateCheck { diff --git a/src/MaksIT.PostClient.Shared/AppInfo.cs b/src/MaksIT.PostClient.Shared/App/AppInfo.cs similarity index 94% rename from src/MaksIT.PostClient.Shared/AppInfo.cs rename to src/MaksIT.PostClient.Shared/App/AppInfo.cs index c69c345..13a47d7 100644 --- a/src/MaksIT.PostClient.Shared/AppInfo.cs +++ b/src/MaksIT.PostClient.Shared/App/AppInfo.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.App; public static class AppInfo { diff --git a/src/MaksIT.PostClient.Shared/AppInstall.cs b/src/MaksIT.PostClient.Shared/App/AppInstall.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/AppInstall.cs rename to src/MaksIT.PostClient.Shared/App/AppInstall.cs index 3325a86..6064a55 100644 --- a/src/MaksIT.PostClient.Shared/AppInstall.cs +++ b/src/MaksIT.PostClient.Shared/App/AppInstall.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.App; public enum AppInstallKind { diff --git a/src/MaksIT.PostClient.Shared/AppLog.cs b/src/MaksIT.PostClient.Shared/App/AppLog.cs similarity index 93% rename from src/MaksIT.PostClient.Shared/AppLog.cs rename to src/MaksIT.PostClient.Shared/App/AppLog.cs index 8ea907e..515a6a3 100644 --- a/src/MaksIT.PostClient.Shared/AppLog.cs +++ b/src/MaksIT.PostClient.Shared/App/AppLog.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.App; public static class AppLog { diff --git a/src/MaksIT.PostClient.Shared/AppPaths.cs b/src/MaksIT.PostClient.Shared/App/AppPaths.cs similarity index 91% rename from src/MaksIT.PostClient.Shared/AppPaths.cs rename to src/MaksIT.PostClient.Shared/App/AppPaths.cs index 2889843..f040c96 100644 --- a/src/MaksIT.PostClient.Shared/AppPaths.cs +++ b/src/MaksIT.PostClient.Shared/App/AppPaths.cs @@ -1,7 +1,7 @@ using System.Text.Json; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.App; /// @@ -125,6 +125,38 @@ public static class AppPaths { public static string WebViewDirectory() => Path.Combine(DataDirectory(), "webview"); + public static bool IsWebViewFile(Uri? uri) { + if (uri is null || !uri.IsFile) + return false; + try { + var full = Path.GetFullPath(uri.LocalPath); + var root = Path.GetFullPath(WebViewDirectory()); + var prefix = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } + catch { + return false; + } + } + + public static void ClearStaleReadingDocuments() { + try { + var dir = WebViewDirectory(); + if (!Directory.Exists(dir)) + return; + foreach (var file in Directory.GetFiles(dir, "reading-*.html")) { + try { + File.Delete(file); + } + catch { + } + } + } + catch { + } + } + public static string LogsDirectory() { if (OperatingSystem.IsMacOS()) { return Path.Combine( diff --git a/src/MaksIT.PostClient.Shared/AppVersion.cs b/src/MaksIT.PostClient.Shared/App/AppVersion.cs similarity index 97% rename from src/MaksIT.PostClient.Shared/AppVersion.cs rename to src/MaksIT.PostClient.Shared/App/AppVersion.cs index 622d0b9..6b27bac 100644 --- a/src/MaksIT.PostClient.Shared/AppVersion.cs +++ b/src/MaksIT.PostClient.Shared/App/AppVersion.cs @@ -1,7 +1,7 @@ using System.Reflection; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.App; public static class AppVersion { diff --git a/src/MaksIT.PostClient.Shared/ErrorReport.cs b/src/MaksIT.PostClient.Shared/App/ErrorReport.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/ErrorReport.cs rename to src/MaksIT.PostClient.Shared/App/ErrorReport.cs index 1709f87..b71763a 100644 --- a/src/MaksIT.PostClient.Shared/ErrorReport.cs +++ b/src/MaksIT.PostClient.Shared/App/ErrorReport.cs @@ -2,7 +2,7 @@ using System.Text; using System.Runtime.InteropServices; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.App; /// Formats unhandled exceptions for a copyable dialog and a crash log file. diff --git a/src/MaksIT.PostClient.Shared/GitHubRelease.cs b/src/MaksIT.PostClient.Shared/App/GitHubRelease.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/GitHubRelease.cs rename to src/MaksIT.PostClient.Shared/App/GitHubRelease.cs index 9414f53..dd6ad6f 100644 --- a/src/MaksIT.PostClient.Shared/GitHubRelease.cs +++ b/src/MaksIT.PostClient.Shared/App/GitHubRelease.cs @@ -2,7 +2,7 @@ using System.Runtime.InteropServices; using System.Text.Json; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.App; public sealed class GitHubRelease { diff --git a/src/MaksIT.PostClient.Shared/ArchiveFiles.cs b/src/MaksIT.PostClient.Shared/Archive/ArchiveFiles.cs similarity index 97% rename from src/MaksIT.PostClient.Shared/ArchiveFiles.cs rename to src/MaksIT.PostClient.Shared/Archive/ArchiveFiles.cs index 572e6df..5748d14 100644 --- a/src/MaksIT.PostClient.Shared/ArchiveFiles.cs +++ b/src/MaksIT.PostClient.Shared/Archive/ArchiveFiles.cs @@ -2,7 +2,7 @@ using System.Security.Cryptography; using System.Text; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Archive; public static class ArchiveFiles { diff --git a/src/MaksIT.PostClient.Shared/FileSentReceiptStore.cs b/src/MaksIT.PostClient.Shared/Archive/FileSentReceiptStore.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/FileSentReceiptStore.cs rename to src/MaksIT.PostClient.Shared/Archive/FileSentReceiptStore.cs index 5d20764..3824065 100644 --- a/src/MaksIT.PostClient.Shared/FileSentReceiptStore.cs +++ b/src/MaksIT.PostClient.Shared/Archive/FileSentReceiptStore.cs @@ -1,7 +1,7 @@ using System.Text.Json; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Archive; public sealed class FileSentReceiptStore : ISentReceiptStore { diff --git a/src/MaksIT.PostClient.Shared/ISentReceiptStore.cs b/src/MaksIT.PostClient.Shared/Archive/ISentReceiptStore.cs similarity index 94% rename from src/MaksIT.PostClient.Shared/ISentReceiptStore.cs rename to src/MaksIT.PostClient.Shared/Archive/ISentReceiptStore.cs index 8aff399..d388cea 100644 --- a/src/MaksIT.PostClient.Shared/ISentReceiptStore.cs +++ b/src/MaksIT.PostClient.Shared/Archive/ISentReceiptStore.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Archive; public sealed class SentDispatch { diff --git a/src/MaksIT.PostClient.Shared/LocalStoreSidecar.cs b/src/MaksIT.PostClient.Shared/Archive/LocalStoreSidecar.cs similarity index 95% rename from src/MaksIT.PostClient.Shared/LocalStoreSidecar.cs rename to src/MaksIT.PostClient.Shared/Archive/LocalStoreSidecar.cs index 3af4265..3a9d50d 100644 --- a/src/MaksIT.PostClient.Shared/LocalStoreSidecar.cs +++ b/src/MaksIT.PostClient.Shared/Archive/LocalStoreSidecar.cs @@ -1,7 +1,7 @@ using MaksIT.Core.Extensions; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Archive; public sealed class LocalStoreSidecar { diff --git a/src/MaksIT.PostClient.Shared/MailArchiveCatalog.cs b/src/MaksIT.PostClient.Shared/Archive/MailArchiveCatalog.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/MailArchiveCatalog.cs rename to src/MaksIT.PostClient.Shared/Archive/MailArchiveCatalog.cs index 49ac4c6..86189eb 100644 --- a/src/MaksIT.PostClient.Shared/MailArchiveCatalog.cs +++ b/src/MaksIT.PostClient.Shared/Archive/MailArchiveCatalog.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Archive; public sealed class MailArchiveCatalog : IDisposable { diff --git a/src/MaksIT.PostClient.Shared/MailArchiveCopy.cs b/src/MaksIT.PostClient.Shared/Archive/MailArchiveCopy.cs similarity index 88% rename from src/MaksIT.PostClient.Shared/MailArchiveCopy.cs rename to src/MaksIT.PostClient.Shared/Archive/MailArchiveCopy.cs index 6e3c8b6..4b5f58a 100644 --- a/src/MaksIT.PostClient.Shared/MailArchiveCopy.cs +++ b/src/MaksIT.PostClient.Shared/Archive/MailArchiveCopy.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Archive; public sealed class MailArchiveCopy { diff --git a/src/MaksIT.PostClient.Shared/MailArchiveLayout.cs b/src/MaksIT.PostClient.Shared/Archive/MailArchiveLayout.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/MailArchiveLayout.cs rename to src/MaksIT.PostClient.Shared/Archive/MailArchiveLayout.cs index 9f21e7d..a9417bc 100644 --- a/src/MaksIT.PostClient.Shared/MailArchiveLayout.cs +++ b/src/MaksIT.PostClient.Shared/Archive/MailArchiveLayout.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Archive; public static class MailArchiveLayout { diff --git a/src/MaksIT.PostClient.Shared/MailArchiveStore.cs b/src/MaksIT.PostClient.Shared/Archive/MailArchiveStore.cs similarity index 94% rename from src/MaksIT.PostClient.Shared/MailArchiveStore.cs rename to src/MaksIT.PostClient.Shared/Archive/MailArchiveStore.cs index a0236d7..b7add12 100644 --- a/src/MaksIT.PostClient.Shared/MailArchiveStore.cs +++ b/src/MaksIT.PostClient.Shared/Archive/MailArchiveStore.cs @@ -1,7 +1,7 @@ using Microsoft.Data.Sqlite; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Archive; public sealed class MailArchiveStore : IDisposable { @@ -73,10 +73,12 @@ public sealed class MailArchiveStore : IDisposable { cmd.Transaction = tx; cmd.CommandText = """ UPDATE messages - SET eml_path = $eml, body_text = $body, attachment_text = $att + SET eml_path = $eml, body_text = $body, attachment_text = $att, + size = CASE WHEN $size > 0 THEN $size ELSE size END WHERE id = $id; """; cmd.Parameters.AddWithValue("$eml", emlPath); + cmd.Parameters.AddWithValue("$size", BodySize(header.Size, emlPath)); cmd.Parameters.AddWithValue("$body", bodyText); cmd.Parameters.AddWithValue("$att", attachmentText); cmd.Parameters.AddWithValue("$id", id); @@ -279,7 +281,8 @@ public sealed class MailArchiveStore : IDisposable { SELECT uid, folder, subject, from_addr, date_utc, is_seen, is_flagged, has_attachments, priority, envelope_kind, envelope_badge, envelope_tipo, message_id, in_reply_to, eml_path, (SELECT GROUP_CONCAT(name, ', ') FROM message_labels l - WHERE l.mailbox_id = messages.mailbox_id AND l.folder = messages.folder AND l.uid = messages.uid) + WHERE l.mailbox_id = messages.mailbox_id AND l.folder = messages.folder AND l.uid = messages.uid), + size FROM messages WHERE mailbox_id = $m AND folder = $f ORDER BY date_utc DESC, uid DESC; @@ -348,14 +351,16 @@ public sealed class MailArchiveStore : IDisposable { lock (_gate) { using var cmd = _db.CreateCommand(); var sql = """ - SELECT mailbox_id, folder, uid FROM messages + SELECT mailbox_id, folder, uid, size FROM messages WHERE (eml_path IS NULL OR eml_path = '') """; sql += MailboxInSql(cmd, mailboxIds); - sql += " ORDER BY date_utc DESC, uid DESC"; + sql += """ + ORDER BY CASE WHEN size <= 0 THEN 1 ELSE 0 END, size ASC, date_utc DESC, uid DESC + """; if (limit > 0) { sql += " LIMIT $n"; - cmd.Parameters.AddWithValue("$n", Math.Clamp(limit, 1, 256)); + cmd.Parameters.AddWithValue("$n", Math.Clamp(limit, 1, 4096)); } cmd.CommandText = sql; @@ -365,7 +370,8 @@ public sealed class MailArchiveStore : IDisposable { rows.Add(new MailArchiveUid( reader.GetString(0), reader.GetString(1), - (uint)reader.GetInt64(2))); + (uint)reader.GetInt64(2), + reader.FieldCount > 3 && !reader.IsDBNull(3) ? reader.GetInt64(3) : 0)); } return rows; @@ -535,7 +541,8 @@ public sealed class MailArchiveStore : IDisposable { SELECT m.uid, m.folder, m.subject, m.from_addr, m.date_utc, m.is_seen, m.is_flagged, m.has_attachments, m.priority, m.envelope_kind, m.envelope_badge, m.envelope_tipo, m.message_id, m.in_reply_to, m.eml_path, (SELECT GROUP_CONCAT(name, ', ') FROM message_labels l - WHERE l.mailbox_id = m.mailbox_id AND l.folder = m.folder AND l.uid = m.uid) + WHERE l.mailbox_id = m.mailbox_id AND l.folder = m.folder AND l.uid = m.uid), + m.size FROM messages_fts JOIN messages m ON m.id = messages_fts.rowid WHERE messages_fts MATCH $q AND m.mailbox_id = $m AND m.folder = $f @@ -543,7 +550,8 @@ public sealed class MailArchiveStore : IDisposable { SELECT m.uid, m.folder, m.subject, m.from_addr, m.date_utc, m.is_seen, m.is_flagged, m.has_attachments, m.priority, m.envelope_kind, m.envelope_badge, m.envelope_tipo, m.message_id, m.in_reply_to, m.eml_path, (SELECT GROUP_CONCAT(name, ', ') FROM message_labels l - WHERE l.mailbox_id = m.mailbox_id AND l.folder = m.folder AND l.uid = m.uid) + WHERE l.mailbox_id = m.mailbox_id AND l.folder = m.folder AND l.uid = m.uid), + m.size FROM messages m WHERE m.mailbox_id = $m AND m.folder = $f AND EXISTS ( @@ -666,6 +674,7 @@ public sealed class MailArchiveStore : IDisposable { m.priority, m.envelope_kind, m.envelope_badge, m.envelope_tipo, m.message_id, m.in_reply_to, m.eml_path, (SELECT GROUP_CONCAT(name, ', ') FROM message_labels l WHERE l.mailbox_id = m.mailbox_id AND l.folder = m.folder AND l.uid = m.uid), + m.size, m.body_text, m.attachment_text, e.model_id, e.vector FROM messages m LEFT JOIN message_embeddings e ON e.message_id = m.id @@ -678,12 +687,12 @@ public sealed class MailArchiveStore : IDisposable { if (!reader.Read()) return null; var header = ReadHeader(reader); - var body = reader.IsDBNull(16) ? "" : reader.GetString(16); - var att = reader.IsDBNull(17) ? "" : reader.GetString(17); - var model = reader.IsDBNull(18) ? "" : reader.GetString(18); + var body = reader.IsDBNull(17) ? "" : reader.GetString(17); + var att = reader.IsDBNull(18) ? "" : reader.GetString(18); + var model = reader.IsDBNull(19) ? "" : reader.GetString(19); float[]? vector = null; - if (!reader.IsDBNull(19)) - vector = EmbeddingVector.FromBlob(reader.GetFieldValue(19)); + if (!reader.IsDBNull(20)) + vector = EmbeddingVector.FromBlob(reader.GetFieldValue(20)); var labels = string.IsNullOrWhiteSpace(header.Labels) ? Array.Empty() : header.Labels.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); @@ -721,7 +730,8 @@ public sealed class MailArchiveStore : IDisposable { MessageId = copy.Header.MessageId, InReplyTo = copy.Header.InReplyTo, EmlPath = copy.Header.EmlPath, - Labels = copy.Header.Labels + Labels = copy.Header.Labels, + Size = copy.Header.Size }; var id = UpsertHeader( tx, @@ -733,10 +743,12 @@ public sealed class MailArchiveStore : IDisposable { body.Transaction = tx; body.CommandText = """ UPDATE messages - SET eml_path = $eml, body_text = $body, attachment_text = $att + SET eml_path = $eml, body_text = $body, attachment_text = $att, + size = CASE WHEN $size > 0 THEN $size ELSE size END WHERE id = $id; """; body.Parameters.AddWithValue("$eml", header.EmlPath ?? ""); + body.Parameters.AddWithValue("$size", BodySize(header.Size, header.EmlPath)); body.Parameters.AddWithValue("$body", copy.BodyText ?? ""); body.Parameters.AddWithValue("$att", copy.AttachmentText ?? ""); body.Parameters.AddWithValue("$id", id); @@ -878,7 +890,8 @@ public sealed class MailArchiveStore : IDisposable { SELECT uid, folder, subject, from_addr, date_utc, is_seen, is_flagged, has_attachments, priority, envelope_kind, envelope_badge, envelope_tipo, message_id, in_reply_to, eml_path, (SELECT GROUP_CONCAT(name, ', ') FROM message_labels l - WHERE l.mailbox_id = messages.mailbox_id AND l.folder = messages.folder AND l.uid = messages.uid) + WHERE l.mailbox_id = messages.mailbox_id AND l.folder = messages.folder AND l.uid = messages.uid), + size FROM messages WHERE mailbox_id = $m AND folder = $f AND (subject LIKE $q OR from_addr LIKE $q OR body_text LIKE $q OR attachment_text LIKE $q @@ -911,8 +924,8 @@ public sealed class MailArchiveStore : IDisposable { cmd.CommandText = $""" INSERT INTO messages ( mailbox_id, folder, uid, message_id, subject, from_addr, date_utc, envelope_kind, envelope_badge, - envelope_tipo, in_reply_to, is_seen, is_flagged, has_attachments, priority, trashed_utc) - VALUES ($m, $f, $u, $mid, $sub, $from, $date, $kind, $badge, $tipo, $reply, $seen, $flag, $att, $pri, $trashed) + envelope_tipo, in_reply_to, is_seen, is_flagged, has_attachments, priority, trashed_utc, size) + VALUES ($m, $f, $u, $mid, $sub, $from, $date, $kind, $badge, $tipo, $reply, $seen, $flag, $att, $pri, $trashed, $size) ON CONFLICT(mailbox_id, folder, uid) DO UPDATE SET message_id = excluded.message_id, subject = excluded.subject, @@ -923,8 +936,9 @@ public sealed class MailArchiveStore : IDisposable { envelope_tipo = excluded.envelope_tipo, in_reply_to = excluded.in_reply_to, {flagAssign} - has_attachments = excluded.has_attachments, - priority = excluded.priority; + has_attachments = CASE WHEN excluded.has_attachments != 0 THEN 1 ELSE messages.has_attachments END, + priority = excluded.priority, + size = CASE WHEN excluded.size > 0 THEN excluded.size ELSE messages.size END; """; var trash = MailRetention.IsTrash(header.Folder); var stampTrash = trash && (stampTrashTime || HasMessageElsewhere(tx, mailboxId, header.Folder, header.MessageId)); @@ -945,6 +959,7 @@ public sealed class MailArchiveStore : IDisposable { cmd.Parameters.AddWithValue("$att", header.HasAttachments ? 1 : 0); cmd.Parameters.AddWithValue("$pri", header.Priority); cmd.Parameters.AddWithValue("$trashed", trashed); + cmd.Parameters.AddWithValue("$size", header.Size); cmd.ExecuteNonQuery(); using var idCmd = _db.CreateCommand(); idCmd.Transaction = tx; @@ -1011,6 +1026,8 @@ public sealed class MailArchiveStore : IDisposable { private static MailArchiveHeader ReadHeader(SqliteDataReader reader) { DateTimeOffset.TryParse(reader.GetString(4), out var date); + var emlPath = reader.IsDBNull(14) ? "" : reader.GetString(14); + var stored = reader.FieldCount > 16 && !reader.IsDBNull(16) ? reader.GetInt64(16) : 0; return new MailArchiveHeader { Uid = (uint)reader.GetInt64(0), Folder = reader.GetString(1), @@ -1026,11 +1043,18 @@ public sealed class MailArchiveStore : IDisposable { EnvelopeTipo = reader.GetString(11), MessageId = reader.GetString(12), InReplyTo = reader.GetString(13), - EmlPath = reader.IsDBNull(14) ? "" : reader.GetString(14), - Labels = reader.FieldCount > 15 && !reader.IsDBNull(15) ? reader.GetString(15) : "" + EmlPath = emlPath, + Labels = reader.FieldCount > 15 && !reader.IsDBNull(15) ? reader.GetString(15) : "", + Size = stored }; } + private static long BodySize(long size, string? emlPath) => + size > 0 ? size : FileSize(emlPath); + + private static long FileSize(string? path) => + !string.IsNullOrWhiteSpace(path) && File.Exists(path) ? new FileInfo(path).Length : 0; + internal static string FtsQuery(string text) { var parts = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length == 0) @@ -1067,6 +1091,7 @@ public sealed class MailArchiveStore : IDisposable { m.priority, m.envelope_kind, m.envelope_badge, m.envelope_tipo, m.message_id, m.in_reply_to, m.eml_path, (SELECT GROUP_CONCAT(name, ', ') FROM message_labels l WHERE l.mailbox_id = m.mailbox_id AND l.folder = m.folder AND l.uid = m.uid), + m.size, e.vector FROM message_embeddings e JOIN messages m ON m.id = e.message_id @@ -1078,7 +1103,7 @@ public sealed class MailArchiveStore : IDisposable { extra = []; using var reader = cmd.ExecuteReader(); while (reader.Read()) { - var blob = reader.IsDBNull(16) ? null : reader.GetFieldValue(16); + var blob = reader.IsDBNull(17) ? null : reader.GetFieldValue(17); if (blob is null || blob.Length == 0) continue; var vector = EmbeddingVector.FromBlob(blob); @@ -1221,6 +1246,7 @@ public sealed class MailArchiveStore : IDisposable { body_text TEXT NOT NULL DEFAULT '', attachment_text TEXT NOT NULL DEFAULT '', trashed_utc TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, UNIQUE(mailbox_id, folder, uid) ); CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( @@ -1242,6 +1268,7 @@ public sealed class MailArchiveStore : IDisposable { """; cmd.ExecuteNonQuery(); EnsureColumn("messages", "trashed_utc", "TEXT NOT NULL DEFAULT ''"); + EnsureColumn("messages", "size", "INTEGER NOT NULL DEFAULT 0"); } private void EnsureColumn(string table, string column, string definition) { @@ -1261,7 +1288,7 @@ public sealed class MailArchiveStore : IDisposable { } -public readonly record struct MailArchiveUid(string MailboxId, string Folder, uint Uid); +public readonly record struct MailArchiveUid(string MailboxId, string Folder, uint Uid, long Size = 0); public readonly record struct EmbeddingWorkItem( @@ -1315,4 +1342,6 @@ public sealed class MailArchiveHeader { public string EmlPath { get; init; } = ""; public string Labels { get; init; } = ""; + + public long Size { get; init; } } diff --git a/src/MaksIT.PostClient.Shared/FileSecretStore.cs b/src/MaksIT.PostClient.Shared/Auth/FileSecretStore.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/FileSecretStore.cs rename to src/MaksIT.PostClient.Shared/Auth/FileSecretStore.cs index 29a8b1f..50f315d 100644 --- a/src/MaksIT.PostClient.Shared/FileSecretStore.cs +++ b/src/MaksIT.PostClient.Shared/Auth/FileSecretStore.cs @@ -4,7 +4,7 @@ using System.Text.Json; using MaksIT.Results; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Auth; public sealed class FileSecretStore : ISecretStore { diff --git a/src/MaksIT.PostClient.Shared/HubDesktopSession.cs b/src/MaksIT.PostClient.Shared/Auth/HubDesktopSession.cs similarity index 97% rename from src/MaksIT.PostClient.Shared/HubDesktopSession.cs rename to src/MaksIT.PostClient.Shared/Auth/HubDesktopSession.cs index 82fd1c3..8d1d73a 100644 --- a/src/MaksIT.PostClient.Shared/HubDesktopSession.cs +++ b/src/MaksIT.PostClient.Shared/Auth/HubDesktopSession.cs @@ -1,7 +1,7 @@ using System.Text.Json; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Auth; public static class HubDesktopSession { diff --git a/src/MaksIT.PostClient.Shared/ISecretStore.cs b/src/MaksIT.PostClient.Shared/Auth/ISecretStore.cs similarity index 80% rename from src/MaksIT.PostClient.Shared/ISecretStore.cs rename to src/MaksIT.PostClient.Shared/Auth/ISecretStore.cs index 2822759..2bfd44b 100644 --- a/src/MaksIT.PostClient.Shared/ISecretStore.cs +++ b/src/MaksIT.PostClient.Shared/Auth/ISecretStore.cs @@ -1,7 +1,7 @@ using MaksIT.Results; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Auth; public interface ISecretStore { diff --git a/src/MaksIT.PostClient.Shared/IdentityHubAddress.cs b/src/MaksIT.PostClient.Shared/Auth/IdentityHubAddress.cs similarity index 95% rename from src/MaksIT.PostClient.Shared/IdentityHubAddress.cs rename to src/MaksIT.PostClient.Shared/Auth/IdentityHubAddress.cs index 69641e6..fa241ca 100644 --- a/src/MaksIT.PostClient.Shared/IdentityHubAddress.cs +++ b/src/MaksIT.PostClient.Shared/Auth/IdentityHubAddress.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Auth; public static class IdentityHubAddress { diff --git a/src/MaksIT.PostClient.Shared/MailAuthKind.cs b/src/MaksIT.PostClient.Shared/Auth/MailAuthKind.cs similarity index 93% rename from src/MaksIT.PostClient.Shared/MailAuthKind.cs rename to src/MaksIT.PostClient.Shared/Auth/MailAuthKind.cs index 284704f..9e58b27 100644 --- a/src/MaksIT.PostClient.Shared/MailAuthKind.cs +++ b/src/MaksIT.PostClient.Shared/Auth/MailAuthKind.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Auth; public static class MailAuthKind { diff --git a/src/MaksIT.PostClient.Shared/OAuthClientId.cs b/src/MaksIT.PostClient.Shared/Auth/OAuthClientId.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/OAuthClientId.cs rename to src/MaksIT.PostClient.Shared/Auth/OAuthClientId.cs index c05773c..4af3a03 100644 --- a/src/MaksIT.PostClient.Shared/OAuthClientId.cs +++ b/src/MaksIT.PostClient.Shared/Auth/OAuthClientId.cs @@ -1,7 +1,7 @@ using System.Text.Json; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Auth; public static class OAuthClientId { diff --git a/src/MaksIT.PostClient.Shared/OAuthClientSecret.cs b/src/MaksIT.PostClient.Shared/Auth/OAuthClientSecret.cs similarity index 96% rename from src/MaksIT.PostClient.Shared/OAuthClientSecret.cs rename to src/MaksIT.PostClient.Shared/Auth/OAuthClientSecret.cs index 72afa95..131ee52 100644 --- a/src/MaksIT.PostClient.Shared/OAuthClientSecret.cs +++ b/src/MaksIT.PostClient.Shared/Auth/OAuthClientSecret.cs @@ -1,7 +1,7 @@ using System.Text.Json; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Auth; public static class OAuthClientSecret { diff --git a/src/MaksIT.PostClient.Shared/OAuthLoginHint.cs b/src/MaksIT.PostClient.Shared/Auth/OAuthLoginHint.cs similarity index 93% rename from src/MaksIT.PostClient.Shared/OAuthLoginHint.cs rename to src/MaksIT.PostClient.Shared/Auth/OAuthLoginHint.cs index b0c4a4a..0346470 100644 --- a/src/MaksIT.PostClient.Shared/OAuthLoginHint.cs +++ b/src/MaksIT.PostClient.Shared/Auth/OAuthLoginHint.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Auth; public static class OAuthLoginHint { diff --git a/src/MaksIT.PostClient.Shared/OAuthMailScope.cs b/src/MaksIT.PostClient.Shared/Auth/OAuthMailScope.cs similarity index 97% rename from src/MaksIT.PostClient.Shared/OAuthMailScope.cs rename to src/MaksIT.PostClient.Shared/Auth/OAuthMailScope.cs index fcb9e5f..c7f6ae3 100644 --- a/src/MaksIT.PostClient.Shared/OAuthMailScope.cs +++ b/src/MaksIT.PostClient.Shared/Auth/OAuthMailScope.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Auth; public static class OAuthMailScope { diff --git a/src/MaksIT.PostClient.Shared/OAuthTokenSet.cs b/src/MaksIT.PostClient.Shared/Auth/OAuthTokenSet.cs similarity index 92% rename from src/MaksIT.PostClient.Shared/OAuthTokenSet.cs rename to src/MaksIT.PostClient.Shared/Auth/OAuthTokenSet.cs index 8d68aeb..dd22968 100644 --- a/src/MaksIT.PostClient.Shared/OAuthTokenSet.cs +++ b/src/MaksIT.PostClient.Shared/Auth/OAuthTokenSet.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Auth; public sealed class OAuthTokenSet { diff --git a/src/MaksIT.PostClient.Shared/Configuration.cs b/src/MaksIT.PostClient.Shared/Config/Configuration.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/Configuration.cs rename to src/MaksIT.PostClient.Shared/Config/Configuration.cs index 0ebdc5a..6b46abf 100644 --- a/src/MaksIT.PostClient.Shared/Configuration.cs +++ b/src/MaksIT.PostClient.Shared/Config/Configuration.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Config; public sealed class Configuration { diff --git a/src/MaksIT.PostClient.Shared/ConfigurationFileService.cs b/src/MaksIT.PostClient.Shared/Config/ConfigurationFileService.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/ConfigurationFileService.cs rename to src/MaksIT.PostClient.Shared/Config/ConfigurationFileService.cs index 553f333..5254ce4 100644 --- a/src/MaksIT.PostClient.Shared/ConfigurationFileService.cs +++ b/src/MaksIT.PostClient.Shared/Config/ConfigurationFileService.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Nodes; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Config; public sealed class ConfigurationFileService { diff --git a/src/MaksIT.PostClient.Shared/FeatureCatalog.cs b/src/MaksIT.PostClient.Shared/Config/FeatureCatalog.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/FeatureCatalog.cs rename to src/MaksIT.PostClient.Shared/Config/FeatureCatalog.cs index 9627be7..f7990eb 100644 --- a/src/MaksIT.PostClient.Shared/FeatureCatalog.cs +++ b/src/MaksIT.PostClient.Shared/Config/FeatureCatalog.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Config; public static class AppRegion { diff --git a/src/MaksIT.PostClient.Shared/FeatureSettings.cs b/src/MaksIT.PostClient.Shared/Config/FeatureSettings.cs similarity index 96% rename from src/MaksIT.PostClient.Shared/FeatureSettings.cs rename to src/MaksIT.PostClient.Shared/Config/FeatureSettings.cs index 8b910e6..b819d4c 100644 --- a/src/MaksIT.PostClient.Shared/FeatureSettings.cs +++ b/src/MaksIT.PostClient.Shared/Config/FeatureSettings.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Config; public sealed class FeatureSettings { diff --git a/src/MaksIT.PostClient.Shared/MessageHtml.cs b/src/MaksIT.PostClient.Shared/Content/MessageHtml.cs similarity index 84% rename from src/MaksIT.PostClient.Shared/MessageHtml.cs rename to src/MaksIT.PostClient.Shared/Content/MessageHtml.cs index d752127..8fe292f 100644 --- a/src/MaksIT.PostClient.Shared/MessageHtml.cs +++ b/src/MaksIT.PostClient.Shared/Content/MessageHtml.cs @@ -1,8 +1,9 @@ using System.Net; +using System.Text; using System.Text.RegularExpressions; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Content; public static class MessageHtml { @@ -22,13 +23,14 @@ public static class MessageHtml { public static string Document(string? html) { var body = (html ?? "").Trim(); + string document; if (LooksLikeDocument(body)) { - if (Head.IsMatch(body)) - return Head.Replace(body, m => m.Value + Csp + Style, 1); - return Csp + Style + body; + document = Head.IsMatch(body) + ? Head.Replace(body, m => m.Value + Csp + Style, 1) + : Csp + Style + body; } - - return """ + else { + document = """ @@ -36,6 +38,16 @@ public static class MessageHtml { """ + body + ""; + } + + return Sanitize(document); + } + + public static string Sanitize(string? html) { + if (string.IsNullOrEmpty(html)) + return html ?? ""; + var cleaned = html.Replace("\0", "", StringComparison.Ordinal); + return Encoding.UTF8.GetString(Encoding.UTF8.GetBytes(cleaned)); } public static bool LooksLikeDocument(string? html) { diff --git a/src/MaksIT.PostClient.Shared/MessageText.cs b/src/MaksIT.PostClient.Shared/Content/MessageText.cs similarity index 87% rename from src/MaksIT.PostClient.Shared/MessageText.cs rename to src/MaksIT.PostClient.Shared/Content/MessageText.cs index 99d1ede..6275664 100644 --- a/src/MaksIT.PostClient.Shared/MessageText.cs +++ b/src/MaksIT.PostClient.Shared/Content/MessageText.cs @@ -2,7 +2,7 @@ using System.Net; using System.Text.RegularExpressions; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Content; public static class MessageText { diff --git a/src/MaksIT.PostClient.Shared/SimplePdf.cs b/src/MaksIT.PostClient.Shared/Content/SimplePdf.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/SimplePdf.cs rename to src/MaksIT.PostClient.Shared/Content/SimplePdf.cs index 38fd810..2cf9111 100644 --- a/src/MaksIT.PostClient.Shared/SimplePdf.cs +++ b/src/MaksIT.PostClient.Shared/Content/SimplePdf.cs @@ -1,7 +1,7 @@ using System.Text; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Content; public static class SimplePdf { diff --git a/src/MaksIT.PostClient.Shared/DaticertParser.cs b/src/MaksIT.PostClient.Shared/Envelope/DaticertParser.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/DaticertParser.cs rename to src/MaksIT.PostClient.Shared/Envelope/DaticertParser.cs index 26bd8f1..a768aa1 100644 --- a/src/MaksIT.PostClient.Shared/DaticertParser.cs +++ b/src/MaksIT.PostClient.Shared/Envelope/DaticertParser.cs @@ -1,7 +1,7 @@ using System.Xml; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Envelope; public static class DaticertParser { diff --git a/src/MaksIT.PostClient.Shared/EnvelopeClassifier.cs b/src/MaksIT.PostClient.Shared/Envelope/EnvelopeClassifier.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/EnvelopeClassifier.cs rename to src/MaksIT.PostClient.Shared/Envelope/EnvelopeClassifier.cs index f83dcff..84e78cd 100644 --- a/src/MaksIT.PostClient.Shared/EnvelopeClassifier.cs +++ b/src/MaksIT.PostClient.Shared/Envelope/EnvelopeClassifier.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Envelope; public static class EnvelopeClassifier { diff --git a/src/MaksIT.PostClient.Shared/EnvelopeInfo.cs b/src/MaksIT.PostClient.Shared/Envelope/EnvelopeInfo.cs similarity index 97% rename from src/MaksIT.PostClient.Shared/EnvelopeInfo.cs rename to src/MaksIT.PostClient.Shared/Envelope/EnvelopeInfo.cs index f5c90ed..2da3007 100644 --- a/src/MaksIT.PostClient.Shared/EnvelopeInfo.cs +++ b/src/MaksIT.PostClient.Shared/Envelope/EnvelopeInfo.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Envelope; public sealed class EnvelopeInfo { diff --git a/src/MaksIT.PostClient.Shared/EnvelopeKind.cs b/src/MaksIT.PostClient.Shared/Envelope/EnvelopeKind.cs similarity index 94% rename from src/MaksIT.PostClient.Shared/EnvelopeKind.cs rename to src/MaksIT.PostClient.Shared/Envelope/EnvelopeKind.cs index c94c671..499d606 100644 --- a/src/MaksIT.PostClient.Shared/EnvelopeKind.cs +++ b/src/MaksIT.PostClient.Shared/Envelope/EnvelopeKind.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Envelope; public static class EnvelopeKind { diff --git a/src/MaksIT.PostClient.Shared/ReceiptStatus.cs b/src/MaksIT.PostClient.Shared/Envelope/ReceiptStatus.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/ReceiptStatus.cs rename to src/MaksIT.PostClient.Shared/Envelope/ReceiptStatus.cs index 91d59d6..090bcc6 100644 --- a/src/MaksIT.PostClient.Shared/ReceiptStatus.cs +++ b/src/MaksIT.PostClient.Shared/Envelope/ReceiptStatus.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Envelope; public static class ReceiptStatus { diff --git a/src/MaksIT.PostClient.Shared/RemEvidenceParser.cs b/src/MaksIT.PostClient.Shared/Envelope/RemEvidenceParser.cs similarity index 97% rename from src/MaksIT.PostClient.Shared/RemEvidenceParser.cs rename to src/MaksIT.PostClient.Shared/Envelope/RemEvidenceParser.cs index b7431d9..0bf2232 100644 --- a/src/MaksIT.PostClient.Shared/RemEvidenceParser.cs +++ b/src/MaksIT.PostClient.Shared/Envelope/RemEvidenceParser.cs @@ -1,7 +1,7 @@ using System.Xml; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Envelope; public static class RemEvidenceParser { diff --git a/src/MaksIT.PostClient.Shared/GlobalUsings.cs b/src/MaksIT.PostClient.Shared/GlobalUsings.cs new file mode 100644 index 0000000..09e34fd --- /dev/null +++ b/src/MaksIT.PostClient.Shared/GlobalUsings.cs @@ -0,0 +1,9 @@ +global using MaksIT.PostClient.Shared.App; +global using MaksIT.PostClient.Shared.Archive; +global using MaksIT.PostClient.Shared.Auth; +global using MaksIT.PostClient.Shared.Config; +global using MaksIT.PostClient.Shared.Content; +global using MaksIT.PostClient.Shared.Envelope; +global using MaksIT.PostClient.Shared.Localization; +global using MaksIT.PostClient.Shared.Mail; +global using MaksIT.PostClient.Shared.Search; diff --git a/src/MaksIT.PostClient.Shared/UiCopy.cs b/src/MaksIT.PostClient.Shared/Localization/UiCopy.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/UiCopy.cs rename to src/MaksIT.PostClient.Shared/Localization/UiCopy.cs index a3b9de8..1cd6765 100644 --- a/src/MaksIT.PostClient.Shared/UiCopy.cs +++ b/src/MaksIT.PostClient.Shared/Localization/UiCopy.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Localization; public sealed partial class UiCopy { @@ -298,6 +298,8 @@ public sealed partial class UiCopy { public string Date { get; init; } = ""; + public string Size { get; init; } = ""; + public string SelectMessage { get; init; } = ""; public string Open { get; init; } = ""; @@ -757,6 +759,7 @@ public sealed partial class UiCopy { Bcc = "Bcc", Subject = "Subject", Date = "Date", + Size = "Size", SelectMessage = "Select a message to read.", Open = "Open", Save = "Save", @@ -1028,6 +1031,7 @@ public sealed partial class UiCopy { Bcc = "Ccn", Subject = "Oggetto", Date = "Data", + Size = "Dimensione", SelectMessage = "Seleziona un messaggio da leggere.", Open = "Apri", Save = "Salva", diff --git a/src/MaksIT.PostClient.Shared/UiCopy.de.cs b/src/MaksIT.PostClient.Shared/Localization/UiCopy.de.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/UiCopy.de.cs rename to src/MaksIT.PostClient.Shared/Localization/UiCopy.de.cs index a3971c5..6001a24 100644 --- a/src/MaksIT.PostClient.Shared/UiCopy.de.cs +++ b/src/MaksIT.PostClient.Shared/Localization/UiCopy.de.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Localization; public sealed partial class UiCopy { @@ -152,6 +152,7 @@ public sealed partial class UiCopy { Bcc = "Bcc", Subject = "Betreff", Date = "Datum", + Size = "Größe", SelectMessage = "Wählen Sie eine Nachricht zum Lesen.", Open = "Öffnen", Save = "Speichern", diff --git a/src/MaksIT.PostClient.Shared/UiCopy.es.cs b/src/MaksIT.PostClient.Shared/Localization/UiCopy.es.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/UiCopy.es.cs rename to src/MaksIT.PostClient.Shared/Localization/UiCopy.es.cs index 36344c5..8a20ace 100644 --- a/src/MaksIT.PostClient.Shared/UiCopy.es.cs +++ b/src/MaksIT.PostClient.Shared/Localization/UiCopy.es.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Localization; public sealed partial class UiCopy { @@ -152,6 +152,7 @@ public sealed partial class UiCopy { Bcc = "Cco", Subject = "Asunto", Date = "Fecha", + Size = "Tamaño", SelectMessage = "Seleccione un mensaje para leer.", Open = "Abrir", Save = "Guardar", diff --git a/src/MaksIT.PostClient.Shared/UiCopy.fr.cs b/src/MaksIT.PostClient.Shared/Localization/UiCopy.fr.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/UiCopy.fr.cs rename to src/MaksIT.PostClient.Shared/Localization/UiCopy.fr.cs index 7c7efac..2367705 100644 --- a/src/MaksIT.PostClient.Shared/UiCopy.fr.cs +++ b/src/MaksIT.PostClient.Shared/Localization/UiCopy.fr.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Localization; public sealed partial class UiCopy { @@ -152,6 +152,7 @@ public sealed partial class UiCopy { Bcc = "Cci", Subject = "Objet", Date = "Date", + Size = "Taille", SelectMessage = "Sélectionnez un message à lire.", Open = "Ouvrir", Save = "Enregistrer", diff --git a/src/MaksIT.PostClient.Shared/UiLanguage.cs b/src/MaksIT.PostClient.Shared/Localization/UiLanguage.cs similarity index 95% rename from src/MaksIT.PostClient.Shared/UiLanguage.cs rename to src/MaksIT.PostClient.Shared/Localization/UiLanguage.cs index 15b7ae8..98b4931 100644 --- a/src/MaksIT.PostClient.Shared/UiLanguage.cs +++ b/src/MaksIT.PostClient.Shared/Localization/UiLanguage.cs @@ -1,7 +1,7 @@ using System.Globalization; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Localization; public static class UiLanguage { diff --git a/src/MaksIT.PostClient.Shared/UiLocale.cs b/src/MaksIT.PostClient.Shared/Localization/UiLocale.cs similarity index 94% rename from src/MaksIT.PostClient.Shared/UiLocale.cs rename to src/MaksIT.PostClient.Shared/Localization/UiLocale.cs index d25db9c..4859b89 100644 --- a/src/MaksIT.PostClient.Shared/UiLocale.cs +++ b/src/MaksIT.PostClient.Shared/Localization/UiLocale.cs @@ -1,7 +1,7 @@ using System.Globalization; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Localization; public static class UiLocale { diff --git a/src/MaksIT.PostClient.Shared/FolderRetention.cs b/src/MaksIT.PostClient.Shared/Mail/FolderRetention.cs similarity index 80% rename from src/MaksIT.PostClient.Shared/FolderRetention.cs rename to src/MaksIT.PostClient.Shared/Mail/FolderRetention.cs index dd99e59..131fc5a 100644 --- a/src/MaksIT.PostClient.Shared/FolderRetention.cs +++ b/src/MaksIT.PostClient.Shared/Mail/FolderRetention.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public sealed class FolderRetention { diff --git a/src/MaksIT.PostClient.Shared/LayoutSettings.cs b/src/MaksIT.PostClient.Shared/Mail/LayoutSettings.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/LayoutSettings.cs rename to src/MaksIT.PostClient.Shared/Mail/LayoutSettings.cs index 143fdec..7d8ad2c 100644 --- a/src/MaksIT.PostClient.Shared/LayoutSettings.cs +++ b/src/MaksIT.PostClient.Shared/Mail/LayoutSettings.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public sealed class SavedColumnSort { diff --git a/src/MaksIT.PostClient.Shared/MailBodyKind.cs b/src/MaksIT.PostClient.Shared/Mail/MailBodyKind.cs similarity index 96% rename from src/MaksIT.PostClient.Shared/MailBodyKind.cs rename to src/MaksIT.PostClient.Shared/Mail/MailBodyKind.cs index 37b1b42..1f83f2b 100644 --- a/src/MaksIT.PostClient.Shared/MailBodyKind.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailBodyKind.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailBodyKind { diff --git a/src/MaksIT.PostClient.Shared/MailCertifiedKind.cs b/src/MaksIT.PostClient.Shared/Mail/MailCertifiedKind.cs similarity index 96% rename from src/MaksIT.PostClient.Shared/MailCertifiedKind.cs rename to src/MaksIT.PostClient.Shared/Mail/MailCertifiedKind.cs index b8f1b7f..f0395a3 100644 --- a/src/MaksIT.PostClient.Shared/MailCertifiedKind.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailCertifiedKind.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailCertifiedKind { diff --git a/src/MaksIT.PostClient.Shared/MailChain.cs b/src/MaksIT.PostClient.Shared/Mail/MailChain.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/MailChain.cs rename to src/MaksIT.PostClient.Shared/Mail/MailChain.cs index 176a18c..bc0a6f9 100644 --- a/src/MaksIT.PostClient.Shared/MailChain.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailChain.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public sealed class MailChainItem { diff --git a/src/MaksIT.PostClient.Shared/MailDragPayload.cs b/src/MaksIT.PostClient.Shared/Mail/MailDragPayload.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/MailDragPayload.cs rename to src/MaksIT.PostClient.Shared/Mail/MailDragPayload.cs index af44c63..6b467fc 100644 --- a/src/MaksIT.PostClient.Shared/MailDragPayload.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailDragPayload.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailDragPayload { diff --git a/src/MaksIT.PostClient.Shared/MailDrop.cs b/src/MaksIT.PostClient.Shared/Mail/MailDrop.cs similarity index 97% rename from src/MaksIT.PostClient.Shared/MailDrop.cs rename to src/MaksIT.PostClient.Shared/Mail/MailDrop.cs index 18eaf61..1b3878b 100644 --- a/src/MaksIT.PostClient.Shared/MailDrop.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailDrop.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailDrop { diff --git a/src/MaksIT.PostClient.Shared/MailFolderLayout.cs b/src/MaksIT.PostClient.Shared/Mail/MailFolderLayout.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/MailFolderLayout.cs rename to src/MaksIT.PostClient.Shared/Mail/MailFolderLayout.cs index 13f1b79..0504609 100644 --- a/src/MaksIT.PostClient.Shared/MailFolderLayout.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailFolderLayout.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public sealed class MailFolderLayout { diff --git a/src/MaksIT.PostClient.Shared/MailFolderPath.cs b/src/MaksIT.PostClient.Shared/Mail/MailFolderPath.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/MailFolderPath.cs rename to src/MaksIT.PostClient.Shared/Mail/MailFolderPath.cs index 684d12b..ed42e41 100644 --- a/src/MaksIT.PostClient.Shared/MailFolderPath.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailFolderPath.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailFolderPath { diff --git a/src/MaksIT.PostClient.Shared/MailFolderRole.cs b/src/MaksIT.PostClient.Shared/Mail/MailFolderRole.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/MailFolderRole.cs rename to src/MaksIT.PostClient.Shared/Mail/MailFolderRole.cs index 8361dff..bb57885 100644 --- a/src/MaksIT.PostClient.Shared/MailFolderRole.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailFolderRole.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailFolderRole { diff --git a/src/MaksIT.PostClient.Shared/MailFolderStats.cs b/src/MaksIT.PostClient.Shared/Mail/MailFolderStats.cs similarity index 87% rename from src/MaksIT.PostClient.Shared/MailFolderStats.cs rename to src/MaksIT.PostClient.Shared/Mail/MailFolderStats.cs index 6106421..7ddc115 100644 --- a/src/MaksIT.PostClient.Shared/MailFolderStats.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailFolderStats.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailFolderStats { diff --git a/src/MaksIT.PostClient.Shared/MailId.cs b/src/MaksIT.PostClient.Shared/Mail/MailId.cs similarity index 96% rename from src/MaksIT.PostClient.Shared/MailId.cs rename to src/MaksIT.PostClient.Shared/Mail/MailId.cs index b75cfa8..b8bcf78 100644 --- a/src/MaksIT.PostClient.Shared/MailId.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailId.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailId { diff --git a/src/MaksIT.PostClient.Shared/MailIndexProgress.cs b/src/MaksIT.PostClient.Shared/Mail/MailIndexProgress.cs similarity index 93% rename from src/MaksIT.PostClient.Shared/MailIndexProgress.cs rename to src/MaksIT.PostClient.Shared/Mail/MailIndexProgress.cs index 784d351..2805224 100644 --- a/src/MaksIT.PostClient.Shared/MailIndexProgress.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailIndexProgress.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailIndexProgress { @@ -6,6 +6,8 @@ public static class MailIndexProgress { var copy = UiLocale.Copy; if (total <= 0) return string.IsNullOrWhiteSpace(folder) ? "" : copy.IndexScan(folder); + if (done >= total) + return ""; return string.IsNullOrWhiteSpace(folder) ? copy.IndexLine(done, total) : copy.IndexLine(done, total, folder); diff --git a/src/MaksIT.PostClient.Shared/MailLayout.cs b/src/MaksIT.PostClient.Shared/Mail/MailLayout.cs similarity index 92% rename from src/MaksIT.PostClient.Shared/MailLayout.cs rename to src/MaksIT.PostClient.Shared/Mail/MailLayout.cs index b0329e2..e62cdc8 100644 --- a/src/MaksIT.PostClient.Shared/MailLayout.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailLayout.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailLayout { diff --git a/src/MaksIT.PostClient.Shared/MailListBudget.cs b/src/MaksIT.PostClient.Shared/Mail/MailListBudget.cs similarity index 93% rename from src/MaksIT.PostClient.Shared/MailListBudget.cs rename to src/MaksIT.PostClient.Shared/Mail/MailListBudget.cs index e8e6d46..9eedf44 100644 --- a/src/MaksIT.PostClient.Shared/MailListBudget.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailListBudget.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailListBudget { diff --git a/src/MaksIT.PostClient.Shared/MailListOrder.cs b/src/MaksIT.PostClient.Shared/Mail/MailListOrder.cs similarity index 97% rename from src/MaksIT.PostClient.Shared/MailListOrder.cs rename to src/MaksIT.PostClient.Shared/Mail/MailListOrder.cs index c02370c..ffd8827 100644 --- a/src/MaksIT.PostClient.Shared/MailListOrder.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailListOrder.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailListOrder { diff --git a/src/MaksIT.PostClient.Shared/MailMessageKey.cs b/src/MaksIT.PostClient.Shared/Mail/MailMessageKey.cs similarity index 96% rename from src/MaksIT.PostClient.Shared/MailMessageKey.cs rename to src/MaksIT.PostClient.Shared/Mail/MailMessageKey.cs index f4053ec..b8c8b65 100644 --- a/src/MaksIT.PostClient.Shared/MailMessageKey.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailMessageKey.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public readonly struct MailMessageKey : IEquatable { diff --git a/src/MaksIT.PostClient.Shared/MailMessageList.cs b/src/MaksIT.PostClient.Shared/Mail/MailMessageList.cs similarity index 79% rename from src/MaksIT.PostClient.Shared/MailMessageList.cs rename to src/MaksIT.PostClient.Shared/Mail/MailMessageList.cs index 303d2d6..7ef7fc2 100644 --- a/src/MaksIT.PostClient.Shared/MailMessageList.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailMessageList.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailMessageList { @@ -67,4 +67,23 @@ public static class MailMessageList { return list; } + + public static T? Live( + IReadOnlyList selected, + T? current, + Func key) { + ArgumentNullException.ThrowIfNull(selected); + ArgumentNullException.ThrowIfNull(key); + if (selected.Count == 0) + return default; + if (current is not null) { + var currentKey = key(current); + foreach (var row in selected) { + if (key(row).Equals(currentKey)) + return row; + } + } + + return selected[0]; + } } diff --git a/src/MaksIT.PostClient.Shared/MailPriority.cs b/src/MaksIT.PostClient.Shared/Mail/MailPriority.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/MailPriority.cs rename to src/MaksIT.PostClient.Shared/Mail/MailPriority.cs index a56d53e..c070d8f 100644 --- a/src/MaksIT.PostClient.Shared/MailPriority.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailPriority.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailPriority { diff --git a/src/MaksIT.PostClient.Shared/MailProtocol.cs b/src/MaksIT.PostClient.Shared/Mail/MailProtocol.cs similarity index 96% rename from src/MaksIT.PostClient.Shared/MailProtocol.cs rename to src/MaksIT.PostClient.Shared/Mail/MailProtocol.cs index c07047a..a02dd41 100644 --- a/src/MaksIT.PostClient.Shared/MailProtocol.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailProtocol.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailProtocol { diff --git a/src/MaksIT.PostClient.Shared/MailProvider.cs b/src/MaksIT.PostClient.Shared/Mail/MailProvider.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/MailProvider.cs rename to src/MaksIT.PostClient.Shared/Mail/MailProvider.cs index 6e0c916..63f2b88 100644 --- a/src/MaksIT.PostClient.Shared/MailProvider.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailProvider.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailProvider { diff --git a/src/MaksIT.PostClient.Shared/MailRetention.cs b/src/MaksIT.PostClient.Shared/Mail/MailRetention.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/MailRetention.cs rename to src/MaksIT.PostClient.Shared/Mail/MailRetention.cs index 1d36f38..649aeec 100644 --- a/src/MaksIT.PostClient.Shared/MailRetention.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailRetention.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailRetention { diff --git a/src/MaksIT.PostClient.Shared/MailRule.cs b/src/MaksIT.PostClient.Shared/Mail/MailRule.cs similarity index 99% rename from src/MaksIT.PostClient.Shared/MailRule.cs rename to src/MaksIT.PostClient.Shared/Mail/MailRule.cs index 473bb60..c325ed8 100644 --- a/src/MaksIT.PostClient.Shared/MailRule.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailRule.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public sealed class MailRule { diff --git a/src/MaksIT.PostClient.Shared/MailSecurity.cs b/src/MaksIT.PostClient.Shared/Mail/MailSecurity.cs similarity index 97% rename from src/MaksIT.PostClient.Shared/MailSecurity.cs rename to src/MaksIT.PostClient.Shared/Mail/MailSecurity.cs index 6b56df7..668d3e9 100644 --- a/src/MaksIT.PostClient.Shared/MailSecurity.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailSecurity.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailSecurity { diff --git a/src/MaksIT.PostClient.Shared/MailSelection.cs b/src/MaksIT.PostClient.Shared/Mail/MailSelection.cs similarity index 95% rename from src/MaksIT.PostClient.Shared/MailSelection.cs rename to src/MaksIT.PostClient.Shared/Mail/MailSelection.cs index ff9bfd0..9723513 100644 --- a/src/MaksIT.PostClient.Shared/MailSelection.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailSelection.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailSelection { diff --git a/src/MaksIT.PostClient.Shared/Mail/MailSize.cs b/src/MaksIT.PostClient.Shared/Mail/MailSize.cs new file mode 100644 index 0000000..75bd053 --- /dev/null +++ b/src/MaksIT.PostClient.Shared/Mail/MailSize.cs @@ -0,0 +1,19 @@ +using System.Globalization; + + +namespace MaksIT.PostClient.Shared.Mail; + + +public static class MailSize { + public static string Line(long bytes) { + if (bytes <= 0) + return ""; + if (bytes < 1024) + return bytes.ToString(CultureInfo.InvariantCulture) + " B"; + if (bytes < 1024 * 1024) + return (bytes / 1024.0).ToString("0.#", CultureInfo.InvariantCulture) + " KB"; + if (bytes < 1024L * 1024 * 1024) + return (bytes / (1024.0 * 1024)).ToString("0.#", CultureInfo.InvariantCulture) + " MB"; + return (bytes / (1024.0 * 1024 * 1024)).ToString("0.##", CultureInfo.InvariantCulture) + " GB"; + } +} diff --git a/src/MaksIT.PostClient.Shared/MailWhen.cs b/src/MaksIT.PostClient.Shared/Mail/MailWhen.cs similarity index 88% rename from src/MaksIT.PostClient.Shared/MailWhen.cs rename to src/MaksIT.PostClient.Shared/Mail/MailWhen.cs index 991a527..6cf2b53 100644 --- a/src/MaksIT.PostClient.Shared/MailWhen.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailWhen.cs @@ -1,7 +1,7 @@ using System.Globalization; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public static class MailWhen { diff --git a/src/MaksIT.PostClient.Shared/MailboxAccount.cs b/src/MaksIT.PostClient.Shared/Mail/MailboxAccount.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/MailboxAccount.cs rename to src/MaksIT.PostClient.Shared/Mail/MailboxAccount.cs index 8aca690..b798470 100644 --- a/src/MaksIT.PostClient.Shared/MailboxAccount.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailboxAccount.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public sealed class MailboxAccount { diff --git a/src/MaksIT.PostClient.Shared/MailboxQuota.cs b/src/MaksIT.PostClient.Shared/Mail/MailboxQuota.cs similarity index 96% rename from src/MaksIT.PostClient.Shared/MailboxQuota.cs rename to src/MaksIT.PostClient.Shared/Mail/MailboxQuota.cs index e8165ed..4ba5c33 100644 --- a/src/MaksIT.PostClient.Shared/MailboxQuota.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailboxQuota.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Mail; public sealed class MailboxQuota { diff --git a/src/MaksIT.PostClient.Shared/EmbeddingModelSpec.cs b/src/MaksIT.PostClient.Shared/Search/EmbeddingModelSpec.cs similarity index 98% rename from src/MaksIT.PostClient.Shared/EmbeddingModelSpec.cs rename to src/MaksIT.PostClient.Shared/Search/EmbeddingModelSpec.cs index 6c8f522..db10cbc 100644 --- a/src/MaksIT.PostClient.Shared/EmbeddingModelSpec.cs +++ b/src/MaksIT.PostClient.Shared/Search/EmbeddingModelSpec.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Search; public static class EmbeddingModelSpec { diff --git a/src/MaksIT.PostClient.Shared/EmbeddingPrompt.cs b/src/MaksIT.PostClient.Shared/Search/EmbeddingPrompt.cs similarity index 95% rename from src/MaksIT.PostClient.Shared/EmbeddingPrompt.cs rename to src/MaksIT.PostClient.Shared/Search/EmbeddingPrompt.cs index ddc77a8..05cb2dc 100644 --- a/src/MaksIT.PostClient.Shared/EmbeddingPrompt.cs +++ b/src/MaksIT.PostClient.Shared/Search/EmbeddingPrompt.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Search; public static class EmbeddingPrompt { diff --git a/src/MaksIT.PostClient.Shared/EmbeddingVector.cs b/src/MaksIT.PostClient.Shared/Search/EmbeddingVector.cs similarity index 97% rename from src/MaksIT.PostClient.Shared/EmbeddingVector.cs rename to src/MaksIT.PostClient.Shared/Search/EmbeddingVector.cs index 6bf9207..826c37c 100644 --- a/src/MaksIT.PostClient.Shared/EmbeddingVector.cs +++ b/src/MaksIT.PostClient.Shared/Search/EmbeddingVector.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Search; public static class EmbeddingVector { diff --git a/src/MaksIT.PostClient.Shared/SemanticDevice.cs b/src/MaksIT.PostClient.Shared/Search/SemanticDevice.cs similarity index 93% rename from src/MaksIT.PostClient.Shared/SemanticDevice.cs rename to src/MaksIT.PostClient.Shared/Search/SemanticDevice.cs index 6626141..dae721c 100644 --- a/src/MaksIT.PostClient.Shared/SemanticDevice.cs +++ b/src/MaksIT.PostClient.Shared/Search/SemanticDevice.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Search; public static class SemanticDevice { diff --git a/src/MaksIT.PostClient.Shared/SemanticSearchSettings.cs b/src/MaksIT.PostClient.Shared/Search/SemanticSearchSettings.cs similarity index 84% rename from src/MaksIT.PostClient.Shared/SemanticSearchSettings.cs rename to src/MaksIT.PostClient.Shared/Search/SemanticSearchSettings.cs index 323ae38..66f84d6 100644 --- a/src/MaksIT.PostClient.Shared/SemanticSearchSettings.cs +++ b/src/MaksIT.PostClient.Shared/Search/SemanticSearchSettings.cs @@ -1,4 +1,4 @@ -namespace MaksIT.PostClient.Shared; +namespace MaksIT.PostClient.Shared.Search; public sealed class SemanticSearchSettings { diff --git a/src/MaksIT.PostClient.Tests/SharedTests.cs b/src/MaksIT.PostClient.Tests/App/SharedTests.cs similarity index 92% rename from src/MaksIT.PostClient.Tests/SharedTests.cs rename to src/MaksIT.PostClient.Tests/App/SharedTests.cs index 379206f..dd7ceaf 100644 --- a/src/MaksIT.PostClient.Tests/SharedTests.cs +++ b/src/MaksIT.PostClient.Tests/App/SharedTests.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.App; public class AppInfoTests { @@ -120,6 +119,49 @@ public class AppPathsTests { } } + [Fact] + public void IsWebViewFile_OnlyUnderWebViewDirectory() { + var previous = Environment.GetEnvironmentVariable(AppPaths.DataEnv); + var root = Path.Combine(Path.GetTempPath(), "postclient-wvfile-" + Guid.NewGuid().ToString("N")); + try { + Environment.SetEnvironmentVariable(AppPaths.DataEnv, root); + Directory.CreateDirectory(AppPaths.WebViewDirectory()); + var inside = Path.Combine(AppPaths.WebViewDirectory(), "reading-abc.html"); + File.WriteAllText(inside, ""); + Assert.True(AppPaths.IsWebViewFile(new Uri(inside))); + Assert.False(AppPaths.IsWebViewFile(new Uri(Path.Combine(root, "other.html")))); + Assert.False(AppPaths.IsWebViewFile(new Uri("https://example.com/"))); + } + finally { + Environment.SetEnvironmentVariable(AppPaths.DataEnv, previous); + if (Directory.Exists(root)) + Directory.Delete(root, true); + } + } + + [Fact] + public void ClearStaleReadingDocuments_RemovesReadingHtml() { + var previous = Environment.GetEnvironmentVariable(AppPaths.DataEnv); + var root = Path.Combine(Path.GetTempPath(), "postclient-wvclear-" + Guid.NewGuid().ToString("N")); + try { + Environment.SetEnvironmentVariable(AppPaths.DataEnv, root); + var dir = AppPaths.WebViewDirectory(); + Directory.CreateDirectory(dir); + var stale = Path.Combine(dir, "reading-stale.html"); + var keep = Path.Combine(dir, "keep.txt"); + File.WriteAllText(stale, ""); + File.WriteAllText(keep, "ok"); + AppPaths.ClearStaleReadingDocuments(); + Assert.False(File.Exists(stale)); + Assert.True(File.Exists(keep)); + } + finally { + Environment.SetEnvironmentVariable(AppPaths.DataEnv, previous); + if (Directory.Exists(root)) + Directory.Delete(root, true); + } + } + [Fact] public void MergeMissingDirectory_CopiesHubProfile() { var dir = Path.Combine(Path.GetTempPath(), "postclient-wv-" + Guid.NewGuid().ToString("N")); @@ -202,6 +244,21 @@ public class MessageHtmlTests { Assert.False(MessageHtml.HasMarkup("")); Assert.False(MessageHtml.HasMarkup("2 < 3 and 4 > 1")); } + + [Fact] + public void Sanitize_DropsNullsAndInvalidUtf16() { + var html = MessageHtml.Sanitize("A\0B\uD800C"); + Assert.DoesNotContain('\0', html); + Assert.StartsWith("AB", html, StringComparison.Ordinal); + Assert.EndsWith("C", html, StringComparison.Ordinal); + } + + [Fact] + public void Document_SanitizesNullChars() { + var html = MessageHtml.Document("

A\0B

"); + Assert.DoesNotContain('\0', html); + Assert.Contains("

AB

", html, StringComparison.Ordinal); + } } @@ -281,6 +338,17 @@ public class MailWhenTests { } +public class MailSizeTests { + [Fact] + public void Line_FormatsBytesAndHidesUnknown() { + Assert.Equal("", MailSize.Line(0)); + Assert.Equal("512 B", MailSize.Line(512)); + Assert.Equal("1.5 KB", MailSize.Line(1536)); + Assert.Equal("2 MB", MailSize.Line(2 * 1024 * 1024)); + } +} + + public class MailIdTests { [Fact] public void Parent_PrefersInReplyToThenLastReferences() { @@ -345,6 +413,27 @@ public class MailMessageListTests { Assert.Same(live, Assert.Single(resolved)); } + [Fact] + public void Live_RemapsCurrentOntoSelectedRows() { + var stale = new Row { Id = 2 }; + Row[] selected = [new() { Id = 1 }, new() { Id = 2 }, new() { Id = 3 }]; + var live = MailMessageList.Live(selected, stale, row => row.Key); + Assert.Same(selected[1], live); + } + + [Fact] + public void Live_FallsBackToFirstWhenCurrentMissing() { + var gone = new Row { Id = 9 }; + Row[] selected = [new() { Id = 1 }, new() { Id = 2 }]; + Assert.Same(selected[0], MailMessageList.Live(selected, gone, row => row.Key)); + } + + [Fact] + public void Live_ClearsCurrentWhenSelectionEmpty() { + var current = new Row { Id = 4 }; + Assert.Null(MailMessageList.Live([], current, row => row.Key)); + } + [Fact] public void CoversAll_RequiresMatchingKeys() { Row[] visible = [new() { Id = 1 }, new() { Id = 2 }]; @@ -436,7 +525,8 @@ public class MailIndexProgressTests { [Fact] public void Line_ShowsDoneOfTotal() { Assert.Equal("Indexing 12 / 400", MailIndexProgress.Line(12, 400)); - Assert.Equal("Indexing 400 / 400", MailIndexProgress.Line(500, 400)); + Assert.Equal("", MailIndexProgress.Line(400, 400)); + Assert.Equal("", MailIndexProgress.Line(500, 400)); } [Fact] diff --git a/src/MaksIT.PostClient.Tests/ArchiveTests.cs b/src/MaksIT.PostClient.Tests/Archive/ArchiveTests.cs similarity index 87% rename from src/MaksIT.PostClient.Tests/ArchiveTests.cs rename to src/MaksIT.PostClient.Tests/Archive/ArchiveTests.cs index ee5e88a..8b2cf1b 100644 --- a/src/MaksIT.PostClient.Tests/ArchiveTests.cs +++ b/src/MaksIT.PostClient.Tests/Archive/ArchiveTests.cs @@ -1,8 +1,6 @@ -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.Archive; public class FatturaPaTests { @@ -193,6 +191,102 @@ public class MailArchiveStoreTests { } } + [Fact] + public void UpsertHeaders_KeepsPreviousSizeWhenIncomingIsZero() { + 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 = 5, + Folder = "INBOX", + Subject = "Heavy", + From = "a@b.c", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + Size = 2_048_000 + } + ]); + store.UpsertHeaders("box", [ + new MailArchiveHeader { + Uid = 5, + Folder = "INBOX", + Subject = "Heavy", + From = "a@b.c", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + Size = 0 + } + ]); + var row = Assert.Single(store.ListFolder("box", "INBOX")); + Assert.Equal(2_048_000, row.Size); + } + finally { + DeleteArchive(path); + } + } + + [Fact] + public void UpsertHeaders_KeepsAttachmentsWhenIncomingIsFalse() { + 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 = 6, + Folder = "INBOX", + Subject = "With file", + From = "a@b.c", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + HasAttachments = true + } + ]); + store.UpsertHeaders("box", [ + new MailArchiveHeader { + Uid = 6, + Folder = "INBOX", + Subject = "With file", + From = "a@b.c", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + HasAttachments = false + } + ]); + var row = Assert.Single(store.ListFolder("box", "INBOX")); + Assert.True(row.HasAttachments); + } + finally { + DeleteArchive(path); + } + } + + [Fact] + public void MissingBodies_PrefersKnownSmallerMessages() { + 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 = 1, + Folder = "INBOX", + Subject = "Huge", + Date = DateTimeOffset.Parse("2026-01-02T10:00:00Z"), + Size = 9_000_000 + }, + new MailArchiveHeader { + Uid = 2, + Folder = "INBOX", + Subject = "Small", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + Size = 1200 + } + ]); + var first = Assert.Single(store.MissingBodies(["box"], 1)); + Assert.Equal(2u, first.Uid); + Assert.Equal(1200, first.Size); + } + finally { + DeleteArchive(path); + } + } + [Fact] public void RemoveUids_LeavesOtherMessages() { var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); @@ -640,6 +734,18 @@ public class MailArchiveMapTests { EnvelopeTipo = "avvenuta-consegna" }); Assert.Equal(ReceiptStatus.Delivered, header.DeliveryStatus); + Assert.Equal(0, header.Size); + } + + [Fact] + public void FromHeader_CopiesSize() { + var mapped = MailArchiveMap.FromHeader(new MailMessageHeader { + Id = 2, + Folder = "INBOX", + Size = 4096 + }); + Assert.Equal(4096, mapped.Size); + Assert.Equal(4096, MailArchiveMap.ToHeader(mapped).Size); } } diff --git a/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs b/src/MaksIT.PostClient.Tests/Archive/StoreAndHubTests.cs similarity index 99% rename from src/MaksIT.PostClient.Tests/StoreAndHubTests.cs rename to src/MaksIT.PostClient.Tests/Archive/StoreAndHubTests.cs index 6f2a2d7..cd2eb83 100644 --- a/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs +++ b/src/MaksIT.PostClient.Tests/Archive/StoreAndHubTests.cs @@ -4,11 +4,9 @@ using MaksIT.IdentityHub.Contracts.Health; using MaksIT.IdentityHub.Contracts.Identity; using MaksIT.IdentityHub.Contracts.Identity.Login; using MaksIT.IdentityHub.Contracts.Identity.Mailbox; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.Archive; public class IdentityHubAddressTests { diff --git a/src/MaksIT.PostClient.Tests/OAuthTests.cs b/src/MaksIT.PostClient.Tests/Auth/OAuthTests.cs similarity index 98% rename from src/MaksIT.PostClient.Tests/OAuthTests.cs rename to src/MaksIT.PostClient.Tests/Auth/OAuthTests.cs index a41d962..fc3ebdc 100644 --- a/src/MaksIT.PostClient.Tests/OAuthTests.cs +++ b/src/MaksIT.PostClient.Tests/Auth/OAuthTests.cs @@ -1,9 +1,7 @@ using System.Text; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.Auth; public class MailAuthKindTests { diff --git a/src/MaksIT.PostClient.Tests/EnvelopeTests.cs b/src/MaksIT.PostClient.Tests/Envelope/EnvelopeTests.cs similarity index 88% rename from src/MaksIT.PostClient.Tests/EnvelopeTests.cs rename to src/MaksIT.PostClient.Tests/Envelope/EnvelopeTests.cs index 1ebb717..e79dbee 100644 --- a/src/MaksIT.PostClient.Tests/EnvelopeTests.cs +++ b/src/MaksIT.PostClient.Tests/Envelope/EnvelopeTests.cs @@ -1,9 +1,7 @@ using MimeKit; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.Envelope; public class EnvelopeClassifierTests { @@ -321,4 +319,43 @@ public class MimeBodyTests { Assert.False(body.Header.IsSeen); Assert.False(body.Header.IsFlagged); } + + [Fact] + public async Task FromMimeAsync_NamedInlinePartCountsAsAttachment() { + var mime = new MimeMessage(); + mime.From.Add(new MailboxAddress("A", "a@b.c")); + mime.To.Add(new MailboxAddress("B", "b@c.d")); + mime.Subject = "Invoice"; + var pdf = new MimePart("application", "pdf") { + Content = new MimeContent(new MemoryStream("pdf"u8.ToArray())), + ContentDisposition = new ContentDisposition(ContentDisposition.Inline), + FileName = "invoice.pdf" + }; + mime.Body = new Multipart("mixed") { + new TextPart("plain") { Text = "See attached." }, + pdf + }; + var body = await MimeBody.FromMimeAsync("INBOX", 9, mime, CancellationToken.None); + Assert.True(body.Header.HasAttachments); + Assert.Contains(body.AttachmentFiles, f => f.Name == "invoice.pdf"); + } + + [Fact] + public async Task FromMimeAsync_CidImageInHtmlIsNotAttachment() { + var mime = new MimeMessage(); + mime.From.Add(new MailboxAddress("A", "a@b.c")); + mime.To.Add(new MailboxAddress("B", "b@c.d")); + mime.Subject = "Logo"; + var image = new MimePart("image", "png") { + Content = new MimeContent(new MemoryStream([0x89, 0x50])), + ContentId = "logo@mail", + ContentDisposition = new ContentDisposition(ContentDisposition.Inline) + }; + mime.Body = new Multipart("related") { + new TextPart("html") { Text = "" }, + image + }; + var body = await MimeBody.FromMimeAsync("INBOX", 10, mime, CancellationToken.None); + Assert.False(body.Header.HasAttachments); + } } diff --git a/src/MaksIT.PostClient.Tests/GlobalUsings.cs b/src/MaksIT.PostClient.Tests/GlobalUsings.cs new file mode 100644 index 0000000..3968f06 --- /dev/null +++ b/src/MaksIT.PostClient.Tests/GlobalUsings.cs @@ -0,0 +1,15 @@ +global using MaksIT.PostClient.Client.Auth; +global using MaksIT.PostClient.Client.Import; +global using MaksIT.PostClient.Client.Mail; +global using MaksIT.PostClient.Client.Mime; +global using MaksIT.PostClient.Client.Search; +global using MaksIT.PostClient.Client.Update; +global using MaksIT.PostClient.Shared.App; +global using MaksIT.PostClient.Shared.Archive; +global using MaksIT.PostClient.Shared.Auth; +global using MaksIT.PostClient.Shared.Config; +global using MaksIT.PostClient.Shared.Content; +global using MaksIT.PostClient.Shared.Envelope; +global using MaksIT.PostClient.Shared.Localization; +global using MaksIT.PostClient.Shared.Mail; +global using MaksIT.PostClient.Shared.Search; diff --git a/src/MaksIT.PostClient.Tests/OutlookRulesTests.cs b/src/MaksIT.PostClient.Tests/Import/OutlookRulesTests.cs similarity index 99% rename from src/MaksIT.PostClient.Tests/OutlookRulesTests.cs rename to src/MaksIT.PostClient.Tests/Import/OutlookRulesTests.cs index fd5a4c6..1140c31 100644 --- a/src/MaksIT.PostClient.Tests/OutlookRulesTests.cs +++ b/src/MaksIT.PostClient.Tests/Import/OutlookRulesTests.cs @@ -1,9 +1,7 @@ using System.Text; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.Import; public class MailRuleEngineTests { diff --git a/src/MaksIT.PostClient.Tests/FolderDropTests.cs b/src/MaksIT.PostClient.Tests/Mail/FolderDropTests.cs similarity index 98% rename from src/MaksIT.PostClient.Tests/FolderDropTests.cs rename to src/MaksIT.PostClient.Tests/Mail/FolderDropTests.cs index 037e4f5..d57feb5 100644 --- a/src/MaksIT.PostClient.Tests/FolderDropTests.cs +++ b/src/MaksIT.PostClient.Tests/Mail/FolderDropTests.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.Mail; public class MailFolderPathTests { diff --git a/src/MaksIT.PostClient.Tests/ProtocolTests.cs b/src/MaksIT.PostClient.Tests/Mail/ProtocolTests.cs similarity index 97% rename from src/MaksIT.PostClient.Tests/ProtocolTests.cs rename to src/MaksIT.PostClient.Tests/Mail/ProtocolTests.cs index 7d9ab5c..cdd8b51 100644 --- a/src/MaksIT.PostClient.Tests/ProtocolTests.cs +++ b/src/MaksIT.PostClient.Tests/Mail/ProtocolTests.cs @@ -1,10 +1,8 @@ using System.Threading; using MaksIT.Results; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.Mail; public class MailProtocolTests { @@ -583,6 +581,21 @@ public class ImapFetchPolicyTests { } +public class MailFetchTests { + [Fact] + public void IsGone_RecognizesMissingServerMessages() { + Assert.True(MailFetch.IsGone("The unique id is invalid.")); + Assert.True(MailFetch.IsGone(["Message not found"])); + Assert.False(MailFetch.IsGone("Connection timed out")); + Assert.False(MailFetch.IsGone((string?)null)); + } + + [Fact] + public void MaxBackfillBytes_IsEightMegabytes() => + Assert.Equal(8L * 1024 * 1024, MailFetch.MaxBackfillBytes); +} + + public class OutlookPstHeaderTests { [Fact] public void IsAnsi_UnicodeEmptyPstIsFalse() { diff --git a/src/MaksIT.PostClient.Tests/Mime/MimeAttachmentTests.cs b/src/MaksIT.PostClient.Tests/Mime/MimeAttachmentTests.cs new file mode 100644 index 0000000..f1c143b --- /dev/null +++ b/src/MaksIT.PostClient.Tests/Mime/MimeAttachmentTests.cs @@ -0,0 +1,55 @@ +using MailKit; +using MimeKit; + + +namespace MaksIT.PostClient.Tests.Mime; + + +public class MimeAttachmentTests { + [Fact] + public void HasFromStructure_NamedInlinePdf() { + var mixed = new BodyPartMultipart(new ContentType("multipart", "mixed"), "1"); + mixed.BodyParts.Add(new BodyPartText(new ContentType("text", "plain"), "1.1")); + mixed.BodyParts.Add(new BodyPartBasic(new ContentType("application", "pdf") { Name = "invoice.pdf" }, "1.2") { + ContentDisposition = new ContentDisposition(ContentDisposition.Inline) { + FileName = "invoice.pdf" + }, + Octets = 1200 + }); + Assert.True(MimeAttachments.HasFromStructure(mixed)); + } + + [Fact] + public void HasFromStructure_PlainTextOnly() { + var text = new BodyPartText(new ContentType("text", "plain"), "1") { + Octets = 12 + }; + Assert.False(MimeAttachments.HasFromStructure(text)); + } + + [Fact] + public void HasFromStructure_CidImageIsSkipped() { + var related = new BodyPartMultipart(new ContentType("multipart", "related"), "1"); + related.BodyParts.Add(new BodyPartText(new ContentType("text", "html"), "1.1") { + Octets = 80 + }); + related.BodyParts.Add(new BodyPartBasic(new ContentType("image", "png"), "1.2") { + ContentDisposition = new ContentDisposition(ContentDisposition.Inline), + ContentId = "logo@mail", + Octets = 40 + }); + Assert.False(MimeAttachments.HasFromStructure(related)); + } + + [Fact] + public void HasFromStructure_NestedRfc822() { + var mixed = new BodyPartMultipart(new ContentType("multipart", "mixed"), "1"); + mixed.BodyParts.Add(new BodyPartText(new ContentType("text", "plain"), "1.1")); + mixed.BodyParts.Add(new BodyPartMessage(new ContentType("message", "rfc822"), "1.2") { + ContentDisposition = new ContentDisposition(ContentDisposition.Attachment) { + FileName = "postacert.eml" + } + }); + Assert.True(MimeAttachments.HasFromStructure(mixed)); + } +} diff --git a/src/MaksIT.PostClient.Tests/RecipientTests.cs b/src/MaksIT.PostClient.Tests/Mime/RecipientTests.cs similarity index 92% rename from src/MaksIT.PostClient.Tests/RecipientTests.cs rename to src/MaksIT.PostClient.Tests/Mime/RecipientTests.cs index 6425c3c..4dfe62c 100644 --- a/src/MaksIT.PostClient.Tests/RecipientTests.cs +++ b/src/MaksIT.PostClient.Tests/Mime/RecipientTests.cs @@ -1,7 +1,6 @@ -using MaksIT.PostClient.Client; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.Mime; public class RecipientParserTests { diff --git a/src/MaksIT.PostClient.Tests/SemanticSearchTests.cs b/src/MaksIT.PostClient.Tests/Search/SemanticSearchTests.cs similarity index 98% rename from src/MaksIT.PostClient.Tests/SemanticSearchTests.cs rename to src/MaksIT.PostClient.Tests/Search/SemanticSearchTests.cs index fba759d..842809e 100644 --- a/src/MaksIT.PostClient.Tests/SemanticSearchTests.cs +++ b/src/MaksIT.PostClient.Tests/Search/SemanticSearchTests.cs @@ -1,8 +1,6 @@ -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.Search; public class EmbeddingVectorTests { diff --git a/src/MaksIT.PostClient.UI/App.axaml.cs b/src/MaksIT.PostClient.UI/App.axaml.cs index be4eadc..c5ba850 100644 --- a/src/MaksIT.PostClient.UI/App.axaml.cs +++ b/src/MaksIT.PostClient.UI/App.axaml.cs @@ -4,8 +4,6 @@ using Avalonia.Controls; using Avalonia.Threading; using Avalonia.Markup.Xaml; using Avalonia.Controls.ApplicationLifetimes; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; using MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/Controls/RecipientBox.axaml.cs b/src/MaksIT.PostClient.UI/Controls/RecipientBox.axaml.cs index 1c5d863..35fd664 100644 --- a/src/MaksIT.PostClient.UI/Controls/RecipientBox.axaml.cs +++ b/src/MaksIT.PostClient.UI/Controls/RecipientBox.axaml.cs @@ -6,7 +6,6 @@ using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; -using MaksIT.PostClient.Client; using MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/DesktopNotice.cs b/src/MaksIT.PostClient.UI/Dialogs/DesktopNotice.cs similarity index 98% rename from src/MaksIT.PostClient.UI/DesktopNotice.cs rename to src/MaksIT.PostClient.UI/Dialogs/DesktopNotice.cs index 9b41971..01f0a0a 100644 --- a/src/MaksIT.PostClient.UI/DesktopNotice.cs +++ b/src/MaksIT.PostClient.UI/Dialogs/DesktopNotice.cs @@ -1,7 +1,7 @@ using System.Diagnostics; -namespace MaksIT.PostClient.UI; +namespace MaksIT.PostClient.UI.Dialogs; public static class DesktopNotice { diff --git a/src/MaksIT.PostClient.UI/ErrorDialog.cs b/src/MaksIT.PostClient.UI/Dialogs/ErrorDialog.cs similarity index 97% rename from src/MaksIT.PostClient.UI/ErrorDialog.cs rename to src/MaksIT.PostClient.UI/Dialogs/ErrorDialog.cs index b8d2da8..4e2c7a2 100644 --- a/src/MaksIT.PostClient.UI/ErrorDialog.cs +++ b/src/MaksIT.PostClient.UI/Dialogs/ErrorDialog.cs @@ -2,13 +2,11 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Threading; using Avalonia.Controls.ApplicationLifetimes; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; using MaksIT.PostClient.UI.Windows; using MaksIT.PostClient.UI.ViewModels; -namespace MaksIT.PostClient.UI; +namespace MaksIT.PostClient.UI.Dialogs; internal static class ErrorDialog { diff --git a/src/MaksIT.PostClient.UI/GlobalUsings.cs b/src/MaksIT.PostClient.UI/GlobalUsings.cs new file mode 100644 index 0000000..b97fb32 --- /dev/null +++ b/src/MaksIT.PostClient.UI/GlobalUsings.cs @@ -0,0 +1,19 @@ +global using MaksIT.PostClient.Client.Auth; +global using MaksIT.PostClient.Client.Extensions; +global using MaksIT.PostClient.Client.Import; +global using MaksIT.PostClient.Client.Mail; +global using MaksIT.PostClient.Client.Mime; +global using MaksIT.PostClient.Client.Search; +global using MaksIT.PostClient.Client.Update; +global using MaksIT.PostClient.Shared.App; +global using MaksIT.PostClient.Shared.Archive; +global using MaksIT.PostClient.Shared.Auth; +global using MaksIT.PostClient.Shared.Config; +global using MaksIT.PostClient.Shared.Content; +global using MaksIT.PostClient.Shared.Envelope; +global using MaksIT.PostClient.Shared.Localization; +global using MaksIT.PostClient.Shared.Mail; +global using MaksIT.PostClient.Shared.Search; +global using MaksIT.PostClient.UI.Dialogs; +global using MaksIT.PostClient.UI.Layout; +global using MaksIT.PostClient.UI.Web; diff --git a/src/MaksIT.PostClient.UI/LayoutPersistence.cs b/src/MaksIT.PostClient.UI/Layout/LayoutPersistence.cs similarity index 99% rename from src/MaksIT.PostClient.UI/LayoutPersistence.cs rename to src/MaksIT.PostClient.UI/Layout/LayoutPersistence.cs index ba50d4a..f5358f7 100644 --- a/src/MaksIT.PostClient.UI/LayoutPersistence.cs +++ b/src/MaksIT.PostClient.UI/Layout/LayoutPersistence.cs @@ -4,11 +4,10 @@ using Avalonia.Controls; using Avalonia.Threading; using Avalonia.VisualTree; using Avalonia.Interactivity; -using MaksIT.PostClient.Shared; using MaksIT.PostClient.UI.ViewModels; -namespace MaksIT.PostClient.UI; +namespace MaksIT.PostClient.UI.Layout; internal sealed class LayoutPersistence { diff --git a/src/MaksIT.PostClient.UI/MainWindow.axaml b/src/MaksIT.PostClient.UI/MainWindow.axaml index 98c532f..02d554e 100644 --- a/src/MaksIT.PostClient.UI/MainWindow.axaml +++ b/src/MaksIT.PostClient.UI/MainWindow.axaml @@ -276,6 +276,7 @@ HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" SelectionChanged="OnMessagesSelectionChanged" + ContextRequested="OnMessagesContextRequested" DoubleTapped="OnMessagesDoubleTapped"> @@ -396,6 +397,16 @@ + + + + + + + diff --git a/src/MaksIT.PostClient.UI/MainWindow.axaml.cs b/src/MaksIT.PostClient.UI/MainWindow.axaml.cs index 60899a2..fcf83d0 100644 --- a/src/MaksIT.PostClient.UI/MainWindow.axaml.cs +++ b/src/MaksIT.PostClient.UI/MainWindow.axaml.cs @@ -9,8 +9,6 @@ using Avalonia.VisualTree; using Avalonia.Interactivity; using Avalonia.Platform.Storage; using Avalonia.Controls.ApplicationLifetimes; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; using MaksIT.PostClient.UI.Windows; using MaksIT.PostClient.UI.ViewModels; @@ -141,6 +139,7 @@ public partial class MainWindow : Window { "Label" => copy.Label, "Subject" => copy.Subject, "Date" => copy.Date, + "Size" => copy.Size, _ => column.Header }; column.IsVisible = (column.Tag as string) switch { @@ -368,6 +367,20 @@ public partial class MainWindow : Window { } } + private void OnMessagesContextRequested(object? sender, ContextRequestedEventArgs e) { + if (DataContext is not MainViewModel vm) + return; + var row = RowAt(e.Source); + if (row is null) + return; + var live = LiveGridRows(); + if (live.Any(item => item.Key == row.Key)) + vm.SetSelectedMessages(live); + else + vm.SetSelectedMessages([row]); + RestoreMessageGridSelection(); + } + private void OnMessagesSelectionChanged(object? sender, SelectionChangedEventArgs e) { if (_syncingGridSelection || DataContext is MainViewModel { IsSyncingMessageList: true }) return; @@ -380,12 +393,8 @@ public partial class MainWindow : Window { _syncingGridSelection = true; try { MessagesGrid.SelectedItems.Clear(); - if (vm.SelectedMessage is not null) - MessagesGrid.SelectedItems.Add(vm.SelectedMessage); - foreach (var row in vm.SelectedMessages) { - if (!MessagesGrid.SelectedItems.Contains(row)) - MessagesGrid.SelectedItems.Add(row); - } + foreach (var row in vm.VisibleSelectedMessages()) + MessagesGrid.SelectedItems.Add(row); } finally { _syncingGridSelection = false; @@ -395,7 +404,14 @@ public partial class MainWindow : Window { private void SyncSelectionFromGrid() { if (DataContext is not MainViewModel vm) return; - vm.SetSelectedMessages(MessagesGrid.SelectedItems.OfType()); + vm.SetSelectedMessages(LiveGridRows()); + } + + private List LiveGridRows() { + if (DataContext is not MainViewModel vm) + return []; + var keys = MessagesGrid.SelectedItems.OfType().Select(row => row.Key); + return MailMessageList.Resolve(keys, vm.VisibleMessages, row => row.Key).ToList(); } private void OnMessagesPointerReleased(object? sender, PointerReleasedEventArgs e) { @@ -606,7 +622,15 @@ public partial class MainWindow : Window { private void OnMessageMenuOpening(object? sender, EventArgs e) { if (sender is not MenuFlyout flyout || DataContext is not MainViewModel vm) return; - SyncSelectionFromGrid(); + var live = LiveGridRows(); + if (live.Count > 0) + vm.SetSelectedMessages(live); + else { + vm.SetSelectedMessages(vm.VisibleSelectedMessages()); + RestoreMessageGridSelection(); + } + + vm.RefreshMessageMenu(); MenuItem? move = null; foreach (var item in flyout.Items) { if (item is MenuItem menu && string.Equals(menu.Header as string, vm.Copy.MoveTo, StringComparison.Ordinal)) @@ -905,8 +929,8 @@ public partial class MainWindow : Window { || modifiers.HasFlag(KeyModifiers.Shift); private static MessageRowViewModel? RowAt(object? source) { - for (var current = source as Control; current is not null; current = current.Parent as Control) { - if (current.DataContext is MessageRowViewModel row) + for (var current = source as Visual; current is not null; current = current.GetVisualParent()) { + if (current is Control { DataContext: MessageRowViewModel row }) return row; } diff --git a/src/MaksIT.PostClient.UI/Program.cs b/src/MaksIT.PostClient.UI/Program.cs index d9d7d36..02879d8 100644 --- a/src/MaksIT.PostClient.UI/Program.cs +++ b/src/MaksIT.PostClient.UI/Program.cs @@ -1,8 +1,6 @@ using Avalonia; using Avalonia.Logging; using Avalonia.Threading; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.UI; @@ -42,8 +40,12 @@ internal static class Program { private static void OnDispatcherUnhandled(object? sender, DispatcherUnhandledExceptionEventArgs e) { e.Handled = true; - if (IsWebViewFailure(e.Exception)) + if (IsWebViewFailure(e.Exception)) { + ErrorReport.Capture(e.Exception); WebViewFailed?.Invoke(e.Exception); + return; + } + ErrorDialog.Report(e.Exception); } @@ -69,6 +71,8 @@ internal static class Program { var text = ex.ToString(); return text.Contains("WebView", StringComparison.OrdinalIgnoreCase) || text.Contains("webkit", StringComparison.OrdinalIgnoreCase) - || (uint)ex.HResult == 0x80070005; + || (uint)ex.HResult == 0x80070005 + || ((uint)ex.HResult == 0x80070057 + && text.Contains("NavigateToString", StringComparison.OrdinalIgnoreCase)); } } diff --git a/src/MaksIT.PostClient.UI/ViewModels/AboutViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/AboutViewModel.cs index b63cac1..b4b205b 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/AboutViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/AboutViewModel.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using CommunityToolkit.Mvvm.Input; -using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/ViewModels/ComposeViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/ComposeViewModel.cs index 2d2eef6..5116000 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/ComposeViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/ComposeViewModel.cs @@ -1,8 +1,6 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/ViewModels/ErrorReportViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/ErrorReportViewModel.cs index 0db5bd9..cbb3ddd 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/ErrorReportViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/ErrorReportViewModel.cs @@ -1,5 +1,4 @@ using CommunityToolkit.Mvvm.ComponentModel; -using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/ViewModels/FeatureMatrixViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/FeatureMatrixViewModel.cs index 0341f4a..db8301a 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/FeatureMatrixViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/FeatureMatrixViewModel.cs @@ -1,6 +1,5 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; -using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/ViewModels/LogViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/LogViewModel.cs index 89585d9..83d6c7c 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/LogViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/LogViewModel.cs @@ -1,5 +1,4 @@ using CommunityToolkit.Mvvm.ComponentModel; -using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs index 416a920..8139bd2 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs @@ -4,8 +4,6 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MaksIT.Results; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; using MaksIT.PostClient.UI; @@ -37,8 +35,11 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private readonly HashSet _indexMailboxes = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _catalogDone = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _catalogFolders = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _indexSkip = []; private const int IndexCatalogChunk = 250; private const int IndexUiChunk = 250; + private const int IndexBodyChunk = 16; + private const int IndexBodyLookahead = 512; private readonly StatusLineHold _indexHold; private readonly StatusLineHold _semanticHold; private bool _uiReady; @@ -1408,18 +1409,32 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { await SignInProviderAsync(MailAuthKind.Microsoft); public void SetSelectedMessages(IEnumerable rows) { + var current = SelectedMessage; + var live = MailMessageList.Resolve( + rows.Select(row => row.Key), + VisibleMessages, + row => row.Key); SelectedMessages.Clear(); _selectedKeys.Clear(); - foreach (var row in rows) { + foreach (var row in live) { SelectedMessages.Add(row); _selectedKeys.Add(row.Key); } - if (SelectedMessages.Count > 0 && (SelectedMessage is null || !SelectedMessages.Contains(SelectedMessage))) - SelectedMessage = SelectedMessages[0]; + SelectedMessage = MailMessageList.Live(SelectedMessages, current, row => row.Key); NotifyMessageCommands(); } + public bool IsMessageSelected(MessageRowViewModel row) => + VisibleMessages.Any(item => item.Key == row.Key) + && (_selectedKeys.Contains(row.Key) || SelectedMessage?.Key == row.Key); + + public IReadOnlyList VisibleSelectedMessages() => + MailMessageList.Resolve( + _selectedKeys.Count > 0 ? _selectedKeys : SelectedMessage is { } one ? [one.Key] : [], + VisibleMessages, + row => row.Key); + public void RestoreMessageGridSelection() => MessageListSelectionRestoreRequested?.Invoke(); @@ -1443,10 +1458,16 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { public void RefreshFolderMenu() => NotifyFolderCommands(); + public void RefreshMessageMenu() => + NotifyMessageCommands(); + public IReadOnlyList DragMessageIds(MessageRowViewModel source) { - if (SelectedMessages.Contains(source)) - return SelectedMessages.Select(row => row.Header.Id).ToList(); - return [source.Header.Id]; + if (!IsMessageSelected(source)) + return [source.Header.Id]; + var rows = TargetRows(); + if (rows.Count == 0) + return [source.Header.Id]; + return rows.Select(row => row.Header.Id).ToList(); } public IReadOnlyList DragFolderNames(FolderNodeViewModel source) { @@ -2291,7 +2312,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { partial void OnSelectedMessageChanged(MessageRowViewModel? value) { NotifyMessageCommands(); if (value is null) { - if (!_syncingList) + if (!_syncingList && SelectedMessages.Count == 0) _selectedKeys.Clear(); return; } @@ -3479,12 +3500,17 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } var keep = MailMessageList.Resolve(keepKeys, VisibleMessages, row => row.Key); + if (keepOneKey is { } key && keep.All(row => row.Key != key)) { + var primary = VisibleMessages.FirstOrDefault(row => row.Key == key); + if (primary is not null) + keep = [primary, .. keep]; + } + SetSelectedMessages(keep); - SelectedMessage = keepOneKey is { } key - ? VisibleMessages.FirstOrDefault(r => r.Key == key) - : keep.FirstOrDefault(); - if (SelectedMessage is { } selected && !IsLoadedBody(selected.Header)) - _ = OpenMessageAsync(selected); + if (SelectedMessage is { } selected && !VisibleMessages.Any(row => row.Key == selected.Key)) + SelectedMessage = null; + if (SelectedMessage is { } open && !IsLoadedBody(open.Header)) + _ = OpenMessageAsync(open); } finally { try { @@ -3521,6 +3547,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { "From" => string.Compare(a.From, b.From, StringComparison.OrdinalIgnoreCase), "Label" => string.Compare(a.Labels, b.Labels, StringComparison.OrdinalIgnoreCase), "Subject" => string.Compare(a.Subject, b.Subject, StringComparison.OrdinalIgnoreCase), + "Size" => a.Size.CompareTo(b.Size), _ => a.SortDate.CompareTo(b.SortDate) }; if (cmp == 0) @@ -3560,8 +3587,11 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { return; } + if (body.Header.HasAttachments) + header.HasAttachments = true; StoreArchivedBody(box.Id, header, body); await Dispatcher.UIThread.InvokeAsync(async () => { + MarkRowAttachments(header); if (SelectedMessage is null || SelectedMessage.Header.Id != header.Id || !SelectedMessage.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase)) @@ -3730,9 +3760,24 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { ReadingFiles.Clear(); } + private void MarkRowAttachments(MailMessageHeader header) { + if (!header.HasAttachments) + return; + foreach (var row in Messages) { + if (row.Header.Id != header.Id) + continue; + if (!row.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase)) + continue; + row.Header.HasAttachments = true; + row.RefreshMarks(); + } + } + private void StoreArchivedBody(string mailboxId, MailMessageHeader header, MailMessageBody body) { if (body.RawEml.Length == 0) return; + if (body.Header.HasAttachments) + header.HasAttachments = true; try { var box = Mailboxes.FirstOrDefault(m => m.Id.Equals(mailboxId, StringComparison.OrdinalIgnoreCase)); var path = box is not null @@ -3776,6 +3821,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { || !Messages[0].Header.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))) { Messages.Clear(); if (SelectedMessage is not null + && !IsVisibleSelection(SelectedMessage) && (!SelectedMessage.MailboxId.Equals(mailboxId, StringComparison.OrdinalIgnoreCase) || !SelectedMessage.Header.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))) { SelectedMessage = null; @@ -3795,8 +3841,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { incoming, row => row.Header.Id, (dest, src) => dest.ApplyHeader(src.Header)); - if (SelectedMessage is { } selected - && !Messages.Any(row => row.Key == selected.Key)) { + if (SelectedMessage is { } selected && !IsVisibleSelection(selected)) { SelectedMessage = null; ClearReading(); } @@ -3826,6 +3871,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { _indexMailboxes.Clear(); _catalogDone.Clear(); _catalogFolders.Clear(); + _indexSkip.Clear(); _backfill?.Cancel(); _backfill?.Dispose(); _backfill = null; @@ -3844,7 +3890,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private void EnsureBodyBackfill(string mailboxId) { if (string.IsNullOrWhiteSpace(mailboxId) || _backfill is not null) return; - if (_archive.MissingBodyCount([mailboxId]) == 0) + if (NextMissingBodies([mailboxId]).Count == 0) return; EnsureIndexing(mailboxId); } @@ -3932,10 +3978,12 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } await WaitIfIndexingPausedAsync(token).ConfigureAwait(false); - var pending = readyIds.Count == 0 ? [] : _archive.MissingBodies(readyIds, 16); + var pending = NextMissingBodies(readyIds); if (pending.Count == 0) { + await SetIndexLineAsync("", token).ConfigureAwait(false); if (!cataloged) break; + await Task.Delay(400, token).ConfigureAwait(false); continue; } @@ -3945,22 +3993,43 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { foreach (var item in pending) { token.ThrowIfCancellationRequested(); await WaitIfIndexingPausedAsync(token).ConfigureAwait(false); - if (!sessions.TryGetValue(item.MailboxId, out var session) || session is not { IsConnected: true }) + if (!sessions.TryGetValue(item.MailboxId, out var session) || session is null) continue; + var key = MailMessageKey.Of(item.MailboxId, item.Folder, item.Uid); try { - var result = await session.GetMessageAsync(item.Folder, item.Uid, token).ConfigureAwait(false); - if (result.IsSuccess && result.Value is not null) + var result = await session + .GetMessageAsync(item.Folder, item.Uid, token, interactive: false) + .ConfigureAwait(false); + if (result.IsSuccess && result.Value is not null) { StoreArchivedBody(item.MailboxId, result.Value.Header, result.Value); - fetched++; + _indexSkip.Remove(key); + fetched++; + continue; + } + + if (MailFetch.IsGone(result.Messages)) { + _archive.RemoveUids(item.MailboxId, item.Folder, [item.Uid]); + _indexSkip.Remove(key); + fetched++; + continue; + } + + _indexSkip.Add(key); } - catch (OperationCanceledException) { + catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } + catch (OperationCanceledException) { + _indexSkip.Add(key); + } catch { + _indexSkip.Add(key); } } if (fetched == 0 && !cataloged) { + if (NextMissingBodies(readyIds).Count == 0) + break; await Task.Delay(500, token).ConfigureAwait(false); continue; } @@ -3978,11 +4047,16 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { if (token.IsCancellationRequested) return; foreach (var id in _indexMailboxes.ToList()) { - if (_catalogDone.Contains(id) && _archive.MissingBodyCount([id]) == 0) + if (NextMissingBodies([id]).Count > 0) + continue; + var box = MailboxById(id); + if (_catalogDone.Contains(id) || (box is not null && AllFoldersCataloged(box))) _indexMailboxes.Remove(id); } - var again = _indexMailboxes.Any(id => SessionFor(MailboxById(id)) is { IsConnected: true }); + var again = _indexMailboxes.Any(id => + SessionFor(MailboxById(id)) is { IsConnected: true } + && (NextMissingBodies([id]).Count > 0 || !_catalogDone.Contains(id))); if (!again) { _indexHold.Set(""); return; @@ -4050,7 +4124,29 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private string GlobalIndexLine() { var total = _archive.TotalMessageCount(); var missing = _archive.MissingBodyCount(); - return MailIndexProgress.Line(Math.Max(0, total - missing), total); + var skip = Math.Min(_indexSkip.Count, Math.Max(0, missing)); + return MailIndexProgress.Line(Math.Max(0, total - missing + skip), total); + } + + private List NextMissingBodies(IReadOnlyCollection readyIds) { + if (readyIds.Count == 0) + return []; + var rows = new List(); + foreach (var item in _archive.MissingBodies(readyIds, IndexBodyLookahead)) { + var key = MailMessageKey.Of(item.MailboxId, item.Folder, item.Uid); + if (_indexSkip.Contains(key)) + continue; + if (item.Size > MailFetch.MaxBackfillBytes) { + _indexSkip.Add(key); + continue; + } + + rows.Add(item); + if (rows.Count >= IndexBodyChunk) + break; + } + + return rows; } private MailboxAccount? MailboxById(string mailboxId) => @@ -4127,6 +4223,12 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { return MailMessageList.Resolve(keys, VisibleMessages.Concat(Messages), row => row.Key); } + private bool IsVisibleSelection(MessageRowViewModel row) { + if (MessageFilter.Trim().Length > 0) + return VisibleMessages.Any(item => item.Key == row.Key); + return Messages.Any(item => item.Key == row.Key); + } + private IReadOnlyList TargetFolderNodes() { if (SelectedFolderNodes.Count > 0) return SelectedFolderNodes.Where(node => node is { IsAccount: false, Folder: not null }).ToList(); @@ -4353,11 +4455,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } private void RemoveDroppedRows(string mailboxId, string folder, IReadOnlyList ids) { - if (SelectedMailbox?.Id != mailboxId - || SelectedFolder is null - || !SelectedFolder.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase)) - return; - var rows = Messages.Where(row => ids.Contains(row.Header.Id)).ToList(); + var keys = ids.Select(id => MailMessageKey.Of(mailboxId, folder, id)); + var rows = MailMessageList.Resolve(keys, VisibleMessages.Concat(Messages), row => row.Key); if (rows.Count > 0) RemoveRows(rows); } @@ -4418,15 +4517,20 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private void RemoveRows(IReadOnlyList rows) { ApplyRemovedCounts(rows); - foreach (var row in rows) { - Messages.Remove(row); - VisibleMessages.Remove(row); - _selectedKeys.Remove(row.Key); + var drop = rows.Select(row => row.Key).ToHashSet(); + for (var i = Messages.Count - 1; i >= 0; i--) { + if (drop.Contains(Messages[i].Key)) + Messages.RemoveAt(i); + } + + for (var i = VisibleMessages.Count - 1; i >= 0; i--) { + if (drop.Contains(VisibleMessages[i].Key)) + VisibleMessages.RemoveAt(i); } - if (SelectedMessage is not null && rows.Contains(SelectedMessage)) - SelectedMessage = VisibleMessages.FirstOrDefault(); SelectedMessages.Clear(); + _selectedKeys.Clear(); + SelectedMessage = null; NotifyMessageCommands(); RestoreMessageGridSelection(); RefreshFolderStats(); diff --git a/src/MaksIT.PostClient.UI/ViewModels/MessageWindowViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/MessageWindowViewModel.cs index d91719c..eff9628 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/MessageWindowViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/MessageWindowViewModel.cs @@ -1,8 +1,6 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/ViewModels/RecipientFieldViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/RecipientFieldViewModel.cs index 4d0fce4..c6a2142 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/RecipientFieldViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/RecipientFieldViewModel.cs @@ -1,7 +1,6 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using MaksIT.PostClient.Client; namespace MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/ViewModels/RetentionViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/RetentionViewModel.cs index 10c746e..8f243c5 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/RetentionViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/RetentionViewModel.cs @@ -1,7 +1,6 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs b/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs index b22c8b2..51e4717 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs @@ -2,8 +2,6 @@ using System.Collections.ObjectModel; using Avalonia; using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.UI.ViewModels; @@ -254,6 +252,12 @@ public sealed class MessageRowViewModel : ObservableObject { public DateTimeOffset SortDate => Header.Date; + public long Size => + Header.Size; + + public string SizeLine => + MailSize.Line(Header.Size); + public int UnreadSort => Header.IsSeen ? 1 : 0; diff --git a/src/MaksIT.PostClient.UI/ViewModels/RulesViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/RulesViewModel.cs index 2a6d7da..072add5 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/RulesViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/RulesViewModel.cs @@ -1,8 +1,6 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/ViewModels/SemanticSearchViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/SemanticSearchViewModel.cs index 76deefe..5191bae 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/SemanticSearchViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/SemanticSearchViewModel.cs @@ -1,8 +1,6 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.ComponentModel; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/WebViewSetup.cs b/src/MaksIT.PostClient.UI/Web/WebViewSetup.cs similarity index 68% rename from src/MaksIT.PostClient.UI/WebViewSetup.cs rename to src/MaksIT.PostClient.UI/Web/WebViewSetup.cs index 655ff77..e308864 100644 --- a/src/MaksIT.PostClient.UI/WebViewSetup.cs +++ b/src/MaksIT.PostClient.UI/Web/WebViewSetup.cs @@ -1,15 +1,18 @@ using System.Diagnostics; +using System.Runtime.CompilerServices; using Avalonia.Controls; using Avalonia.Platform; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.UI; +namespace MaksIT.PostClient.UI.Web; internal static class WebViewSetup { + private static readonly ConditionalWeakTable ReadingFiles = new(); + public static void ConfigureProcess() { AppPaths.EnsureDirectories(); + AppPaths.ClearStaleReadingDocuments(); Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", AppPaths.WebViewDirectory()); } @@ -46,22 +49,18 @@ internal static class WebViewSetup { public static void NavigateHtml(NativeWebView web, string html) { ArgumentNullException.ThrowIfNull(web); - var document = string.IsNullOrWhiteSpace(html) ? MessageHtml.Document("") : html; - try { - web.NavigateToString(document); - } - catch { - var path = Path.Combine(AppPaths.WebViewDirectory(), "reading-" + Guid.NewGuid().ToString("N") + ".html"); - File.WriteAllText(path, document); - web.Navigate(new Uri(path)); - } + var document = MessageHtml.Sanitize(string.IsNullOrWhiteSpace(html) ? MessageHtml.Document("") : html); + var path = NextReadingPath(web); + Directory.CreateDirectory(AppPaths.WebViewDirectory()); + File.WriteAllText(path, document); + web.Navigate(new Uri(path)); } public static void OpenExternalLink(object? sender, WebViewNavigationStartingEventArgs e) { var uri = e.Request; if (uri is null) return; - if (uri.Scheme is "about" or "data" or "blob") + if (uri.Scheme is "about" or "data" or "blob" || AppPaths.IsWebViewFile(uri)) return; e.Cancel = true; if (uri.Scheme is not "http" and not "https") @@ -80,4 +79,24 @@ internal static class WebViewSetup { return "HTML view needs Edge WebView2 and a writable profile under LocalAppData."; return "HTML view could not start. Showing text."; } + + private static string NextReadingPath(NativeWebView web) { + var file = ReadingFiles.GetOrCreateValue(web); + var previous = file.Path; + var path = Path.Combine(AppPaths.WebViewDirectory(), "reading-" + Guid.NewGuid().ToString("N") + ".html"); + file.Path = path; + if (!string.IsNullOrWhiteSpace(previous)) { + try { + File.Delete(previous); + } + catch { + } + } + + return path; + } + + private sealed class ReadingFile { + public string Path { get; set; } = ""; + } } diff --git a/src/MaksIT.PostClient.UI/Windows/AboutWindow.axaml.cs b/src/MaksIT.PostClient.UI/Windows/AboutWindow.axaml.cs index 0a259a5..8959a2c 100644 --- a/src/MaksIT.PostClient.UI/Windows/AboutWindow.axaml.cs +++ b/src/MaksIT.PostClient.UI/Windows/AboutWindow.axaml.cs @@ -1,6 +1,5 @@ using Avalonia.Controls; using Avalonia.Interactivity; -using MaksIT.PostClient.Shared; using MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/Windows/IdentityHubLoginWindow.axaml.cs b/src/MaksIT.PostClient.UI/Windows/IdentityHubLoginWindow.axaml.cs index dc7888a..580171c 100644 --- a/src/MaksIT.PostClient.UI/Windows/IdentityHubLoginWindow.axaml.cs +++ b/src/MaksIT.PostClient.UI/Windows/IdentityHubLoginWindow.axaml.cs @@ -1,6 +1,5 @@ using Avalonia.Controls; using Avalonia.Threading; -using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.UI.Windows; diff --git a/src/MaksIT.PostClient.UI/Windows/MessageWindow.axaml.cs b/src/MaksIT.PostClient.UI/Windows/MessageWindow.axaml.cs index 90e04a7..e5e174f 100644 --- a/src/MaksIT.PostClient.UI/Windows/MessageWindow.axaml.cs +++ b/src/MaksIT.PostClient.UI/Windows/MessageWindow.axaml.cs @@ -4,8 +4,6 @@ using Avalonia.Controls; using Avalonia.Data; using Avalonia.Interactivity; using Avalonia.Platform.Storage; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; using MaksIT.PostClient.UI.ViewModels; diff --git a/src/MaksIT.PostClient.UI/Windows/ZipSendWindow.axaml.cs b/src/MaksIT.PostClient.UI/Windows/ZipSendWindow.axaml.cs index f7ca5ef..a712270 100644 --- a/src/MaksIT.PostClient.UI/Windows/ZipSendWindow.axaml.cs +++ b/src/MaksIT.PostClient.UI/Windows/ZipSendWindow.axaml.cs @@ -1,6 +1,5 @@ using Avalonia.Controls; using Avalonia.Interactivity; -using MaksIT.PostClient.Shared; using MaksIT.PostClient.UI.ViewModels;