feat: Add Gmail Catchall Labeler service with configuration and functionality
Build and Push Docker Image / build-and-push (push) Successful in 2m21s
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.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GmailCatchallLabeler\GmailCatchallLabeler.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,198 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using GmailCatchallLabeler.Labels;
|
||||
|
||||
namespace GmailCatchallLabeler.Tests;
|
||||
|
||||
public sealed class LabelNameResolverTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("every-foods", "ArneM.be/Every Foods")]
|
||||
[InlineData("science_alert", "ArneM.be/Science Alert")]
|
||||
[InlineData("energyid", "ArneM.be/Energyid")]
|
||||
public void Resolve_UsesAutomaticTitleCasing(string localPart, string expected)
|
||||
{
|
||||
var resolver = new LabelNameResolver("ArneM.be", "{}");
|
||||
|
||||
Assert.Equal(expected, resolver.Resolve(localPart));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("tomscott", "ArneM.be/Tom Scott")]
|
||||
[InlineData("TOMSCOTT", "ArneM.be/Tom Scott")]
|
||||
public void Resolve_PrefersCaseInsensitiveOverride(string localPart, string expected)
|
||||
{
|
||||
var resolver = new LabelNameResolver("ArneM.be/", "{\"tomscott\":\"Tom Scott\"}");
|
||||
|
||||
Assert.Equal(expected, resolver.Resolve(localPart));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using GmailCatchallLabeler.Configuration;
|
||||
using GmailCatchallLabeler.Gmail;
|
||||
using GmailCatchallLabeler.Labels;
|
||||
|
||||
namespace GmailCatchallLabeler.Tests;
|
||||
|
||||
public sealed class RecipientExtractorTests
|
||||
{
|
||||
private readonly RecipientExtractor extractor = new(new GmailOptions
|
||||
{
|
||||
CatchallDomain = "arnemoerman.be"
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void ExtractLocalParts_ReadsDeliveryAndRecipientHeaders()
|
||||
{
|
||||
GmailHeader[] headers =
|
||||
[
|
||||
new("Delivered-To", "tomscott@arnemoerman.be"),
|
||||
new("To", "Example <every-foods@arnemoerman.be>, other@example.com"),
|
||||
new("Subject", "Ignored")
|
||||
];
|
||||
|
||||
var result = extractor.ExtractLocalParts(headers);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains("tomscott", result);
|
||||
Assert.Contains("every-foods", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractLocalParts_IsCaseInsensitiveAndDeduplicates()
|
||||
{
|
||||
GmailHeader[] headers =
|
||||
[
|
||||
new("X-Original-To", "News@ArneMoerman.be"),
|
||||
new("Cc", "news@arnemoerman.be")
|
||||
];
|
||||
|
||||
var result = extractor.ExtractLocalParts(headers);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Contains("News", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractLocalParts_IgnoresOtherDomainsAndMalformedHeaders()
|
||||
{
|
||||
GmailHeader[] headers =
|
||||
[
|
||||
new("To", "other@example.com"),
|
||||
new("Envelope-To", "not an address <")
|
||||
];
|
||||
|
||||
Assert.Empty(extractor.ExtractLocalParts(headers));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user