48488b72b5
Build and Push Docker Image / build-and-push (push) Successful in 2m21s
- Introduced a new Docker Compose configuration for the Gmail Catchall Labeler service. - Implemented GmailOptions class for managing configuration settings. - Developed GmailApiClient to interact with the Gmail API, including methods for fetching messages and managing labels. - Created models for Gmail messages, labels, and headers. - Added IGmailClient interface to define the contract for Gmail client implementations. - Implemented GmailOrganizer to handle the organization of emails based on recipient addresses. - Developed LabelNameResolver for generating label names based on local parts of email addresses. - Created RecipientExtractor to extract local parts from email headers. - Implemented state management with OrganizerState and StateStore for persisting state information. - Added Worker class to manage the background processing of email organization. - Created unit tests for GmailOrganizer, LabelNameResolver, and RecipientExtractor to ensure functionality. - Added necessary project files and configurations for building and running the application.
198 lines
7.5 KiB
C#
198 lines
7.5 KiB
C#
using GmailCatchallLabeler.Configuration;
|
|
using GmailCatchallLabeler.Gmail;
|
|
using GmailCatchallLabeler.Labels;
|
|
using GmailCatchallLabeler.State;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace GmailCatchallLabeler.Tests;
|
|
|
|
public sealed class GmailOrganizerTests
|
|
{
|
|
[Fact]
|
|
public async Task Initialize_LabelsOnlyInboxMessagesAndSkipsExistingLabel()
|
|
{
|
|
var statePath = Path.Combine(Path.GetTempPath(), $"gmail-labeler-{Guid.NewGuid():N}.json");
|
|
try
|
|
{
|
|
var options = new GmailOptions
|
|
{
|
|
CatchallDomain = "arnemoerman.be",
|
|
LabelPrefix = "ArneM.be",
|
|
StatePath = statePath,
|
|
ProcessExisting = true
|
|
};
|
|
var gmailClient = new FakeGmailClient
|
|
{
|
|
Labels =
|
|
{
|
|
["ArneM.be/Tom Scott"] = new GmailLabel(
|
|
"label-tom-scott",
|
|
"ArneM.be/Tom Scott",
|
|
false)
|
|
},
|
|
Messages =
|
|
{
|
|
["new"] = new GmailMessage(
|
|
"new",
|
|
["INBOX"],
|
|
[new GmailHeader("Delivered-To", "tomscott@arnemoerman.be")]),
|
|
["already-labeled"] = new GmailMessage(
|
|
"already-labeled",
|
|
["INBOX", "label-tom-scott"],
|
|
[new GmailHeader("To", "tomscott@arnemoerman.be")]),
|
|
["sent"] = new GmailMessage(
|
|
"sent",
|
|
["SENT"],
|
|
[new GmailHeader("To", "tomscott@arnemoerman.be")])
|
|
}
|
|
};
|
|
var organizer = new GmailOrganizer(
|
|
gmailClient,
|
|
new RecipientExtractor(options),
|
|
new LabelNameResolver("ArneM.be", "{\"tomscott\":\"Tom Scott\"}"),
|
|
new StateStore(options),
|
|
options,
|
|
NullLogger<GmailOrganizer>.Instance);
|
|
|
|
await organizer.InitializeAsync(CancellationToken.None);
|
|
|
|
var modification = Assert.Single(gmailClient.Modifications);
|
|
Assert.Equal("new", modification.MessageId);
|
|
Assert.Equal(["label-tom-scott"], modification.AddedLabelIds);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(statePath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initialize_MigratesChangedOverrideAcrossAllMail()
|
|
{
|
|
var statePath = Path.Combine(Path.GetTempPath(), $"gmail-labeler-{Guid.NewGuid():N}.json");
|
|
try
|
|
{
|
|
var options = new GmailOptions
|
|
{
|
|
CatchallDomain = "arnemoerman.be",
|
|
LabelPrefix = "ArneM.be",
|
|
StatePath = statePath,
|
|
MigrateChangedLabels = true
|
|
};
|
|
var stateStore = new StateStore(options);
|
|
await stateStore.SaveAsync(
|
|
new OrganizerState
|
|
{
|
|
HistoryId = 42,
|
|
LabelMappings = { ["tomscott"] = "ArneM.be/Tomscott" },
|
|
CreatedLabels = { "ArneM.be/Tomscott" }
|
|
},
|
|
CancellationToken.None);
|
|
var gmailClient = new FakeGmailClient
|
|
{
|
|
Labels =
|
|
{
|
|
["ArneM.be/Tomscott"] = new GmailLabel("old-label", "ArneM.be/Tomscott", false),
|
|
["ArneM.be/Tom Scott"] = new GmailLabel("new-label", "ArneM.be/Tom Scott", false)
|
|
},
|
|
LabeledMessageIds = { "archived-message" }
|
|
};
|
|
var organizer = new GmailOrganizer(
|
|
gmailClient,
|
|
new RecipientExtractor(options),
|
|
new LabelNameResolver("ArneM.be", "{\"tomscott\":\"Tom Scott\"}"),
|
|
stateStore,
|
|
options,
|
|
NullLogger<GmailOrganizer>.Instance);
|
|
|
|
await organizer.InitializeAsync(CancellationToken.None);
|
|
|
|
var modification = Assert.Single(gmailClient.Modifications);
|
|
Assert.Equal("archived-message", modification.MessageId);
|
|
Assert.Equal(["new-label"], modification.AddedLabelIds);
|
|
Assert.Equal(["old-label"], modification.RemovedLabelIds);
|
|
Assert.Equal(["old-label"], gmailClient.DeletedLabelIds);
|
|
var persistedState = await stateStore.LoadAsync(CancellationToken.None);
|
|
Assert.Equal("ArneM.be/Tom Scott", persistedState.LabelMappings["tomscott"]);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(statePath);
|
|
}
|
|
}
|
|
|
|
private sealed class FakeGmailClient : IGmailClient
|
|
{
|
|
public Dictionary<string, GmailMessage> Messages { get; } = [];
|
|
public Dictionary<string, GmailLabel> Labels { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
public List<string> LabeledMessageIds { get; } = [];
|
|
public List<Modification> Modifications { get; } = [];
|
|
public List<string> DeletedLabelIds { get; } = [];
|
|
|
|
public Task<ulong> GetCurrentHistoryIdAsync(CancellationToken cancellationToken) =>
|
|
Task.FromResult<ulong>(42);
|
|
|
|
public Task<GmailHistory> GetHistoryAsync(ulong startHistoryId, CancellationToken cancellationToken) =>
|
|
Task.FromResult(new GmailHistory([], startHistoryId));
|
|
|
|
public async IAsyncEnumerable<string> ListInboxMessageIdsAsync(
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
await Task.Yield();
|
|
foreach (var messageId in Messages.Keys)
|
|
{
|
|
yield return messageId;
|
|
}
|
|
}
|
|
|
|
public async IAsyncEnumerable<string> ListMessageIdsByLabelAsync(
|
|
string labelId,
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
await Task.Yield();
|
|
foreach (var messageId in LabeledMessageIds)
|
|
{
|
|
yield return messageId;
|
|
}
|
|
}
|
|
|
|
public Task<GmailMessage> GetMessageAsync(string messageId, CancellationToken cancellationToken) =>
|
|
Task.FromResult(Messages[messageId]);
|
|
|
|
public Task<GmailLabel?> FindLabelAsync(string name, CancellationToken cancellationToken) =>
|
|
Task.FromResult(Labels.GetValueOrDefault(name));
|
|
|
|
public Task<GmailLabel> EnsureLabelAsync(string name, CancellationToken cancellationToken)
|
|
{
|
|
if (Labels.TryGetValue(name, out var label))
|
|
{
|
|
return Task.FromResult(label);
|
|
}
|
|
|
|
label = new GmailLabel($"label-{Labels.Count + 1}", name, true);
|
|
Labels[name] = label;
|
|
return Task.FromResult(label);
|
|
}
|
|
|
|
public Task ModifyLabelsAsync(
|
|
string messageId,
|
|
IReadOnlyCollection<string> addLabelIds,
|
|
IReadOnlyCollection<string> removeLabelIds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Modifications.Add(new Modification(messageId, addLabelIds, removeLabelIds));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task DeleteLabelAsync(string labelId, CancellationToken cancellationToken)
|
|
{
|
|
DeletedLabelIds.Add(labelId);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed record Modification(
|
|
string MessageId,
|
|
IReadOnlyCollection<string> AddedLabelIds,
|
|
IReadOnlyCollection<string> RemovedLabelIds);
|
|
} |