package de.stadtbote.votian; import de.stadtbote.votian.database.DatabaseService; import org.springframework.stereotype.Service; import java.time.*; import java.time.format.DateTimeFormatter; import java.util.*; import jakarta.mail.internet.InternetAddress; import static de.stadtbote.votian.AdminSupport.*; import static de.stadtbote.votian.LegacyMapping.*; import static de.stadtbote.votian.OrderEntryService.fields; @Service class AppointmentNotifications { enum Kind {NEW,CHANGED,CANCELLED} record Draft(Kind kind,boolean participants,String external,String salutation,String greeting){} private final DatabaseService db;private final AppointmentService appointments;private final AppointmentMailTransport transport; private final Map pending=new LinkedHashMap<>(); private static class Pending {long user,hq,appointment;String revision,from,reply,subject,body;List recipients;Instant expires;Map result;boolean sending;} AppointmentNotifications(DatabaseService db,AppointmentService appointments,AppointmentMailTransport transport){this.db=db;this.appointments=appointments;this.transport=transport;} static String address(String raw){if(raw==null||raw.isBlank()||raw.length()>254||raw.contains("\r")||raw.contains("\n"))throw bad("Ungültige E-Mail-Adresse.");try{var a=new InternetAddress(raw.strip(),true);a.validate();if(a.getPersonal()!=null||!a.getAddress().contains("@"))throw bad("Bitte reine E-Mail-Adressen ohne Anzeigenamen eingeben.");return a.getAddress();}catch(jakarta.mail.internet.AddressException e){throw bad("Ungültige E-Mail-Adresse.");}} Map preview(LegacyUser u,long appointment,Draft d){var row=appointments.appointment(u,appointment);AppointmentService.editable(u,row);if(d==null||d.kind()==null)throw bad("Benachrichtigungsart fehlt.");if(d.kind()==Kind.CANCELLED&&id(row,"usr_id")!=u.userId())u.require(3);if(!transport.configured())throw bad("E-Mail-Versand ist nicht konfiguriert. SMTP_HOST in der .env ergänzen."); String external=d.external()==null?"":d.external().strip();if(external.length()>2000)throw bad("Zu viele externe Adressen.");var recipients=new TreeMap();var missing=new ArrayList();String actor=address(text(one(db.queryForList("SELECT usr_email FROM user WHERE usr_id=? AND hq_id=?",u.userId(),u.headquartersId())),"usr_email")); if(d.participants()){recipients.put(actor.toLowerCase(Locale.ROOT),actor);var ids=GroupAdministration.memberships(text(row,"ap_participants"));if(!ids.isEmpty()){var args=new ArrayList(ids);args.add(u.headquartersId());var found=new HashSet();for(var person:db.queryForList("SELECT DISTINCT u.usr_id,u.usr_email,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label FROM user u JOIN mandatorheadquarters m ON m.hq_id=u.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE u.usr_id IN ("+CrmScope.marks(ids.size())+") AND own.hq_id=? AND own.md_id>0",args.toArray())){found.add(id(person,"usr_id"));try{String a=address(text(person,"usr_email"));recipients.put(a.toLowerCase(Locale.ROOT),a);}catch(org.springframework.web.server.ResponseStatusException e){missing.add(text(person,"label"));}}for(long id:ids)if(!found.contains(id))missing.add("Benutzer "+id+" (nicht verfügbar)");}} if(!external.isEmpty())for(String part:external.split("[,;]",-1)){String a=address(part);recipients.put(a.toLowerCase(Locale.ROOT),a);}if(recipients.isEmpty()||recipients.size()>100)throw bad("Bitte 1 bis 100 Empfänger auswählen."); String salutation=d.salutation()==null?"":d.salutation().strip(),greeting=d.greeting()==null?"":d.greeting().strip();if(salutation.length()>1000||greeting.length()>1000)throw bad("Anrede oder Grußformel ist zu lang.");String label=switch(d.kind()){case NEW->"Neuer Termin";case CHANGED->"Termin geändert";case CANCELLED->"Termin abgesagt";};if(dateTime(row,"ap_execdate")==null)throw bad("Der gespeicherte Termin hat keinen gültigen Beginn.");String start=dateTime(row,"ap_execdate").format(DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm"));var p=new Pending();p.user=u.userId();p.hq=u.headquartersId();p.appointment=appointment;p.revision=revision(AppointmentService.version(row));p.from=transport.from.isBlank()?actor:address(transport.from);p.reply=actor;p.subject=label+": "+start;p.body=salutation+"\n\n"+label+"\nBeginn: "+start+"\nEnde: "+(dateTime(row,"ap_enddate")==null?"offen":dateTime(row,"ap_enddate").format(DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm")))+"\n\n"+text(row,"ap_text")+"\n\n"+greeting;p.recipients=List.copyOf(recipients.values());p.expires=Instant.now().plusSeconds(900);String token=UUID.randomUUID().toString();synchronized(pending){pending.values().removeIf(v->v.expires.isBefore(Instant.now())&&!v.sending);if(pending.size()>=1000)throw bad("Zu viele Versandvorschauen. Bitte später versuchen.");pending.put(token,p);}return fields("token",token,"from",p.from,"replyTo",p.reply,"recipients",p.recipients,"missing",missing,"subject",p.subject,"body",p.body);} Map send(LegacyUser u,String token){u.require(0);u.require(11);Pending p;synchronized(pending){p=pending.get(token);if(p==null||p.user!=u.userId()||p.hq!=u.headquartersId()||p.expires.isBefore(Instant.now()))throw bad("Versandvorschau abgelaufen. Bitte neu erstellen.");if(p.result!=null)return p.result;if(p.sending)throw bad("Versand läuft bereits. Bitte warten.");var row=appointments.appointment(u,p.appointment);AppointmentService.editable(u,row);unchanged(AppointmentService.version(row),p.revision);p.sending=true;} Map result;try{transport.send(p.from,p.reply,p.recipients,p.subject,p.body);result=fields("sent",true,"message","E-Mail vom SMTP-Server angenommen. Der Termin bleibt gespeichert.");}catch(org.springframework.mail.MailException e){result=fields("sent",false,"message","Der Versand konnte nicht vollständig bestätigt werden. Der Termin bleibt gespeichert. Bitte Zustellung prüfen, bevor Sie eine neue Nachricht senden.");}synchronized(pending){p.result=result;p.sending=false;}return result;} }