Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions Sources/Mailozaurr.Tests/ComposeProfileUtilitiesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Collections.Generic;
using Xunit;

namespace Mailozaurr.Tests;

public class ComposeProfileUtilitiesTests {
[Fact]
public void NormalizeProfiles_AppliesFallbackValues_AndKeepsOneDefault() {
var profiles = new[] {
new MailComposeProfile {
Id = "sales",
Name = "Sales",
From = "sales@example.com",
IsDefault = true
},
new MailComposeProfile {
Name = "Billing",
From = "billing@example.com",
ReplyTo = "billing-replies@example.com"
}
};

var fallback = new MailComposeProfile {
ReplyTo = "reply@example.com",
SignatureText = "Regards",
IsDefault = true
};

var normalized = ComposeProfileUtilities.NormalizeProfiles(profiles, fallback);

Assert.Equal(2, normalized.Count);
Assert.Equal("sales", normalized[0].Id);
Assert.Equal("reply@example.com", normalized[0].ReplyTo);
Assert.Equal("Regards", normalized[0].SignatureText);
Assert.True(normalized[0].IsDefault);
Assert.Equal("billing", normalized[1].Id);
Assert.Equal("billing-replies@example.com", normalized[1].ReplyTo);
Assert.False(normalized[1].IsDefault);
}

[Fact]
public void NormalizeProfiles_SynthesizesFallbackProfile_WhenConfiguredProfilesMissing() {
var normalized = ComposeProfileUtilities.NormalizeProfiles(
profiles: null,
fallbackProfile: new MailComposeProfile {
From = "default@example.com",
ReplyTo = "reply@example.com",
SignatureText = "Thanks"
});

Assert.Single(normalized);
Assert.Equal("default", normalized[0].Id);
Assert.Equal("default@example.com", normalized[0].From);
Assert.True(normalized[0].IsDefault);
}

[Fact]
public void GetDefaultProfile_ReturnsFirstMarkedDefault() {
var profiles = new List<MailComposeProfile> {
new MailComposeProfile { Id = "one", IsDefault = false },
new MailComposeProfile { Id = "two", IsDefault = true }
};

var selected = ComposeProfileUtilities.GetDefaultProfile(profiles);

Assert.NotNull(selected);
Assert.Equal("two", selected!.Id);
}
}
53 changes: 52 additions & 1 deletion Sources/Mailozaurr.Tests/GmailMailboxBrowserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ public async System.Threading.Tasks.Task ListThreadMessagesPageAsync_ReturnsPage
public async System.Threading.Tasks.Task GetMessageContentAsync_ReturnsMimeAndFlags() {
var mime = "From: a@example.test\r\nTo: b@example.test\r\nSubject: Sample\r\nMessage-Id: <m1@example.test>\r\n\r\nhello";
var raw = Convert.ToBase64String(Encoding.UTF8.GetBytes(mime)).Replace('+', '-').Replace('/', '_').TrimEnd('=');
var rawJson = "{\"id\":\"m1\",\"labelIds\":[\"UNREAD\",\"STARRED\"],\"raw\":\"" + raw + "\"}";
var rawJson = "{\"id\":\"m1\",\"threadId\":\"thr-1\",\"labelIds\":[\"UNREAD\",\"STARRED\"],\"raw\":\"" + raw + "\"}";
var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(rawJson) });
var browser = CreateBrowser(handler);

Expand All @@ -175,6 +175,7 @@ public async System.Threading.Tasks.Task GetMessageContentAsync_ReturnsMimeAndFl
Assert.Equal("Sample", result.Message.Subject);
Assert.False(result.Seen);
Assert.True(result.Flagged);
Assert.Equal("thr-1", result.NativeThreadId);
}

[Fact]
Expand Down Expand Up @@ -457,6 +458,56 @@ public async System.Threading.Tasks.Task DeleteThreadsAsync_MapsPerThreadFailure
Assert.Contains("/users/me/threads/t2", handler.Requests[1].RequestUri!.ToString());
}

