📧

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