E-Mail-Client: Spam-Ordner als eigener Tab
Neuer Tab "Spam" (zwischen Gesendet und Papierkorb), zeigt den Junk-/ Spam-Ordner des gewählten Postfachs – damit fälschlich als Spam einsortierte Mails auffindbar sind. Backend: - EmailFolder-Enum um SPAM erweitert (Migration, Wert angehängt → kein Rewrite bestehender Zeilen). - imapService.findJunkFolderPath: ermittelt den Junk-Ordner per Special-Use-Flag \Junk + üblicher Namensliste (Junk/Spam/…). - syncAllFoldersForAccount synct den Junk-Ordner zusätzlich als dbFolder=SPAM (syncEmailsForAccount bekommt dbFolder-Option). - getCachedEmails + getFolderCountsForAccount um SPAM erweitert. - Papierkorb-Move/Restore für Spam-Mails nutzt den echten Junk-Pfad als Quell-/Zielordner. Frontend: Tab + Badge (ungelesen/gesamt), gleicher List-/Detail-Pfad wie INBOX; Zuordnen-zu-Vertrag auch aus Spam möglich. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
-- EmailFolder-Enum um SPAM erweitern (Spam-/Junk-Ordner des Postfachs).
|
||||
-- Neuer Wert wird ans Ende gehängt → bestehende INBOX/SENT-Zeilen bleiben
|
||||
-- unverändert, kein Rewrite.
|
||||
|
||||
ALTER TABLE `CachedEmail`
|
||||
MODIFY COLUMN `folder` ENUM('INBOX', 'SENT', 'SPAM') NOT NULL DEFAULT 'INBOX';
|
||||
@@ -448,6 +448,7 @@ model StressfreiEmail {
|
||||
enum EmailFolder {
|
||||
INBOX
|
||||
SENT
|
||||
SPAM
|
||||
}
|
||||
|
||||
model CachedEmail {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { CachedEmail, Prisma, EmailFolder } from '@prisma/client';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { decrypt } from '../utils/encryption.js';
|
||||
import { fetchEmails, ImapCredentials, FetchedEmail, moveToTrash, restoreFromTrash, permanentDelete, humanizeImapError } from './imapService.js';
|
||||
import { fetchEmails, ImapCredentials, FetchedEmail, moveToTrash, restoreFromTrash, permanentDelete, humanizeImapError, findJunkFolderPath } from './imapService.js';
|
||||
import { getImapSmtpSettings } from './emailProvider/emailProviderService.js';
|
||||
|
||||
// ==================== TYPES ====================
|
||||
@@ -68,7 +68,7 @@ export interface EmailListOptions {
|
||||
// E-Mails für eine StressfreiEmail synchronisieren
|
||||
export async function syncEmailsForAccount(
|
||||
stressfreiEmailId: number,
|
||||
options?: { folder?: string; fullSync?: boolean }
|
||||
options?: { folder?: string; fullSync?: boolean; dbFolder?: EmailFolder }
|
||||
): Promise<SyncResult> {
|
||||
const folder = options?.folder || 'INBOX';
|
||||
const fullSync = options?.fullSync || false;
|
||||
@@ -111,10 +111,12 @@ export async function syncEmailsForAccount(
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
|
||||
// Folder-Mapping: IMAP-Ordner zu DB-Ordner
|
||||
const dbFolder = folder.toUpperCase() === 'SENT' || folder.toLowerCase() === 'sent'
|
||||
? EmailFolder.SENT
|
||||
: EmailFolder.INBOX;
|
||||
// Folder-Mapping: IMAP-Ordner zu DB-Ordner. Explizites dbFolder (z.B.
|
||||
// SPAM für den Junk-Ordner, dessen IMAP-Name variiert) hat Vorrang.
|
||||
const dbFolder = options?.dbFolder
|
||||
?? (folder.toUpperCase() === 'SENT' || folder.toLowerCase() === 'sent'
|
||||
? EmailFolder.SENT
|
||||
: EmailFolder.INBOX);
|
||||
|
||||
// Für inkrementellen Sync: Höchste UID des Ordners ermitteln
|
||||
let sinceUid: number | undefined;
|
||||
@@ -168,7 +170,33 @@ export async function syncEmailsForAccount(
|
||||
}
|
||||
}
|
||||
|
||||
// Alle Ordner synchronisieren (INBOX + SENT)
|
||||
/**
|
||||
* Baut die IMAP-Credentials für ein Postfach (für Operationen, die eine
|
||||
* eigene Verbindung brauchen – z.B. Junk-Ordner-Pfad ermitteln). Gibt null
|
||||
* zurück, wenn kein Postfach/Passwort/Provider-Settings vorhanden sind.
|
||||
*/
|
||||
async function getImapCredentialsForAccount(stressfreiEmailId: number): Promise<ImapCredentials | null> {
|
||||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({ where: { id: stressfreiEmailId } });
|
||||
if (!stressfreiEmail?.hasMailbox || !stressfreiEmail.emailPasswordEncrypted) return null;
|
||||
const settings = await getImapSmtpSettings();
|
||||
if (!settings) return null;
|
||||
let password: string;
|
||||
try {
|
||||
password = decrypt(stressfreiEmail.emailPasswordEncrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
host: settings.imapServer,
|
||||
port: settings.imapPort,
|
||||
user: stressfreiEmail.email,
|
||||
password,
|
||||
encryption: settings.imapEncryption,
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
}
|
||||
|
||||
// Alle Ordner synchronisieren (INBOX + SENT + SPAM/Junk)
|
||||
export async function syncAllFoldersForAccount(
|
||||
stressfreiEmailId: number,
|
||||
options?: { fullSync?: boolean }
|
||||
@@ -181,6 +209,23 @@ export async function syncAllFoldersForAccount(
|
||||
// SENT synchronisieren (Plesk verwendet "Sent" als Ordnername)
|
||||
const sentResult = await syncEmailsForAccount(stressfreiEmailId, { folder: 'Sent', fullSync });
|
||||
|
||||
// SPAM/Junk synchronisieren – Ordnername variiert je Server, daher erst
|
||||
// per IMAP ermitteln. Existiert kein Junk-Ordner, wird Spam übersprungen
|
||||
// (kein Fehler). Wird explizit als dbFolder=SPAM abgelegt.
|
||||
let spamNew = 0;
|
||||
const credentials = await getImapCredentialsForAccount(stressfreiEmailId);
|
||||
if (credentials) {
|
||||
const junkPath = await findJunkFolderPath(credentials);
|
||||
if (junkPath) {
|
||||
const spamResult = await syncEmailsForAccount(stressfreiEmailId, {
|
||||
folder: junkPath,
|
||||
dbFolder: EmailFolder.SPAM,
|
||||
fullSync,
|
||||
});
|
||||
if (spamResult.success) spamNew = spamResult.newEmails;
|
||||
}
|
||||
}
|
||||
|
||||
// Ergebnisse kombinieren
|
||||
if (!inboxResult.success && !sentResult.success) {
|
||||
return {
|
||||
@@ -193,7 +238,7 @@ export async function syncAllFoldersForAccount(
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newEmails: inboxResult.newEmails + sentResult.newEmails,
|
||||
newEmails: inboxResult.newEmails + sentResult.newEmails + spamNew,
|
||||
totalEmails: inboxResult.totalEmails,
|
||||
};
|
||||
}
|
||||
@@ -280,9 +325,11 @@ export async function getCachedEmails(
|
||||
where.contractId = options.contractId;
|
||||
}
|
||||
|
||||
// Folder-Filter (INBOX oder SENT)
|
||||
// Folder-Filter (INBOX, SENT oder SPAM)
|
||||
if (options.folder === 'SENT') {
|
||||
where.folder = EmailFolder.SENT;
|
||||
} else if (options.folder === 'SPAM') {
|
||||
where.folder = EmailFolder.SPAM;
|
||||
} else if (options.folder === 'INBOX' || !options.folder) {
|
||||
// Standard: Nur Posteingang
|
||||
where.folder = EmailFolder.INBOX;
|
||||
@@ -573,10 +620,12 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom
|
||||
inboxUnread: number;
|
||||
sent: number;
|
||||
sentUnread: number;
|
||||
spam: number;
|
||||
spamUnread: number;
|
||||
trash: number;
|
||||
trashUnread: number;
|
||||
}> {
|
||||
const [inbox, inboxUnread, sent, sentUnread, trash, trashUnread] = await Promise.all([
|
||||
const [inbox, inboxUnread, sent, sentUnread, spam, spamUnread, trash, trashUnread] = await Promise.all([
|
||||
// INBOX total
|
||||
prisma.cachedEmail.count({
|
||||
where: { stressfreiEmailId, folder: EmailFolder.INBOX, isDeleted: false },
|
||||
@@ -593,6 +642,14 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom
|
||||
prisma.cachedEmail.count({
|
||||
where: { stressfreiEmailId, folder: EmailFolder.SENT, isDeleted: false, isRead: false },
|
||||
}),
|
||||
// SPAM total
|
||||
prisma.cachedEmail.count({
|
||||
where: { stressfreiEmailId, folder: EmailFolder.SPAM, isDeleted: false },
|
||||
}),
|
||||
// SPAM unread
|
||||
prisma.cachedEmail.count({
|
||||
where: { stressfreiEmailId, folder: EmailFolder.SPAM, isDeleted: false, isRead: false },
|
||||
}),
|
||||
// TRASH total (isDeleted = true)
|
||||
prisma.cachedEmail.count({
|
||||
where: { stressfreiEmailId, isDeleted: true },
|
||||
@@ -603,7 +660,7 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom
|
||||
}),
|
||||
]);
|
||||
|
||||
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
||||
return { inbox, inboxUnread, sent, sentUnread, spam, spamUnread, trash, trashUnread };
|
||||
}
|
||||
|
||||
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails).
|
||||
@@ -759,8 +816,17 @@ export async function moveEmailToTrash(id: number): Promise<TrashOperationResult
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
|
||||
// Ordner bestimmen (INBOX oder Sent)
|
||||
const sourceFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||||
// IMAP-Quellordner bestimmen. Bei SPAM den tatsächlichen Junk-Pfad
|
||||
// ermitteln (Name variiert je Server) – sonst würde moveToTrash im
|
||||
// falschen Ordner nach der UID suchen.
|
||||
let sourceFolder: string;
|
||||
if (email.folder === 'SENT') {
|
||||
sourceFolder = 'Sent';
|
||||
} else if (email.folder === 'SPAM') {
|
||||
sourceFolder = (await findJunkFolderPath(credentials)) || 'Junk';
|
||||
} else {
|
||||
sourceFolder = 'INBOX';
|
||||
}
|
||||
|
||||
// Auf IMAP-Server in Trash verschieben
|
||||
const imapResult = await moveToTrash(credentials, email.uid, sourceFolder);
|
||||
@@ -826,8 +892,16 @@ export async function restoreEmailFromTrash(id: number): Promise<TrashOperationR
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
|
||||
// Ziel-Ordner bestimmen (basierend auf originalem Ordner)
|
||||
const targetFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||||
// Ziel-Ordner bestimmen (basierend auf originalem Ordner). SPAM zurück
|
||||
// in den tatsächlichen Junk-Ordner (Name variiert je Server).
|
||||
let targetFolder: string;
|
||||
if (email.folder === 'SENT') {
|
||||
targetFolder = 'Sent';
|
||||
} else if (email.folder === 'SPAM') {
|
||||
targetFolder = (await findJunkFolderPath(credentials)) || 'Junk';
|
||||
} else {
|
||||
targetFolder = 'INBOX';
|
||||
}
|
||||
|
||||
// Auf IMAP-Server aus Trash wiederherstellen
|
||||
const imapResult = await restoreFromTrash(credentials, email.uid, targetFolder);
|
||||
|
||||
@@ -784,6 +784,9 @@ export async function fetchAttachmentList(
|
||||
// Typische Namen für Trash-Ordner (verschiedene Server/Sprachen)
|
||||
const TRASH_FOLDER_NAMES = ['Trash', 'INBOX.Trash', 'Deleted', 'Deleted Items', 'Deleted Messages', 'Papierkorb'];
|
||||
|
||||
// Übliche Namen für den Spam-/Junk-Ordner (Plesk/Dovecot: "Junk").
|
||||
const JUNK_FOLDER_NAMES = ['Junk', 'INBOX.Junk', 'Spam', 'INBOX.Spam', 'Junk E-mail', 'Junk Email', 'Bulk Mail'];
|
||||
|
||||
// Helper: Trash-Ordner finden
|
||||
async function findTrashFolder(client: ImapFlow): Promise<string | null> {
|
||||
const mailboxes = await client.list();
|
||||
@@ -803,6 +806,48 @@ async function findTrashFolder(client: ImapFlow): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den tatsächlichen Pfad des Spam-/Junk-Ordners eines Postfachs.
|
||||
* Bevorzugt das IMAP-Special-Use-Flag \Junk, fällt sonst auf übliche
|
||||
* Ordnernamen zurück. Öffnet eine eigene Verbindung; gibt null zurück,
|
||||
* wenn kein Junk-Ordner existiert (dann wird Spam einfach nicht synct).
|
||||
*/
|
||||
export async function findJunkFolderPath(credentials: ImapCredentials): Promise<string | null> {
|
||||
const encryption = credentials.encryption ?? 'SSL';
|
||||
const clientOptions: ConstructorParameters<typeof ImapFlow>[0] = {
|
||||
host: credentials.host,
|
||||
port: credentials.port,
|
||||
secure: encryption === 'SSL',
|
||||
auth: { user: credentials.user, pass: credentials.password },
|
||||
logger: false,
|
||||
};
|
||||
if (encryption !== 'NONE') {
|
||||
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
||||
}
|
||||
|
||||
const client = new ImapFlow(clientOptions);
|
||||
try {
|
||||
await client.connect();
|
||||
const mailboxes = await client.list();
|
||||
// Zuerst per Special-Use-Flag \Junk
|
||||
const byFlag = mailboxes.find(m => m.specialUse === '\\Junk');
|
||||
if (byFlag) return byFlag.path;
|
||||
// Sonst per Namensliste
|
||||
for (const name of JUNK_FOLDER_NAMES) {
|
||||
const found = mailboxes.find(m =>
|
||||
m.path.toLowerCase() === name.toLowerCase() ||
|
||||
m.name.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
if (found) return found.path;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
try { await client.logout(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
export interface MoveToTrashResult {
|
||||
success: boolean;
|
||||
newUid?: number; // UID im Trash-Ordner
|
||||
|
||||
@@ -97,6 +97,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
|
||||
## ✅ Erledigt
|
||||
|
||||
- [x] **📧 E-Mail-Client: Spam-Ordner als eigener Tab**
|
||||
- Neuer Tab „Spam" (zwischen Gesendet und Papierkorb) im E-Mail-Client,
|
||||
zeigt den Junk-/Spam-Ordner des gewählten Postfachs – damit
|
||||
fälschlich als Spam eingestufte Mails auffindbar sind.
|
||||
- Backend: `EmailFolder`-Enum um `SPAM` erweitert (Migration
|
||||
`20260727110000_email_folder_spam`, Enum-Wert angehängt). IMAP-Sync
|
||||
ermittelt den Junk-Ordnerpfad per Special-Use-Flag `\Junk` +
|
||||
Namensliste (`findJunkFolderPath`), `syncAllFoldersForAccount` synct
|
||||
ihn zusätzlich als `dbFolder=SPAM`. `getCachedEmails` +
|
||||
`getFolderCountsForAccount` um SPAM erweitert.
|
||||
- Papierkorb-Verschieben/Wiederherstellen für Spam-Mails nutzt den
|
||||
tatsächlichen Junk-Pfad als Quell-/Zielordner.
|
||||
- Frontend: Tab + Badge (ungelesen/gesamt), nutzt denselben
|
||||
List-/Detail-Pfad wie INBOX; Zuordnen-zu-Vertrag auch aus Spam
|
||||
möglich (Rettung legitimer Mails).
|
||||
|
||||
- [x] **📱 Mobilfunk: Feld „Mobilfunknetz" unter Anbieter & Tarif**
|
||||
- Neues Dropdown „Mobilfunknetz" in der Anbieter-&-Tarif-Karte, nur
|
||||
sichtbar wenn Vertragstyp = Mobilfunk. Optionen: Bitte auswählen
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { RefreshCw, Plus, Mail, Inbox, Send, Trash2, Search, SlidersHorizontal, X } from 'lucide-react';
|
||||
import { RefreshCw, Plus, Mail, Inbox, Send, Trash2, Search, SlidersHorizontal, X, ShieldAlert } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { cachedEmailApi, stressfreiEmailApi, CachedEmail, EmailFilterParams } from '../../services/api';
|
||||
@@ -13,7 +13,7 @@ import ComposeEmailModal from './ComposeEmailModal';
|
||||
import AssignToContractModal from './AssignToContractModal';
|
||||
import TrashEmailList from './TrashEmailList';
|
||||
|
||||
type EmailFolder = 'INBOX' | 'SENT' | 'TRASH';
|
||||
type EmailFolder = 'INBOX' | 'SENT' | 'SPAM' | 'TRASH';
|
||||
|
||||
interface EmailClientTabProps {
|
||||
customerId: number;
|
||||
@@ -117,7 +117,7 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
||||
queryFn: () =>
|
||||
cachedEmailApi.getForCustomer(customerId, {
|
||||
accountId: selectedAccountId || undefined,
|
||||
folder: selectedFolder as 'INBOX' | 'SENT',
|
||||
folder: selectedFolder as 'INBOX' | 'SENT' | 'SPAM',
|
||||
...filterParams,
|
||||
}),
|
||||
enabled: !!selectedAccountId && selectedFolder !== 'TRASH',
|
||||
@@ -152,6 +152,8 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
||||
inboxUnread: 0,
|
||||
sent: 0,
|
||||
sentUnread: 0,
|
||||
spam: 0,
|
||||
spamUnread: 0,
|
||||
trash: 0,
|
||||
trashUnread: 0,
|
||||
};
|
||||
@@ -396,6 +398,31 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFolderChange('SPAM')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
selectedFolder === 'SPAM'
|
||||
? 'bg-white text-amber-600 shadow-sm font-medium'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
Spam
|
||||
{folderCounts.spam > 0 && (
|
||||
<span
|
||||
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
|
||||
folderCounts.spamUnread > 0
|
||||
? 'bg-amber-100 text-amber-600 font-medium'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
title={`${folderCounts.spamUnread} ungelesen / ${folderCounts.spam} gesamt`}
|
||||
>
|
||||
{folderCounts.spamUnread > 0
|
||||
? `${folderCounts.spamUnread}/${folderCounts.spam}`
|
||||
: folderCounts.spam}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{canAccessTrash && (
|
||||
<button
|
||||
onClick={() => handleFolderChange('TRASH')}
|
||||
@@ -626,7 +653,7 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
||||
queryClient.invalidateQueries({ queryKey: ['folder-counts', selectedAccountId] });
|
||||
}}
|
||||
isLoading={emailsLoading}
|
||||
folder={selectedFolder as 'INBOX' | 'SENT'}
|
||||
folder={selectedFolder as 'INBOX' | 'SENT' | 'SPAM'}
|
||||
accountId={selectedAccountId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,7 @@ interface EmailListProps {
|
||||
onSelectEmail: (email: CachedEmail) => void;
|
||||
onEmailDeleted?: (emailId: number) => void;
|
||||
isLoading?: boolean;
|
||||
folder?: 'INBOX' | 'SENT';
|
||||
folder?: 'INBOX' | 'SENT' | 'SPAM';
|
||||
accountId?: number | null; // Für Folder-Count-Aktualisierung bei Lesen/Ungelesen
|
||||
}
|
||||
|
||||
@@ -250,8 +250,8 @@ export default function EmailList({
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
{email.contract.contractNumber}
|
||||
</span>
|
||||
{/* X-Button nur für INBOX oder manuell zugeordnete gesendete E-Mails */}
|
||||
{(folder === 'INBOX' || (folder === 'SENT' && !email.isAutoAssigned)) && (
|
||||
{/* X-Button nur für empfangene (INBOX/SPAM) oder manuell zugeordnete gesendete E-Mails */}
|
||||
{(folder === 'INBOX' || folder === 'SPAM' || (folder === 'SENT' && !email.isAutoAssigned)) && (
|
||||
<button
|
||||
onClick={(e) => handleUnassign(e, email.id)}
|
||||
className="p-0.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||
|
||||
@@ -585,6 +585,8 @@ export const stressfreiEmailApi = {
|
||||
inboxUnread: number;
|
||||
sent: number;
|
||||
sentUnread: number;
|
||||
spam: number;
|
||||
spamUnread: number;
|
||||
trash: number;
|
||||
trashUnread: number;
|
||||
}>>(`/stressfrei-emails/${id}/folder-counts`);
|
||||
@@ -595,7 +597,7 @@ export const stressfreiEmailApi = {
|
||||
// Cached Email API (E-Mail-Client)
|
||||
export interface EmailFilterParams {
|
||||
accountId?: number;
|
||||
folder?: 'INBOX' | 'SENT';
|
||||
folder?: 'INBOX' | 'SENT' | 'SPAM';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
// Suche / Filter (alle AND-verknüpft)
|
||||
|
||||
Reference in New Issue
Block a user