Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions mailer/bilf-mail-council.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="sv">
<head>
<meta charset="UTF-8">
<title>Ny kollegie-bilbokning {{ booking.date }}</title>
<title>Ny utskotts-bilbokning {{ booking.date }}</title>
</head>
<body style="font-family: sans-serif; color: #333; line-height: 1.5; margin:0; padding:0;">
<div style="text-align: center; padding: 10px; background-color: #f5f5f5; font-size: 14px; color: #666;">
Expand All @@ -23,7 +23,7 @@
<tr>
<td align="center">
<p>Hej Bilf!,</p>
<p>En ny kollegie-bilbokning har gjorts!</p>
<p>En ny utskotts-bilbokning har gjorts!</p>
<p>F-älgen är bokad av {{ booking.name }} från {{ booking.council_sv }} den {{ booking.date }} klockan {{ booking.time }}. Detta bör hantera sig självt men vi tänkte att du kanske vill veta.</p>
<p>Med Vänliga hälsningar,<br>
De små tomtarna som sitter i sektionens SMTP-server och matar brevduvorna</p>
Expand All @@ -49,7 +49,7 @@
<td align="center">
<p>Hello BilF!,</p>
<p>A new council booking has been made!</p>
<p>The car is booked by {{ booking.name }} from {{ booking.council_en }} on {{ booking.date }} at {{ booking.time }}. It should handle itself but we though you might want to know.</p>
<p>The car is booked by {{ booking.name }} from {{ booking.council_en }} on {{ booking.date }} at {{ booking.time }}. It should handle itself, but we thought you might want to know.</p>
<p>Best regards,<br>
The little gnomes that inhabit the guild's SMTP server and feed the carrier pidgeons</p>
</td>
Expand Down
10 changes: 5 additions & 5 deletions mailer/bilf-mail-private.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="sv">
<head>
<meta charset="UTF-8">
<title>Ny privat bilbokning {{ booking.date }}</title>
<title>{{ title_sv }} {{ booking.date }}</title>
</head>
<body style="font-family: sans-serif; color: #333; line-height: 1.5; margin:0; padding:0;">
<div style="text-align: center; padding: 10px; background-color: #f5f5f5; font-size: 14px; color: #666;">
Expand All @@ -23,8 +23,8 @@
<tr>
<td align="center">
<p>Hej Bilf!,</p>
<p>En ny <strong>PRIVAT</strong> bilbokning har gjorts!</p>
<p>F-älgen är bokad av {{ booking.name }} den {{ booking.date }} klockan {{ booking.time }}. HANTERA!</p>
<p>{{ heading_sv }}</p>
<p>{{ body_sv }}</p>
<p>Med Vänliga hälsningar,<br>
De små tomtarna som sitter i sektionens SMTP-server och matar brevduvorna</p>
</td>
Expand All @@ -48,8 +48,8 @@
<tr>
<td align="center">
<p>Hello BilF!,</p>
<p>A new <strong>PRIVATE</strong> booking has been made!</p>
<p>The car is booked by {{ booking.name }} on {{ booking.date }} at {{ booking.time }}. TAKE CARE OF IT!</p>
<p>{{ heading_en }}</p>
<p>{{ body_en }}</p>
<p>Best regards,<br>
The little gnomes that inhabit the guild's SMTP server and feed the carrier pidgeons</p>
</td>
Expand Down
186 changes: 168 additions & 18 deletions mailer/bilf_mailer.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,80 @@
import os
import html as python_html
import uuid
from datetime import datetime, timezone
from typing import Literal
from zoneinfo import ZoneInfo


from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from db_models.car_booking_model import CarBooking_DB
from mailer.mail_constants import (
STANDARD_SENDER,
)
from mailer.mail_core import send_mail_to_address

BILF_ADDRESS = "[email protected]"

def bilf_mailer(booking: CarBooking_DB) -> None:
BookingAction = Literal["created", "updated", "deleted", "moved_to_council"]

path = os.getcwd()
# Actions that remove the private booking from BilF's calendar
CANCEL_ACTIONS = ("deleted", "moved_to_council")