[Fact]
public async System.Threading.Tasks.Task MoveThreadsAsync_UsesThreadModifyLabels() {
var labelsJson = "{\"labels\":[{\"id\":\"Label_1\",\"name\":\"Project\"}]}";
var handler = new RecordingHandler(
new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(labelsJson) },
new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"id\":\"t1\"}") });
var browser = CreateBrowser(handler);

var results = await browser.MoveThreadsAsync(new[] { "t1" }, sourceFolder: "INBOX", targetFolder: "Project");

Assert.Single(results);
Assert.True(results[0].Ok, results[0].Error);
Assert.Equal(2, handler.Requests.Count);
Assert.Contains("/users/me/labels", handler.Requests[0].RequestUri!.ToString());
Assert.Contains("/users/me/threads/t1/modify", handler.Requests[1].RequestUri!.ToString());
var body = await handler.Requests[1].Content!.ReadAsStringAsync();
Assert.Contains("\"addLabelIds\":[\"Label_1\"]", body, StringComparison.Ordinal);
Assert.Contains("\"removeLabelIds\":[\"INBOX\",\"TRASH\"]", body, StringComparison.Ordinal);
}

[Fact]
public async System.Threading.Tasks.Task SetThreadsSeenAsync_UsesThreadModifyLabels() {
var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"id\":\"t1\"}") });
var browser = CreateBrowser(handler);

var results = await browser.SetThreadsSeenAsync(new[] { "t1" }, seen: true);

Assert.Single(results);
Assert.True(results[0].Ok, results[0].Error);
Assert.Single(handler.Requests);
Assert.Contains("/users/me/threads/t1/modify", handler.Requests[0].RequestUri!.ToString());
var body = await handler.Requests[0].Content!.ReadAsStringAsync();
Assert.Contains("\"removeLabelIds\":[\"UNREAD\"]", body, StringComparison.Ordinal);
}

[Fact]
public async System.Threading.Tasks.Task SetThreadsFlaggedAsync_UsesThreadModifyLabels() {
var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"id\":\"t1\"}") });
var browser = CreateBrowser(handler);

var results = await browser.SetThreadsFlaggedAsync(new[] { "t1" }, flagged: false);

Assert.Single(results);
Assert.True(results[0].Ok, results[0].Error);
Assert.Single(handler.Requests);
Assert.Contains("/users/me/threads/t1/modify", handler.Requests[0].RequestUri!.ToString());
var body = await handler.Requests[0].Content!.ReadAsStringAsync();
Assert.Contains("\"removeLabelIds\":[\"STARRED\"]", body, StringComparison.Ordinal);
}

private static Dictionary<string, List<string>> ParseQueryParams(Uri uri) {
var dict = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
var q = uri.Query;
Expand Down
45 changes: 44 additions & 1 deletion Sources/Mailozaurr.Tests/GraphMailboxBrowserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ public async System.Threading.Tasks.Task DeltaMessagesAsync_MapsUpsertsDeletesAn

[Fact]
public async System.Threading.Tasks.Task GetMessageContentAsync_ReturnsMimeAndFlags() {
var metaJson = "{\"id\":\"m1\",\"isRead\":true,\"flag\":{\"flagStatus\":\"flagged\"}}";
var metaJson = "{\"id\":\"m1\",\"isRead\":true,\"flag\":{\"flagStatus\":\"flagged\"},\"conversationId\":\"conv-1\"}";
var mime = "From: a@example.test\r\nTo: b@example.test\r\nSubject: Sample\r\nMessage-Id: <m1@example.test>\r\n\r\nhello";
var handler = new RecordingHandler(
new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(metaJson) },
Expand All @@ -299,13 +299,15 @@ public async System.Threading.Tasks.Task GetMessageContentAsync_ReturnsMimeAndFl

Assert.True(result.Seen);
Assert.True(result.Flagged);
Assert.Equal("conv-1", result.NativeThreadId);
Assert.Equal("Sample", result.Message.Subject);
Assert.Equal(2, handler.Requests.Count);
var metaUri = handler.Requests[0].RequestUri!.ToString();
Assert.Contains("/me/messages/m1?", metaUri);
Assert.Contains("$select=", metaUri);
Assert.Contains("isRead", metaUri);
Assert.Contains("flag", metaUri);
Assert.Contains("conversationId", metaUri);
Assert.Contains("/me/messages/m1/$value", handler.Requests[1].RequestUri!.ToString());
}

Expand Down Expand Up @@ -615,6 +617,47 @@ public async System.Threading.Tasks.Task DeleteConversationsAsync_ExpandsAndDele
Assert.Contains("me/messages/m2", body, StringComparison.Ordinal);
}

