Files
keinasystem/backend/apps/mail/views.py
Akira 04b1ca1bb9 実装完了
Backend(Django)
backend/apps/mail/serializers.py

MailEmailListSerializer を新規追加(フロントエンド向けメール一覧用)
feedback_token フィールドを含む(フィードバックリンク表示用)
backend/apps/mail/views.py

MailEmailCreateView → MailEmailView に変更(GET+POST を統合)
GET /api/mail/emails/ : JWT認証でメール履歴取得(最新100件、account/verdict フィルター対応)
POST /api/mail/emails/ : APIキー認証でWindmillからのメール記録(既存動作を維持)
get_permissions() でメソッドごとに認証方法を切替
MailStatsView を新規追加
GET /api/mail/stats/ : 今日の処理件数、LINE通知数、フィードバック待ち、ルール数を返す
backend/apps/mail/urls.py

emails/ → MailEmailView(GET+POST)
stats/ → MailStatsView を追加
Frontend(Next.js)
frontend/src/app/mail/history/page.tsx (新規作成)

メール処理履歴の一覧テーブル
アカウント・LLM判定でフィルタリング可能
LLM判定・フィードバック状態をバッジで表示
フィードバックトークンがあれば「回答」リンクを表示
frontend/src/app/dashboard/page.tsx (再設計)

2カラムのモジュールカード形式に変更
作付け計画カード: 年度セレクタ、集計数値、作物別集計、クイックアクセス
メール通知カード: 今日の処理件数、LINE通知数、フィードバック待ち、ルール数、メール履歴・ルール管理ボタン
2026-02-22 15:01:50 +09:00

247 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 向け APIAPIキー認証
# ---------------------------------------------------------------------------
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 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']