feat: Gmail OAuth flow, per-user SMTP, message dedup, account disable toggle

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/b77800ca-b452-4427-b0bf-63403e906916
This commit is contained in:
copilot-swe-agent[bot]
2026-03-25 11:34:52 +00:00
parent a15216c630
commit caa8eecd0a
12 changed files with 986 additions and 15 deletions
@@ -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>
+79
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;
@@ -250,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;