# Texts for private booking mails, keyed by action. {{ booking.* }} placeholders are filled in afterwards.
PRIVATE_TEXTS: dict[str, dict[str, str]] = {
"created": {
"subject": "Ny PRIVAT bilbokning / New PRIVATE car booking",
"title_sv": "Ny privat bilbokning",
"heading_sv": "En ny <strong>PRIVAT</strong> bilbokning har gjorts!",
"body_sv": "F-älgen är bokad av {{ booking.name }} den {{ booking.date }} klockan {{ booking.time }}. HANTERA!",
"heading_en": "A new <strong>PRIVATE</strong> booking has been made!",
"body_en": "The car is booked by {{ booking.name }} on {{ booking.date }} at {{ booking.time }}. TAKE CARE OF IT!",
},
"updated": {
"subject": "Updated private car booking / Uppdaterad privat bilbokning",
"title_sv": "Uppdaterad privat bilbokning",
"heading_sv": "En <strong>PRIVAT</strong> bilbokning har uppdaterats!",
"body_sv": "Bokningen av {{ booking.name }} gäller nu den {{ booking.date }} klockan {{ booking.time }}. HANTERA!",
"heading_en": "A <strong>PRIVATE</strong> booking has been updated!",
"body_en": "The booking by {{ booking.name }} is now on {{ booking.date }} at {{ booking.time }}. TAKE CARE OF IT!",
},
"deleted": {
"subject": "Avbokad PRIVAT bilbokning / Cancelled PRIVATE car booking",
"title_sv": "Avbokad privat bilbokning",
"heading_sv": "En <strong>PRIVAT</strong> bilbokning har avbokats!",
"body_sv": "Bokningen av {{ booking.name }} den {{ booking.date }} klockan {{ booking.time }} har tagits bort.",
"heading_en": "A <strong>PRIVATE</strong> booking has been cancelled!",
"body_en": "The booking by {{ booking.name }} on {{ booking.date }} at {{ booking.time }} has been deleted.",
},
"moved_to_council": {
"subject": "PRIVAT bilbokning flyttad till utskott / PRIVATE car booking moved to council",
"title_sv": "Privat bilbokning flyttad till utskott",
"heading_sv": "En <strong>PRIVAT</strong> bilbokning har gjorts om till en utskottsbokning!",
"body_sv": (
"Bokningen av {{ booking.name }} den {{ booking.date }} klockan {{ booking.time }} "
"är nu en utskottsbokning för {{ booking.council_sv }}."
),
"heading_en": "A <strong>PRIVATE</strong> booking has been changed to a council booking!",
"body_en": (
"The booking by {{ booking.name }} on {{ booking.date }} at {{ booking.time }} "
"is now a council booking for {{ booking.council_en }}."
),
},
}

# ICS SEQUENCE must increase with every revision of an event, so derive it from the send time
SEQUENCE_EPOCH = datetime(2026, 1, 1, tzinfo=timezone.utc)

if booking.personal:
with open(f"{path}/mailer/bilf-mail-private.html", "r", encoding="utf-8") as f:
html = f.read()
else:
with open(f"{path}/mailer/bilf-mail-council.html", "r", encoding="utf-8") as f:
html = f.read()

def render_bilf_mail(booking: CarBooking_DB, action: BookingAction = "created") -> str:
template_name = "bilf-mail-private.html" if booking.personal or action in CANCEL_ACTIONS else "bilf-mail-council.html"
template_path = os.path.join(os.path.dirname(__file__), template_name)

with open(template_path, "r", encoding="utf-8") as f:
html = f.read()

for key, text in PRIVATE_TEXTS[action].items():
html = html.replace("{{ " + key + " }}", text)

stockholm_tz = ZoneInfo("Europe/Stockholm")
date_string = booking.start_time.astimezone(stockholm_tz).strftime("%Y-%m-%d")
Expand All @@ -29,22 +83,118 @@ def bilf_mailer(booking: CarBooking_DB) -> None:
html = html.replace(
"{{ booking.name }}", python_html.escape(booking.user.first_name + " " + booking.user.last_name, quote=True)
)

html = html.replace("{{ booking.date }}", date_string)

html = html.replace("{{ booking.time }}", time_string)

if booking.council is not None:
html = html.replace("{{ booking.council_en }}", python_html.escape(booking.council.name_en, quote=True))
html = html.replace("{{ booking.council_sv }}", python_html.escape(booking.council.name_sv, quote=True))
else:
html = html.replace("{{ booking.council_en }}", "Unknown council")
html = html.replace("{{ booking.council_sv }}", "Okänt utskott")

msg = MIMEText(html, "html", "utf-8")
return html

msg["From"] = STANDARD_SENDER
msg["To"] = "[email protected]"
if booking.personal:
msg["Subject"] = "Ny PRIVAT bilbokning / New PRIVATE car booking"

def fold_ics_line(line: str) -> str:
# lines longer than 75 octets are split with CRLF followed by a space
parts: list[str] = []
current = ""
limit = 75
for char in line:
if len((current + char).encode("utf-8")) > limit:
parts.append(current)
current = ""
limit = 74 # continuation lines start with a space
current += char
parts.append(current)
return "\r\n ".join(parts)


