Merge branch 'main' into copilot/add-adr-003-through-adr-010

This commit is contained in:
Christian Krakau-Louis
2026-03-25 13:08:44 +01:00
committed by GitHub
15 changed files with 1230 additions and 61 deletions
+11
View File
@@ -34,6 +34,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Architecture Decision Records ADR-003 through ADR-010**: Added eight new ADRs covering FastAPI web framework (ADR-003), PostgreSQL database (ADR-004), Celery task retry strategy (ADR-005), key management in production (ADR-006), JWT authentication (ADR-007), Next.js frontend (ADR-008), Gmail API email delivery (ADR-009), and hybrid configuration model (ADR-010)
- `userApi.updateProfile()` method in `frontend/src/lib/api.ts` for updating user profile via `PUT /users/me`
- **Account enable/disable toggle**: `PATCH /mail-accounts/{id}/toggle` backend endpoint and a Power-icon toggle button on each account card in the UI. Disabled accounts are visually dimmed. Re-enabling an account that was in ERROR state resets its status to ACTIVE so the scheduler picks it up again.
- **`is_enabled` checkbox in edit modal**: The Add/Edit mail account form now includes an "Enabled" checkbox so the flag can be set when creating or editing an account.
- **Message deduplication tracking** (`DownloadedMessageId` table): Both POP3 and IMAP fetch paths now track downloaded message UIDs so the same message is never delivered twice, even when `delete_after_forward=False`.
- IMAP: messages are marked `\Seen` after fetching so they don't appear in future `UNSEEN` searches. DB UIDs provide a secondary guard.
- POP3: UIDL-based deduplication; messages are skipped if their UID is already in the DB.
- Old UID records are pruned by `cleanup_old_logs` after `days_to_keep` days.
- **Gmail API "one-click" OAuth grant flow**: New `GET /providers/gmail/authorize-url` and `POST /providers/gmail/callback` endpoints. The flow requests `gmail.insert + gmail.labels` scopes with `access_type=offline` so a long-lived refresh token is issued. A new `/auth/gmail-callback` frontend page handles the redirect from Google, exchanges the code, and redirects the user back to Settings.
- **Gmail token auto-refresh and persistence**: `GmailService` now records whether the `google-auth` library refreshed the access token during a Celery run. If it did, the Celery task writes the new access token and expiry back to `GmailCredential`, eliminating an unnecessary extra refresh call on the next run. A `401/403` or `invalid_grant` error during delivery marks `GmailCredential.is_valid = False` so the user is prompted to re-authorise.
- **Per-user SMTP relay configuration** (`UserSmtpConfig` table): New `GET/PUT/DELETE /users/smtp-config` endpoints let each user store their own SMTP relay (host, port, username, password, TLS flag). The Celery task checks for per-user SMTP first; falls back to the global `AppSetting` SMTP config if none is set.
- **Settings page Gmail & SMTP sections**: The Settings page now shows a "Gmail API Delivery" card with connection status, "Connect Gmail" / "Re-authorise" / "Disconnect" buttons, and a token-lifetime explanation. An "SMTP Fallback" card lets users save their own SMTP relay credentials.
- **Celery scheduling fix**: `process_all_enabled_accounts` previously only polled accounts with `status IN [ACTIVE, TESTING]`, causing ERROR-status accounts to be silently skipped forever. It now polls all `is_enabled = True` accounts regardless of status, so transient errors are retried automatically.
- **Backend URL logged at startup**: The Next.js server now logs the resolved `BACKEND_URL` (e.g. `[proxy] BACKEND_URL = http://backend:8000`) via `src/instrumentation.ts` when the server starts, making it easy to diagnose `ECONNREFUSED` proxy errors. The per-request error log now also includes the full target URL.
- **Dual-registry Docker deployment**: CI now builds separate backend and frontend images and pushes to both GHCR (`ghcr.io`) and private registry (`registry.cklnet.com`) using a matrix strategy
- **Database-backed configuration**: `AppSetting` model and `ConfigService` for hybrid config (DB-first, env-var fallback)
+33 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.core.security import encrypt_credential
from app.models.database_models import User, MailAccount
from app.models.database_models import User, MailAccount, AccountStatus
from app.models.schemas import (
MailAccountCreate,
MailAccountResponse,
@@ -182,6 +182,38 @@ async def delete_mail_account(
await db.commit()
@router.patch("/{account_id}/toggle", response_model=MailAccountResponse)
async def toggle_mail_account(
account_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Toggle the enabled/disabled state of a mail account"""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id, MailAccount.user_id == current_user.id
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
account.is_enabled = not account.is_enabled # type: ignore[assignment]
# When re-enabling a previously errored account, reset status to ACTIVE
# so the scheduler picks it up on the next run.
if account.is_enabled and account.status == AccountStatus.ERROR:
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
await db.commit()
await db.refresh(account)
return account
@router.post("/test", response_model=MailAccountTestResponse)
async def test_mail_connection(
test_request: MailAccountTestRequest,
+178 -2
View File
@@ -1,10 +1,12 @@
"""Provider presets and Gmail credential management endpoints"""
from datetime import datetime, timezone
from typing import List
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import httpx
import logging
from app.core.database import get_db
from app.core.deps import get_current_active_user
@@ -16,10 +18,21 @@ from app.models.schemas import (
ProviderListResponse,
GmailCredentialCreate,
GmailCredentialResponse,
GmailAuthorizeResponse,
GmailCallbackRequest,
)
from app.services.gmail_service import GmailService
router = APIRouter()
logger = logging.getLogger(__name__)
# Gmail API scopes needed for email injection
GMAIL_API_SCOPES = [
"openid",
"email",
"https://www.googleapis.com/auth/gmail.insert",
"https://www.googleapis.com/auth/gmail.labels",
]
# Provider presets with server configurations
PROVIDER_PRESETS: List[ProviderPreset] = [
@@ -268,3 +281,166 @@ async def delete_gmail_credential(
await db.delete(credential)
await db.commit()
@router.get("/gmail/authorize-url", response_model=GmailAuthorizeResponse)
async def get_gmail_authorize_url(
redirect_uri: str,
current_user: User = Depends(get_current_active_user),
):
"""
Return a Google OAuth2 URL that grants this app Gmail API write permissions.
The URL requests the gmail.insert + gmail.labels scopes with
access_type=offline so a long-lived refresh token is issued.
prompt=consent forces Google to always issue a new refresh token even if
the user has authorised the app before.
"""
if not settings.GOOGLE_CLIENT_ID:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Google OAuth2 is not configured on this server.",
)
scope = " ".join(GMAIL_API_SCOPES)
url = (
"https://accounts.google.com/o/oauth2/v2/auth"
f"?client_id={settings.GOOGLE_CLIENT_ID}"
"&response_type=code"
f"&scope={scope}"
f"&redirect_uri={redirect_uri}"
"&access_type=offline"
"&prompt=consent"
)
return GmailAuthorizeResponse(authorization_url=url)
@router.post(
"/gmail/callback",
response_model=GmailCredentialResponse,
status_code=status.HTTP_201_CREATED,
)
async def gmail_oauth_callback(
callback_in: GmailCallbackRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""
Exchange a Google authorization code for Gmail API tokens and persist them.
This is the backend half of the "Connect Gmail" one-click flow. The
frontend redirects the user to Google, Google sends a code back to the
frontend callback page, and the frontend posts that code here.
Token lifetime
--------------
* Access token : 1 hour. The google-auth library auto-refreshes it
during Celery tasks; the new value is written back to this row so the
next run doesn't need an extra refresh round-trip.
* Refresh token : does not expire unless the user revokes access or the
app credentials change. If revoked, ``is_valid`` is set to False by
the next Celery run that hits a 401, and the user must re-authorise.
"""
if not settings.GOOGLE_CLIENT_ID or not settings.GOOGLE_CLIENT_SECRET:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Google OAuth2 is not configured on this server.",
)
# Exchange code for tokens
async with httpx.AsyncClient() as client:
token_resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"code": callback_in.code,
"client_id": settings.GOOGLE_CLIENT_ID,
"client_secret": settings.GOOGLE_CLIENT_SECRET,
"redirect_uri": callback_in.redirect_uri,
"grant_type": "authorization_code",
},
)
if token_resp.status_code != 200:
logger.error(f"Gmail token exchange failed: {token_resp.text}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code with Google.",
)
token_data = token_resp.json()
access_token: Optional[str] = token_data.get("access_token")
refresh_token: Optional[str] = token_data.get("refresh_token")
if not access_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Google did not return an access token.",
)
# Fetch the Gmail email address to associate with this credential
async with httpx.AsyncClient() as client:
profile_resp = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
gmail_email = current_user.email # fallback
if profile_resp.status_code == 200:
gmail_email = profile_resp.json().get("email", current_user.email)
# Calculate token expiry (Google access tokens last 1 hour)
token_expiry = datetime.now(timezone.utc) + timedelta(
seconds=int(token_data.get("expires_in", 3600))
)
# Verify the credentials actually work with the Gmail API
gmail_service = GmailService(
access_token=access_token,
refresh_token=refresh_token,
client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET,
)
is_valid = await gmail_service.verify_access()
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Obtained tokens but could not verify Gmail API access. "
"Ensure the gmail.insert scope was granted.",
)
# Persist tokens
result = await db.execute(
select(GmailCredential).where(GmailCredential.user_id == current_user.id)
)
existing = result.scalar_one_or_none()
encrypted_access = encrypt_credential(access_token)
encrypted_refresh = encrypt_credential(refresh_token) if refresh_token else None
if existing:
existing.gmail_email = gmail_email # type: ignore[assignment]
existing.encrypted_access_token = encrypted_access # type: ignore[assignment]
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.is_valid = True # type: ignore[assignment]
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
await db.commit()
await db.refresh(existing)
return existing
else:
credential = GmailCredential(
user_id=current_user.id,
gmail_email=gmail_email,
encrypted_access_token=encrypted_access,
encrypted_refresh_token=encrypted_refresh,
token_expiry=token_expiry,
scopes=token_data.get("scope", "").split(),
is_valid=True,
last_verified_at=datetime.now(timezone.utc),
)
db.add(credential)
await db.commit()
await db.refresh(credential)
return credential
+103 -3
View File
@@ -1,12 +1,19 @@
"""User management endpoints"""
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import User
from app.models.schemas import UserDetailResponse, UserUpdate
from app.core.security import encrypt_credential
from app.models.database_models import User, UserSmtpConfig
from app.models.schemas import (
UserDetailResponse,
UserUpdate,
UserSmtpConfigUpdate,
UserSmtpConfigResponse,
)
router = APIRouter()
@@ -34,3 +41,96 @@ async def update_current_user_profile(
await db.commit()
await db.refresh(current_user)
return current_user
@router.get("/smtp-config", response_model=UserSmtpConfigResponse)
async def get_smtp_config(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Get the current user's SMTP relay configuration"""
result = await db.execute(
select(UserSmtpConfig).where(UserSmtpConfig.user_id == current_user.id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No SMTP configuration found. Save one first.",
)
return UserSmtpConfigResponse(
id=config.id, # type: ignore[arg-type]
user_id=config.user_id, # type: ignore[arg-type]
host=config.host, # type: ignore[arg-type]
port=config.port, # type: ignore[arg-type]
username=config.username, # type: ignore[arg-type]
use_tls=config.use_tls, # type: ignore[arg-type]
has_password=bool(config.encrypted_password),
created_at=config.created_at, # type: ignore[arg-type]
updated_at=config.updated_at, # type: ignore[arg-type]
)
@router.put("/smtp-config", response_model=UserSmtpConfigResponse)
async def upsert_smtp_config(
config_in: UserSmtpConfigUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Create or update the current user's SMTP relay configuration"""
result = await db.execute(
select(UserSmtpConfig).where(UserSmtpConfig.user_id == current_user.id)
)
config = result.scalar_one_or_none()
if config:
config.host = config_in.host # type: ignore[assignment]
config.port = config_in.port # type: ignore[assignment]
config.username = config_in.username # type: ignore[assignment]
config.use_tls = config_in.use_tls # type: ignore[assignment]
if config_in.password is not None:
config.encrypted_password = encrypt_credential(config_in.password) # type: ignore[assignment]
else:
config = UserSmtpConfig(
user_id=current_user.id,
host=config_in.host,
port=config_in.port,
username=config_in.username,
encrypted_password=(
encrypt_credential(config_in.password) if config_in.password else ""
),
use_tls=config_in.use_tls,
)
db.add(config)
await db.commit()
await db.refresh(config)
return UserSmtpConfigResponse(
id=config.id, # type: ignore[arg-type]
user_id=config.user_id, # type: ignore[arg-type]
host=config.host, # type: ignore[arg-type]
port=config.port, # type: ignore[arg-type]
username=config.username, # type: ignore[arg-type]
use_tls=config.use_tls, # type: ignore[arg-type]
has_password=bool(config.encrypted_password),
created_at=config.created_at, # type: ignore[arg-type]
updated_at=config.updated_at, # type: ignore[arg-type]
)
@router.delete("/smtp-config", status_code=status.HTTP_204_NO_CONTENT)
async def delete_smtp_config(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Delete the current user's SMTP relay configuration"""
result = await db.execute(
select(UserSmtpConfig).where(UserSmtpConfig.user_id == current_user.id)
)
config = result.scalar_one_or_none()
if config:
await db.delete(config)
await db.commit()
+70
View File
@@ -446,6 +446,76 @@ class AuditLog(Base):
)
class UserSmtpConfig(Base):
"""Per-user SMTP relay configuration for email forwarding fallback."""
__tablename__ = "user_smtp_configs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True
)
host = Column(String(255), nullable=False, default="smtp.gmail.com")
port = Column(Integer, nullable=False, default=587)
username = Column(String(255), nullable=False, default="")
encrypted_password = Column(Text, nullable=False, default="")
use_tls = Column(Boolean, default=True)
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
# Relationships
user = relationship("User", backref="smtp_config")
class DownloadedMessageId(Base):
"""
Tracks unique message IDs that have already been downloaded and forwarded.
- For POP3: stores the UIDL string returned by the server.
- For IMAP: stores the IMAP UID (numeric string) of the message.
This prevents re-processing the same message when delete_after_forward=False.
"""
__tablename__ = "downloaded_message_ids"
id = Column(Integer, primary_key=True, index=True)
mail_account_id = Column(
Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False
)
# Unique message identifier (UIDL for POP3, UID for IMAP)
message_uid = Column(String(512), nullable=False)
downloaded_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
# Indexes — unique constraint prevents duplicates
__table_args__ = (
Index(
"idx_account_message_uid",
"mail_account_id",
"message_uid",
unique=True,
),
Index("idx_downloaded_at", "downloaded_at"),
)
class GmailCredential(Base):
"""Stores OAuth2 credentials for Gmail API access (per-user)"""
+32
View File
@@ -339,3 +339,35 @@ class ProviderPreset(BaseModel):
class ProviderListResponse(BaseModel):
providers: List[ProviderPreset]
# User SMTP Config Schemas
class UserSmtpConfigBase(BaseModel):
host: str = "smtp.gmail.com"
port: int = Field(587, gt=0, lt=65536)
username: str = ""
use_tls: bool = True
class UserSmtpConfigUpdate(UserSmtpConfigBase):
password: Optional[str] = None # Only provided when changing the password
class UserSmtpConfigResponse(UserSmtpConfigBase):
id: int
user_id: int
has_password: bool # True if a password is stored (value is never returned)
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Gmail OAuth Schemas
class GmailAuthorizeResponse(BaseModel):
authorization_url: str
class GmailCallbackRequest(BaseModel):
code: str
redirect_uri: str
+23
View File
@@ -56,6 +56,7 @@ class GmailService:
client_id: Google OAuth2 client ID
client_secret: Google OAuth2 client secret
"""
self._initial_access_token = access_token
self.credentials = Credentials(
token=access_token,
refresh_token=refresh_token,
@@ -135,6 +136,7 @@ class GmailService:
f"Gmail API error: {e.reason if hasattr(e, 'reason') else str(e)}"
)
logger.error(error_msg)
# Surface 401 so callers can mark credentials as invalid
raise GmailInjectionError(error_msg)
except Exception as e:
error_msg = f"Failed to inject email into Gmail: {str(e)}"
@@ -180,3 +182,24 @@ class GmailService:
except Exception as e:
logger.error(f"Failed to get Gmail email address: {e}")
return None
def get_refreshed_token(self) -> Optional[Dict[str, Any]]:
"""
Return the current access token and expiry if the token was refreshed
since this service instance was created.
The google-auth library auto-refreshes the access token when an API
call is made with an expired token. Call this after inject_email() to
check whether a refresh happened and persist the new token.
Returns:
Dict with ``access_token`` and ``expiry`` (datetime | None), or
None if the token has not changed from the one passed to __init__.
"""
current_token = self.credentials.token
if current_token and current_token != self._initial_access_token:
return {
"access_token": current_token,
"expiry": self.credentials.expiry,
}
return None
+87 -30
View File
@@ -11,7 +11,7 @@ from email import parser
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.utils import formatdate, make_msgid
from typing import List, Dict, Any, Optional, Tuple
from typing import List, Dict, Any, Optional, Set, Tuple
import logging
from aioimaplib import aioimaplib
@@ -143,26 +143,42 @@ class MailProcessor:
except Exception as e:
return False, f"IMAP connection failed: {str(e)}"
async def fetch_emails(self, max_count: Optional[int] = None) -> List[bytes]:
async def fetch_emails(
self,
max_count: Optional[int] = None,
already_seen_uids: Optional[Set[str]] = None,
) -> Tuple[List[bytes], List[str]]:
"""
Fetch emails from the mail server.
Returns list of raw email data.
Args:
max_count: Maximum number of messages to fetch.
already_seen_uids: Set of message UIDs that have already been
processed and should be skipped.
Returns:
A tuple of (raw_email_bytes_list, new_uid_strings_list).
The caller should persist the new UIDs to prevent re-processing.
"""
effective_max: int = max_count if max_count is not None else self.account.max_emails_per_check # type: ignore[assignment]
seen: Set[str] = already_seen_uids or set()
if self.account.protocol in [MailProtocol.POP3, MailProtocol.POP3_SSL]:
return await self._fetch_pop3_emails(effective_max)
return await self._fetch_pop3_emails(effective_max, seen)
else:
return await self._fetch_imap_emails(effective_max)
return await self._fetch_imap_emails(effective_max, seen)
async def _fetch_pop3_emails(self, max_count: int) -> List[bytes]:
"""Fetch emails via POP3"""
emails = []
async def _fetch_pop3_emails(
self, max_count: int, already_seen_uids: Set[str]
) -> Tuple[List[bytes], List[str]]:
"""Fetch emails via POP3, skipping already-downloaded UIDs."""
emails: List[bytes] = []
new_uids: List[str] = []
try:
loop = asyncio.get_event_loop()
def fetch_pop3():
def fetch_pop3() -> Tuple[List[bytes], List[str]]:
# Connect
if self.account.protocol == MailProtocol.POP3_SSL:
context = ssl.create_default_context()
@@ -181,27 +197,49 @@ class MailProcessor:
pop_conn.user(self.account.username)
pop_conn.pass_(self.password)
# Get message count
num_messages = len(pop_conn.list()[1])
# Retrieve UIDL map: {msg_number: uid_string}
uidl_response = pop_conn.uidl()
uid_map: Dict[int, str] = {}
for entry in uidl_response[1]:
parts = entry.decode().split(" ", 1)
if len(parts) == 2:
uid_map[int(parts[0])] = parts[1].strip()
num_messages = len(uid_map)
logger.info(
f"Found {num_messages} messages for account {self.account.id}"
)
fetched_emails = []
messages_to_delete = []
fetched: List[bytes] = []
fetched_uids: List[str] = []
messages_to_delete: List[int] = []
fetched_count = 0
for msg_num, uid in uid_map.items():
if fetched_count >= max_count:
break
# Skip messages we already processed
if uid in already_seen_uids:
logger.debug(
f"Skipping already-downloaded message {uid} "
f"for account {self.account.id}"
)
continue
# Fetch emails (limited by max_count)
for i in range(1, min(num_messages + 1, max_count + 1)):
try:
response, lines, octets = pop_conn.retr(i)
response, lines, octets = pop_conn.retr(msg_num)
email_data = b"\r\n".join(lines)
fetched_emails.append(email_data)
messages_to_delete.append(i)
fetched.append(email_data)
fetched_uids.append(uid)
messages_to_delete.append(msg_num)
fetched_count += 1
logger.info(
f"Retrieved message {i} from account {self.account.id}"
f"Retrieved message {msg_num} (uid={uid}) "
f"from account {self.account.id}"
)
except Exception as e:
logger.error(f"Error retrieving message {i}: {e}")
logger.error(f"Error retrieving message {msg_num}: {e}")
# Delete messages if configured
if self.account.delete_after_forward:
@@ -212,19 +250,22 @@ class MailProcessor:
logger.error(f"Error deleting message {msg_id}: {e}")
pop_conn.quit()
return fetched_emails
return fetched, fetched_uids
emails = await loop.run_in_executor(None, fetch_pop3)
emails, new_uids = await loop.run_in_executor(None, fetch_pop3)
except Exception as e:
logger.error(f"Error fetching POP3 emails: {e}")
raise MailFetchError(f"POP3 fetch error: {str(e)}")
return emails
return emails, new_uids
async def _fetch_imap_emails(self, max_count: int) -> List[bytes]:
"""Fetch emails via IMAP"""
emails = []
async def _fetch_imap_emails(
self, max_count: int, already_seen_uids: Set[str]
) -> Tuple[List[bytes], List[str]]:
"""Fetch emails via IMAP, marking each message \Seen to prevent re-fetch."""
emails: List[bytes] = []
new_uids: List[str] = []
try:
# Create IMAP client
@@ -241,8 +282,8 @@ class MailProcessor:
await imap_client.login(self.account.username, self.password)
await imap_client.select("INBOX")
# Search for all messages
response = await imap_client.search("UNSEEN") # Only fetch unread
# Search for unseen messages only
response = await imap_client.search("UNSEEN")
message_ids = response.lines[0].split()
# Limit to max_count
@@ -254,6 +295,18 @@ class MailProcessor:
# Fetch each message
for msg_id in message_ids:
uid_str = msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id)
# Skip messages already tracked in our DB
if uid_str in already_seen_uids:
logger.debug(
f"Skipping already-processed IMAP message {uid_str} "
f"for account {self.account.id}"
)
# Still mark as Seen so it doesn't show up in UNSEEN searches
await imap_client.store(msg_id, "+FLAGS", "\\Seen")
continue
try:
response = await imap_client.fetch(msg_id, "(RFC822)")
@@ -272,8 +325,12 @@ class MailProcessor:
if email_data:
emails.append(email_data)
new_uids.append(uid_str)
# Always mark as Seen after fetching so the message is
# not picked up again on the next UNSEEN search.
await imap_client.store(msg_id, "+FLAGS", "\\Seen")
# Mark as seen if deleting after forward
if self.account.delete_after_forward:
await imap_client.store(msg_id, "+FLAGS", "\\Deleted")
@@ -290,7 +347,7 @@ class MailProcessor:
logger.error(f"Error fetching IMAP emails: {e}")
raise MailFetchError(f"IMAP fetch error: {str(e)}")
return emails
return emails, new_uids
@staticmethod
async def forward_email(
+99 -16
View File
@@ -9,7 +9,7 @@ import logging
from app.workers.celery_app import celery_app
from app.core.database import async_session_maker
from app.core.security import decrypt_credential
from app.core.security import decrypt_credential, encrypt_credential
from app.models.database_models import (
MailAccount,
ProcessingRun,
@@ -17,12 +17,14 @@ from app.models.database_models import (
AccountStatus,
DeliveryMethod,
GmailCredential,
DownloadedMessageId,
UserSmtpConfig,
)
from app.services.mail_processor import MailProcessor
from app.services.gmail_service import GmailService
from app.services.config_service import ConfigService
from app.core.config import settings
from sqlalchemy import select, and_
from sqlalchemy import select, delete
logger = logging.getLogger(__name__)
@@ -69,11 +71,22 @@ async def process_mail_account(account_id: int):
# Decrypt password
password = decrypt_credential(account.encrypted_password) # type: ignore[arg-type]
# Load already-downloaded UIDs to prevent re-processing
seen_result = await db.execute(
select(DownloadedMessageId.message_uid).where(
DownloadedMessageId.mail_account_id == account.id
)
)
already_seen_uids = set(seen_result.scalars().all())
# Create processor
processor = MailProcessor(account, password)
# Fetch emails
emails = await processor.fetch_emails(account.max_emails_per_check) # type: ignore[arg-type]
# Fetch emails (returns raw bytes + new UIDs)
emails, new_uids = await processor.fetch_emails(
account.max_emails_per_check, # type: ignore[arg-type]
already_seen_uids=already_seen_uids,
)
run.emails_fetched = len(emails) # type: ignore[assignment]
@@ -118,8 +131,24 @@ async def process_mail_account(account_id: int):
use_gmail_api = False # type: ignore[assignment]
if not use_gmail_api:
# Fall back to SMTP read config from DB with env fallback
smtp_config = await ConfigService.get_smtp_config(db=db)
# Fall back to SMTP check per-user config first, then global
user_smtp_result = await db.execute(
select(UserSmtpConfig).where(
UserSmtpConfig.user_id == account.user_id
)
)
user_smtp = user_smtp_result.scalar_one_or_none()
if user_smtp and user_smtp.username and user_smtp.encrypted_password:
smtp_config = {
"host": user_smtp.host,
"port": user_smtp.port,
"username": user_smtp.username,
"password": decrypt_credential(user_smtp.encrypted_password), # type: ignore[arg-type]
"use_tls": user_smtp.use_tls,
}
else:
smtp_config = await ConfigService.get_smtp_config(db=db)
if not smtp_config["username"] or not smtp_config["password"]:
logger.error(
@@ -130,7 +159,15 @@ async def process_mail_account(account_id: int):
await db.commit()
return
for email_data in emails:
successfully_forwarded_uids: list[str] = []
if len(emails) != len(new_uids):
logger.error(
f"emails/uids length mismatch ({len(emails)} vs {len(new_uids)}) "
f"for account {account.id}; truncating to shorter list"
)
for email_data, uid in zip(emails, new_uids):
try:
if use_gmail_api and gmail_service:
# Inject via Gmail API (preferred)
@@ -140,6 +177,7 @@ async def process_mail_account(account_id: int):
source_account_name=account.name, # type: ignore[arg-type]
)
emails_forwarded += 1
successfully_forwarded_uids.append(uid)
else:
# Forward via SMTP (fallback)
success = await MailProcessor.forward_email(
@@ -147,13 +185,54 @@ async def process_mail_account(account_id: int):
)
if success:
emails_forwarded += 1
successfully_forwarded_uids.append(uid)
else:
emails_failed += 1
except Exception as e:
error_str = str(e).lower()
# If Gmail returns 401/403 the refresh token was revoked
# mark credentials invalid so the user gets notified.
if (
use_gmail_api
and gmail_cred
and (
"401" in error_str
or "403" in error_str
or "invalid_grant" in error_str
)
):
gmail_cred.is_valid = False # type: ignore[assignment]
logger.warning(
f"Gmail credentials revoked for user {account.user_id}. "
"User must re-authorise."
)
logger.error(f"Error delivering email: {e}")
emails_failed += 1
# Persist new message UIDs so they are not processed again
for uid in successfully_forwarded_uids:
if uid not in already_seen_uids:
db.add(
DownloadedMessageId(
mail_account_id=account.id,
message_uid=uid,
)
)
# If Gmail API was used, persist any refreshed access token back to
# the DB so the next run doesn't need an extra token-refresh call.
if use_gmail_api and gmail_service and gmail_cred:
refreshed = gmail_service.get_refreshed_token()
if refreshed and refreshed["access_token"] != access_token:
gmail_cred.encrypted_access_token = encrypt_credential(refreshed["access_token"]) # type: ignore[assignment]
if refreshed.get("expiry"):
gmail_cred.token_expiry = refreshed["expiry"] # type: ignore[assignment]
gmail_cred.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
logger.info(
f"Persisted refreshed Gmail access token for user {account.user_id}"
)
# Update run
run.emails_forwarded = emails_forwarded # type: ignore[assignment]
run.emails_failed = emails_failed # type: ignore[assignment]
@@ -210,15 +289,11 @@ async def process_all_enabled_accounts():
"""
async with async_session_maker() as db:
try:
# Get all enabled accounts
# Fetch all enabled accounts regardless of operational status so
# that accounts in ERROR state are retried automatically.
result = await db.execute(
select(MailAccount).where(
and_(
MailAccount.is_enabled == True, # noqa: E712
MailAccount.status.in_(
[AccountStatus.ACTIVE, AccountStatus.TESTING]
),
)
MailAccount.is_enabled == True, # noqa: E712
)
)
accounts = result.scalars().all()
@@ -248,10 +323,10 @@ async def process_all_enabled_accounts():
@celery_app.task(base=AsyncTask, name="app.workers.tasks.cleanup_old_logs")
async def cleanup_old_logs(days_to_keep: int = 30):
"""
Clean up old processing logs and runs.
Clean up old processing logs, runs, and downloaded message ID records.
Args:
days_to_keep: Number of days of logs to retain
days_to_keep: Number of days of data to retain
"""
async with async_session_maker() as db:
try:
@@ -275,6 +350,14 @@ async def cleanup_old_logs(days_to_keep: int = 30):
for log in old_logs:
await db.delete(log)
# Delete old downloaded message ID records so the table doesn't
# grow unboundedly for accounts that never delete messages.
await db.execute(
delete(DownloadedMessageId).where(
DownloadedMessageId.downloaded_at < cutoff_date
)
)
await db.commit()
logger.info(
+8 -3
View File
@@ -179,6 +179,10 @@ Comprehensive task breakdown for repository improvements and production readines
### Not Started 📋
- [ ] Implement Stripe webhook handling
- [ ] Add scheduled Celery tasks for email processing
- [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] Message deduplication (POP3 UIDL + IMAP \Seen flag + DB tracking)
- [ ] Implement GDPR data export endpoint
- [ ] Complete notification service integration (Apprise)
- [ ] Add advanced email filtering
@@ -207,12 +211,13 @@ because the API client layer is missing.
- [x] Registration page
- [x] OAuth callback handler
- [x] Dashboard with stats cards and processing runs table
- [x] Mail accounts list with CRUD operations
- [x] Settings page
- [x] `AddMailAccountModal` component (auto-detect, test connection, all required fields)
- [x] Mail accounts list with CRUD operations + enable/disable toggle
- [x] Settings page — Profile, Gmail API connection, SMTP relay, Account info, Security
- [x] `AddMailAccountModal` component (auto-detect, test connection, all required fields, is_enabled checkbox)
- [x] `DashboardLayout` with responsive sidebar
- [x] `AuthGuard` for protected routes
- [x] Fix wizard grey screen (Tailwind v4 `bg-opacity``/75` syntax, modal restructure)
- [x] `/auth/gmail-callback` page for Gmail OAuth one-click flow
### Not Started 📋
- [ ] End-to-end testing of frontend against backend API
+39 -4
View File
@@ -4,7 +4,7 @@ import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { mailAccountsApi, MailAccount } from '@/lib/api';
import { Plus, Edit2, Trash2, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
import { Plus, Edit2, Trash2, CheckCircle, XCircle, AlertTriangle, Power } from 'lucide-react';
import { useState } from 'react';
import { AddMailAccountModal } from '@/components/AddMailAccountModal';
@@ -25,6 +25,13 @@ export default function AccountsPage() {
},
});
const toggleMutation = useMutation({
mutationFn: mailAccountsApi.toggle,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mail-accounts'] });
},
});
const handleEdit = (account: MailAccount) => {
setEditingAccount(account);
setIsModalOpen(true);
@@ -40,6 +47,14 @@ export default function AccountsPage() {
}
};
const handleToggle = async (id: number) => {
try {
await toggleMutation.mutateAsync(id);
} catch {
alert('Failed to update account');
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingAccount(null);
@@ -69,7 +84,9 @@ export default function AccountsPage() {
{accounts.map((account) => (
<div
key={account.id}
className="bg-white rounded-lg shadow-md border border-gray-200 overflow-hidden"
className={`bg-white rounded-lg shadow-md border overflow-hidden transition-opacity ${
account.is_enabled ? 'border-gray-200' : 'border-gray-200 opacity-60'
}`}
>
<div className="p-6">
<div className="flex items-start justify-between mb-4">
@@ -81,9 +98,15 @@ export default function AccountsPage() {
</div>
<div className="flex items-center gap-2">
{account.is_enabled ? (
<CheckCircle className="h-5 w-5 text-green-500" aria-label="Enabled" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
<CheckCircle className="h-3 w-3" />
Enabled
</span>
) : (
<XCircle className="h-5 w-5 text-gray-400" aria-label="Disabled" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">
<XCircle className="h-3 w-3" />
Disabled
</span>
)}
</div>
</div>
@@ -129,6 +152,18 @@ export default function AccountsPage() {
)}
<div className="flex items-center gap-2 pt-4 border-t border-gray-200">
<button
onClick={() => handleToggle(account.id)}
disabled={toggleMutation.isPending}
title={account.is_enabled ? 'Disable account' : 'Enable account'}
className={`flex items-center justify-center px-3 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 ${
account.is_enabled
? 'text-yellow-600 bg-yellow-50 hover:bg-yellow-100'
: 'text-green-600 bg-green-50 hover:bg-green-100'
}`}
>
<Power className="h-4 w-4" />
</button>
<button
onClick={() => handleEdit(account)}
className="flex-1 flex items-center justify-center px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors"
@@ -0,0 +1,102 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { gmailApi } from '@/lib/api';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
function GmailCallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [message, setMessage] = useState('Connecting your Gmail account…');
useEffect(() => {
async function handleCallback() {
const code = searchParams.get('code');
const error = searchParams.get('error');
if (error) {
setStatus('error');
setMessage(
error === 'access_denied'
? 'You declined the Gmail permission request. No changes were made.'
: `Google returned an error: ${error}`
);
return;
}
if (!code) {
setStatus('error');
setMessage('No authorization code received from Google.');
return;
}
const redirectUri = `${window.location.origin}/auth/gmail-callback`;
try {
await gmailApi.saveCallback(code, redirectUri);
setStatus('success');
setMessage('Gmail connected successfully! Redirecting to settings…');
setTimeout(() => router.push('/settings'), 2000);
} catch (err: unknown) {
const detail =
err instanceof Error && 'response' in err
? (err as { response?: { data?: { detail?: string } } }).response?.data
?.detail
: null;
setStatus('error');
setMessage(detail || 'Failed to connect Gmail. Please try again.');
}
}
handleCallback();
}, [searchParams, router]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white rounded-lg shadow-md p-8 max-w-md w-full text-center">
{status === 'loading' && (
<>
<Loader2 className="h-12 w-12 text-blue-500 animate-spin mx-auto mb-4" />
<p className="text-gray-700">{message}</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">Connected!</h2>
<p className="text-gray-600">{message}</p>
</>
)}
{status === 'error' && (
<>
<XCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">Connection failed</h2>
<p className="text-gray-600 mb-6">{message}</p>
<button
onClick={() => router.push('/settings')}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Back to Settings
</button>
</>
)}
</div>
</div>
);
}
export default function GmailCallbackPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
</div>
}
>
<GmailCallbackContent />
</Suspense>
);
}
+348 -2
View File
@@ -4,9 +4,18 @@ import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { userApi } from '@/lib/api';
import { userApi, gmailApi, smtpApi } from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { CheckCircle, Loader2, User, Mail, Shield } from 'lucide-react';
import {
CheckCircle,
Loader2,
User,
Mail,
Shield,
Server,
AlertTriangle,
XCircle,
} from 'lucide-react';
export default function SettingsPage() {
return (
@@ -28,6 +37,16 @@ function SettingsContent() {
});
const [profileSaved, setProfileSaved] = useState(false);
// SMTP form state
const [smtpForm, setSmtpForm] = useState({
host: 'smtp.gmail.com',
port: 587,
username: '',
password: '',
use_tls: true,
});
const [smtpSaved, setSmtpSaved] = useState(false);
// Refresh user data from the server
const { data: currentUser } = useQuery({
queryKey: ['current-user'],
@@ -35,6 +54,38 @@ function SettingsContent() {
initialData: user ?? undefined,
});
// Gmail credential status
const {
data: gmailCredential,
isLoading: gmailLoading,
error: gmailError,
} = useQuery({
queryKey: ['gmail-credential'],
queryFn: gmailApi.getCredential,
retry: false,
});
// SMTP config
const { data: smtpConfig, isLoading: smtpLoading } = useQuery({
queryKey: ['smtp-config'],
queryFn: smtpApi.get,
retry: false,
});
// Pre-populate SMTP form when data loads
const [smtpFormPopulated, setSmtpFormPopulated] = useState(false);
if (smtpConfig && !smtpFormPopulated) {
setSmtpFormPopulated(true);
setSmtpForm((prev) => ({
...prev,
host: smtpConfig.host,
port: smtpConfig.port,
username: smtpConfig.username,
use_tls: smtpConfig.use_tls,
password: '', // never pre-fill password
}));
}
const updateProfileMutation = useMutation({
mutationFn: (data: { full_name: string; email: string }) =>
userApi.updateProfile(data),
@@ -46,11 +97,45 @@ function SettingsContent() {
},
});
const disconnectGmailMutation = useMutation({
mutationFn: gmailApi.disconnect,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gmail-credential'] });
},
});
const saveSmtpMutation = useMutation({
mutationFn: smtpApi.save,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['smtp-config'] });
setSmtpSaved(true);
setTimeout(() => setSmtpSaved(false), 3000);
},
});
const deleteSmtpMutation = useMutation({
mutationFn: smtpApi.remove,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['smtp-config'] });
setSmtpForm({ host: 'smtp.gmail.com', port: 587, username: '', password: '', use_tls: true });
},
});
const handleProfileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setProfileForm((prev) => ({ ...prev, [name]: value }));
};
const handleSmtpChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
setSmtpForm((prev) => ({
...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked
: name === 'port' ? Number(value)
: value,
}));
};
const handleProfileSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
@@ -68,7 +153,55 @@ function SettingsContent() {
}
};
const handleSmtpSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await saveSmtpMutation.mutateAsync({
host: smtpForm.host,
port: smtpForm.port,
username: smtpForm.username,
password: smtpForm.password || undefined,
use_tls: smtpForm.use_tls,
});
} catch (error) {
const errorMessage =
error instanceof Error && 'response' in error
? (error as { response?: { data?: { detail?: string } } }).response?.data
?.detail
: null;
alert(errorMessage || 'Failed to save SMTP settings');
}
};
const handleConnectGmail = async () => {
try {
const redirectUri = `${window.location.origin}/auth/gmail-callback`;
const url = await gmailApi.getAuthorizeUrl(redirectUri);
window.location.href = url;
} catch (error) {
const errorMessage =
error instanceof Error && 'response' in error
? (error as { response?: { data?: { detail?: string } } }).response?.data
?.detail
: null;
alert(errorMessage || 'Failed to start Gmail authorization');
}
};
const handleDisconnectGmail = async () => {
if (confirm('Disconnect Gmail? Mail accounts using Gmail API delivery will fall back to SMTP.')) {
try {
await disconnectGmailMutation.mutateAsync();
} catch {
alert('Failed to disconnect Gmail');
}
}
};
const displayUser = currentUser ?? user;
const gmailConnected = gmailCredential?.is_valid === true;
// gmail 404 just means "not connected yet" — not a real error
const gmailNotConnected = !gmailCredential && !gmailLoading;
return (
<div className="space-y-8">
@@ -134,6 +267,218 @@ function SettingsContent() {
</form>
</div>
{/* Gmail API Section */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
<Mail className="h-5 w-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">Gmail API Delivery</h2>
</div>
<div className="px-6 py-6">
<p className="text-sm text-gray-600 mb-4">
Grant this app permission to inject emails directly into your Gmail inbox.
This is the preferred delivery method emails arrive with original headers
intact, bypassing SMTP entirely.
</p>
<p className="text-xs text-gray-500 mb-6">
<strong>Token lifetime:</strong> Access tokens expire after 1 hour and are
refreshed automatically. Refresh tokens do not expire unless you revoke
access via your{' '}
<a
href="https://myaccount.google.com/permissions"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline"
>
Google Account permissions
</a>
. If revoked, click &ldquo;Connect Gmail&rdquo; again to re-authorise.
</p>
{gmailLoading && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
Checking Gmail connection
</div>
)}
{!gmailLoading && gmailConnected && (
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="text-sm font-medium text-gray-900">
Connected as <span className="font-semibold">{gmailCredential.gmail_email}</span>
</p>
{gmailCredential.last_verified_at && (
<p className="text-xs text-gray-500">
Last verified: {new Date(gmailCredential.last_verified_at).toLocaleString()}
</p>
)}
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleConnectGmail}
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors"
>
Re-authorise
</button>
<button
onClick={handleDisconnectGmail}
disabled={disconnectGmailMutation.isPending}
className="px-4 py-2 text-sm bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors disabled:opacity-50"
>
Disconnect
</button>
</div>
</div>
)}
{!gmailLoading && gmailCredential && !gmailCredential.is_valid && (
<div className="flex items-start gap-3 p-3 bg-red-50 border border-red-200 rounded-md mb-4">
<AlertTriangle className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-700">
<strong>Gmail access was revoked.</strong> Click &ldquo;Re-authorise&rdquo; below to restore Gmail API delivery.
</div>
</div>
)}
{!gmailLoading && (gmailNotConnected || (gmailCredential && !gmailCredential.is_valid)) && (
<button
onClick={handleConnectGmail}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<Mail className="h-4 w-4" />
{gmailError ? 'Connect Gmail' : 'Re-authorise Gmail'}
</button>
)}
{!gmailLoading && gmailNotConnected && (
<div className="mt-3 flex items-center gap-2 text-sm text-gray-500">
<XCircle className="h-4 w-4 text-gray-400" />
No Gmail account connected yet.
</div>
)}
</div>
</div>
{/* SMTP Fallback Section */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
<Server className="h-5 w-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">SMTP Fallback</h2>
</div>
<div className="px-6 py-6">
<p className="text-sm text-gray-600 mb-6">
Used when Gmail API is not connected or when a mail account is configured
to use SMTP delivery. Your credentials are stored encrypted.
</p>
{smtpLoading ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
Loading SMTP settings
</div>
) : (
<form onSubmit={handleSmtpSubmit} className="space-y-4 max-w-lg">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">SMTP Host</label>
<input
type="text"
name="host"
value={smtpForm.host}
onChange={handleSmtpChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="smtp.gmail.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Port</label>
<input
type="number"
name="port"
value={smtpForm.port}
onChange={handleSmtpChange}
min={1}
max={65535}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input
type="text"
name="username"
value={smtpForm.username}
onChange={handleSmtpChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
name="password"
value={smtpForm.password}
onChange={handleSmtpChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={smtpConfig?.has_password ? '•••••••• (leave blank to keep current)' : 'App password or SMTP password'}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="smtp_use_tls"
name="use_tls"
checked={smtpForm.use_tls}
onChange={handleSmtpChange}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<label htmlFor="smtp_use_tls" className="text-sm text-gray-700">
Use STARTTLS (recommended for port 587)
</label>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={saveSmtpMutation.isPending}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{saveSmtpMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</>
) : (
'Save SMTP Settings'
)}
</button>
{smtpConfig && (
<button
type="button"
onClick={() => deleteSmtpMutation.mutate()}
disabled={deleteSmtpMutation.isPending}
className="px-4 py-2 text-sm text-red-600 bg-red-50 rounded-md hover:bg-red-100 disabled:opacity-50 transition-colors"
>
Remove
</button>
)}
{smtpSaved && (
<span className="flex items-center text-sm text-green-600">
<CheckCircle className="h-4 w-4 mr-1" />
Saved
</span>
)}
</div>
</form>
)}
</div>
</div>
{/* Account Information */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
@@ -204,3 +549,4 @@ function SettingsContent() {
</div>
);
}
@@ -315,6 +315,19 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
Delete after forwarding
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
name="is_enabled"
id="is_enabled"
checked={formData.is_enabled ?? true}
onChange={handleChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="is_enabled" className="ml-2 block text-sm text-gray-700">
Enabled
</label>
</div>
</div>
<div>
+84
View File
@@ -116,6 +116,28 @@ export interface AutoDetectSuggestion {
[key: string]: unknown;
}
export interface GmailCredential {
id: number;
user_id: number;
gmail_email: string;
is_valid: boolean;
last_verified_at?: string | null;
created_at: string;
updated_at: string;
}
export interface UserSmtpConfig {
id: number;
user_id: number;
host: string;
port: number;
username: string;
use_tls: boolean;
has_password: boolean;
created_at: string;
updated_at: string;
}
interface TokenResponse {
access_token: string;
refresh_token: string;
@@ -204,6 +226,11 @@ export const mailAccountsApi = {
return response.data;
},
async toggle(id: number): Promise<MailAccount> {
const response = await api.patch<MailAccount>(`/mail-accounts/${id}/toggle`);
return response.data;
},
async delete(id: number): Promise<void> {
await api.delete(`/mail-accounts/${id}`);
},
@@ -245,4 +272,61 @@ export const processingRunsApi = {
},
};
// ── Gmail API ───────────────────────────────────────────────────────────
export const gmailApi = {
/** Returns the Google OAuth2 URL the user should be redirected to. */
async getAuthorizeUrl(redirectUri: string): Promise<string> {
const response = await api.get<{ authorization_url: string }>(
'/providers/gmail/authorize-url',
{ params: { redirect_uri: redirectUri } }
);
return response.data.authorization_url;
},
/** Exchange an OAuth2 code for Gmail tokens and persist them. */
async saveCallback(code: string, redirectUri: string): Promise<GmailCredential> {
const response = await api.post<GmailCredential>('/providers/gmail/callback', {
code,
redirect_uri: redirectUri,
});
return response.data;
},
/** Get the current user's stored Gmail credential status. */
async getCredential(): Promise<GmailCredential> {
const response = await api.get<GmailCredential>('/providers/gmail-credential');
return response.data;
},
/** Remove stored Gmail credentials. */
async disconnect(): Promise<void> {
await api.delete('/providers/gmail-credential');
},
};
// ── SMTP Config API ─────────────────────────────────────────────────────
export const smtpApi = {
async get(): Promise<UserSmtpConfig> {
const response = await api.get<UserSmtpConfig>('/users/smtp-config');
return response.data;
},
async save(data: {
host: string;
port: number;
username: string;
password?: string;
use_tls: boolean;
}): Promise<UserSmtpConfig> {
const response = await api.put<UserSmtpConfig>('/users/smtp-config', data);
return response.data;
},
async remove(): Promise<void> {
await api.delete('/users/smtp-config');
},
};
export default api;