From df16ab1ee0de1e11e1dea1079cdc15656878f31c Mon Sep 17 00:00:00 2001 From: Akira Date: Sun, 22 Feb 2026 09:49:28 +0900 Subject: [PATCH] =?UTF-8?q?=E5=A4=89=E6=9B=B4=E5=86=85=E5=AE=B9=E3=81=BE?= =?UTF-8?q?=E3=81=A8=E3=82=81=20=E3=83=90=E3=83=83=E3=82=AF=E3=82=A8?= =?UTF-8?q?=E3=83=B3=E3=83=89=20models.py=20=E2=80=94=20MailSender.rule=20?= =?UTF-8?q?=E3=81=AB=20always=5Fnotify=20=E8=BF=BD=E5=8A=A0=E3=80=81MailEm?= =?UTF-8?q?ail.feedback=20=E3=81=AB=E3=82=82=E8=BF=BD=E5=8A=A0=E3=80=81?= =?UTF-8?q?=E3=83=9E=E3=82=A4=E3=82=B0=E3=83=AC=E3=83=BC=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E9=81=A9=E7=94=A8=E6=B8=88=E3=81=BF=20views.py=20?= =?UTF-8?q?=E2=80=94=20FeedbackView.post=20=E3=81=8C=20always=5Fnotify=20?= =?UTF-8?q?=E3=82=92=E5=8F=97=E3=81=91=E5=8F=96=E3=81=A3=E3=81=9F=E3=82=89?= =?UTF-8?q?=20MailSender=20=E3=83=AB=E3=83=BC=E3=83=AB=E3=82=92=E4=BD=9C?= =?UTF-8?q?=E6=88=90=EF=BC=88never=5Fnotify=20=E3=81=A8=E5=90=8C=E3=81=98?= =?UTF-8?q?=E4=BB=95=E7=B5=84=E3=81=BF=EF=BC=89=20=E3=83=95=E3=83=AD?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=82=A8=E3=83=B3=E3=83=89=20feedback/[token?= =?UTF-8?q?]/page.tsx=20=E2=80=94=204=E6=8A=9E=E7=9B=AE=E3=80=8C?= =?UTF-8?q?=F0=9F=94=94=20=E5=B8=B8=E3=81=AB=E9=80=9A=E7=9F=A5=E3=81=97?= =?UTF-8?q?=E3=81=A6=E3=81=BB=E3=81=97=E3=81=84=E3=80=8D=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E3=80=82=E3=82=B9=E3=82=B3=E3=83=BC=E3=83=97=E9=81=B8?= =?UTF-8?q?=E6=8A=9E=EF=BC=88=E3=82=A2=E3=83=89=E3=83=AC=E3=82=B9/?= =?UTF-8?q?=E3=83=89=E3=83=A1=E3=82=A4=E3=83=B3=EF=BC=89=E3=82=82=E3=81=82?= =?UTF-8?q?=E3=82=8A=E3=80=82=E8=89=B2=E3=81=AFteal=E7=B3=BB=E3=81=A7?= =?UTF-8?q?=E5=8C=BA=E5=88=A5=20mail/rules/page.tsx=20=E2=80=94=20?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=83=95=E3=82=A9=E3=83=BC=E3=83=A0=E3=81=AB?= =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=AB=E7=A8=AE=E5=88=A5=E3=82=BB=E3=83=AC?= =?UTF-8?q?=E3=82=AF=E3=82=BF=E3=82=92=E8=BF=BD=E5=8A=A0=E3=80=81=E4=B8=80?= =?UTF-8?q?=E8=A6=A7=E3=81=AB=E3=80=8C=E5=B8=B8=E3=81=AB=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E3=80=8D=E3=83=90=E3=83=83=E3=82=B8=EF=BC=88teal=EF=BC=89?= =?UTF-8?q?=E3=82=92=E8=A1=A8=E7=A4=BA=20Windmill=E5=81=B4=E3=81=AE?= =?UTF-8?q?=E4=BD=BF=E3=81=84=E6=96=B9=EF=BC=88=E3=83=A1=E3=83=A2=EF=BC=89?= =?UTF-8?q?=20GET=20/api/mail/sender-rule/=20=E3=81=AE=E3=83=AC=E3=82=B9?= =?UTF-8?q?=E3=83=9D=E3=83=B3=E3=82=B9=E3=81=AB=20"rule":=20"always=5Fnoti?= =?UTF-8?q?fy"=20=E3=81=8C=E8=BF=94=E3=81=A3=E3=81=A6=E3=81=8D=E3=81=9F?= =?UTF-8?q?=E3=82=89=E3=80=81LLM=E3=82=92=E3=82=B9=E3=82=AD=E3=83=83?= =?UTF-8?q?=E3=83=97=E3=81=97=E3=81=A6=20llm=5Fverdict:=20"important"=20?= =?UTF-8?q?=E3=81=A7=E7=9B=B4=E6=8E=A5=20POST=20/api/mail/emails/=20?= =?UTF-8?q?=E3=82=92=E5=91=BC=E3=81=B9=E3=81=B0OK=E3=81=A7=E3=81=99?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ailemail_feedback_alter_mailsender_rule.py | 23 +++++ backend/apps/mail/models.py | 11 ++- backend/apps/mail/views.py | 10 +-- .../src/app/mail/feedback/[token]/page.tsx | 84 ++++++++++++------- frontend/src/app/mail/rules/page.tsx | 42 ++++++++-- frontend/src/types/index.ts | 4 +- 6 files changed, 129 insertions(+), 45 deletions(-) create mode 100644 backend/apps/mail/migrations/0002_alter_mailemail_feedback_alter_mailsender_rule.py diff --git a/backend/apps/mail/migrations/0002_alter_mailemail_feedback_alter_mailsender_rule.py b/backend/apps/mail/migrations/0002_alter_mailemail_feedback_alter_mailsender_rule.py new file mode 100644 index 0000000..42248c5 --- /dev/null +++ b/backend/apps/mail/migrations/0002_alter_mailemail_feedback_alter_mailsender_rule.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0 on 2026-02-22 00:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mail', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='mailemail', + name='feedback', + field=models.CharField(blank=True, choices=[('important', '重要だった'), ('not_important', '普通のメール'), ('never_notify', '今後通知しない'), ('always_notify', '常に通知してほしい')], max_length=20, null=True, verbose_name='フィードバック'), + ), + migrations.AlterField( + model_name='mailsender', + name='rule', + field=models.CharField(choices=[('always_notify', '常に通知'), ('never_notify', '通知しない')], default='never_notify', max_length=20, verbose_name='ルール'), + ), + ] diff --git a/backend/apps/mail/models.py b/backend/apps/mail/models.py index b2b075a..2f5c4a6 100644 --- a/backend/apps/mail/models.py +++ b/backend/apps/mail/models.py @@ -2,13 +2,19 @@ import uuid from django.db import models +SENDER_RULE_CHOICES = [ + ('always_notify', '常に通知'), + ('never_notify', '通知しない'), +] + + 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', '通知しない')], + choices=SENDER_RULE_CHOICES, default='never_notify', verbose_name="ルール" ) @@ -45,6 +51,7 @@ FEEDBACK_CHOICES = [ ('important', '重要だった'), ('not_important', '普通のメール'), ('never_notify', '今後通知しない'), + ('always_notify', '常に通知してほしい'), ] diff --git a/backend/apps/mail/views.py b/backend/apps/mail/views.py index 5b30490..8d496c6 100644 --- a/backend/apps/mail/views.py +++ b/backend/apps/mail/views.py @@ -159,7 +159,7 @@ class FeedbackView(APIView): mail_email = self._get_mail_email(token) feedback = request.data.get('feedback') - valid_feedbacks = ['important', 'not_important', 'never_notify'] + valid_feedbacks = ['important', 'not_important', 'never_notify', 'always_notify'] if feedback not in valid_feedbacks: return Response( {'error': f'feedback は {valid_feedbacks} のいずれかを指定してください'}, @@ -171,18 +171,18 @@ class FeedbackView(APIView): mail_email.feedback_at = timezone.now() mail_email.save(update_fields=['feedback', 'feedback_at']) - # 「今後通知しない」の場合、送信者ルールを作成/更新 - if feedback == 'never_notify': + # 送信者ルールを伴うフィードバックの処理 + 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': 'never_notify'} + defaults={'domain': None, 'rule': feedback} ) elif scope == 'domain': MailSender.objects.update_or_create( domain=mail_email.sender_domain, - defaults={'email': None, 'rule': 'never_notify'} + defaults={'email': None, 'rule': feedback} ) return Response({'status': 'ok'}) diff --git a/frontend/src/app/mail/feedback/[token]/page.tsx b/frontend/src/app/mail/feedback/[token]/page.tsx index 7142845..4d7115d 100644 --- a/frontend/src/app/mail/feedback/[token]/page.tsx +++ b/frontend/src/app/mail/feedback/[token]/page.tsx @@ -3,6 +3,8 @@ import { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; +type FeedbackValue = 'important' | 'not_important' | 'never_notify' | 'always_notify'; + interface MailEmailFeedback { id: number; sender_email: string; @@ -10,11 +12,14 @@ interface MailEmailFeedback { subject: string; body_preview: string; received_at: string; - feedback: 'important' | 'not_important' | 'never_notify' | null; + feedback: FeedbackValue | null; } const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; +// スコープ選択が必要なフィードバック +const NEEDS_SCOPE: FeedbackValue[] = ['never_notify', 'always_notify']; + export default function FeedbackPage() { const params = useParams(); const token = params.token as string; @@ -23,7 +28,7 @@ export default function FeedbackPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [selected, setSelected] = useState<'important' | 'not_important' | 'never_notify' | null>(null); + const [selected, setSelected] = useState(null); const [showScopeChoice, setShowScopeChoice] = useState(false); const [scope, setScope] = useState<'address' | 'domain'>('address'); const [submitting, setSubmitting] = useState(false); @@ -34,18 +39,14 @@ export default function FeedbackPage() { try { const res = await fetch(`${API_URL}/api/mail/feedback/${token}/`); if (!res.ok) { - if (res.status === 404) { - setError('このフィードバックリンクは無効です'); - } else { - setError('メール情報の取得に失敗しました'); - } + setError(res.status === 404 ? 'このフィードバックリンクは無効です' : 'メール情報の取得に失敗しました'); return; } const data = await res.json(); setEmail(data); if (data.feedback) { setSelected(data.feedback); - if (data.feedback === 'never_notify') { + if (NEEDS_SCOPE.includes(data.feedback)) { setShowScopeChoice(true); } } @@ -58,19 +59,17 @@ export default function FeedbackPage() { fetchEmail(); }, [token]); - const handleSelect = (value: 'important' | 'not_important' | 'never_notify') => { + const handleSelect = (value: FeedbackValue) => { setSelected(value); - setShowScopeChoice(value === 'never_notify'); + setShowScopeChoice(NEEDS_SCOPE.includes(value)); setSubmitted(false); - if (value !== 'never_notify') { + setError(null); + if (!NEEDS_SCOPE.includes(value)) { submitFeedback(value, undefined); } }; - const submitFeedback = async ( - feedback: 'important' | 'not_important' | 'never_notify', - feedbackScope: 'address' | 'domain' | undefined - ) => { + const submitFeedback = async (feedback: FeedbackValue, feedbackScope: 'address' | 'domain' | undefined) => { setSubmitting(true); try { const body: Record = { feedback }; @@ -81,7 +80,7 @@ export default function FeedbackPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); - if (!res.ok) throw new Error('送信に失敗しました'); + if (!res.ok) throw new Error(); setSubmitted(true); setShowScopeChoice(false); setEmail((prev) => (prev ? { ...prev, feedback } : prev)); @@ -92,8 +91,10 @@ export default function FeedbackPage() { } }; - const handleNeverNotifyConfirm = () => { - submitFeedback('never_notify', scope); + const handleScopeConfirm = () => { + if (selected && NEEDS_SCOPE.includes(selected)) { + submitFeedback(selected, scope); + } }; const formatDate = (iso: string) => { @@ -124,10 +125,11 @@ export default function FeedbackPage() { if (!email) return null; - const feedbackLabel = { + const feedbackLabel: Record = { important: '✅ 重要だった', not_important: '📧 普通のメール', never_notify: '🔇 今後通知しない', + always_notify: '🔔 常に通知してほしい', }; return ( @@ -178,7 +180,7 @@ export default function FeedbackPage() { {/* 現在のフィードバック表示 */} {email.feedback && !submitted && (

- 現在の評価: {feedbackLabel[email.feedback]} (変更できます) + 現在の評価: {feedbackLabel[email.feedback]} (変更できます)

)} @@ -209,6 +211,20 @@ export default function FeedbackPage() { 📧 普通のメール(通知不要) + {/* 常に通知してほしい */} + + {/* 今後通知しない */} - {/* 今後通知しない → スコープ選択 */} - {showScopeChoice && ( -
-

通知をやめる範囲を選んでください

+ {/* スコープ選択(常に通知 / 今後通知しない で展開) */} + {showScopeChoice && selected && NEEDS_SCOPE.includes(selected) && ( +
+

+ {selected === 'always_notify' ? '常に通知する範囲を選んでください' : '通知をやめる範囲を選んでください'} +