(feature): show message size and keep html mail and attachments reliable

This commit is contained in:
Maksym Sadovnychyy 2026-09-21 20:14:20 +02:00
parent 8c76e2f82e
commit 4f8665ab90
174 changed files with 1246 additions and 359 deletions

View File

@ -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.

View File

@ -30,6 +30,11 @@
<display_length compare="ge">768</display_length>
</requires>
<releases>
<release version="0.3.8" date="2026-09-21">
<description>
<p>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.</p>
</description>
</release>
<release version="0.3.7" date="2026-09-20">
<description>
<p>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.</p>

View File

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

View File

@ -1,8 +1,7 @@
using MaksIT.Results;
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Auth;
public sealed class MailAuthMaterial {

View File

@ -1,7 +1,6 @@
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Auth;
internal static class MailAuthHint {

View File

@ -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 {

View File

@ -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 {

View File

@ -4,7 +4,7 @@ using System.Text;
using MaksIT.Results;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Auth;
internal sealed class OAuthLoopback : IDisposable {

View File

@ -3,7 +3,7 @@ using System.Text;
using System.Text.Json;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Auth;
public static class OAuthPkce {

View File

@ -1,7 +1,6 @@
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Auth;
internal sealed class OAuthProviderProfile {

View File

@ -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 {

View File

@ -1,7 +1,6 @@
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Auth;
internal static class OAuthTrace {

View File

@ -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 {

View File

@ -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;

View File

@ -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 {

View File

@ -1,8 +1,7 @@
using System.Text;
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Import;
internal static class MapiRestriction {

View File

@ -1,7 +1,7 @@
using System.Text;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Import;
public static class MboxReader {

View File

@ -1,7 +1,7 @@
using System.Text;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Import;
internal static class OleCompound {

View File

@ -1,7 +1,7 @@
using OfficeIMO.Email.Store;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Import;
public static class OutlookPst {

View File

@ -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 {

View File

@ -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 {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Import;
public static class PstFile {

View File

@ -1,8 +1,7 @@
using XstReader;
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Import;
public static class PstImport {

View File

@ -2,7 +2,7 @@ using MimeKit;
using XstReader;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Import;
public static class PstMime {

View File

@ -1,7 +1,6 @@
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Import;
public static class PstStoreMigrator {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Import;
public static class PstSubject {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Import;
public static class ThunderbirdProfiles {

View File

@ -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<Result<MailMessageBody>> GetMessageAsync(
string folder,
uint id,
CancellationToken cancellationToken = default);
CancellationToken cancellationToken = default,
bool interactive = true);
Task<Result<MailboxQuota?>> GetQuotaAsync(CancellationToken cancellationToken = default);

View File

@ -1,7 +1,6 @@
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Mail;
internal static class ImapFetchPolicy {

View File

@ -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<uint>? knownIds,
CancellationToken cancellationToken,
int itemBudget) {
var client = RequireImap();
var client = await EnsureImapAsync(cancellationToken).ConfigureAwait(false);
if (client is null)
return Result<MailFolderSync>.UnprocessableEntity(null, "Not connected.");
if (string.IsNullOrWhiteSpace(folder))
@ -238,26 +239,41 @@ public sealed class ImapMailSession : IMailSession {
public Task<Result<MailMessageBody>> 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<Result<MailMessageBody>> 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<MailMessageBody>.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<MailMessageBody>.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<MailMessageBody>.UnprocessableEntity(null, "Timed out.");
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result<MailMessageBody>.UnprocessableEntity(null, ex.Message);
}
abort.Unregister();
return Result<MailMessageBody>.Ok(await MimeBody.FromMimeAsync(folder, id, mime, cancellationToken).ConfigureAwait(false));
}
public Task<Result<MailboxQuota?>> 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<ImapClient?> 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);

View File

@ -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<Result<MailMessageBody>> 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<MailMessageBody>.NotFound(null, "Message file is missing.");
return Result<MailMessageBody>.Ok(body);
}, cancellationToken, interactive: true);
}, cancellationToken, interactive);
public Task<Result<MailboxQuota?>> GetQuotaAsync(CancellationToken cancellationToken = default) {
_ = cancellationToken;

View File

@ -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) {

View File

@ -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<string>? 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);
}
}

View File

@ -1,7 +1,6 @@
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Mail;
internal static class MailFolderCatalog {

View File

@ -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; }
}

View File

@ -1,7 +1,6 @@
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Mail;
public sealed class MailSessionFactory : IMailSessionFactory {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Mail;
internal sealed class MailSessionGate : IDisposable {

View File

@ -1,8 +1,7 @@
using MailKit.Security;
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Mail;
internal static class MailSocket {

View File

@ -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 {

View File

@ -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<uint>? knownIds,
CancellationToken cancellationToken,
int itemBudget) {
var client = RequirePop();
var client = await EnsurePopAsync(cancellationToken).ConfigureAwait(false);
if (client is null)
return Result<MailFolderSync>.UnprocessableEntity(null, "Not connected.");
_ = folder;
@ -109,12 +108,13 @@ public sealed class Pop3MailSession : IMailSession {
var count = client.Count;
if (count == 0)
return Result<MailFolderSync>.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<Result<MailMessageBody>> 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<Result<MailMessageBody>> 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<MailMessageBody>.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<MailMessageBody>.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<MailMessageBody>.UnprocessableEntity(null, "Timed out.");
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result<MailMessageBody>.UnprocessableEntity(null, ex.Message);
}
abort.Unregister();
return Result<MailMessageBody>.Ok(await MimeBody.FromMimeAsync(Inbox, id, mime, cancellationToken).ConfigureAwait(false));
}
public Task<Result<MailboxQuota?>> 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<Pop3Client?> 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<string, string>(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<IList<int>?> MessageSizesAsync(Pop3Client client, CancellationToken cancellationToken) {
try {
return await client.GetMessageSizesAsync(cancellationToken).ConfigureAwait(false);
}
catch {
return null;
}
}
private static long SizeAt(IList<int>? 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))

View File

@ -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<Result<MailMessageBody>> 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<Result<MailboxQuota?>> GetQuotaAsync(CancellationToken cancellationToken = default) {
_ = cancellationToken;
@ -308,6 +308,9 @@ public sealed class PstMailSession : IMailSession {
return Result<MailMessageBody>.Ok(
await MimeBody.FromMimeAsync(folder, id, mime, cancellationToken, seen, flagged).ConfigureAwait(false));
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result<MailMessageBody>.UnprocessableEntity(null, ex.Message);
}

View File

@ -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 {

View File

@ -2,7 +2,7 @@ using System.Text;
using UglyToad.PdfPig;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Mime;
public static class AttachmentText {

View File

@ -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 {

View File

@ -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 {

View File

@ -2,7 +2,7 @@ using System.Text;
using System.Xml.Linq;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Mime;
public sealed class FatturaPaDocument {

View File

@ -1,7 +1,6 @@
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Mime;
public static class MessagePrint {

View File

@ -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<string> Names(IEnumerable<MailFileAttachment> files) =>
files.Select(f => f.Name).ToList();
public static bool HasVisible(IEnumerable<MailFileAttachment> 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<MailFileAttachment> 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)

View File

@ -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<MailMessageBody> 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
};
}

View File

@ -1,7 +1,7 @@
using MimeKit;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Mime;
public sealed class MailRecipient {

View File

@ -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 {

View File

@ -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(

View File

@ -1,7 +1,6 @@
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Search;
public interface ISemanticSearchService : IDisposable {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Search;
public interface ITextEmbedder : IDisposable {

View File

@ -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 {

View File

@ -1,7 +1,6 @@
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
namespace MaksIT.PostClient.Client.Search;
public sealed class SemanticSearchService : ISemanticSearchService {

View File

@ -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 {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.App;
public static class AppInfo {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.App;
public enum AppInstallKind {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.App;
public static class AppLog {

View File

@ -1,7 +1,7 @@
using System.Text.Json;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.App;
/// <summary>
@ -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(

View File

@ -1,7 +1,7 @@
using System.Reflection;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.App;
public static class AppVersion {

View File

@ -2,7 +2,7 @@ using System.Text;
using System.Runtime.InteropServices;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.App;
/// <summary>Formats unhandled exceptions for a copyable dialog and a crash log file.</summary>

View File

@ -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 {

View File

@ -2,7 +2,7 @@ using System.Security.Cryptography;
using System.Text;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Archive;
public static class ArchiveFiles {

View File

@ -1,7 +1,7 @@
using System.Text.Json;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Archive;
public sealed class FileSentReceiptStore : ISentReceiptStore {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Archive;
public sealed class SentDispatch {

View File

@ -1,7 +1,7 @@
using MaksIT.Core.Extensions;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Archive;
public sealed class LocalStoreSidecar {

View File

@ -1,7 +1,7 @@
using System.Collections.Concurrent;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Archive;
public sealed class MailArchiveCatalog : IDisposable {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Archive;
public sealed class MailArchiveCopy {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Archive;
public static class MailArchiveLayout {

View File

@ -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<byte[]>(19));
if (!reader.IsDBNull(20))
vector = EmbeddingVector.FromBlob(reader.GetFieldValue<byte[]>(20));
var labels = string.IsNullOrWhiteSpace(header.Labels)
? Array.Empty<string>()
: 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<byte[]>(16);
var blob = reader.IsDBNull(17) ? null : reader.GetFieldValue<byte[]>(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; }
}

View File

@ -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 {

View File

@ -1,7 +1,7 @@
using System.Text.Json;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Auth;
public static class HubDesktopSession {

View File

@ -1,7 +1,7 @@
using MaksIT.Results;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Auth;
public interface ISecretStore {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Auth;
public static class IdentityHubAddress {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Auth;
public static class MailAuthKind {

View File

@ -1,7 +1,7 @@
using System.Text.Json;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Auth;
public static class OAuthClientId {

View File

@ -1,7 +1,7 @@
using System.Text.Json;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Auth;
public static class OAuthClientSecret {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Auth;
public static class OAuthLoginHint {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Auth;
public static class OAuthMailScope {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Auth;
public sealed class OAuthTokenSet {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Config;
public sealed class Configuration {

View File

@ -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 {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Config;
public static class AppRegion {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Config;
public sealed class FeatureSettings {

View File

@ -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 = """
<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
@ -38,6 +40,16 @@ public static class MessageHtml {
""" + body + "</body></html>";
}
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) {
var value = (html ?? "").TrimStart();
return value.StartsWith("<!DOCTYPE", StringComparison.OrdinalIgnoreCase)

View File

@ -2,7 +2,7 @@ using System.Net;
using System.Text.RegularExpressions;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Content;
public static class MessageText {

View File

@ -1,7 +1,7 @@
using System.Text;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Content;
public static class SimplePdf {

View File

@ -1,7 +1,7 @@
using System.Xml;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Envelope;
public static class DaticertParser {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Envelope;
public static class EnvelopeClassifier {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Envelope;
public sealed class EnvelopeInfo {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Envelope;
public static class EnvelopeKind {

View File

@ -1,4 +1,4 @@
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Envelope;
public static class ReceiptStatus {

View File

@ -1,7 +1,7 @@
using System.Xml;
namespace MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Shared.Envelope;
public static class RemEvidenceParser {

View File

@ -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;

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

Some files were not shown because too many files have changed in this diff Show More