- 全メール(重要・通常問わず)に対してフィードバックボタンを追加 - PATCH /api/mail/emails/<pk>/feedback/ エンドポイントを追加(JWT認証) - フィードバックモーダル: 重要/普通/今後通知しない/常に通知 の4択 - never_notify/always_notify 選択時はアドレス/ドメインの適用範囲を選択可能 - gmail_service アカウントのフィルタオプションを追加 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
288 lines
10 KiB
Python
288 lines
10 KiB
Python
import secrets
|
||
from django.conf import settings
|
||
from django.shortcuts import get_object_or_404
|
||
from django.utils import timezone
|
||
from rest_framework import viewsets, permissions, status
|
||
from rest_framework.views import APIView
|
||
from rest_framework.response import Response
|
||
from rest_framework.permissions import BasePermission, AllowAny, IsAuthenticated
|
||
from django.db.models import Count, Q
|
||
|
||
from .models import MailSender, MailEmail, MailNotificationToken
|
||
from .serializers import (
|
||
MailSenderSerializer,
|
||
MailEmailCreateSerializer,
|
||
MailEmailListSerializer,
|
||
FeedbackDetailSerializer,
|
||
)
|
||
|
||
|
||
class MailAPIKeyPermission(BasePermission):
|
||
"""X-API-Key ヘッダーで認証(Windmill向け)"""
|
||
|
||
def has_permission(self, request, view):
|
||
key = request.headers.get('X-API-Key', '')
|
||
expected = getattr(settings, 'MAIL_API_KEY', '')
|
||
if not key or not expected:
|
||
return False
|
||
return secrets.compare_digest(key, expected)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Windmill 向け API(APIキー認証)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class SenderRuleView(APIView):
|
||
"""
|
||
GET /api/mail/sender-rule/?email=...&domain=...
|
||
送信者ルールを確認する(アドレス優先 > ドメイン優先)
|
||
"""
|
||
permission_classes = [MailAPIKeyPermission]
|
||
authentication_classes = []
|
||
|
||
def get(self, request):
|
||
email = request.query_params.get('email', '')
|
||
domain = request.query_params.get('domain', '')
|
||
|
||
# アドレスルールを先に確認(具体的なほど優先)
|
||
if email:
|
||
sender = MailSender.objects.filter(email=email).first()
|
||
if sender:
|
||
return Response({
|
||
'matched': True,
|
||
'rule': sender.rule,
|
||
'match_type': 'address',
|
||
})
|
||
|
||
# ドメインルールを確認
|
||
if domain:
|
||
sender = MailSender.objects.filter(domain=domain).first()
|
||
if sender:
|
||
return Response({
|
||
'matched': True,
|
||
'rule': sender.rule,
|
||
'match_type': 'domain',
|
||
})
|
||
|
||
return Response({'matched': False})
|
||
|
||
|
||
class SenderContextView(APIView):
|
||
"""
|
||
GET /api/mail/sender-context/?email=...&domain=...
|
||
LLM用フィードバック集計を返す(トークン肥大化防止のため集計値のみ)
|
||
"""
|
||
permission_classes = [MailAPIKeyPermission]
|
||
authentication_classes = []
|
||
|
||
def get(self, request):
|
||
email = request.query_params.get('email', '')
|
||
domain = request.query_params.get('domain', '')
|
||
|
||
# アドレスで絞り込み(なければドメインで絞り込み)
|
||
if email:
|
||
qs = MailEmail.objects.filter(sender_email=email)
|
||
elif domain:
|
||
qs = MailEmail.objects.filter(sender_domain=domain)
|
||
else:
|
||
return Response({
|
||
'total_notified': 0,
|
||
'important': 0,
|
||
'not_important': 0,
|
||
'never_notify': 0,
|
||
'no_feedback': 0,
|
||
})
|
||
|
||
total = qs.count()
|
||
important = qs.filter(feedback='important').count()
|
||
not_important = qs.filter(feedback='not_important').count()
|
||
never_notify = qs.filter(feedback='never_notify').count()
|
||
no_feedback = qs.filter(feedback__isnull=True).count()
|
||
|
||
return Response({
|
||
'total_notified': total,
|
||
'important': important,
|
||
'not_important': not_important,
|
||
'never_notify': never_notify,
|
||
'no_feedback': no_feedback,
|
||
})
|
||
|
||
|
||
class MailEmailView(APIView):
|
||
"""
|
||
GET /api/mail/emails/ メール処理履歴を取得(JWT認証)
|
||
POST /api/mail/emails/ メールを記録する(APIキー認証、Windmill向け)
|
||
"""
|
||
|
||
def get_permissions(self):
|
||
if self.request.method == 'POST':
|
||
return [MailAPIKeyPermission()]
|
||
return [IsAuthenticated()]
|
||
|
||
def get(self, request):
|
||
qs = MailEmail.objects.select_related('notification_token').order_by('-received_at')
|
||
|
||
account = request.query_params.get('account')
|
||
if account:
|
||
qs = qs.filter(account=account)
|
||
verdict = request.query_params.get('verdict')
|
||
if verdict:
|
||
qs = qs.filter(llm_verdict=verdict)
|
||
|
||
serializer = MailEmailListSerializer(qs[:100], many=True)
|
||
return Response(serializer.data)
|
||
|
||
def post(self, request):
|
||
serializer = MailEmailCreateSerializer(data=request.data)
|
||
if not serializer.is_valid():
|
||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
mail_email = serializer.save()
|
||
response_data = {'id': mail_email.id}
|
||
|
||
if mail_email.llm_verdict == 'important':
|
||
token_obj = MailNotificationToken.objects.create(email=mail_email)
|
||
mail_email.notified_at = timezone.now()
|
||
mail_email.save(update_fields=['notified_at'])
|
||
|
||
frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
|
||
response_data['feedback_url'] = f"{frontend_url}/mail/feedback/{token_obj.token}"
|
||
|
||
return Response(response_data, status=status.HTTP_201_CREATED)
|
||
|
||
|
||
class MailStatsView(APIView):
|
||
"""
|
||
GET /api/mail/stats/ ダッシュボード用統計
|
||
"""
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def get(self, request):
|
||
today = timezone.now().date()
|
||
|
||
today_processed = MailEmail.objects.filter(received_at__date=today).count()
|
||
today_notified = MailEmail.objects.filter(notified_at__date=today).count()
|
||
feedback_pending = MailEmail.objects.filter(
|
||
llm_verdict='important',
|
||
feedback__isnull=True
|
||
).count()
|
||
total_rules = MailSender.objects.count()
|
||
|
||
return Response({
|
||
'today_processed': today_processed,
|
||
'today_notified': today_notified,
|
||
'feedback_pending': feedback_pending,
|
||
'total_rules': total_rules,
|
||
})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# フィードバックビュー(認証不要)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class FeedbackView(APIView):
|
||
"""
|
||
GET /api/mail/feedback/<token>/ メール情報と現在のフィードバックを返す
|
||
POST /api/mail/feedback/<token>/ フィードバックを保存する
|
||
"""
|
||
permission_classes = [AllowAny]
|
||
authentication_classes = []
|
||
|
||
def _get_mail_email(self, token):
|
||
token_obj = get_object_or_404(MailNotificationToken, token=token)
|
||
return token_obj.email
|
||
|
||
def get(self, request, token):
|
||
mail_email = self._get_mail_email(token)
|
||
serializer = FeedbackDetailSerializer(mail_email)
|
||
return Response(serializer.data)
|
||
|
||
def post(self, request, token):
|
||
mail_email = self._get_mail_email(token)
|
||
|
||
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') # 'address' or 'domain'
|
||
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'})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 履歴画面からのフィードバック更新(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認証)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class MailSenderViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
GET /api/mail/senders/ ルール一覧
|
||
POST /api/mail/senders/ ルール追加
|
||
DELETE /api/mail/senders/{id}/ ルール削除
|
||
"""
|
||
queryset = MailSender.objects.all().order_by('-created_at')
|
||
serializer_class = MailSenderSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
http_method_names = ['get', 'post', 'delete', 'head', 'options']
|