- 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.
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.
- Create or select a project in Google Cloud Console.
- 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.
- 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.
- Create an OAuth 2.0 client under Credentials. Choose Web application and add this exact Authorized redirect URI:
https://developers.google.com/oauthplayground
- Open OAuth 2.0 Playground, select the gear icon, enable Use your own OAuth credentials, and enter the Web application's client ID and secret.
- Under Gmail API v1, select
https://www.googleapis.com/auth/gmail.modifyand choose Authorize APIs. - Sign in with the Gmail account to organize. If the consent screen is in testing mode, this account must be listed under Test users.
- Choose Exchange authorization code for tokens and copy the refresh token into
GMAIL_REFRESH_TOKEN. - 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, 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, 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:
{
"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:
docker compose up -d
docker compose logs -f gmail-catchall-labeler
The included compose.yml also works as a Docker Swarm stack:
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:
- Open Gmail Settings and select Filters and Blocked Addresses.
- Select the catch-all filters replaced by this service and delete them.
- 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.
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:
git.arnemoerman.be/arne/gmail-catchall-labeler
Create a repository secret named PAT_TOKEN with permission to push packages to the registry.