[Fact]
public async System.Threading.Tasks.Task SetConversationsSeenAsync_ExpandsAndBatchesReadStateChanges() {
var listJson = "{\"value\":[{\"id\":\"m1\"},{\"id\":\"m2\"}]}";
var batchJson = "{\"responses\":[{\"id\":\"1\",\"status\":200},{\"id\":\"2\",\"status\":200}]}";
var handler = new RecordingHandler(
new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(listJson) },
new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(batchJson) });
var client = CreateClient(handler);
var browser = new GraphMailboxBrowser(client);

var results = await browser.SetConversationsSeenAsync(new[] { "conv-1" }, seen: true);

Assert.Single(results);
Assert.True(results[0].Ok, results[0].Error);
Assert.Equal("conv-1", results[0].Id);
Assert.Equal(2, handler.Requests.Count);
Assert.Contains("/me/messages?", handler.Requests[0].RequestUri!.ToString());
Assert.Contains("conversationId", handler.Requests[0].RequestUri!.ToString());
var body = await handler.Requests[1].Content!.ReadAsStringAsync();
Assert.Contains("me/messages/m1", body, StringComparison.Ordinal);
Assert.Contains("isRead", body, StringComparison.Ordinal);
Assert.Contains("true", body, StringComparison.Ordinal);
}

[Fact]
public async System.Threading.Tasks.Task SetConversationsFlaggedAsync_MapsConversationFailures() {
var handler = new RecordingHandler(
new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("boom") });
var client = CreateClient(handler);
var browser = new GraphMailboxBrowser(client);

var results = await browser.SetConversationsFlaggedAsync(new[] { "conv-1" }, flagged: true);

Assert.Single(results);
Assert.Equal("conv-1", results[0].Id);
Assert.False(results[0].Ok);
Assert.Contains("conversation list failed", results[0].Error ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Single(handler.Requests);
Assert.Contains("/me/messages?", handler.Requests[0].RequestUri!.ToString());
}

