feat: add configurable gmail import labels
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/4d504045-2740-4bab-b51e-960fe4dafe4b Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Configurable Gmail import labels**: Users can now define which Gmail labels are applied to imported messages from the Settings page. The default setup is opinionated: `{{source_email}}` (rendered to the mailbox address each message came from) plus `imported`, and a reset button restores those defaults instantly.
|
||||
- **Prometheus metrics** (`/metrics` endpoint on the FastAPI backend, scraped every 15 s):
|
||||
- **HTTP layer** — `http_requests_total` (counter, labelled `method`/`endpoint`/`status_code`) and `http_request_duration_seconds` (histogram). Path segments that are numeric IDs are normalised to `{id}` to avoid label-set explosion.
|
||||
- **Mail processing** — `mail_processing_runs_total` (counter, by `status`: `completed` / `partial_failure` / `failed`), `mail_processing_emails_total` (counter, by `operation`: `fetched` / `forwarded` / `failed`), `mail_processing_duration_seconds` (histogram), `active_mail_accounts_total` (gauge — set each scheduler cycle).
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.models.database_models import User, SubscriptionTier, GmailCredential
|
||||
from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest
|
||||
from app.services.auth_service import oauth_service
|
||||
from app.services.gmail_service import GmailService, GMAIL_SCOPES
|
||||
from app.utils.gmail_labels import build_gmail_credential_scopes
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -286,7 +287,10 @@ async def google_oauth(
|
||||
if encrypted_refresh:
|
||||
existing_cred.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
|
||||
existing_cred.token_expiry = token_expiry # type: ignore[assignment]
|
||||
existing_cred.scopes = scope_list # type: ignore[assignment]
|
||||
existing_cred.scopes = build_gmail_credential_scopes( # type: ignore[assignment]
|
||||
scope_list,
|
||||
existing_cred.import_label_templates,
|
||||
)
|
||||
existing_cred.is_valid = True # type: ignore[assignment]
|
||||
existing_cred.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
else:
|
||||
@@ -296,7 +300,7 @@ async def google_oauth(
|
||||
encrypted_access_token=encrypted_access,
|
||||
encrypted_refresh_token=encrypted_refresh,
|
||||
token_expiry=token_expiry,
|
||||
scopes=scope_list,
|
||||
scopes=build_gmail_credential_scopes(scope_list),
|
||||
is_valid=True,
|
||||
last_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
@@ -21,12 +21,30 @@ from app.models.schemas import (
|
||||
GmailCredentialResponse,
|
||||
GmailAuthorizeResponse,
|
||||
GmailCallbackRequest,
|
||||
GmailImportLabelsUpdate,
|
||||
)
|
||||
from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES
|
||||
from app.utils.gmail_labels import (
|
||||
MAX_IMPORT_LABELS,
|
||||
build_gmail_credential_scopes,
|
||||
extract_granted_scopes,
|
||||
normalize_import_label_templates,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validated_import_label_templates(label_templates: List[str]) -> List[str]:
|
||||
normalized = normalize_import_label_templates(label_templates)
|
||||
if len(normalized) > MAX_IMPORT_LABELS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"You can configure up to {MAX_IMPORT_LABELS} Gmail import labels.",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
# Gmail API scopes requested during the "Connect Gmail" OAuth flow.
|
||||
# GMAIL_SCOPES (gmail.insert, gmail.labels, gmail.readonly) are imported from
|
||||
# gmail_service so the scope list stays in sync with what GmailService uses.
|
||||
@@ -223,6 +241,10 @@ async def save_gmail_credential(
|
||||
existing.gmail_email = credential_in.gmail_email # type: ignore[assignment]
|
||||
existing.encrypted_access_token = encrypted_access # type: ignore[assignment]
|
||||
existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
|
||||
existing.scopes = build_gmail_credential_scopes(
|
||||
existing.granted_scopes,
|
||||
existing.import_label_templates,
|
||||
) # type: ignore[assignment]
|
||||
existing.is_valid = True # type: ignore[assignment]
|
||||
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
await db.commit()
|
||||
@@ -235,6 +257,7 @@ async def save_gmail_credential(
|
||||
gmail_email=credential_in.gmail_email,
|
||||
encrypted_access_token=encrypted_access,
|
||||
encrypted_refresh_token=encrypted_refresh,
|
||||
scopes=build_gmail_credential_scopes(None, None),
|
||||
is_valid=True,
|
||||
last_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -285,6 +308,33 @@ async def delete_gmail_credential(
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.put("/gmail-credential/labels", response_model=GmailCredentialResponse)
|
||||
async def update_gmail_import_labels(
|
||||
labels_in: GmailImportLabelsUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update the Gmail labels applied to imported messages."""
|
||||
result = await db.execute(
|
||||
select(GmailCredential).where(GmailCredential.user_id == current_user.id)
|
||||
)
|
||||
credential = result.scalar_one_or_none()
|
||||
|
||||
if not credential:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No Gmail credentials found. Connect Gmail first.",
|
||||
)
|
||||
|
||||
credential.scopes = build_gmail_credential_scopes( # type: ignore[assignment]
|
||||
extract_granted_scopes(credential.scopes),
|
||||
_validated_import_label_templates(labels_in.import_label_templates),
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(credential)
|
||||
return credential
|
||||
|
||||
|
||||
@router.get("/gmail/authorize-url", response_model=GmailAuthorizeResponse)
|
||||
async def get_gmail_authorize_url(
|
||||
redirect_uri: str,
|
||||
@@ -365,6 +415,7 @@ async def send_gmail_debug_email(
|
||||
try:
|
||||
inject_result = await gmail_service.inject_debug_email(
|
||||
recipient_email=credential.gmail_email, # type: ignore[arg-type]
|
||||
import_label_templates=credential.import_label_templates,
|
||||
)
|
||||
except GmailInjectionError as exc:
|
||||
raise HTTPException(
|
||||
@@ -498,7 +549,10 @@ async def gmail_oauth_callback(
|
||||
if encrypted_refresh:
|
||||
existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
|
||||
existing.token_expiry = token_expiry # type: ignore[assignment]
|
||||
existing.scopes = token_data.get("scope", "").split() # type: ignore[assignment]
|
||||
existing.scopes = build_gmail_credential_scopes( # type: ignore[assignment]
|
||||
token_data.get("scope", "").split(),
|
||||
existing.import_label_templates,
|
||||
)
|
||||
existing.is_valid = True # type: ignore[assignment]
|
||||
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
await db.commit()
|
||||
@@ -511,7 +565,7 @@ async def gmail_oauth_callback(
|
||||
encrypted_access_token=encrypted_access,
|
||||
encrypted_refresh_token=encrypted_refresh,
|
||||
token_expiry=token_expiry,
|
||||
scopes=token_data.get("scope", "").split(),
|
||||
scopes=build_gmail_credential_scopes(token_data.get("scope", "").split()),
|
||||
is_valid=True,
|
||||
last_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
@@ -17,6 +17,12 @@ from sqlalchemy import (
|
||||
Index,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.utils.gmail_labels import (
|
||||
DEFAULT_IMPORT_LABEL_TEMPLATES,
|
||||
extract_granted_scopes,
|
||||
extract_import_label_templates,
|
||||
)
|
||||
import enum
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -557,6 +563,18 @@ class GmailCredential(Base):
|
||||
# Relationships
|
||||
user = relationship("User", backref="gmail_credential")
|
||||
|
||||
@property
|
||||
def granted_scopes(self) -> list[str]:
|
||||
return extract_granted_scopes(self.scopes)
|
||||
|
||||
@property
|
||||
def import_label_templates(self) -> list[str]:
|
||||
return extract_import_label_templates(self.scopes)
|
||||
|
||||
@property
|
||||
def default_import_label_templates(self) -> list[str]:
|
||||
return DEFAULT_IMPORT_LABEL_TEMPLATES.copy()
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
"""
|
||||
|
||||
@@ -326,6 +326,8 @@ class GmailCredentialResponse(BaseModel):
|
||||
user_id: int
|
||||
gmail_email: str
|
||||
is_valid: bool
|
||||
import_label_templates: List[str] = Field(default_factory=list)
|
||||
default_import_label_templates: List[str] = Field(default_factory=list)
|
||||
last_verified_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -380,6 +382,10 @@ class GmailCallbackRequest(BaseModel):
|
||||
redirect_uri: str
|
||||
|
||||
|
||||
class GmailImportLabelsUpdate(BaseModel):
|
||||
import_label_templates: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# Admin Schemas
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.core.metrics import (
|
||||
GMAIL_API_DURATION_SECONDS,
|
||||
GMAIL_TOKEN_REFRESHES_TOTAL,
|
||||
)
|
||||
from app.utils.gmail_labels import render_import_labels
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -294,6 +295,7 @@ class GmailService:
|
||||
async def inject_debug_email(
|
||||
self,
|
||||
recipient_email: str,
|
||||
import_label_templates: Optional[list[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Inject a debug/test email into the user's Gmail inbox.
|
||||
@@ -344,11 +346,10 @@ class GmailService:
|
||||
|
||||
raw_bytes = msg.as_bytes()
|
||||
|
||||
# Resolve label IDs (create labels if they don't exist yet)
|
||||
label_ids = await self.build_import_label_ids(import_label_templates)
|
||||
test_label_id = await self.get_or_create_label("test")
|
||||
imported_label_id = await self.get_or_create_label("imported")
|
||||
|
||||
label_ids = ["INBOX", test_label_id, imported_label_id]
|
||||
if test_label_id not in label_ids:
|
||||
label_ids.append(test_label_id)
|
||||
|
||||
return await self.inject_email(
|
||||
raw_email=raw_bytes,
|
||||
@@ -356,6 +357,23 @@ class GmailService:
|
||||
source_account_name="debug",
|
||||
)
|
||||
|
||||
async def build_import_label_ids(
|
||||
self,
|
||||
import_label_templates: Optional[list[str]] = None,
|
||||
source_email: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
"""Resolve configured import labels into Gmail label IDs."""
|
||||
label_ids = ["INBOX"]
|
||||
|
||||
for label_name in render_import_labels(import_label_templates, source_email):
|
||||
if label_name.upper() == "INBOX":
|
||||
continue
|
||||
label_id = await self.get_or_create_label(label_name)
|
||||
if label_id not in label_ids:
|
||||
label_ids.append(label_id)
|
||||
|
||||
return label_ids
|
||||
|
||||
def get_refreshed_token(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Return the current access token and expiry if the token was refreshed
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Helpers for Gmail import label configuration and rendering."""
|
||||
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
SOURCE_EMAIL_LABEL_TEMPLATE = "{{source_email}}"
|
||||
DEFAULT_IMPORT_LABEL_TEMPLATES = [SOURCE_EMAIL_LABEL_TEMPLATE, "imported"]
|
||||
MAX_IMPORT_LABELS = 10
|
||||
|
||||
|
||||
def _normalize_string_list(values: Optional[Iterable[str]]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for value in values or []:
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
continue
|
||||
lowered = cleaned.casefold()
|
||||
if lowered in seen:
|
||||
continue
|
||||
seen.add(lowered)
|
||||
normalized.append(cleaned)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_import_label_templates(
|
||||
label_templates: Optional[Iterable[str]],
|
||||
) -> list[str]:
|
||||
"""Return a cleaned, de-duplicated label template list."""
|
||||
normalized = _normalize_string_list(label_templates)
|
||||
return normalized or DEFAULT_IMPORT_LABEL_TEMPLATES.copy()
|
||||
|
||||
|
||||
def extract_granted_scopes(scopes_data: Any) -> list[str]:
|
||||
"""Read granted scopes from legacy list or new JSON object storage."""
|
||||
if isinstance(scopes_data, list):
|
||||
return _normalize_string_list(
|
||||
value for value in scopes_data if isinstance(value, str)
|
||||
)
|
||||
|
||||
if isinstance(scopes_data, dict):
|
||||
granted_scopes = scopes_data.get("granted_scopes", [])
|
||||
if isinstance(granted_scopes, list):
|
||||
return _normalize_string_list(
|
||||
value for value in granted_scopes if isinstance(value, str)
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def extract_import_label_templates(scopes_data: Any) -> list[str]:
|
||||
"""Read import label templates from stored Gmail credential metadata."""
|
||||
if isinstance(scopes_data, dict):
|
||||
stored_templates = scopes_data.get("import_label_templates", [])
|
||||
if isinstance(stored_templates, list):
|
||||
return normalize_import_label_templates(
|
||||
value for value in stored_templates if isinstance(value, str)
|
||||
)
|
||||
|
||||
return DEFAULT_IMPORT_LABEL_TEMPLATES.copy()
|
||||
|
||||
|
||||
def build_gmail_credential_scopes(
|
||||
granted_scopes: Optional[Iterable[str]],
|
||||
import_label_templates: Optional[Iterable[str]] = None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""Persist Gmail metadata in the existing JSON column."""
|
||||
return {
|
||||
"granted_scopes": _normalize_string_list(granted_scopes),
|
||||
"import_label_templates": normalize_import_label_templates(
|
||||
import_label_templates
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def render_import_labels(
|
||||
import_label_templates: Optional[Iterable[str]],
|
||||
source_email: Optional[str],
|
||||
) -> list[str]:
|
||||
"""Render label templates into actual Gmail label names."""
|
||||
rendered_labels: list[str] = []
|
||||
seen: set[str] = set()
|
||||
resolved_source_email = source_email.strip() if source_email else ""
|
||||
|
||||
for template in normalize_import_label_templates(import_label_templates):
|
||||
rendered = template.replace(SOURCE_EMAIL_LABEL_TEMPLATE, resolved_source_email)
|
||||
rendered = rendered.strip()
|
||||
if not rendered:
|
||||
continue
|
||||
lowered = rendered.casefold()
|
||||
if lowered in seen:
|
||||
continue
|
||||
seen.add(lowered)
|
||||
rendered_labels.append(rendered)
|
||||
|
||||
return rendered_labels
|
||||
@@ -202,9 +202,17 @@ async def process_mail_account(account_id: int):
|
||||
try:
|
||||
if use_gmail_api and gmail_service:
|
||||
# Inject via Gmail API (preferred)
|
||||
label_ids = await gmail_service.build_import_label_ids(
|
||||
import_label_templates=(
|
||||
gmail_cred.import_label_templates
|
||||
if gmail_cred
|
||||
else None
|
||||
),
|
||||
source_email=account.email_address, # type: ignore[arg-type]
|
||||
)
|
||||
await gmail_service.inject_email(
|
||||
raw_email=email_data,
|
||||
label_ids=["INBOX"],
|
||||
label_ids=label_ids,
|
||||
source_account_name=account.name, # type: ignore[arg-type]
|
||||
)
|
||||
emails_forwarded += 1
|
||||
|
||||
@@ -3,8 +3,16 @@ Unit tests for Gmail service module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES
|
||||
from app.utils.gmail_labels import (
|
||||
DEFAULT_IMPORT_LABEL_TEMPLATES,
|
||||
SOURCE_EMAIL_LABEL_TEMPLATE,
|
||||
build_gmail_credential_scopes,
|
||||
extract_granted_scopes,
|
||||
extract_import_label_templates,
|
||||
render_import_labels,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailService:
|
||||
@@ -153,3 +161,44 @@ class TestGmailService:
|
||||
|
||||
email = await service.get_email_address()
|
||||
assert email is None
|
||||
|
||||
def test_gmail_label_metadata_helpers(self):
|
||||
"""Test Gmail metadata extraction remains backward compatible."""
|
||||
scopes = build_gmail_credential_scopes(
|
||||
["scope-a", "scope-b"],
|
||||
[SOURCE_EMAIL_LABEL_TEMPLATE, "Imported", " imported "],
|
||||
)
|
||||
|
||||
assert extract_granted_scopes(scopes) == ["scope-a", "scope-b"]
|
||||
assert extract_import_label_templates(scopes) == [
|
||||
SOURCE_EMAIL_LABEL_TEMPLATE,
|
||||
"Imported",
|
||||
]
|
||||
assert (
|
||||
extract_import_label_templates(["legacy-scope"])
|
||||
== DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
)
|
||||
|
||||
def test_render_import_labels_uses_source_email_template(self):
|
||||
"""Test that source email templates render to the source mailbox address."""
|
||||
rendered = render_import_labels(
|
||||
[SOURCE_EMAIL_LABEL_TEMPLATE, "Imported", ""],
|
||||
"source@example.com",
|
||||
)
|
||||
|
||||
assert rendered == ["source@example.com", "Imported"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_import_label_ids_creates_configured_labels(self):
|
||||
"""Test that configured import labels are created and added alongside INBOX."""
|
||||
service = GmailService(access_token="test-access-token")
|
||||
service.get_or_create_label = AsyncMock(
|
||||
side_effect=["Label-source", "Label-imported"]
|
||||
) # type: ignore[method-assign]
|
||||
|
||||
label_ids = await service.build_import_label_ids(
|
||||
import_label_templates=[SOURCE_EMAIL_LABEL_TEMPLATE, "imported"],
|
||||
source_email="source@example.com",
|
||||
)
|
||||
|
||||
assert label_ids == ["INBOX", "Label-source", "Label-imported"]
|
||||
|
||||
@@ -193,6 +193,7 @@ Comprehensive task breakdown for repository improvements and production readines
|
||||
- [x] Account enable/disable toggle (UX + backend)
|
||||
- [x] Per-user SMTP configuration (UX + backend)
|
||||
- [x] Gmail API one-click OAuth grant flow with token refresh and revocation handling
|
||||
- [x] Configurable Gmail import labels (default `{{source_email}}` + `imported`, editable in Settings with reset-to-default action)
|
||||
- [x] Unified Google OAuth flow: sign-in requests all Gmail scopes; single `/auth/callback` redirect URI needed in Google Console
|
||||
- [x] Message deduplication (POP3 UIDL + IMAP \Seen flag + DB tracking)
|
||||
- [x] **Debug email**: "Send Debug Email" button in Settings injects a test message (from christian@docuelevate.org, dated today, labelled `test` + `imported`, placed in inbox) to verify end-to-end Gmail API delivery
|
||||
|
||||
@@ -16,8 +16,112 @@ import {
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Bug,
|
||||
RotateCcw,
|
||||
Tags,
|
||||
} from 'lucide-react';
|
||||
|
||||
const DEFAULT_GMAIL_IMPORT_LABEL_TEMPLATES = ['{{source_email}}', 'imported'];
|
||||
|
||||
function parseImportLabelTemplates(input: string): string[] {
|
||||
return input
|
||||
.split('\n')
|
||||
.map((value) => value.trim())
|
||||
.filter((value, index, values) => value.length > 0 && values.indexOf(value) === index);
|
||||
}
|
||||
|
||||
function GmailImportLabelsForm({
|
||||
gmailCredential,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: {
|
||||
gmailCredential: {
|
||||
gmail_email: string;
|
||||
import_label_templates: string[];
|
||||
default_import_label_templates: string[];
|
||||
};
|
||||
onSave: (labels: string[]) => void;
|
||||
isSaving: boolean;
|
||||
}) {
|
||||
const [labelsInput, setLabelsInput] = useState(
|
||||
gmailCredential.import_label_templates.join('\n')
|
||||
);
|
||||
|
||||
const defaultTemplates =
|
||||
gmailCredential.default_import_label_templates.length > 0
|
||||
? gmailCredential.default_import_label_templates
|
||||
: DEFAULT_GMAIL_IMPORT_LABEL_TEMPLATES;
|
||||
const parsedLabels = parseImportLabelTemplates(labelsInput);
|
||||
const isDefaultSelection =
|
||||
parsedLabels.length === defaultTemplates.length &&
|
||||
parsedLabels.every((value, index) => value === defaultTemplates[index]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Tags className="h-4 w-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">Import labels</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
One label is created per line. We recommend keeping{' '}
|
||||
<code className="rounded bg-white px-1 py-0.5 text-xs text-gray-700">
|
||||
{'{{source_email}}'}
|
||||
</code>{' '}
|
||||
so each imported message is tagged with the mailbox it came from, plus a
|
||||
catch-all label like <strong>imported</strong>.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Example: a mail pulled from <strong>billing@example.com</strong> will be
|
||||
labeled as <strong>billing@example.com</strong> when{' '}
|
||||
<code className="rounded bg-white px-1 py-0.5 text-xs text-gray-700">
|
||||
{'{{source_email}}'}
|
||||
</code>{' '}
|
||||
is present.
|
||||
</p>
|
||||
<textarea
|
||||
value={labelsInput}
|
||||
onChange={(e) => setLabelsInput(e.target.value)}
|
||||
rows={4}
|
||||
className="mt-4 w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={`{{source_email}}\nimported`}
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
<span>Suggested defaults:</span>
|
||||
{defaultTemplates.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="rounded-full border border-gray-200 bg-white px-2 py-1 text-gray-700"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(parsedLabels)}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Save label setup
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLabelsInput(defaultTemplates.join('\n'))}
|
||||
disabled={isSaving || isDefaultSelection}
|
||||
className="flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm text-gray-700 ring-1 ring-gray-300 transition-colors hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset defaults
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-gray-500">
|
||||
Connected Gmail target: <strong>{gmailCredential.gmail_email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<AuthGuard>
|
||||
@@ -106,6 +210,7 @@ function SettingsContent() {
|
||||
});
|
||||
|
||||
const [debugEmailResult, setDebugEmailResult] = useState<string | null>(null);
|
||||
const [gmailLabelsSaved, setGmailLabelsSaved] = useState(false);
|
||||
const sendDebugEmailMutation = useMutation({
|
||||
mutationFn: gmailApi.sendDebugEmail,
|
||||
onSuccess: () => {
|
||||
@@ -118,6 +223,15 @@ function SettingsContent() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateGmailLabelsMutation = useMutation({
|
||||
mutationFn: gmailApi.updateImportLabels,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['gmail-credential'] });
|
||||
setGmailLabelsSaved(true);
|
||||
setTimeout(() => setGmailLabelsSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
const saveSmtpMutation = useMutation({
|
||||
mutationFn: smtpApi.save,
|
||||
onSuccess: () => {
|
||||
@@ -362,7 +476,9 @@ function SettingsContent() {
|
||||
{debugEmailResult === 'success' && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
Debug email injected successfully. Check your Gmail inbox — it should be labelled <strong className="mx-1">test</strong> and <strong className="mx-1">imported</strong>.
|
||||
Debug email injected successfully. Check your Gmail inbox for the
|
||||
configured import labels plus a <strong className="mx-1">test</strong>{' '}
|
||||
label.
|
||||
</div>
|
||||
)}
|
||||
{debugEmailResult === 'error' && (
|
||||
@@ -371,6 +487,18 @@ function SettingsContent() {
|
||||
Failed to inject debug email. Check that Gmail API access is still valid.
|
||||
</div>
|
||||
)}
|
||||
<GmailImportLabelsForm
|
||||
key={gmailCredential.updated_at}
|
||||
gmailCredential={gmailCredential}
|
||||
isSaving={updateGmailLabelsMutation.isPending}
|
||||
onSave={(labels) => updateGmailLabelsMutation.mutate(labels)}
|
||||
/>
|
||||
{gmailLabelsSaved && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
Gmail import labels saved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -589,4 +717,3 @@ function SettingsContent() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ export interface GmailCredential {
|
||||
user_id: number;
|
||||
gmail_email: string;
|
||||
is_valid: boolean;
|
||||
import_label_templates: string[];
|
||||
default_import_label_templates: string[];
|
||||
last_verified_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -336,6 +338,14 @@ export const gmailApi = {
|
||||
const response = await api.post<GmailDebugEmailResponse>('/providers/gmail/debug-email');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** Update the labels applied to imported Gmail messages. */
|
||||
async updateImportLabels(importLabelTemplates: string[]): Promise<GmailCredential> {
|
||||
const response = await api.put<GmailCredential>('/providers/gmail-credential/labels', {
|
||||
import_label_templates: importLabelTemplates,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ── SMTP Config API ─────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user