実装完了

作成・変更したファイル
バックエンド(新規):

apps/mail/models.py — MailSender, MailEmail, MailNotificationToken
apps/mail/serializers.py
apps/mail/views.py — Windmill用API、フィードバック、ルール管理
apps/mail/urls.py
apps/mail/admin.py
マイグレーション(自動生成・適用済み)
バックエンド(変更):

settings.py — apps.mail 追加、MAIL_API_KEY/FRONTEND_URL 環境変数
urls.py — /api/mail/ 追加
フロントエンド(新規):

mail/feedback/[token]/page.tsx — 認証不要、フィードバック3択+スコープ選択
mail/rules/page.tsx — ルール管理(一覧・追加・削除)
フロントエンド(変更):

Navbar.tsx — 「メールルール」メニュー追加
types/index.ts — MailSender, MailEmailFeedback 型追加
次のステップ(Windmill側)
Keinaシステム側の実装は完了しています。次はWindmillにIMAPポーリングスクリプトを書く必要があります。Windmillのスクリプトが必要になったタイミングでお声がけください。
This commit is contained in:
Akira
2026-02-22 09:27:27 +09:00
parent 24fa9b4e64
commit 7a1aa81f9f
17 changed files with 1367 additions and 1 deletions

204
backend/apps/mail/views.py Normal file
View File

@@ -0,0 +1,204 @@
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,
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 MailEmailCreateView(APIView):
"""
POST /api/mail/emails/
メールを記録する。llm_verdict == 'important' の場合はトークンも発行する。
"""
permission_classes = [MailAPIKeyPermission]
authentication_classes = []
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 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']
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 == 'never_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': 'never_notify'}
)
elif scope == 'domain':
MailSender.objects.update_or_create(
domain=mail_email.sender_domain,
defaults={'email': None, 'rule': 'never_notify'}
)
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']