From 48488b72b5d40755277e2f31e4b768713a6cfedd Mon Sep 17 00:00:00 2001 From: Arne Moerman Date: Sat, 26 Sep 2026 20:05:21 +0200 Subject: [PATCH] feat: Add Gmail Catchall Labeler service with configuration and functionality - 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. --- .dockerignore | 8 + .env.example | 3 + .gitea/workflows/build-and-push.yml | 36 +++ .gitignore | 11 + .vscode/launch.json | 18 ++ .vscode/tasks.json | 17 ++ Dockerfile | 20 ++ GmailCatchallLabeler.slnx | 8 + README.md | 120 ++++++++++ compose.yml | 30 +++ .../Configuration/GmailOptions.cs | 16 ++ .../Gmail/GmailApiClient.cs | 226 ++++++++++++++++++ src/GmailCatchallLabeler/Gmail/GmailModels.cs | 14 ++ .../Gmail/IGmailClient.cs | 18 ++ .../GmailCatchallLabeler.csproj | 14 ++ src/GmailCatchallLabeler/GmailOrganizer.cs | 165 +++++++++++++ .../Labels/LabelNameResolver.cs | 44 ++++ .../Labels/RecipientExtractor.cs | 45 ++++ src/GmailCatchallLabeler/Program.cs | 48 ++++ .../Properties/launchSettings.json | 12 + .../State/OrganizerState.cs | 8 + src/GmailCatchallLabeler/State/StateStore.cs | 48 ++++ src/GmailCatchallLabeler/Worker.cs | 30 +++ src/GmailCatchallLabeler/appsettings.json | 8 + src/GmailCatchallLabeler/data/state.json | 5 + .../GmailCatchallLabeler.Tests.csproj | 25 ++ .../GmailOrganizerTests.cs | 198 +++++++++++++++ .../LabelNameResolverTests.cs | 27 +++ .../RecipientExtractorTests.cs | 57 +++++ 29 files changed, 1279 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/build-and-push.yml create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 Dockerfile create mode 100644 GmailCatchallLabeler.slnx create mode 100644 README.md create mode 100644 compose.yml create mode 100644 src/GmailCatchallLabeler/Configuration/GmailOptions.cs create mode 100644 src/GmailCatchallLabeler/Gmail/GmailApiClient.cs create mode 100644 src/GmailCatchallLabeler/Gmail/GmailModels.cs create mode 100644 src/GmailCatchallLabeler/Gmail/IGmailClient.cs create mode 100644 src/GmailCatchallLabeler/GmailCatchallLabeler.csproj create mode 100644 src/GmailCatchallLabeler/GmailOrganizer.cs create mode 100644 src/GmailCatchallLabeler/Labels/LabelNameResolver.cs create mode 100644 src/GmailCatchallLabeler/Labels/RecipientExtractor.cs create mode 100644 src/GmailCatchallLabeler/Program.cs create mode 100644 src/GmailCatchallLabeler/Properties/launchSettings.json create mode 100644 src/GmailCatchallLabeler/State/OrganizerState.cs create mode 100644 src/GmailCatchallLabeler/State/StateStore.cs create mode 100644 src/GmailCatchallLabeler/Worker.cs create mode 100644 src/GmailCatchallLabeler/appsettings.json create mode 100644 src/GmailCatchallLabeler/data/state.json create mode 100644 tests/GmailCatchallLabeler.Tests/GmailCatchallLabeler.Tests.csproj create mode 100644 tests/GmailCatchallLabeler.Tests/GmailOrganizerTests.cs create mode 100644 tests/GmailCatchallLabeler.Tests/LabelNameResolverTests.cs create mode 100644 tests/GmailCatchallLabeler.Tests/RecipientExtractorTests.cs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dc6ee96 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +**/bin/ +**/obj/ +.git/ +.gitea/ +.env +compose.yml +README.md +tests/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5d96609 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +GMAIL_CLIENT_ID=replace-with-google-oauth-client-id +GMAIL_CLIENT_SECRET=replace-with-google-oauth-client-secret +GMAIL_REFRESH_TOKEN=replace-with-google-oauth-refresh-token \ No newline at end of file diff --git a/.gitea/workflows/build-and-push.yml b/.gitea/workflows/build-and-push.yml new file mode 100644 index 0000000..abc1f5d --- /dev/null +++ b/.gitea/workflows/build-and-push.yml @@ -0,0 +1,36 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + +jobs: + build-and-push: + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Generate datetime tag + id: vars + run: echo "tag=$(date +'%Y%m%d%H%M')" >> "$GITHUB_OUTPUT" + + - name: Log in to Gitea Registry + uses: docker/login-action@v3 + with: + registry: git.arnemoerman.be + username: arne + password: ${{ secrets.PAT_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + git.arnemoerman.be/arne/gmail-catchall-labeler:latest + git.arnemoerman.be/arne/gmail-catchall-labeler:${{ steps.vars.outputs.tag }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e0c131 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.env +src/GmailCatchallLabeler/appsettings.Development.json +.idea/ +.vscode/* +!.vscode/launch.json +!.vscode/tasks.json +**/bin/ +**/obj/ +TestResults/ +*.user +*.suo \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..20a869f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "GmailCatchallLabeler", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build GmailCatchallLabeler", + "program": "${workspaceFolder}/src/GmailCatchallLabeler/bin/Debug/net10.0/GmailCatchallLabeler.dll", + "cwd": "${workspaceFolder}/src/GmailCatchallLabeler", + "console": "integratedTerminal", + "justMyCode": true, + "env": { + "DOTNET_ENVIRONMENT": "Development" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..d320e3e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build GmailCatchallLabeler", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}/src/GmailCatchallLabeler/GmailCatchallLabeler.csproj", + "--configuration", + "Debug" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..affbdc3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /source + +COPY src/GmailCatchallLabeler/GmailCatchallLabeler.csproj src/GmailCatchallLabeler/ +RUN dotnet restore src/GmailCatchallLabeler/GmailCatchallLabeler.csproj + +COPY src/GmailCatchallLabeler/ src/GmailCatchallLabeler/ +RUN dotnet publish src/GmailCatchallLabeler/GmailCatchallLabeler.csproj \ + --configuration Release \ + --no-restore \ + --output /app + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +RUN mkdir /data && chown "$APP_UID:$APP_UID" /data +COPY --from=build --chown=$APP_UID:$APP_UID /app . +USER $APP_UID +VOLUME ["/data"] +EXPOSE 8080 +ENTRYPOINT ["dotnet", "GmailCatchallLabeler.dll"] \ No newline at end of file diff --git a/GmailCatchallLabeler.slnx b/GmailCatchallLabeler.slnx new file mode 100644 index 0000000..06a829b --- /dev/null +++ b/GmailCatchallLabeler.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..7fcef89 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# Gmail Catch-all Labeler + +Gmail Catch-all Labeler watches a Gmail inbox and labels messages according to the local part of recipients at a configured catch-all domain. For example, mail delivered to `every-foods@arnemoerman.be` receives the label `ArneM.be/Every Foods`. + +The service processes the existing inbox on its first run, then uses Gmail history checkpoints to process new inbox messages. State is persisted under `/data`, and all label changes are idempotent. + +## Gmail OAuth setup + +Gmail no longer supports account username/password authentication for this use case. The service uses a Google OAuth client and refresh token with the `gmail.modify` scope. + +1. Create or select a project in [Google Cloud Console](https://console.cloud.google.com/). +2. In that same project, open **APIs & Services** → **Library** → **Gmail API** and choose **Enable**. Selecting a Gmail scope later in OAuth Playground does not enable the API. +3. Configure the OAuth consent screen under **Google Auth Platform** → **Audience**. Add the Gmail account that will be organized under **Test users** while the app is in testing mode. +4. Create an OAuth 2.0 client under **Credentials**. Choose **Web application** and add this exact **Authorized redirect URI**: + + ```text + https://developers.google.com/oauthplayground + ``` + +5. Open [OAuth 2.0 Playground](https://developers.google.com/oauthplayground/), select the gear icon, enable **Use your own OAuth credentials**, and enter the Web application's client ID and secret. +6. Under **Gmail API v1**, select `https://www.googleapis.com/auth/gmail.modify` and choose **Authorize APIs**. +7. Sign in with the Gmail account to organize. If the consent screen is in testing mode, this account must be listed under **Test users**. +8. Choose **Exchange authorization code for tokens** and copy the refresh token into `GMAIL_REFRESH_TOKEN`. +9. Use the client ID, client secret, and refresh token from this same OAuth client together. A refresh token is tied to the client that issued it. + +The redirect URI must match exactly, including scheme and trailing slash. A **Desktop app** OAuth client cannot register the Playground callback and produces `Error 400: redirect_uri_mismatch`. Create a Web application client instead; changing settings on the Desktop client is not sufficient. + +If the exchange returns no refresh token, revoke the app under your [Google Account connections](https://myaccount.google.com/connections), then repeat the authorization. Google may omit a new refresh token when the account has already granted offline access to that OAuth client. + +Google refresh tokens for external apps left in **Testing** mode can expire after seven days. Publish the consent screen to production for a long-running personal deployment. Verification is generally not required when the app is only used by its owner. + +### OAuth troubleshooting + +| Error | Cause and fix | +| --- | --- | +| `OptionsValidationException: GMAIL_REFRESH_TOKEN is required` | The service started before a refresh token was configured. Generate one in OAuth Playground and set `GMAIL_REFRESH_TOKEN`. | +| `Error 400: redirect_uri_mismatch` | OAuth Playground is being used with a Desktop client, or the callback differs. Create a **Web application** client and register exactly `https://developers.google.com/oauthplayground`. | +| `Error 403: access_denied` mentioning verification or developer-approved testers | The app is in testing mode and the Gmail account is not approved. Add it under **Google Auth Platform** → **Audience** → **Test users**. | +| `GoogleApiException` from `users.getProfile` after authentication | First verify that **Gmail API is enabled in the same Google Cloud project as the OAuth client**. Then verify that the refresh token was issued with the `gmail.modify` scope using that client's ID and secret. | +| OAuth exchange has no refresh token | Revoke the app under [Google Account connections](https://myaccount.google.com/connections), then authorize it again. Google may omit another refresh token for an existing grant. | + +Keep `GMAIL_USER_ID` set to `me`. Gmail resolves `me` to the account represented by the refresh token; changing it to the email address does not fix authorization or API enablement errors. + +## Configuration + +| Variable | Required | Default | Purpose | +| --- | --- | --- | --- | +| `GMAIL_CLIENT_ID` | Yes | | Google OAuth client ID | +| `GMAIL_CLIENT_SECRET` | Yes | | Google OAuth client secret | +| `GMAIL_REFRESH_TOKEN` | Yes | | Google OAuth refresh token | +| `GMAIL_USER_ID` | No | `me` | Gmail API user identifier | +| `CATCHALL_DOMAIN` | Yes | | Catch-all domain without `@` | +| `LABEL_PREFIX` | Yes | | Parent label, such as `ArneM.be` | +| `LABEL_NAME_OVERRIDES` | No | `{}` | JSON object mapping local parts to display names | +| `POLL_INTERVAL_SECONDS` | No | `60` | Delay between Gmail history polls | +| `PROCESS_EXISTING` | No | `true` | Process the inbox when no state exists | +| `MIGRATE_CHANGED_LABELS` | No | `true` | Relabel old mail when a known mapping changes | +| `STATE_PATH` | No | `/data/state.json` | Persistent checkpoint and mapping file | +| `ASPNETCORE_HTTP_PORTS` | No | `8080` | Internal port for the `/health` endpoint | + +Without an override, hyphens, underscores, dots, and plus signs become spaces and each word is title-cased. Overrides are case-insensitive and contain only the local part: + +```json +{ + "tomscott": "Tom Scott", + "wildewesten": "Wilde Westen", + "degeleflamingo": "De Gele Flamingo" +} +``` + +When an override changes, the service uses its persisted mapping to find every message carrying the old label, including archived messages. It applies the new label and removes the old one. An empty old label is deleted only when the service originally created it. + +## Docker Compose + +Copy `.env.example` to `.env`, replace the three OAuth values, review `compose.yml`, and start the service: + +```shell +docker compose up -d +docker compose logs -f gmail-catchall-labeler +``` + +The included `compose.yml` also works as a Docker Swarm stack: + +```shell +set -a +. ./.env +set +a +docker stack deploy --compose-file compose.yml gmail-labeler +``` + +Keep the `gmail-catchall-labeler-data` volume when updating or recreating the service. Losing it does not affect Gmail messages, but the next run processes the inbox as a fresh installation and cannot infer earlier override names for migration. + +## Existing Gmail filters + +After the service has completed its first inbox scan: + +1. Open Gmail **Settings** and select **Filters and Blocked Addresses**. +2. Select the catch-all filters replaced by this service and delete them. +3. Keep the existing labels. The service reuses labels whose names match. + +Existing labels not created by this service are never deleted automatically. They may become empty after a later override migration and can then be removed manually. + +## Local development + +Docker is not required to run or debug the service. Fill the OAuth values in `src/GmailCatchallLabeler/appsettings.Development.json`; this file is ignored by Git because it contains secrets. In VS Code, select **Run and Debug** → **GmailCatchallLabeler** and press `F5`. + +```shell +dotnet test GmailCatchallLabeler.slnx +dotnet run --project src/GmailCatchallLabeler +``` + +## Gitea build + +The workflow at `.gitea/workflows/build-and-push.yml` runs on pushes to `main` and publishes both `latest` and a UTC timestamp tag to: + +```text +git.arnemoerman.be/arne/gmail-catchall-labeler +``` + +Create a repository secret named `PAT_TOKEN` with permission to push packages to the registry. \ No newline at end of file diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..f6fa21c --- /dev/null +++ b/compose.yml @@ -0,0 +1,30 @@ +services: + gmail-catchall-labeler: + image: git.arnemoerman.be/arne/gmail-catchall-labeler:latest + restart: unless-stopped + environment: + GMAIL_CLIENT_ID: ${GMAIL_CLIENT_ID} + GMAIL_CLIENT_SECRET: ${GMAIL_CLIENT_SECRET} + GMAIL_REFRESH_TOKEN: ${GMAIL_REFRESH_TOKEN} + GMAIL_USER_ID: me + CATCHALL_DOMAIN: arnemoerman.be + LABEL_PREFIX: ArneM.be + LABEL_NAME_OVERRIDES: >- + {"wildewesten":"Wilde Westen","sciencealert":"Science Alert","ecopower":"EcoPower","tomscott":"Tom Scott","linkedin":"LinkedIn","degeleflamingo":"De Gele Flamingo","shopapp":"ShopApp","ccdesteiger":"CC De Steiger","leseitanist":"Le Seitanist","dewatergroep":"De Watergroep","veganbox":"VeganBox","energyid":"EnergyId"} + POLL_INTERVAL_SECONDS: 60 + PROCESS_EXISTING: "true" + MIGRATE_CHANGED_LABELS: "true" + STATE_PATH: /data/state.json + ASPNETCORE_HTTP_PORTS: 8080 + expose: + - "8080" + volumes: + - gmail-catchall-labeler-data:/data + deploy: + replicas: 1 + restart_policy: + condition: any + delay: 5s + +volumes: + gmail-catchall-labeler-data: \ No newline at end of file diff --git a/src/GmailCatchallLabeler/Configuration/GmailOptions.cs b/src/GmailCatchallLabeler/Configuration/GmailOptions.cs new file mode 100644 index 0000000..c01bc89 --- /dev/null +++ b/src/GmailCatchallLabeler/Configuration/GmailOptions.cs @@ -0,0 +1,16 @@ +namespace GmailCatchallLabeler.Configuration; + +public sealed class GmailOptions +{ + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string RefreshToken { get; set; } = string.Empty; + public string UserId { get; set; } = "me"; + public string CatchallDomain { get; set; } = string.Empty; + public string LabelPrefix { get; set; } = string.Empty; + public string LabelNameOverrides { get; set; } = "{}"; + public string StatePath { get; set; } = "/data/state.json"; + public int PollIntervalSeconds { get; set; } = 60; + public bool ProcessExisting { get; set; } = true; + public bool MigrateChangedLabels { get; set; } = true; +} \ No newline at end of file diff --git a/src/GmailCatchallLabeler/Gmail/GmailApiClient.cs b/src/GmailCatchallLabeler/Gmail/GmailApiClient.cs new file mode 100644 index 0000000..a4bc3ed --- /dev/null +++ b/src/GmailCatchallLabeler/Gmail/GmailApiClient.cs @@ -0,0 +1,226 @@ +using GmailCatchallLabeler.Configuration; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Auth.OAuth2.Flows; +using Google.Apis.Auth.OAuth2.Responses; +using Google.Apis.Gmail.v1; +using Google.Apis.Gmail.v1.Data; +using Google.Apis.Services; +using Google.Apis.Util; + +namespace GmailCatchallLabeler.Gmail; + +public sealed class GmailApiClient : IGmailClient, IDisposable +{ + private readonly GmailOptions options; + private readonly GmailService service; + private readonly SemaphoreSlim labelLock = new(1, 1); + private Dictionary? labelsByName; + + public GmailApiClient(GmailOptions options) + { + this.options = options; + + var flow = new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer + { + ClientSecrets = new ClientSecrets + { + ClientId = options.ClientId, + ClientSecret = options.ClientSecret + }, + Scopes = [GmailService.Scope.GmailModify] + }); + var credential = new UserCredential( + flow, + options.UserId, + new TokenResponse { RefreshToken = options.RefreshToken }); + + service = new GmailService(new BaseClientService.Initializer + { + ApplicationName = "GmailCatchallLabeler", + HttpClientInitializer = credential + }); + } + + public async Task GetCurrentHistoryIdAsync(CancellationToken cancellationToken) + { + var profile = await service.Users.GetProfile(options.UserId).ExecuteAsync(cancellationToken); + return profile.HistoryId ?? throw new InvalidOperationException("Gmail did not return a history ID."); + } + + public async Task GetHistoryAsync( + ulong startHistoryId, + CancellationToken cancellationToken) + { + var messageIds = new HashSet(StringComparer.Ordinal); + string? pageToken = null; + ulong latestHistoryId = startHistoryId; + + do + { + var request = service.Users.History.List(options.UserId); + request.StartHistoryId = startHistoryId; + request.PageToken = pageToken; + request.HistoryTypes = UsersResource.HistoryResource.ListRequest.HistoryTypesEnum.MessageAdded; + + var response = await request.ExecuteAsync(cancellationToken); + latestHistoryId = response.HistoryId ?? latestHistoryId; + foreach (var history in response.History ?? []) + { + foreach (var added in history.MessagesAdded ?? []) + { + if (added.Message?.Id is not null) + { + messageIds.Add(added.Message.Id); + } + } + } + + pageToken = response.NextPageToken; + } + while (pageToken is not null); + + return new GmailHistory(messageIds, latestHistoryId); + } + + public async IAsyncEnumerable ListInboxMessageIdsAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var messageId in ListMessageIdsAsync("in:inbox", null, cancellationToken)) + { + yield return messageId; + } + } + + public async IAsyncEnumerable ListMessageIdsByLabelAsync( + string labelId, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var messageId in ListMessageIdsAsync(null, labelId, cancellationToken)) + { + yield return messageId; + } + } + + public async Task GetMessageAsync(string messageId, CancellationToken cancellationToken) + { + var request = service.Users.Messages.Get(options.UserId, messageId); + request.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Metadata; + var message = await request.ExecuteAsync(cancellationToken); + + return new GmailMessage( + message.Id, + message.LabelIds?.ToArray() ?? [], + message.Payload?.Headers? + .Where(header => header.Name is not null && header.Value is not null) + .Select(header => new GmailHeader(header.Name, header.Value)) + .ToArray() ?? []); + } + + public async Task FindLabelAsync(string name, CancellationToken cancellationToken) + { + await EnsureLabelCacheAsync(cancellationToken); + return labelsByName!.GetValueOrDefault(name); + } + + public async Task EnsureLabelAsync(string name, CancellationToken cancellationToken) + { + await labelLock.WaitAsync(cancellationToken); + try + { + await EnsureLabelCacheAsync(cancellationToken); + if (labelsByName!.TryGetValue(name, out var existing)) + { + return existing; + } + + var created = await service.Users.Labels.Create( + new Label + { + Name = name, + LabelListVisibility = "labelShow", + MessageListVisibility = "show" + }, + options.UserId).ExecuteAsync(cancellationToken); + var result = new GmailLabel(created.Id, created.Name, true); + labelsByName[name] = result; + return result; + } + finally + { + labelLock.Release(); + } + } + + public async Task ModifyLabelsAsync( + string messageId, + IReadOnlyCollection addLabelIds, + IReadOnlyCollection removeLabelIds, + CancellationToken cancellationToken) + { + var request = new ModifyMessageRequest + { + AddLabelIds = addLabelIds.ToArray(), + RemoveLabelIds = removeLabelIds.ToArray() + }; + await service.Users.Messages.Modify(request, options.UserId, messageId) + .ExecuteAsync(cancellationToken); + } + + public async Task DeleteLabelAsync(string labelId, CancellationToken cancellationToken) + { + await service.Users.Labels.Delete(options.UserId, labelId).ExecuteAsync(cancellationToken); + labelsByName = null; + } + + public void Dispose() + { + service.Dispose(); + labelLock.Dispose(); + } + + private async IAsyncEnumerable ListMessageIdsAsync( + string? query, + string? labelId, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + string? pageToken = null; + do + { + var request = service.Users.Messages.List(options.UserId); + request.Q = query; + request.PageToken = pageToken; + if (labelId is not null) + { + request.LabelIds = new Repeatable([labelId]); + } + + var response = await request.ExecuteAsync(cancellationToken); + foreach (var message in response.Messages ?? []) + { + if (message.Id is not null) + { + yield return message.Id; + } + } + + pageToken = response.NextPageToken; + } + while (pageToken is not null); + } + + private async Task EnsureLabelCacheAsync(CancellationToken cancellationToken) + { + if (labelsByName is not null) + { + return; + } + + var response = await service.Users.Labels.List(options.UserId).ExecuteAsync(cancellationToken); + labelsByName = (response.Labels ?? []) + .Where(label => label.Name is not null) + .ToDictionary( + label => label.Name, + label => new GmailLabel(label.Id, label.Name, false), + StringComparer.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/src/GmailCatchallLabeler/Gmail/GmailModels.cs b/src/GmailCatchallLabeler/Gmail/GmailModels.cs new file mode 100644 index 0000000..78ba243 --- /dev/null +++ b/src/GmailCatchallLabeler/Gmail/GmailModels.cs @@ -0,0 +1,14 @@ +namespace GmailCatchallLabeler.Gmail; + +public sealed record GmailHeader(string Name, string Value); + +public sealed record GmailMessage( + string Id, + IReadOnlyCollection LabelIds, + IReadOnlyCollection Headers); + +public sealed record GmailLabel(string Id, string Name, bool WasCreated); + +public sealed record GmailHistory( + IReadOnlyCollection AddedMessageIds, + ulong LatestHistoryId); \ No newline at end of file diff --git a/src/GmailCatchallLabeler/Gmail/IGmailClient.cs b/src/GmailCatchallLabeler/Gmail/IGmailClient.cs new file mode 100644 index 0000000..5205ac4 --- /dev/null +++ b/src/GmailCatchallLabeler/Gmail/IGmailClient.cs @@ -0,0 +1,18 @@ +namespace GmailCatchallLabeler.Gmail; + +public interface IGmailClient +{ + Task GetCurrentHistoryIdAsync(CancellationToken cancellationToken); + Task GetHistoryAsync(ulong startHistoryId, CancellationToken cancellationToken); + IAsyncEnumerable ListInboxMessageIdsAsync(CancellationToken cancellationToken); + IAsyncEnumerable ListMessageIdsByLabelAsync(string labelId, CancellationToken cancellationToken); + Task GetMessageAsync(string messageId, CancellationToken cancellationToken); + Task FindLabelAsync(string name, CancellationToken cancellationToken); + Task EnsureLabelAsync(string name, CancellationToken cancellationToken); + Task ModifyLabelsAsync( + string messageId, + IReadOnlyCollection addLabelIds, + IReadOnlyCollection removeLabelIds, + CancellationToken cancellationToken); + Task DeleteLabelAsync(string labelId, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/GmailCatchallLabeler/GmailCatchallLabeler.csproj b/src/GmailCatchallLabeler/GmailCatchallLabeler.csproj new file mode 100644 index 0000000..efc753e --- /dev/null +++ b/src/GmailCatchallLabeler/GmailCatchallLabeler.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + dotnet-GmailCatchallLabeler-59b4ab7a-531f-4304-9045-03e0d7f86cb7 + + + + + + + diff --git a/src/GmailCatchallLabeler/GmailOrganizer.cs b/src/GmailCatchallLabeler/GmailOrganizer.cs new file mode 100644 index 0000000..3848551 --- /dev/null +++ b/src/GmailCatchallLabeler/GmailOrganizer.cs @@ -0,0 +1,165 @@ +using System.Net; +using GmailCatchallLabeler.Configuration; +using GmailCatchallLabeler.Gmail; +using GmailCatchallLabeler.Labels; +using GmailCatchallLabeler.State; +using Google; + +namespace GmailCatchallLabeler; + +public sealed class GmailOrganizer( + IGmailClient gmailClient, + RecipientExtractor recipientExtractor, + LabelNameResolver labelNameResolver, + StateStore stateStore, + GmailOptions options, + ILogger logger) +{ + private OrganizerState state = new(); + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + state = await stateStore.LoadAsync(cancellationToken); + if (options.MigrateChangedLabels) + { + await MigrateChangedLabelsAsync(cancellationToken); + } + + if (state.HistoryId is null) + { + var initialHistoryId = await gmailClient.GetCurrentHistoryIdAsync(cancellationToken); + if (options.ProcessExisting) + { + logger.LogInformation("Processing the existing inbox."); + await ProcessInboxAsync(cancellationToken); + } + + state.HistoryId = initialHistoryId; + await stateStore.SaveAsync(state, cancellationToken); + } + } + + public async Task PollAsync(CancellationToken cancellationToken) + { + if (state.HistoryId is null) + { + throw new InvalidOperationException("The organizer has not been initialized."); + } + + try + { + var history = await gmailClient.GetHistoryAsync(state.HistoryId.Value, cancellationToken); + foreach (var messageId in history.AddedMessageIds) + { + await ProcessMessageAsync(messageId, cancellationToken); + } + + state.HistoryId = history.LatestHistoryId; + await stateStore.SaveAsync(state, cancellationToken); + } + catch (GoogleApiException exception) when (exception.HttpStatusCode == HttpStatusCode.NotFound) + { + logger.LogWarning("The Gmail history checkpoint expired; rescanning the inbox."); + var currentHistoryId = await gmailClient.GetCurrentHistoryIdAsync(cancellationToken); + await ProcessInboxAsync(cancellationToken); + state.HistoryId = currentHistoryId; + await stateStore.SaveAsync(state, cancellationToken); + } + } + + private async Task ProcessInboxAsync(CancellationToken cancellationToken) + { + await foreach (var messageId in gmailClient.ListInboxMessageIdsAsync(cancellationToken)) + { + await ProcessMessageAsync(messageId, cancellationToken); + } + } + + private async Task ProcessMessageAsync(string messageId, CancellationToken cancellationToken) + { + var message = await gmailClient.GetMessageAsync(messageId, cancellationToken); + if (!message.LabelIds.Contains("INBOX", StringComparer.Ordinal)) + { + return; + } + + var localParts = recipientExtractor.ExtractLocalParts(message.Headers); + var labelsToAdd = new HashSet(StringComparer.Ordinal); + + foreach (var localPart in localParts) + { + var labelName = labelNameResolver.Resolve(localPart); + var label = await gmailClient.EnsureLabelAsync(labelName, cancellationToken); + if (label.WasCreated) + { + state.CreatedLabels.Add(labelName); + } + + state.LabelMappings[localPart] = labelName; + if (!message.LabelIds.Contains(label.Id, StringComparer.Ordinal)) + { + labelsToAdd.Add(label.Id); + } + } + + if (labelsToAdd.Count > 0) + { + await gmailClient.ModifyLabelsAsync(message.Id, labelsToAdd, [], cancellationToken); + logger.LogInformation("Applied {LabelCount} catch-all label(s) to message {MessageId}.", labelsToAdd.Count, message.Id); + } + } + + private async Task MigrateChangedLabelsAsync(CancellationToken cancellationToken) + { + var obsoleteCreatedLabels = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var mapping in state.LabelMappings.ToArray()) + { + var desiredName = labelNameResolver.Resolve(mapping.Key); + if (desiredName.Equals(mapping.Value, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var oldLabel = await gmailClient.FindLabelAsync(mapping.Value, cancellationToken); + var newLabel = await gmailClient.EnsureLabelAsync(desiredName, cancellationToken); + if (newLabel.WasCreated) + { + state.CreatedLabels.Add(desiredName); + } + + if (oldLabel is not null) + { + await foreach (var messageId in gmailClient.ListMessageIdsByLabelAsync(oldLabel.Id, cancellationToken)) + { + await gmailClient.ModifyLabelsAsync(messageId, [newLabel.Id], [oldLabel.Id], cancellationToken); + } + } + + if (state.CreatedLabels.Contains(mapping.Value)) + { + obsoleteCreatedLabels.Add(mapping.Value); + } + + state.LabelMappings[mapping.Key] = desiredName; + logger.LogInformation("Migrated label {OldLabel} to {NewLabel}.", mapping.Value, desiredName); + } + + foreach (var obsoleteName in obsoleteCreatedLabels) + { + if (state.LabelMappings.Values.Contains(obsoleteName, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + var obsoleteLabel = await gmailClient.FindLabelAsync(obsoleteName, cancellationToken); + if (obsoleteLabel is not null) + { + await gmailClient.DeleteLabelAsync(obsoleteLabel.Id, cancellationToken); + } + + state.CreatedLabels.Remove(obsoleteName); + } + + await stateStore.SaveAsync(state, cancellationToken); + } +} \ No newline at end of file diff --git a/src/GmailCatchallLabeler/Labels/LabelNameResolver.cs b/src/GmailCatchallLabeler/Labels/LabelNameResolver.cs new file mode 100644 index 0000000..8895231 --- /dev/null +++ b/src/GmailCatchallLabeler/Labels/LabelNameResolver.cs @@ -0,0 +1,44 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; +using GmailCatchallLabeler.Configuration; +using Microsoft.Extensions.Options; + +namespace GmailCatchallLabeler.Labels; + +public sealed partial class LabelNameResolver +{ + private readonly string labelPrefix; + private readonly IReadOnlyDictionary overrides; + + public LabelNameResolver(IOptions options) + : this(options.Value.LabelPrefix, options.Value.LabelNameOverrides) + { + } + + public LabelNameResolver(string labelPrefix, string labelNameOverrides) + { + this.labelPrefix = labelPrefix.Trim().TrimEnd('/'); + overrides = JsonSerializer.Deserialize>(labelNameOverrides) + ?.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public string Resolve(string localPart) + { + if (overrides.TryGetValue(localPart, out var labelName)) + { + return $"{labelPrefix}/{labelName.Trim()}"; + } + + var words = SeparatorRegex() + .Split(localPart) + .Where(word => word.Length > 0) + .Select(word => char.ToUpper(word[0], CultureInfo.InvariantCulture) + word[1..].ToLowerInvariant()); + + return $"{labelPrefix}/{string.Join(' ', words)}"; + } + + [GeneratedRegex("[-_.+]+")] + private static partial Regex SeparatorRegex(); +} \ No newline at end of file diff --git a/src/GmailCatchallLabeler/Labels/RecipientExtractor.cs b/src/GmailCatchallLabeler/Labels/RecipientExtractor.cs new file mode 100644 index 0000000..bdf5076 --- /dev/null +++ b/src/GmailCatchallLabeler/Labels/RecipientExtractor.cs @@ -0,0 +1,45 @@ +using GmailCatchallLabeler.Configuration; +using GmailCatchallLabeler.Gmail; +using MimeKit; + +namespace GmailCatchallLabeler.Labels; + +public sealed class RecipientExtractor(GmailOptions options) +{ + private static readonly HashSet RecipientHeaders = new( + ["Delivered-To", "X-Original-To", "Envelope-To", "To", "Cc"], + StringComparer.OrdinalIgnoreCase); + + public IReadOnlyCollection ExtractLocalParts(IEnumerable headers) + { + var localParts = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var header in headers.Where(header => RecipientHeaders.Contains(header.Name))) + { + try + { + var addresses = InternetAddressList.Parse(header.Value); + foreach (var mailbox in addresses.Mailboxes) + { + var separatorIndex = mailbox.Address.LastIndexOf('@'); + if (separatorIndex <= 0) + { + continue; + } + + var domain = mailbox.Address[(separatorIndex + 1)..]; + if (domain.Equals(options.CatchallDomain.TrimStart('@'), StringComparison.OrdinalIgnoreCase)) + { + localParts.Add(mailbox.Address[..separatorIndex]); + } + } + } + catch (ParseException) + { + // Ignore malformed recipient headers; another delivery header may still be valid. + } + } + + return localParts; + } +} \ No newline at end of file diff --git a/src/GmailCatchallLabeler/Program.cs b/src/GmailCatchallLabeler/Program.cs new file mode 100644 index 0000000..41c9e25 --- /dev/null +++ b/src/GmailCatchallLabeler/Program.cs @@ -0,0 +1,48 @@ +using GmailCatchallLabeler; +using GmailCatchallLabeler.Configuration; +using GmailCatchallLabeler.Gmail; +using GmailCatchallLabeler.Labels; +using GmailCatchallLabeler.State; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services + .AddOptions() + .Configure(options => + { + options.ClientId = builder.Configuration["GMAIL_CLIENT_ID"] ?? string.Empty; + options.ClientSecret = builder.Configuration["GMAIL_CLIENT_SECRET"] ?? string.Empty; + options.RefreshToken = builder.Configuration["GMAIL_REFRESH_TOKEN"] ?? string.Empty; + options.UserId = builder.Configuration["GMAIL_USER_ID"] ?? "me"; + options.CatchallDomain = builder.Configuration["CATCHALL_DOMAIN"] ?? string.Empty; + options.LabelPrefix = builder.Configuration["LABEL_PREFIX"] ?? string.Empty; + options.LabelNameOverrides = builder.Configuration["LABEL_NAME_OVERRIDES"] ?? "{}"; + options.StatePath = builder.Configuration["STATE_PATH"] ?? "/data/state.json"; + options.PollIntervalSeconds = int.TryParse( + builder.Configuration["POLL_INTERVAL_SECONDS"], out var pollIntervalSeconds) + ? pollIntervalSeconds + : 60; + options.ProcessExisting = !bool.TryParse( + builder.Configuration["PROCESS_EXISTING"], out var processExisting) || processExisting; + options.MigrateChangedLabels = !bool.TryParse( + builder.Configuration["MIGRATE_CHANGED_LABELS"], out var migrateChangedLabels) || migrateChangedLabels; + }) + .Validate(options => !string.IsNullOrWhiteSpace(options.ClientId), "GMAIL_CLIENT_ID is required.") + .Validate(options => !string.IsNullOrWhiteSpace(options.ClientSecret), "GMAIL_CLIENT_SECRET is required.") + .Validate(options => !string.IsNullOrWhiteSpace(options.RefreshToken), "GMAIL_REFRESH_TOKEN is required.") + .Validate(options => !string.IsNullOrWhiteSpace(options.CatchallDomain), "CATCHALL_DOMAIN is required.") + .Validate(options => !string.IsNullOrWhiteSpace(options.LabelPrefix), "LABEL_PREFIX is required.") + .Validate(options => options.PollIntervalSeconds > 0, "POLL_INTERVAL_SECONDS must be greater than zero.") + .ValidateOnStart(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); +builder.Services.AddHostedService(); + +var host = builder.Build(); +host.Run(); diff --git a/src/GmailCatchallLabeler/Properties/launchSettings.json b/src/GmailCatchallLabeler/Properties/launchSettings.json new file mode 100644 index 0000000..706c19b --- /dev/null +++ b/src/GmailCatchallLabeler/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "GmailCatchallLabeler": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/GmailCatchallLabeler/State/OrganizerState.cs b/src/GmailCatchallLabeler/State/OrganizerState.cs new file mode 100644 index 0000000..cc11701 --- /dev/null +++ b/src/GmailCatchallLabeler/State/OrganizerState.cs @@ -0,0 +1,8 @@ +namespace GmailCatchallLabeler.State; + +public sealed class OrganizerState +{ + public ulong? HistoryId { get; set; } + public Dictionary LabelMappings { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public HashSet CreatedLabels { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/src/GmailCatchallLabeler/State/StateStore.cs b/src/GmailCatchallLabeler/State/StateStore.cs new file mode 100644 index 0000000..81d4d07 --- /dev/null +++ b/src/GmailCatchallLabeler/State/StateStore.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using GmailCatchallLabeler.Configuration; + +namespace GmailCatchallLabeler.State; + +public sealed class StateStore(GmailOptions options) +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public async Task LoadAsync(CancellationToken cancellationToken) + { + if (!File.Exists(options.StatePath)) + { + return new OrganizerState(); + } + + await using var stream = File.OpenRead(options.StatePath); + var state = await JsonSerializer.DeserializeAsync( + stream, + SerializerOptions, + cancellationToken) ?? new OrganizerState(); + state.LabelMappings = new Dictionary( + state.LabelMappings, + StringComparer.OrdinalIgnoreCase); + state.CreatedLabels = new HashSet(state.CreatedLabels, StringComparer.OrdinalIgnoreCase); + return state; + } + + public async Task SaveAsync(OrganizerState state, CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(options.StatePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var temporaryPath = $"{options.StatePath}.tmp"; + await using (var stream = File.Create(temporaryPath)) + { + await JsonSerializer.SerializeAsync(stream, state, SerializerOptions, cancellationToken); + } + + File.Move(temporaryPath, options.StatePath, true); + } +} \ No newline at end of file diff --git a/src/GmailCatchallLabeler/Worker.cs b/src/GmailCatchallLabeler/Worker.cs new file mode 100644 index 0000000..bef83bc --- /dev/null +++ b/src/GmailCatchallLabeler/Worker.cs @@ -0,0 +1,30 @@ +namespace GmailCatchallLabeler; + +public sealed class Worker( + GmailOrganizer organizer, + Configuration.GmailOptions options, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await organizer.InitializeAsync(stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await organizer.PollAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + logger.LogError(exception, "Gmail polling failed; the service will retry."); + } + + await Task.Delay(TimeSpan.FromSeconds(options.PollIntervalSeconds), stoppingToken); + } + } +} diff --git a/src/GmailCatchallLabeler/appsettings.json b/src/GmailCatchallLabeler/appsettings.json new file mode 100644 index 0000000..6901764 --- /dev/null +++ b/src/GmailCatchallLabeler/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/GmailCatchallLabeler/data/state.json b/src/GmailCatchallLabeler/data/state.json new file mode 100644 index 0000000..7d009a4 --- /dev/null +++ b/src/GmailCatchallLabeler/data/state.json @@ -0,0 +1,5 @@ +{ + "historyId": null, + "labelMappings": {}, + "createdLabels": [] +} \ No newline at end of file diff --git a/tests/GmailCatchallLabeler.Tests/GmailCatchallLabeler.Tests.csproj b/tests/GmailCatchallLabeler.Tests/GmailCatchallLabeler.Tests.csproj new file mode 100644 index 0000000..d52a468 --- /dev/null +++ b/tests/GmailCatchallLabeler.Tests/GmailCatchallLabeler.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/GmailCatchallLabeler.Tests/GmailOrganizerTests.cs b/tests/GmailCatchallLabeler.Tests/GmailOrganizerTests.cs new file mode 100644 index 0000000..5011ea2 --- /dev/null +++ b/tests/GmailCatchallLabeler.Tests/GmailOrganizerTests.cs @@ -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.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.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 Messages { get; } = []; + public Dictionary Labels { get; } = new(StringComparer.OrdinalIgnoreCase); + public List LabeledMessageIds { get; } = []; + public List Modifications { get; } = []; + public List DeletedLabelIds { get; } = []; + + public Task GetCurrentHistoryIdAsync(CancellationToken cancellationToken) => + Task.FromResult(42); + + public Task GetHistoryAsync(ulong startHistoryId, CancellationToken cancellationToken) => + Task.FromResult(new GmailHistory([], startHistoryId)); + + public async IAsyncEnumerable ListInboxMessageIdsAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + foreach (var messageId in Messages.Keys) + { + yield return messageId; + } + } + + public async IAsyncEnumerable ListMessageIdsByLabelAsync( + string labelId, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + foreach (var messageId in LabeledMessageIds) + { + yield return messageId; + } + } + + public Task GetMessageAsync(string messageId, CancellationToken cancellationToken) => + Task.FromResult(Messages[messageId]); + + public Task FindLabelAsync(string name, CancellationToken cancellationToken) => + Task.FromResult(Labels.GetValueOrDefault(name)); + + public Task 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 addLabelIds, + IReadOnlyCollection 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 AddedLabelIds, + IReadOnlyCollection RemovedLabelIds); +} \ No newline at end of file diff --git a/tests/GmailCatchallLabeler.Tests/LabelNameResolverTests.cs b/tests/GmailCatchallLabeler.Tests/LabelNameResolverTests.cs new file mode 100644 index 0000000..a54a5c9 --- /dev/null +++ b/tests/GmailCatchallLabeler.Tests/LabelNameResolverTests.cs @@ -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)); + } +} \ No newline at end of file diff --git a/tests/GmailCatchallLabeler.Tests/RecipientExtractorTests.cs b/tests/GmailCatchallLabeler.Tests/RecipientExtractorTests.cs new file mode 100644 index 0000000..4a81770 --- /dev/null +++ b/tests/GmailCatchallLabeler.Tests/RecipientExtractorTests.cs @@ -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 , 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)); + } +} \ No newline at end of file