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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 “Connect Gmail” 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 “Re-authorise” 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user