メール処理履歴画面にフィードバック機能を追加

- 全メール(重要・通常問わず)に対してフィードバックボタンを追加
- PATCH /api/mail/emails/<pk>/feedback/ エンドポイントを追加(JWT認証)
- フィードバックモーダル: 重要/普通/今後通知しない/常に通知 の4択
- never_notify/always_notify 選択時はアドレス/ドメインの適用範囲を選択可能
- gmail_service アカウントのフィルタオプションを追加

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Akira
2026-02-24 13:52:21 +09:00
parent 757371cdc4
commit b19e08a8dd
3 changed files with 185 additions and 30 deletions

View File

@@ -12,6 +12,8 @@ urlpatterns = [
# メール記録POST: APIキー認証履歴取得GET: JWT認証 # メール記録POST: APIキー認証履歴取得GET: JWT認証
path('emails/', views.MailEmailView.as_view(), name='mail-emails'), path('emails/', views.MailEmailView.as_view(), name='mail-emails'),
# 履歴画面からのフィードバック更新JWT認証
path('emails/<int:pk>/feedback/', views.MailEmailFeedbackView.as_view(), name='mail-email-feedback'),
# ダッシュボード用統計JWT認証 # ダッシュボード用統計JWT認証
path('stats/', views.MailStatsView.as_view(), name='mail-stats'), path('stats/', views.MailStatsView.as_view(), name='mail-stats'),

View File

@@ -230,6 +230,47 @@ class FeedbackView(APIView):
return Response({'status': 'ok'}) return Response({'status': 'ok'})
# ---------------------------------------------------------------------------
# 履歴画面からのフィードバック更新JWT認証
# ---------------------------------------------------------------------------
class MailEmailFeedbackView(APIView):
"""
PATCH /api/mail/emails/<pk>/feedback/ 履歴画面から直接フィードバックを更新JWT認証
"""
permission_classes = [IsAuthenticated]
def patch(self, request, pk):
mail_email = get_object_or_404(MailEmail, pk=pk)
feedback = request.data.get('feedback')
valid_feedbacks = ['important', 'not_important', 'never_notify', 'always_notify']
if feedback not in valid_feedbacks:
return Response(
{'error': f'feedback は {valid_feedbacks} のいずれかを指定してください'},
status=status.HTTP_400_BAD_REQUEST
)
mail_email.feedback = feedback
mail_email.feedback_at = timezone.now()
mail_email.save(update_fields=['feedback', 'feedback_at'])
if feedback in ('never_notify', 'always_notify'):
scope = request.data.get('scope')
if scope == 'address':
MailSender.objects.update_or_create(
email=mail_email.sender_email,
defaults={'domain': None, 'rule': feedback}
)
elif scope == 'domain':
MailSender.objects.update_or_create(
domain=mail_email.sender_domain,
defaults={'email': None, 'rule': feedback}
)
return Response({'status': 'ok', 'feedback': feedback})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ルール管理JWT認証 # ルール管理JWT認証
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import { Mail, Loader2, ExternalLink, CheckCircle, XCircle, Clock, Shield } from 'lucide-react'; import { Mail, Loader2, CheckCircle, XCircle, Clock, Shield, MessageSquare } from 'lucide-react';
interface MailEmail { interface MailEmail {
id: number; id: number;
@@ -34,6 +34,13 @@ const ACCOUNT_LABELS: Record<string, string> = {
gmail_service: 'Gmail (サービス用)', gmail_service: 'Gmail (サービス用)',
}; };
const FEEDBACK_OPTIONS = [
{ value: 'important', label: '重要だった', color: 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100' },
{ value: 'not_important',label: '普通のメール', color: 'bg-gray-50 text-gray-600 border-gray-200 hover:bg-gray-100' },
{ value: 'never_notify', label: '今後通知しない', color: 'bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100' },
{ value: 'always_notify',label: '常に通知してほしい', color: 'bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100' },
];
export default function MailHistoryPage() { export default function MailHistoryPage() {
const router = useRouter(); const router = useRouter();
const [emails, setEmails] = useState<MailEmail[]>([]); const [emails, setEmails] = useState<MailEmail[]>([]);
@@ -41,6 +48,12 @@ export default function MailHistoryPage() {
const [filterAccount, setFilterAccount] = useState(''); const [filterAccount, setFilterAccount] = useState('');
const [filterVerdict, setFilterVerdict] = useState(''); const [filterVerdict, setFilterVerdict] = useState('');
// フィードバックモーダル
const [feedbackModal, setFeedbackModal] = useState<MailEmail | null>(null);
const [pendingFeedback, setPendingFeedback] = useState('');
const [pendingScope, setPendingScope] = useState<'address' | 'domain'>('address');
const [submitting, setSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
const fetchEmails = async () => { const fetchEmails = async () => {
setLoading(true); setLoading(true);
@@ -68,6 +81,41 @@ export default function MailHistoryPage() {
}); });
}; };
const openFeedbackModal = (email: MailEmail) => {
setFeedbackModal(email);
setPendingFeedback(email.feedback ?? '');
setPendingScope('address');
};
const closeFeedbackModal = () => {
setFeedbackModal(null);
setPendingFeedback('');
};
const submitFeedback = async () => {
if (!feedbackModal || !pendingFeedback) return;
setSubmitting(true);
try {
const body: Record<string, string> = { feedback: pendingFeedback };
if (['never_notify', 'always_notify'].includes(pendingFeedback)) {
body.scope = pendingScope;
}
await api.patch(`/mail/emails/${feedbackModal.id}/feedback/`, body);
setEmails(prev => prev.map(e =>
e.id === feedbackModal.id
? { ...e, feedback: pendingFeedback as MailEmail['feedback'], feedback_at: new Date().toISOString() }
: e
));
closeFeedbackModal();
} catch (error) {
console.error('Failed to submit feedback:', error);
} finally {
setSubmitting(false);
}
};
const needsScope = ['never_notify', 'always_notify'].includes(pendingFeedback);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Navbar /> <Navbar />
@@ -128,7 +176,6 @@ export default function MailHistoryPage() {
<th className="text-left px-4 py-3 text-gray-500 font-medium"></th> <th className="text-left px-4 py-3 text-gray-500 font-medium"></th>
<th className="text-center px-4 py-3 text-gray-500 font-medium whitespace-nowrap">LLM判定</th> <th className="text-center px-4 py-3 text-gray-500 font-medium whitespace-nowrap">LLM判定</th>
<th className="text-center px-4 py-3 text-gray-500 font-medium whitespace-nowrap"></th> <th className="text-center px-4 py-3 text-gray-500 font-medium whitespace-nowrap"></th>
<th className="text-center px-4 py-3 text-gray-500 font-medium"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -156,6 +203,7 @@ export default function MailHistoryPage() {
)} )}
</td> </td>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-2">
{email.feedback ? ( {email.feedback ? (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${ <span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
email.feedback === 'important' ? 'bg-green-50 text-green-700' : email.feedback === 'important' ? 'bg-green-50 text-green-700' :
@@ -172,19 +220,14 @@ export default function MailHistoryPage() {
) : ( ) : (
<span className="text-gray-400 text-xs"></span> <span className="text-gray-400 text-xs"></span>
)} )}
</td> <button
<td className="px-4 py-3 text-center"> onClick={() => openFeedbackModal(email)}
{email.feedback_token && ( className="inline-flex items-center gap-1 text-gray-400 hover:text-blue-600 text-xs transition-colors"
<a title="フィードバックを設定"
href={`/mail/feedback/${email.feedback_token}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800 text-xs"
> >
<ExternalLink className="h-3 w-3" /> <MessageSquare className="h-3.5 w-3.5" />
</button>
</a> </div>
)}
</td> </td>
</tr> </tr>
))} ))}
@@ -196,6 +239,75 @@ export default function MailHistoryPage() {
</div> </div>
)} )}
</div> </div>
{/* フィードバックモーダル */}
{feedbackModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={closeFeedbackModal}>
<div className="bg-white rounded-xl shadow-xl p-6 w-80 mx-4" onClick={(e) => e.stopPropagation()}>
<h3 className="font-semibold text-gray-900 mb-1"></h3>
<p className="text-xs text-gray-500 mb-1 truncate">{feedbackModal.sender_email}</p>
<p className="text-xs text-gray-700 mb-4 truncate">{feedbackModal.subject}</p>
<div className="space-y-2 mb-3">
{FEEDBACK_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => setPendingFeedback(opt.value)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm border transition-colors ${
pendingFeedback === opt.value
? opt.color + ' font-medium'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{opt.label}
</button>
))}
</div>
{needsScope && (
<div className="mb-4 p-3 bg-gray-50 rounded-lg text-sm">
<p className="text-xs text-gray-500 mb-2">:</p>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 cursor-pointer text-gray-700">
<input
type="radio"
value="address"
checked={pendingScope === 'address'}
onChange={() => setPendingScope('address')}
/>
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-gray-700">
<input
type="radio"
value="domain"
checked={pendingScope === 'domain'}
onChange={() => setPendingScope('domain')}
/>
</label>
</div>
</div>
)}
<div className="flex gap-2">
<button
onClick={closeFeedbackModal}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700"
>
</button>
<button
onClick={submitFeedback}
disabled={!pendingFeedback || submitting}
className="flex-1 px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? '送信中...' : '確定'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }