CHAT_OUTPUT_BEGIN
COMMAND_ID=SUPPORT-260918-GRAM-SUPPORT-STAFF-EMAIL-SOURCE-READONLY-516R1R4
STATUS=OK
RC=0
HOST=pve01
MODE=read-only
COMPONENT=gram-support-staff-email-source-readonly
REFERENCE_REGISTER_CHECK=OK
REFERENCE_SHA256=5a3d8e5154c41cb582a4e0aca68090be1f0138918bf82131a948df326f9d8d66
ERROR_REGISTER_CHECK=OK
ERROR_REGISTER_SHA256=3b09a553ec0f527ed3afeed4753f52a74ee3036045bbfb3c685e9f8af4ba7ba0
COMMAND_SHA256=08ff6acd78dea5c4a5621dcca2fedad234c67817f25ff1f3b4ee9b9b2d14c5f0
DUPLICATE_FAILED_COMMAND_BLOCKED=false
EXECUTION_STARTED=true
CHANGE_DECLARED=false
RESULT_CONTRACT_VALID=true
RESULT_CONTRACT_STATUS=NOT_APPLICABLE
RESULT_CONTRACT_ERROR=NONE
COMMAND_RC=0
CHANGES_MADE=false
ROLLBACK_STARTED=false
ROLLBACK_RESTORED=null
MUTATION_OUTCOME=NO_MUTATION
SANITIZED=yes
SECRETS_INCLUDED=no
PRIVATE_ADDRESSES_INCLUDED=no
RAW_EVIDENCE_SHA256=3375e95bfa30e7b6adf9c22d4a03b7407be9113f1d2ed450ec96bed61add1e9a
SANITIZED_OUTPUT_SHA256=3375e95bfa30e7b6adf9c22d4a03b7407be9113f1d2ed450ec96bed61add1e9a
OUTPUT_BEGIN
GRAM516R1R4_SAFE_REPORT={"classification":{"all_sites_eligible_staff_match_target":true,"changes_made":false,"eligible_staff_target_match_total":7,"eligible_staff_total":7,"email_consumer_present":true,"event_dispatcher_present":true,"runtime_probe_7of7":true,"send_attempts":0,"source_probe_ok":true},"schema_version":1,"scope":"READ_ONLY_GRAM_SUPPORT_STAFF_EMAIL_SOURCE","secret_policy":"NO_NON_TARGET_EMAIL_ADDRESSES_EXPORTED","sites":[{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"codevipe","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]},{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"dsmods","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]},{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"gamevipe","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]},{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"hkmods","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]},{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"kingofwolk","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]},{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"zakrutim","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]},{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"newfi","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]}],"source":{"files":{"Notification/DeliveryGate.php":{"line_count":16,"present":true,"text":"<?php\n\nnamespace Gram\\Support\\Notification;\n\ninterface DeliveryGate\n{\n    /**\n     * Return true only when this exact event/consumer/recipient delivery\n     * has not already been completed and this caller owns the retry lease.\n     */\n    public function acquire(int $eventId, string $consumer, string $recipientKey): bool;\n\n    public function markSent(int $eventId, string $consumer, string $recipientKey): void;\n\n    public function markFailed(int $eventId, string $consumer, string $recipientKey, string $failureClass): void;\n}\n"},"Notification/Dispatcher.php":{"line_count":67,"present":true,"text":"<?php\n\nnamespace Gram\\Support\\Notification;\n\nuse Gram\\Support\\Entity\\SupportEvent;\nuse Gram\\Support\\Entity\\Ticket;\nuse XF\\Entity\\User;\n\nfinal class Dispatcher\n{\n    private AlertConsumer $alerts;\n    private EmailConsumer $email;\n\n    public function __construct(AlertConsumer $alerts, EmailConsumer $email)\n    {\n        $this->alerts = $alerts;\n        $this->email = $email;\n    }\n\n    /**\n     * Owner-facing delivery. Staff alert fan-out is intentionally provided by\n     * dispatchStaff() so staff recipients must still pass Gram Support ACL.\n     */\n    public function dispatchOwner(SupportEvent $event, Ticket $ticket, ?User $owner): void\n    {\n        if (!Policy::mayEmitExternal($event))\n        {\n            return;\n        }\n\n        if ($owner && $owner->user_id)\n        {\n            $this->alerts->consumeForUser($event, $ticket, $owner);\n            $this->email->consumeForRegistered($event, $ticket, $owner);\n            return;\n        }\n\n        $this->email->consumeForGuest($event, $ticket);\n    }\n\n    /**\n     * @param User[] $staffUsers\n     */\n    public function dispatchStaff(SupportEvent $event, Ticket $ticket, array $staffUsers): void\n    {\n        if (!Policy::mayEmitExternal($event))\n        {\n            return;\n        }\n\n        foreach ($staffUsers as $staff)\n        {\n            if (!$staff instanceof User || !$staff->user_id)\n            {\n                continue;\n            }\n\n            if (!$staff->hasPermission('gramSupport', 'viewAll')\n                && !$staff->hasPermission('gramSupport', 'manage'))\n            {\n                continue;\n            }\n\n            $this->alerts->consumeForUser($event, $ticket, $staff);\n        }\n    }\n}\n"},"Notification/EmailConsumer.php":{"line_count":107,"present":true,"text":"<?php\n\nnamespace Gram\\Support\\Notification;\n\nuse Gram\\Support\\Entity\\SupportEvent;\nuse Gram\\Support\\Entity\\Ticket;\nuse XF\\Entity\\User;\n\nfinal class EmailConsumer\n{\n    private DeliveryGate $gate;\n\n    public function __construct(DeliveryGate $gate)\n    {\n        $this->gate = $gate;\n    }\n\n    public function consumeForRegistered(SupportEvent $event, Ticket $ticket, User $recipient): bool\n    {\n        if (!Policy::mayEmitExternal($event) || !$recipient->user_id)\n        {\n            return false;\n        }\n\n        if (!$ticket->user_id || (int)$ticket->user_id !== (int)$recipient->user_id)\n        {\n            return false;\n        }\n\n        $recipientKey = RecipientKey::user((int)$recipient->user_id);\n        return $this->send(\n            $event,\n            $recipientKey,\n            function () use ($event, $ticket, $recipient)\n            {\n                \\XF::app()->mailer()->newMail()\n                    ->setToUser($recipient)\n                    ->setTemplate('gram_support_ticket_event', [\n                        'ticket' => $ticket,\n                        'event' => $event,\n                        'accessUrl' => null\n                    ])\n                    ->queue();\n            }\n        );\n    }\n\n    public function consumeForGuest(SupportEvent $event, Ticket $ticket): bool\n    {\n        if (!Policy::mayEmitExternal($event))\n        {\n            return false;\n        }\n\n        $email = trim((string)$ticket->guest_email);\n        if ($email === '')\n        {\n            return false;\n        }\n\n        $recipientKey = RecipientKey::email($email);\n\n        return $this->send(\n            $event,\n            $recipientKey,\n            function () use ($event, $ticket, $email)\n            {\n                // Raw guest access token is intentionally unavailable here:\n                // only its hash is persisted. Email links to the manual access\n                // page; the guest uses the one-time/saved access code.\n                \\XF::app()->mailer()->newMail()\n                    ->setTo($email)\n                    ->setTemplate('gram_support_ticket_event_guest', [\n                        'ticket' => $ticket,\n                        'event' => $event,\n                        'accessUrl' => \\XF::app()->router('public')->buildLink('canonical:support/access')\n                    ])\n                    ->queue();\n            }\n        );\n    }\n\n    private function send(SupportEvent $event, string $recipientKey, callable $sender): bool\n    {\n        if (!$this->gate->acquire((int)$event->event_id, 'email', $recipientKey))\n        {\n            return false;\n        }\n\n        try\n        {\n            $sender();\n            $this->gate->markSent((int)$event->event_id, 'email', $recipientKey);\n            return true;\n        }\n        catch (\\Throwable $e)\n        {\n            $this->gate->markFailed(\n                (int)$event->event_id,\n                'email',\n                $recipientKey,\n                get_class($e)\n            );\n            throw $e;\n        }\n    }\n}\n"},"Notification/EntityDeliveryGate.php":{"line_count":118,"present":true,"text":"<?php\n\nnamespace Gram\\Support\\Notification;\n\nfinal class EntityDeliveryGate implements DeliveryGate\n{\n    private const LEASE_SECONDS = 120;\n\n    public function acquire(int $eventId, string $consumer, string $recipientKey): bool\n    {\n        if ($eventId < 1 || $consumer === '' || $recipientKey === '')\n        {\n            return false;\n        }\n\n        $deliveryKey = $this->deliveryKey($eventId, $consumer, $recipientKey);\n        $lockName = 'gram_support_delivery_' . substr($deliveryKey, 0, 32);\n        $db = \\XF::db();\n\n        if ((int)$db->fetchOne('SELECT GET_LOCK(?, 2)', [$lockName]) !== 1)\n        {\n            return false;\n        }\n\n        try\n        {\n            $now = \\XF::$time;\n            $delivery = \\XF::em()->find('Gram\\Support:Delivery', $deliveryKey);\n\n            if (!$delivery)\n            {\n                $delivery = \\XF::em()->create('Gram\\Support:Delivery');\n                $delivery->delivery_key = $deliveryKey;\n                $delivery->event_id = $eventId;\n                $delivery->consumer = $consumer;\n                $delivery->recipient_key = $recipientKey;\n                $delivery->state = 'pending';\n                $delivery->lease_expires = $now + self::LEASE_SECONDS;\n                $delivery->attempt_count = 1;\n                $delivery->created_date = $now;\n                $delivery->updated_date = $now;\n                $delivery->save();\n                return true;\n            }\n\n            if ((string)$delivery->state === 'sent')\n            {\n                return false;\n            }\n\n            if ((string)$delivery->state === 'pending' && (int)$delivery->lease_expires > $now)\n            {\n                return false;\n            }\n\n            $delivery->state = 'pending';\n            $delivery->lease_expires = $now + self::LEASE_SECONDS;\n            $delivery->attempt_count = (int)$delivery->attempt_count + 1;\n            $delivery->updated_date = $now;\n            $delivery->save();\n\n            return true;\n        }\n        finally\n        {\n            try\n            {\n                $db->fetchOne('SELECT RELEASE_LOCK(?)', [$lockName]);\n            }\n            catch (\\Throwable $ignored)\n            {\n            }\n        }\n    }\n\n    public function markSent(int $eventId, string $consumer, string $recipientKey): void\n    {\n        $delivery = \\XF::em()->find(\n            'Gram\\Support:Delivery',\n            $this->deliveryKey($eventId, $consumer, $recipientKey)\n        );\n\n        if (!$delivery)\n        {\n            return;\n        }\n\n        $delivery->state = 'sent';\n        $delivery->lease_expires = 0;\n        $delivery->updated_date = \\XF::$time;\n        $delivery->sent_date = \\XF::$time;\n        $delivery->save();\n    }\n\n    public function markFailed(int $eventId, string $consumer, string $recipientKey, string $failureClass): void\n    {\n        $delivery = \\XF::em()->find(\n            'Gram\\Support:Delivery',\n            $this->deliveryKey($eventId, $consumer, $recipientKey)\n        );\n\n        if (!$delivery)\n        {\n            return;\n        }\n\n        $delivery->state = 'failed';\n        $delivery->lease_expires = 0;\n        $delivery->last_failure_class = substr($failureClass, 0, 191);\n        $delivery->updated_date = \\XF::$time;\n        $delivery->save();\n    }\n\n    private function deliveryKey(int $eventId, string $consumer, string $recipientKey): string\n    {\n        return hash('sha256', $eventId . \"\\0\" . $consumer . \"\\0\" . $recipientKey);\n    }\n}\n"},"Notification/EventDispatcher.php":{"line_count":42,"present":true,"text":"<?php\n\nnamespace Gram\\Support\\Notification;\n\nuse Gram\\Support\\Entity\\SupportEvent;\nuse Gram\\Support\\Entity\\Ticket;\n\nfinal class EventDispatcher\n{\n    public function dispatchSafely(SupportEvent $event, Ticket $ticket): void\n    {\n        try\n        {\n            $gate = new EntityDeliveryGate();\n            $alerts = new AlertConsumer($gate);\n            $email = new EmailConsumer($gate);\n            $dispatcher = new Dispatcher($alerts, $email);\n\n            $owner = null;\n            if ((int)$ticket->user_id > 0)\n            {\n                $owner = \\XF::em()->find('XF:User', (int)$ticket->user_id);\n            }\n\n            $dispatcher->dispatchOwner($event, $ticket, $owner);\n\n            if ((int)$ticket->assigned_user_id > 0)\n            {\n                $staff = \\XF::em()->find('XF:User', (int)$ticket->assigned_user_id);\n                if ($staff)\n                {\n                    $dispatcher->dispatchStaff($event, $ticket, [$staff]);\n                }\n            }\n        }\n        catch (\\Throwable $e)\n        {\n            // Event/Ticket/Message are already persisted truth.\n            \\XF::logException($e, false, 'Gram Support notification consumer: ');\n        }\n    }\n}\n"},"Notification/Policy.php":{"line_count":61,"present":true,"text":"<?php\n\nnamespace Gram\\Support\\Notification;\n\nuse Gram\\Support\\Entity\\SupportEvent;\n\nfinal class Policy\n{\n    private const INTERNAL_EVENT_TYPES = [\n        'internal_note_created',\n        'internal_note_updated',\n        'internal_note_deleted'\n    ];\n\n    public static function mayEmitExternal(SupportEvent $event): bool\n    {\n        if (in_array((string)$event->event_type, self::INTERNAL_EVENT_TYPES, true))\n        {\n            return false;\n        }\n\n        $payload = self::safePayload($event);\n\n        if (!empty($payload['is_internal']))\n        {\n            return false;\n        }\n\n        return true;\n    }\n\n    public static function safePayload(SupportEvent $event): array\n    {\n        $payload = $event->safe_payload_json;\n\n        if (is_array($payload))\n        {\n            return $payload;\n        }\n\n        if (!is_string($payload) || $payload === '')\n        {\n            return [];\n        }\n\n        $decoded = json_decode($payload, true);\n        return is_array($decoded) ? $decoded : [];\n    }\n\n    public static function alertExtraData(SupportEvent $event): array\n    {\n        $payload = self::safePayload($event);\n\n        return [\n            'support_event_id' => (int)$event->event_id,\n            'ticket_id' => (int)$event->ticket_id,\n            'event_type' => (string)$event->event_type,\n            'public_ref' => isset($payload['public_ref']) ? (string)$payload['public_ref'] : ''\n        ];\n    }\n}\n"},"Notification/RecipientKey.php":{"line_count":28,"present":true,"text":"<?php\n\nnamespace Gram\\Support\\Notification;\n\nfinal class RecipientKey\n{\n    public static function user(int $userId): string\n    {\n        if ($userId < 1)\n        {\n            throw new \\InvalidArgumentException('User recipient ID must be positive.');\n        }\n\n        return 'user:' . $userId;\n    }\n\n    public static function email(string $email): string\n    {\n        $email = strtolower(trim($email));\n        if ($email === '')\n        {\n            throw new \\InvalidArgumentException('Email recipient must not be empty.');\n        }\n\n        // Never persist raw guest email in the delivery key.\n        return 'email-sha256:' . hash('sha256', $email);\n    }\n}\n"}},"ok":true},"source_transport":{"err_truncated":false,"guest_stderr_present":false,"inner_rc":0,"ok":true,"out_truncated":false,"transport_rc":0,"transport_stderr_bytes":0,"transport_stderr_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"},"target_email":"aleisaev@yandex.ru","transport_evidence":[{"slug":"codevipe","t":{"err_truncated":false,"guest_stderr_present":false,"inner_rc":0,"ok":true,"out_truncated":false,"transport_rc":0,"transport_stderr_bytes":0,"transport_stderr_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},{"slug":"dsmods","t":{"err_truncated":false,"guest_stderr_present":false,"inner_rc":0,"ok":true,"out_truncated":false,"transport_rc":0,"transport_stderr_bytes":0,"transport_stderr_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},{"slug":"gamevipe","t":{"err_truncated":false,"guest_stderr_present":false,"inner_rc":0,"ok":true,"out_truncated":false,"transport_rc":0,"transport_stderr_bytes":0,"transport_stderr_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},{"slug":"hkmods","t":{"err_truncated":false,"guest_stderr_present":false,"inner_rc":0,"ok":true,"out_truncated":false,"transport_rc":0,"transport_stderr_bytes":0,"transport_stderr_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},{"slug":"kingofwolk","t":{"err_truncated":false,"guest_stderr_present":false,"inner_rc":0,"ok":true,"out_truncated":false,"transport_rc":0,"transport_stderr_bytes":0,"transport_stderr_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},{"slug":"zakrutim","t":{"err_truncated":false,"guest_stderr_present":false,"inner_rc":0,"ok":true,"out_truncated":false,"transport_rc":0,"transport_stderr_bytes":0,"transport_stderr_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},{"slug":"newfi","t":{"err_truncated":false,"guest_stderr_present":false,"inner_rc":0,"ok":true,"out_truncated":false,"transport_rc":0,"transport_stderr_bytes":0,"transport_stderr_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}}]}
GRAM516R1R4_DECISION=PASS_STAFF_EMAIL_SOURCE_READONLY
GRAM516R1R4_NEXT_GATE=BUILD_MINIMAL_STAFF_EMAIL_CANARY_PATCH
HOMELAB_RESULT_CONTRACT={"version":1,"command_id":"SUPPORT-260918-GRAM-SUPPORT-STAFF-EMAIL-SOURCE-READONLY-516R1R4","status":"OK","changes_made":false,"rollback_started":false,"rollback_restored":null}

OUTPUT_END
CHAT_OUTPUT_END
