実装完了
作成・変更したファイル バックエンド(新規): 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:
0
backend/apps/mail/__init__.py
Normal file
0
backend/apps/mail/__init__.py
Normal file
23
backend/apps/mail/admin.py
Normal file
23
backend/apps/mail/admin.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.contrib import admin
|
||||
from .models import MailSender, MailEmail, MailNotificationToken
|
||||
|
||||
|
||||
@admin.register(MailSender)
|
||||
class MailSenderAdmin(admin.ModelAdmin):
|
||||
list_display = ('__str__', 'rule', 'note', 'created_at')
|
||||
list_filter = ('rule',)
|
||||
search_fields = ('email', 'domain', 'note')
|
||||
|
||||
|
||||
@admin.register(MailEmail)
|
||||
class MailEmailAdmin(admin.ModelAdmin):
|
||||
list_display = ('subject', 'sender_email', 'account', 'llm_verdict', 'feedback', 'received_at')
|
||||
list_filter = ('account', 'llm_verdict', 'feedback')
|
||||
search_fields = ('subject', 'sender_email', 'sender_domain')
|
||||
readonly_fields = ('message_id', 'received_at', 'notified_at', 'feedback_at')
|
||||
|
||||
|
||||
@admin.register(MailNotificationToken)
|
||||
class MailNotificationTokenAdmin(admin.ModelAdmin):
|
||||
list_display = ('token', 'email', 'created_at')
|
||||
readonly_fields = ('token', 'created_at')
|
||||
71
backend/apps/mail/migrations/0001_initial.py
Normal file
71
backend/apps/mail/migrations/0001_initial.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# Generated by Django 5.0 on 2026-02-22 00:20
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MailEmail',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('account', models.CharField(choices=[('xserver', 'Xserver'), ('gmail', 'Gmail'), ('hotmail', 'Hotmail')], max_length=20, verbose_name='アカウント')),
|
||||
('message_id', models.CharField(max_length=500, unique=True, verbose_name='Message-ID')),
|
||||
('sender_email', models.EmailField(max_length=254, verbose_name='送信者アドレス')),
|
||||
('sender_domain', models.CharField(max_length=255, verbose_name='送信者ドメイン')),
|
||||
('subject', models.CharField(max_length=500, verbose_name='件名')),
|
||||
('body_preview', models.TextField(verbose_name='本文冒頭')),
|
||||
('received_at', models.DateTimeField(verbose_name='受信日時')),
|
||||
('llm_verdict', models.CharField(choices=[('important', '重要'), ('not_important', '重要でない')], max_length=20, verbose_name='LLM判定')),
|
||||
('notified_at', models.DateTimeField(blank=True, null=True, verbose_name='LINE通知日時')),
|
||||
('feedback', models.CharField(blank=True, choices=[('important', '重要だった'), ('not_important', '普通のメール'), ('never_notify', '今後通知しない')], max_length=20, null=True, verbose_name='フィードバック')),
|
||||
('feedback_at', models.DateTimeField(blank=True, null=True, verbose_name='フィードバック日時')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '受信メール',
|
||||
'verbose_name_plural': '受信メール',
|
||||
'ordering': ['-received_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MailSender',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='メールアドレス')),
|
||||
('domain', models.CharField(blank=True, max_length=255, null=True, verbose_name='ドメイン')),
|
||||
('rule', models.CharField(choices=[('never_notify', '通知しない')], default='never_notify', max_length=20, verbose_name='ルール')),
|
||||
('note', models.TextField(blank=True, verbose_name='メモ')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '送信者ルール',
|
||||
'verbose_name_plural': '送信者ルール',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MailNotificationToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='トークン')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('email', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='notification_token', to='mail.mailemail', verbose_name='メール')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '通知トークン',
|
||||
'verbose_name_plural': '通知トークン',
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='mailsender',
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('domain__isnull', True), ('email__isnull', False)), models.Q(('domain__isnull', False), ('email__isnull', True)), _connector='OR'), name='mail_sender_email_or_domain'),
|
||||
),
|
||||
]
|
||||
0
backend/apps/mail/migrations/__init__.py
Normal file
0
backend/apps/mail/migrations/__init__.py
Normal file
99
backend/apps/mail/models.py
Normal file
99
backend/apps/mail/models.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
|
||||
|
||||
class MailSender(models.Model):
|
||||
"""送信者ルール(never_notify: 通知しない)"""
|
||||
email = models.EmailField(null=True, blank=True, verbose_name="メールアドレス")
|
||||
domain = models.CharField(max_length=255, null=True, blank=True, verbose_name="ドメイン")
|
||||
rule = models.CharField(
|
||||
max_length=20,
|
||||
choices=[('never_notify', '通知しない')],
|
||||
default='never_notify',
|
||||
verbose_name="ルール"
|
||||
)
|
||||
note = models.TextField(blank=True, verbose_name="メモ")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "送信者ルール"
|
||||
verbose_name_plural = "送信者ルール"
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=(
|
||||
models.Q(email__isnull=False, domain__isnull=True) |
|
||||
models.Q(email__isnull=True, domain__isnull=False)
|
||||
),
|
||||
name='mail_sender_email_or_domain'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
value = self.email or self.domain
|
||||
kind = "アドレス" if self.email else "ドメイン"
|
||||
return f"[{kind}] {value}"
|
||||
|
||||
|
||||
ACCOUNT_CHOICES = [
|
||||
('xserver', 'Xserver'),
|
||||
('gmail', 'Gmail'),
|
||||
('hotmail', 'Hotmail'),
|
||||
]
|
||||
|
||||
FEEDBACK_CHOICES = [
|
||||
('important', '重要だった'),
|
||||
('not_important', '普通のメール'),
|
||||
('never_notify', '今後通知しない'),
|
||||
]
|
||||
|
||||
|
||||
class MailEmail(models.Model):
|
||||
"""受信メール記録(LLMに渡したメール)"""
|
||||
account = models.CharField(max_length=20, choices=ACCOUNT_CHOICES, verbose_name="アカウント")
|
||||
message_id = models.CharField(max_length=500, unique=True, verbose_name="Message-ID")
|
||||
sender_email = models.EmailField(verbose_name="送信者アドレス")
|
||||
sender_domain = models.CharField(max_length=255, verbose_name="送信者ドメイン")
|
||||
subject = models.CharField(max_length=500, verbose_name="件名")
|
||||
body_preview = models.TextField(verbose_name="本文冒頭")
|
||||
received_at = models.DateTimeField(verbose_name="受信日時")
|
||||
llm_verdict = models.CharField(
|
||||
max_length=20,
|
||||
choices=[('important', '重要'), ('not_important', '重要でない')],
|
||||
verbose_name="LLM判定"
|
||||
)
|
||||
notified_at = models.DateTimeField(null=True, blank=True, verbose_name="LINE通知日時")
|
||||
feedback = models.CharField(
|
||||
max_length=20,
|
||||
choices=FEEDBACK_CHOICES,
|
||||
null=True, blank=True,
|
||||
verbose_name="フィードバック"
|
||||
)
|
||||
feedback_at = models.DateTimeField(null=True, blank=True, verbose_name="フィードバック日時")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "受信メール"
|
||||
verbose_name_plural = "受信メール"
|
||||
ordering = ['-received_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.subject} ({self.sender_email})"
|
||||
|
||||
|
||||
class MailNotificationToken(models.Model):
|
||||
"""LINEフィードバックURL用トークン(有効期限なし)"""
|
||||
email = models.OneToOneField(
|
||||
MailEmail,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='notification_token',
|
||||
verbose_name="メール"
|
||||
)
|
||||
token = models.UUIDField(default=uuid.uuid4, unique=True, verbose_name="トークン")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "通知トークン"
|
||||
verbose_name_plural = "通知トークン"
|
||||
|
||||
def __str__(self):
|
||||
return str(self.token)
|
||||
42
backend/apps/mail/serializers.py
Normal file
42
backend/apps/mail/serializers.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from rest_framework import serializers
|
||||
from .models import MailSender, MailEmail
|
||||
|
||||
|
||||
class MailSenderSerializer(serializers.ModelSerializer):
|
||||
type = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MailSender
|
||||
fields = ['id', 'type', 'email', 'domain', 'rule', 'note', 'created_at']
|
||||
|
||||
def get_type(self, obj):
|
||||
return 'address' if obj.email else 'domain'
|
||||
|
||||
def validate(self, data):
|
||||
email = data.get('email')
|
||||
domain = data.get('domain')
|
||||
if not email and not domain:
|
||||
raise serializers.ValidationError("email または domain のどちらかを指定してください")
|
||||
if email and domain:
|
||||
raise serializers.ValidationError("email と domain を同時に指定することはできません")
|
||||
return data
|
||||
|
||||
|
||||
class MailEmailCreateSerializer(serializers.ModelSerializer):
|
||||
"""Windmill からの POST 用"""
|
||||
class Meta:
|
||||
model = MailEmail
|
||||
fields = [
|
||||
'account', 'message_id', 'sender_email', 'sender_domain',
|
||||
'subject', 'body_preview', 'received_at', 'llm_verdict'
|
||||
]
|
||||
|
||||
|
||||
class FeedbackDetailSerializer(serializers.ModelSerializer):
|
||||
"""フィードバックページ表示用"""
|
||||
class Meta:
|
||||
model = MailEmail
|
||||
fields = [
|
||||
'id', 'sender_email', 'sender_domain', 'subject',
|
||||
'body_preview', 'received_at', 'feedback'
|
||||
]
|
||||
19
backend/apps/mail/urls.py
Normal file
19
backend/apps/mail/urls.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from . import views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'senders', views.MailSenderViewSet, basename='mail-sender')
|
||||
|
||||
urlpatterns = [
|
||||
# Windmill向けAPI(APIキー認証)
|
||||
path('sender-rule/', views.SenderRuleView.as_view(), name='mail-sender-rule'),
|
||||
path('sender-context/', views.SenderContextView.as_view(), name='mail-sender-context'),
|
||||
path('emails/', views.MailEmailCreateView.as_view(), name='mail-email-create'),
|
||||
|
||||
# フィードバック(認証不要、UUIDトークン)
|
||||
path('feedback/<uuid:token>/', views.FeedbackView.as_view(), name='mail-feedback'),
|
||||
|
||||
# ルール管理(JWT認証)
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
204
backend/apps/mail/views.py
Normal file
204
backend/apps/mail/views.py
Normal 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 向け 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 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']
|
||||
Reference in New Issue
Block a user