📧
Django REST Framework + Celeryで予約確認メール自動化
はじめに
ホテルの予約確認メールは、多言語対応やチェックイン時刻に応じた送信タイミングなど、複雑な要件があります。Django REST Framework + Celeryで、スケーラブルなメール自動化システムを実装します。
Celeryタスクの基本設定
# settings.py
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'Asia/Tokyo'
# Celery Beat スケジュール
CELERY_BEAT_SCHEDULE = {
'send-reminder-emails': {
'task': 'bookings.tasks.send_checkin_reminders',
'schedule': crontab(hour=9, minute=0), # 毎日9:00
},
'retry-failed-emails': {
'task': 'bookings.tasks.retry_failed_emails',
'schedule': timedelta(minutes=30),
},
}
# celery.py
from celery import Celery
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hotel.settings')
app = Celery('hotel')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
多言語テンプレート管理システム
# models.py
from django.db import models
from django.contrib.postgres.fields import JSONField
class EmailTemplate(models.Model):
TEMPLATE_TYPES = [
('confirmation', '予約確認'),
('reminder', 'チェックインリマインダー'),
('thank_you', 'サンキューメール'),
('cancellation', 'キャンセル確認'),
]
template_type = models.CharField(max_length=20, choices=TEMPLATE_TYPES)
language = models.CharField(max_length=5, db_index=True) # ja, en, zh, ko
subject = models.CharField(max_length=200)
body_html = models.TextField()
body_text = models.TextField()
variables = JSONField(default=dict) # 使用可能な変数のスキーマ
is_active = models.BooleanField(default=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['template_type', 'language']
indexes = [
models.Index(fields=['template_type', 'language', 'is_active']),
]
class EmailLog(models.Model):
STATUS_CHOICES = [
('pending', '送信待ち'),
('sent', '送信済み'),
('failed', '失敗'),
('bounced', 'バウンス'),
]
booking = models.ForeignKey('Booking', on_delete=models.CASCADE)
template_type = models.CharField(max_length=20)
recipient = models.EmailField()
language = models.CharField(max_length=5)
status = models.CharField(max_length=10, choices=STATUS_CHOICES)
sent_at = models.DateTimeField(null=True)
error_message = models.TextField(null=True)
retry_count = models.IntegerField(default=0)
message_id = models.CharField(max_length=255, null=True) # SendGrid/SES ID
非同期メール送信タスク
# tasks.py
from celery import shared_task, Task
from celery.exceptions import Retry
from django.template import Template, Context
from django.core.mail import EmailMultiAlternatives
from django.conf import settings
import logging
from typing import Dict, Optional
import json
logger = logging.getLogger(__name__)
class EmailTask(Task):
"""カスタムタスククラスでリトライロジック実装"""
autoretry_for = (Exception,)
max_retries = 3
default_retry_delay = 60 # 1分後にリトライ
def retry(self, **kwargs):
kwargs['countdown'] = self.default_retry_delay * (self.request.retries + 1)
return super().retry(**kwargs)
@shared_task(base=EmailTask, bind=True)
def send_booking_confirmation(self, booking_id: int, force_language: Optional[str] = None):
"""予約確認メール送信タスク"""
try:
from .models import Booking, EmailTemplate, EmailLog
booking = Booking.objects.select_related('guest', 'room_type').get(id=booking_id)
# 言語決定ロジック
language = force_language or booking.guest.preferred_language or 'en'
# テンプレート取得(フォールバック付き)
template = get_email_template('confirmation', language)
# コンテキストデータ準備
context_data = {
'guest_name': booking.guest.get_full_name(),
'booking_number': booking.confirmation_number,
'check_in_date': booking.check_in_date.strftime('%Y年%m月%d日') if language == 'ja' else booking.check_in_date.strftime('%B %d, %Y'),
'check_out_date': booking.check_out_date.strftime('%Y年%m月%d日') if language == 'ja' else booking.check_out_date.strftime('%B %d, %Y'),
'room_type': booking.room_type.get_display_name(language),
'total_amount': format_currency(booking.total_amount, booking.currency, language),
'hotel_name': settings.HOTEL_NAME,
'hotel_phone': settings.HOTEL_PHONE,
'google_maps_link': generate_maps_link(language),
'special_requests': booking.special_requests,
}
# テンプレートレンダリング
subject = Template(template.subject).render(Context(context_data))
html_body = Template(template.body_html).render(Context(context_data))
text_body = Template(template.body_text).render(Context(context_data))
# メール送信
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[booking.guest.email],
reply_to=[settings.HOTEL_REPLY_EMAIL],
)
msg.attach_alternative(html_body, "text/html")
# カレンダー招待添付(オプション)
if booking.guest.wants_calendar_invite:
ics_content = generate_ics(booking, language)
msg.attach('booking.ics', ics_content, 'text/calendar')
# PDFバウチャー添付
pdf_content = generate_booking_pdf(booking, language)
msg.attach(f'booking_{booking.confirmation_number}.pdf', pdf_content, 'application/pdf')
# 送信実行
message_id = msg.send()
# ログ記録
EmailLog.objects.create(
booking=booking,
template_type='confirmation',
recipient=booking.guest.email,
language=language,
status='sent',
sent_at=timezone.now(),
message_id=message_id,
)
logger.info(f"Confirmation email sent for booking {booking_id}")
return {'status': 'success', 'message_id': message_id}
except Booking.DoesNotExist:
logger.error(f"Booking {booking_id} not found")
raise
except EmailTemplate.DoesNotExist:
# テンプレートが見つからない場合は管理者に通知
notify_admin_template_missing('confirmation', language)
raise
except Exception as exc:
logger.error(f"Failed to send email for booking {booking_id}: {exc}")
# エラーログ記録
EmailLog.objects.create(
booking_id=booking_id,
template_type='confirmation',
status='failed',
error_message=str(exc),
retry_count=self.request.retries,
)
# リトライ
raise self.retry(exc=exc)
@shared_task
def send_checkin_reminders():
"""チェックイン前日のリマインダー一括送信"""
from .models import Booking
from datetime import timedelta
tomorrow = timezone.now().date() + timedelta(days=1)
bookings = Booking.objects.filter(
check_in_date=tomorrow,
status='confirmed',
reminder_sent=False
).select_related('guest')
for booking in bookings:
send_reminder_email.delay(booking.id)
logger.info(f"Queued {bookings.count()} reminder emails")
@shared_task(bind=True)
def send_reminder_email(self, booking_id: int):
"""チェックインリマインダー送信"""
from .models import Booking, EmailTemplate
booking = Booking.objects.get(id=booking_id)
language = booking.guest.preferred_language or 'en'
template = get_email_template('reminder', language)
# 時間帯別メッセージ
check_in_time = booking.estimated_arrival_time or '15:00'
early_checkin_available = check_in_time < '14:00'
context_data = {
'guest_name': booking.guest.first_name, # リマインダーは名前のみ
'check_in_time': check_in_time,
'early_checkin_note': get_early_checkin_message(language) if early_checkin_available else '',
'weather_forecast': get_weather_forecast(booking.check_in_date, language),
'local_events': get_local_events(booking.check_in_date, language),
'mobile_checkin_url': f"{settings.MOBILE_CHECKIN_URL}?code={booking.confirmation_number}",
}
# 送信処理(省略)
send_templated_email(template, context_data, booking.guest.email)
booking.reminder_sent = True
booking.save(update_fields=['reminder_sent'])
def get_email_template(template_type: str, language: str) -> 'EmailTemplate':
"""言語フォールバック付きテンプレート取得"""
from .models import EmailTemplate
# 優先順位: requested language -> English -> Japanese -> any active
languages_priority = [language, 'en', 'ja']
for lang in languages_priority:
try:
return EmailTemplate.objects.get(
template_type=template_type,
language=lang,
is_active=True
)
except EmailTemplate.DoesNotExist:
continue
# 最後の手段:アクティブなテンプレートを何でも取得
return EmailTemplate.objects.filter(
template_type=template_type,
is_active=True
).first()
APIエンドポイント実装
# views.py
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
class BookingViewSet(viewsets.ModelViewSet):
queryset = Booking.objects.all()
serializer_class = BookingSerializer
@action(detail=True, methods=['post'])
def resend_confirmation(self, request, pk=None):
"""確認メール再送信エンドポイント"""
booking = self.get_object()
# レート制限チェック
recent_sends = EmailLog.objects.filter(
booking=booking,
template_type='confirmation',
sent_at__gte=timezone.now() - timedelta(hours=1)
).count()
if recent_sends >= 3:
return Response(
{'error': '1時間に3回までしか再送信できません'},
status=status.HTTP_429_TOO_MANY_REQUESTS
)
# 非同期タスクキュー
task = send_booking_confirmation.delay(
booking.id,
force_language=request.data.get('language')
)
return Response({
'task_id': task.id,
'status': 'queued',
'message': '確認メールを再送信しています'
})
まとめ
Celeryによる非同期処理により、大量のメール送信でもAPIレスポンスを高速に保てます。多言語テンプレート管理、リトライ機構、レート制限により、国際的なホテル運営に対応できる堅牢なシステムを実現しました。
次回は、宿泊需要予測をブラウザで実行する方法について解説します。
Discussion