Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.70% covered (success)
96.70%
88 / 91
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
InboundCalendarReplyProcessor
96.67% covered (success)
96.67%
87 / 90
71.43% covered (warning)
71.43%
5 / 7
29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 processInboundReply
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 parseIcsReply
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 extractReplyFromVCalendar
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
10.05
 applyReplyUpdate
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 parseSubjectReply
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 findEventBySubjectAndAttendee
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Calendar\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use PDO;
12use Sabre\VObject\Component\VCalendar;
13use Sabre\VObject\Component\VEvent;
14use Sabre\VObject\Reader;
15use Throwable;
16
17/**
18 * Inbound Email Processor for standard RFC 6047 iTIP METHOD:REPLY calendar responses.
19 *
20 * Automatically parses incoming iCalendar replies sent by email servers (Gmail, Outlook, Apple Mail)
21 * and updates attendee RSVP status in the CRM calendar database.
22 *
23 * @package App\Modules\Calendar\Application\Service
24 */
25final readonly class InboundCalendarReplyProcessor
26{
27    private const string PARTSTAT_ACCEPTED = 'accepted';
28    private const string PARTSTAT_DECLINED = 'declined';
29    private const string PARTSTAT_TENTATIVE = 'tentative';
30
31    public function __construct(
32        private CalendarInvitationServiceInterface $invitationService,
33        private PDO $pdo,
34        private string $tablePrefix = 'c_'
35    ) {
36    }
37
38    /**
39     * Inspects and processes incoming email raw payload or attachments for iTIP replies.
40     *
41     * @param string $fromEmail Responding sender email.
42     * @param string $subject   Email subject.
43     * @param list<string> $icsContents List of raw iCalendar strings found in email parts or attachments.
44     * @return array{handled: bool, calendar_id?: int, status?: string, uid?: string}
45     */
46    public function processInboundReply(string $fromEmail, string $subject, array $icsContents): array
47    {
48        foreach ($icsContents as $ics) {
49            $parsed = $this->parseIcsReply($ics, $fromEmail);
50            if ($parsed !== null) {
51                $updated = $this->applyReplyUpdate($parsed['uid'], $parsed['email'], $parsed['status']);
52                if ($updated['success']) {
53                    return [
54                        'handled'     => true,
55                        'calendar_id' => $updated['calendar_id'],
56                        'status'      => $parsed['status'],
57                        'uid'         => $parsed['uid'],
58                    ];
59                }
60            }
61        }
62
63        // Fallback to subject-based RSVP recognition if no valid iCalendar attachment is present
64        $subjectParsed = $this->parseSubjectReply($subject);
65        if ($subjectParsed !== null) {
66            $matchedEvent = $this->findEventBySubjectAndAttendee($subjectParsed['subject'], $fromEmail);
67            if ($matchedEvent !== null) {
68                $calendarId = (int) $matchedEvent['id'];
69                $this->invitationService->updateAttendeeStatus($calendarId, $fromEmail, $subjectParsed['status']);
70                return [
71                    'handled'     => true,
72                    'calendar_id' => $calendarId,
73                    'status'      => $subjectParsed['status'],
74                    'uid'         => (string) ($matchedEvent['c_uid'] ?? ''),
75                ];
76            }
77        }
78
79        return ['handled' => false];
80    }
81
82    /**
83     * Parses raw iCalendar string looking for METHOD:REPLY and attendee decision.
84     *
85     * @return array{uid: string, email: string, status: string}|null
86     */
87    public function parseIcsReply(string $icsContent, string $fallbackEmail): ?array
88    {
89        if (!str_contains($icsContent, 'METHOD:REPLY') && !str_contains($icsContent, 'method=REPLY')) {
90            return null;
91        }
92
93        try {
94            $vcal = Reader::read($icsContent);
95            return $vcal instanceof VCalendar ? $this->extractReplyFromVCalendar($vcal, $fallbackEmail) : null;
96        } catch (Throwable) {
97            return null;
98        }
99    }
100
101    /**
102     * @return array{uid: string, email: string, status: string}|null
103     */
104    private function extractReplyFromVCalendar(VCalendar $vcal, string $fallbackEmail): ?array
105    {
106        /** @var VEvent|null $event */
107        $event = $vcal->VEVENT ?? null;
108        if (!($event instanceof VEvent)) {
109            return null;
110        }
111
112        $uid = trim((string) ($event->UID ?? ''));
113        if ($uid === '') {
114            return null;
115        }
116
117        $partStat = 'ACCEPTED';
118        $attendeeEmail = strtolower(trim($fallbackEmail));
119
120        if (isset($event->ATTENDEE)) {
121            foreach ($event->ATTENDEE as $att) {
122                $attEmail = strtolower(str_ireplace('mailto:', '', (string) $att));
123                if ($attEmail !== '') {
124                    $attendeeEmail = $attEmail;
125                }
126                if (isset($att['PARTSTAT'])) {
127                    $partStat = strtoupper((string) $att['PARTSTAT']);
128                }
129            }
130        }
131
132        $status = match ($partStat) {
133            'DECLINED'  => self::PARTSTAT_DECLINED,
134            'TENTATIVE' => self::PARTSTAT_TENTATIVE,
135            default     => self::PARTSTAT_ACCEPTED,
136        };
137
138        return [
139            'uid'    => $uid,
140            'email'  => $attendeeEmail,
141            'status' => $status,
142        ];
143    }
144
145    /**
146     * Applies parsed iTIP reply status to the corresponding calendar record.
147     *
148     * @return array{success: bool, calendar_id?: int}
149     */
150    private function applyReplyUpdate(string $uid, string $attendeeEmail, string $status): array
151    {
152        $table = $this->tablePrefix . 'mod_calendar_records';
153        $sql = "SELECT `id` FROM `{$table}` WHERE `c_uid` = :uid LIMIT 1";
154
155        $stmt = $this->pdo->prepare($sql);
156        $stmt->execute([':uid' => $uid]);
157        $row = $stmt->fetch(PDO::FETCH_ASSOC);
158
159        if ($row === false) {
160            return ['success' => false];
161        }
162
163        $calendarId = (int) $row['id'];
164        $ok = $this->invitationService->updateAttendeeStatus($calendarId, $attendeeEmail, $status);
165
166        return ['success' => $ok, 'calendar_id' => $calendarId];
167    }
168
169    /**
170     * Parses standard subject line prefixes (Accepted / Declined / Tentative / Zaakceptowano / Odrzucono).
171     *
172     * @return array{status: string, subject: string}|null
173     */
174    private function parseSubjectReply(string $subject): ?array
175    {
176        $clean = trim($subject);
177        $patterns = [
178            '/^(?:accepted|zaakceptowano|przyjęto):\s*(.+)$/i' => self::PARTSTAT_ACCEPTED,
179            '/^(?:declined|odrzucono|odrzucone):\s*(.+)$/i'     => self::PARTSTAT_DECLINED,
180            '/^(?:tentative|niepewne|być może):\s*(.+)$/i'     => self::PARTSTAT_TENTATIVE,
181        ];
182
183        foreach ($patterns as $pattern => $status) {
184            if (preg_match($pattern, $clean, $matches)) {
185                return [
186                    'status'  => $status,
187                    'subject' => trim($matches[1]),
188                ];
189            }
190        }
191
192        return null;
193    }
194
195    /**
196     * Searches for calendar event by matching subject and attendee email.
197     *
198     * @return array<string, mixed>|null
199     */
200    private function findEventBySubjectAndAttendee(string $subject, string $attendeeEmail): ?array
201    {
202        $table = $this->tablePrefix . 'mod_calendar_records';
203        $sql = "SELECT `id`, `c_uid`, `subject` FROM `{$table}"
204            . "WHERE `subject` = :subj AND `attendees` LIKE :email "
205            . "ORDER BY `id` DESC LIMIT 1";
206
207        $stmt = $this->pdo->prepare($sql);
208        $stmt->execute([
209            ':subj'  => $subject,
210            ':email' => '%' . $attendeeEmail . '%',
211        ]);
212        $row = $stmt->fetch(PDO::FETCH_ASSOC);
213
214        return $row !== false ? $row : null;
215    }
216}