feat: Add Gmail Catchall Labeler service with configuration and functionality
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:
2026-09-26 20:05:21 +02:00
commit 48488b72b5
29 changed files with 1279 additions and 0 deletions
@@ -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));
}
}