def render_bilf_ics(booking: CarBooking_DB, action: BookingAction = "created") -> str:
now = datetime.now(timezone.utc)
booking_id = getattr(booking, "booking_id", None)
uid = (
f"car-booking-{booking_id}@fsektionen.se"
if booking_id is not None
else f"car-booking-{uuid.uuid4()}@fsektionen.se"
)
dtstamp = now.strftime("%Y%m%dT%H%M%SZ")
dtstart = booking.start_time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
dtend = booking.end_time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
sequence = int((now - SEQUENCE_EPOCH).total_seconds())
if action in CANCEL_ACTIONS:
method, event_status = "CANCEL", "CANCELLED"
else:
msg["Subject"] = "Ny kollegie-bilbokning / New council car booking"
method, event_status = "REQUEST", "CONFIRMED" if booking.confirmed else "TENTATIVE"
summary = {
"created": "Private car booking",
"updated": "Updated private car booking",
"deleted": "Cancelled private car booking",
"moved_to_council": "Private car booking moved to council",
}[action]
name = f"{booking.user.first_name} {booking.user.last_name}"
description = (
f"Private car booking for {name}.\n"
f"Start: {booking.start_time.astimezone(ZoneInfo('Europe/Stockholm')).strftime('%Y-%m-%d %H:%M')}\n"
f"End: {booking.end_time.astimezone(ZoneInfo('Europe/Stockholm')).strftime('%Y-%m-%d %H:%M')}"
)

def escape_ics(value: str) -> str:
return (
value.replace("\r", "").replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
)

lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//F-sektionen//Car Booking Mailer//EN",
"CALSCALE:GREGORIAN",
f"METHOD:{method}",
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{dtstamp}",
f"DTSTART:{dtstart}",
f"DTEND:{dtend}",
f"SUMMARY:{escape_ics(summary)}",
f"DESCRIPTION:{escape_ics(description)}",
f"ORGANIZER;CN=F-sektionen:mailto:{STANDARD_SENDER}",
f"ATTENDEE;CN=BilF;RSVP=FALSE:mailto:{BILF_ADDRESS}",
f"SEQUENCE:{sequence}",
f"STATUS:{event_status}",
"END:VEVENT",
"END:VCALENDAR",
]
return "".join(fold_ics_line(line) + "\r\n" for line in lines)


def build_bilf_mail(booking: CarBooking_DB, action: BookingAction = "created") -> MIMEText | MIMEMultipart:
html = render_bilf_mail(booking, action)

if booking.personal or action in CANCEL_ACTIONS:
msg = MIMEMultipart("mixed")
html_part = MIMEText(html, "html", "utf-8")
msg.attach(html_part)

calendar_part = MIMEText(render_bilf_ics(booking, action), "calendar", "utf-8")
calendar_part.set_param("method", "CANCEL" if action in CANCEL_ACTIONS else "REQUEST")
calendar_part.add_header("Content-Disposition", "attachment; filename=bilbokning.ics")
msg.attach(calendar_part)

msg["Subject"] = PRIVATE_TEXTS[action]["subject"]
else:
msg = MIMEText(html, "html", "utf-8")
msg["Subject"] = "Ny utskotts-bilbokning / New council car booking"

msg["From"] = STANDARD_SENDER
msg["To"] = BILF_ADDRESS

return msg


def send_bilf_mail(msg: MIMEText | MIMEMultipart) -> None:
send_mail_to_address(BILF_ADDRESS, msg)


send_mail_to_address("[email protected]", msg)
def bilf_mailer(booking: CarBooking_DB, action: BookingAction = "created") -> None:
send_bilf_mail(build_bilf_mail(booking, action))
5 changes: 5 additions & 0 deletions routes/car_renting_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from user.permission import Permission
from db_models.user_model import User_DB
from db_models.car_booking_model import CarBooking_DB
from mailer import bilf_mailer

car_router = APIRouter()

Expand Down Expand Up @@ -67,8 +68,12 @@ def remove_car_booking(
if car_booking is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Booking not found.")
if (car_booking.user == current_user) or manage_permission:
# Build the cancellation before deleting, while the booking's relationships can still be loaded
cancel_mail = bilf_mailer.build_bilf_mail(car_booking, action="deleted") if car_booking.personal else None
db.delete(car_booking)
db.commit()
if cancel_mail is not None:
bilf_mailer.send_bilf_mail(cancel_mail)
return car_booking

raise HTTPException(status.HTTP_401_UNAUTHORIZED)
Expand Down
9 changes: 7 additions & 2 deletions services/car_renting_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,10 @@ def create_new_booking(
db.add(db_booking)

db.flush()
db.commit()

bilf_mailer.bilf_mailer(db_booking)

db.commit()

return db_booking


Expand Down Expand Up @@ -255,6 +254,7 @@ def booking_update(
status.HTTP_400_BAD_REQUEST, detail="Council ID is required when making non-personal booking."
)

was_personal = car_booking.personal
car_booking.confirmed = booking_confirmed

for attr in ["description", "start_time", "end_time", "personal", "council_id"]:
Expand All @@ -264,4 +264,9 @@ def booking_update(

db.commit()

if car_booking.personal:
bilf_mailer.bilf_mailer(car_booking, action="updated")
elif was_personal:
bilf_mailer.bilf_mailer(car_booking, action="moved_to_council")

return car_booking
Loading
Loading