private static GraphApiClient CreateClient(HttpMessageHandler handler) {
var api = new GraphApiClient(new OAuthCredential { UserName = "u", AccessToken = "t", ExpiresOn = DateTimeOffset.MaxValue });
var field = typeof(GraphApiClient).GetField("_client", BindingFlags.NonPublic | BindingFlags.Instance)!;
Expand Down
169 changes: 169 additions & 0 deletions Sources/Mailozaurr/ComposeProfileUtilities.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace Mailozaurr;

/// <summary>
/// Helper methods for normalizing reusable compose profiles.
/// </summary>
public static class ComposeProfileUtilities {
/// <summary>
/// Normalizes compose profiles and guarantees at most one default entry.
/// </summary>
/// <param name="profiles">Configured profiles.</param>
/// <param name="fallbackProfile">Fallback values applied to profiles missing fields.</param>
/// <returns>Normalized profile list.</returns>
public static IReadOnlyList<MailComposeProfile> NormalizeProfiles(
IEnumerable<MailComposeProfile>? profiles,
MailComposeProfile? fallbackProfile = null) {
var normalizedFallback = NormalizeSingle(fallbackProfile, "default", 0, fallbackProfile);
var items = new List<MailComposeProfile>();
var index = 0;

if (profiles is not null) {
foreach (var profile in profiles) {
var normalized = NormalizeSingle(profile, profile?.Id, index, normalizedFallback);
if (normalized is not null) {
items.Add(normalized);
}
index++;
}
}

if (items.Count == 0 && HasMeaningfulContent(normalizedFallback)) {
items.Add(new MailComposeProfile {
Id = normalizedFallback!.Id,
Name = normalizedFallback.Name,
From = normalizedFallback.From,
ReplyTo = normalizedFallback.ReplyTo,
SignatureText = normalizedFallback.SignatureText,
IsDefault = true
});
}

if (items.Count == 0) {
return items;
}

var selectedIndex = items.FindIndex(static profile => profile.IsDefault);
if (selectedIndex < 0) {
selectedIndex = 0;
}

for (var i = 0; i < items.Count; i++) {
items[i].IsDefault = i == selectedIndex;
}

return items;
}

/// <summary>
/// Resolves the default profile from a list of profiles.
/// </summary>
/// <param name="profiles">Profiles to inspect.</param>
/// <returns>The default profile, when available.</returns>
public static MailComposeProfile? GetDefaultProfile(IEnumerable<MailComposeProfile>? profiles) {
if (profiles is null) {
return null;
}

MailComposeProfile? first = null;
foreach (var profile in profiles) {
first ??= profile;
if (profile?.IsDefault == true) {
return profile;
}
}

return first;
}

private static MailComposeProfile? NormalizeSingle(
MailComposeProfile? profile,
string? rawId,
int index,
MailComposeProfile? fallbackProfile) {
var normalizedId = NormalizeOptional(profile?.Id) ?? NormalizeOptional(rawId);
var from = NormalizeOptional(profile?.From) ?? NormalizeOptional(fallbackProfile?.From);
var replyTo = NormalizeOptional(profile?.ReplyTo) ?? NormalizeOptional(fallbackProfile?.ReplyTo);
var signature = NormalizeOptional(profile?.SignatureText) ?? NormalizeOptional(fallbackProfile?.SignatureText);
var name = NormalizeOptional(profile?.Name);

if (string.IsNullOrWhiteSpace(name) &&
string.IsNullOrWhiteSpace(from) &&
string.IsNullOrWhiteSpace(replyTo) &&
string.IsNullOrWhiteSpace(signature)) {
return null;
}

normalizedId = BuildProfileId(normalizedId, name, from, index);
name ??= from ?? normalizedId;

return new MailComposeProfile {
Id = normalizedId,
Name = name,
From = from,
ReplyTo = replyTo,
SignatureText = signature,
IsDefault = profile?.IsDefault ?? fallbackProfile?.IsDefault ?? false
};
}

private static bool HasMeaningfulContent(MailComposeProfile? profile) =>
!string.IsNullOrWhiteSpace(profile?.Name) ||
!string.IsNullOrWhiteSpace(profile?.From) ||
!string.IsNullOrWhiteSpace(profile?.ReplyTo) ||
!string.IsNullOrWhiteSpace(profile?.SignatureText);

private static string BuildProfileId(string? rawId, string? name, string? from, int index) {
if (rawId is not null) {
var trimmedId = rawId.Trim();
if (trimmedId.Length > 0) {
return trimmedId;
}
}

string? source = null;
if (name is not null) {
var trimmedName = name.Trim();
if (trimmedName.Length > 0) {
source = trimmedName;
}
}
if (source is null && from is not null) {
var trimmedFrom = from.Trim();
if (trimmedFrom.Length > 0) {
source = trimmedFrom;
}
}

if (source is not null) {
var normalizedSource = source.Trim();
var chars = normalizedSource
.Trim()
.ToLowerInvariant()
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')
.ToArray();
var collapsed = new string(chars).Trim('-');
if (!string.IsNullOrWhiteSpace(collapsed)) {
return collapsed;
}
}

return $"profile-{index + 1}";
}

private static string? NormalizeOptional(string? value) {
if (value is null) {
return null;
}

var trimmed = value.Trim();
if (trimmed.Length == 0) {
return null;
}

return trimmed;
}
}
Loading
Loading