# 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.