Skip to content
Merged

Dev #3552

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
47 changes: 47 additions & 0 deletions app/Console/Commands/Support/GmailResendCompletionCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Console\Commands\Support;

use App\Models\Support\SupportApproval;
use App\Models\Support\SupportCase;
use App\Services\Support\SupportApprovalEmailService;
use Illuminate\Console\Command;

class GmailResendCompletionCommand extends Command
{
protected $signature = 'support:gmail:resend-completion {case_id : Support case id}';

protected $description = 'Resend the action completed/failed email for a support case (after APPROVE).';

public function handle(SupportApprovalEmailService $approvalEmail): int
{
$case = SupportCase::findOrFail((int) $this->argument('case_id'));
$approval = SupportApproval::query()
->where('support_case_id', $case->id)
->where('status', 'approved')
->latest('id')
->first();

if (!$approval) {
$this->error('No approved approval record for case #'.$case->id);

return self::FAILURE;
}

$executed = $case->actions()->where('action_name', 'approved_action_executed')->latest()->first();
$output = is_array($executed?->output_json) ? $executed->output_json : [];
$succeeded = (bool) ($output['ok'] ?? false) || $case->status === 'verified';

$payload = $approvalEmail->sendActionCompletion(
$case,
$approval,
(string) $approval->requested_action,
$output,
$succeeded,
);

$this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

return ($payload['ok'] ?? false) ? self::SUCCESS : self::FAILURE;
}
}
44 changes: 42 additions & 2 deletions app/Jobs/Support/ExecuteApprovedSupportActionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public function handle(
approvedBy: $approval->approved_by,
correlationId: $case->correlation_id,
);
$approvalEmail->sendActionCompletion($case, $approval, $action, $noneResult, true);
$this->sendCompletionEmail($approvalEmail, $logger, $case, $approval, $action, $noneResult, true);

return;
}
Expand Down Expand Up @@ -114,7 +114,47 @@ public function handle(
errorMessage: $ok ? null : implode(';', (array) ($result['errors'] ?? [])),
);

$approvalEmail->sendActionCompletion($case, $approval, $action, $result ?? [], $ok);
$this->sendCompletionEmail($approvalEmail, $logger, $case, $approval, $action, $result ?? [], $ok);
}

/**
* @param array<string, mixed> $result
*/
private function sendCompletionEmail(
SupportApprovalEmailService $approvalEmail,
SupportActionLogger $logger,
SupportCase $case,
SupportApproval $approval,
string $action,
array $result,
bool $succeeded,
): void {
try {
$payload = $approvalEmail->sendActionCompletion($case, $approval, $action, $result, $succeeded);
$logger->log(
case: $case,
actionName: 'support_completion_email',
actionType: 'notify',
input: ['approval_id' => $approval->id, 'action' => $action],
output: $payload,
succeeded: (bool) ($payload['ok'] ?? false),
executedBy: 'system',
correlationId: $case->correlation_id,
errorMessage: ($payload['ok'] ?? false) ? null : implode(';', (array) ($payload['errors'] ?? [])),
);
} catch (\Throwable $e) {
$logger->log(
case: $case,
actionName: 'support_completion_email',
actionType: 'notify',
input: ['approval_id' => $approval->id, 'action' => $action],
output: ['ok' => false, 'error' => $e->getMessage()],
succeeded: false,
executedBy: 'system',
correlationId: $case->correlation_id,
errorMessage: $e->getMessage(),
);
}
}
}

21 changes: 20 additions & 1 deletion app/Services/Support/Gmail/GmailIngestService.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public function __construct(
private readonly SupportActionLogger $logger,
private readonly SupportSenderAllowlist $allowlist,
private readonly SupportApprovalEmailService $approvalEmail,
private readonly SupportGmailIngestFilter $ingestFilter,
) {
}

Expand All @@ -29,6 +30,8 @@ public function __construct(
* ingested: int,
* duplicates: int,
* skipped_untrusted: int,
* skipped_system: int,
* skipped_non_ticket: int,
* approvals_processed: int,
* cursor_updated: bool,
* warnings: string[]
Expand Down Expand Up @@ -71,9 +74,16 @@ public function pollAndIngest(int $max = 25): array
$ingested = 0;
$duplicates = 0;
$skippedUntrusted = 0;
$skippedSystem = 0;
$skippedNonTicket = 0;
$approvalsProcessed = 0;

foreach ($result['messages'] as $msg) {
if ($this->ingestFilter->isSystemOutbound($msg)) {
$skippedSystem++;
continue;
}

if ($this->allowlist->isAllowed($msg->from)) {
$approvalResult = $this->approvalEmail->processApprovalReply($msg, (string) $msg->from);
if ($approvalResult !== null) {
Expand All @@ -87,6 +97,11 @@ public function pollAndIngest(int $max = 25): array
continue;
}

if (!$this->ingestFilter->isTicketSubject($msg->subject)) {
$skippedNonTicket++;
continue;
}

$requester = SupportEmailAddress::fromHeader($msg->from);

$case = $this->intake->intake([
Expand Down Expand Up @@ -131,6 +146,8 @@ public function pollAndIngest(int $max = 25): array
'ingested' => $ingested,
'duplicates' => $duplicates,
'skipped_untrusted' => $skippedUntrusted,
'skipped_system' => $skippedSystem,
'skipped_non_ticket' => $skippedNonTicket,
'approvals_processed' => $approvalsProcessed,
'cursor_updated' => true,
'warnings' => $result['warnings'] ?? [],
Expand All @@ -141,14 +158,16 @@ public function pollAndIngest(int $max = 25): array
}

/**
* @return array{ingested: int, duplicates: int, skipped_untrusted: int, approvals_processed: int, cursor_updated: bool, warnings: string[]}
* @return array{ingested: int, duplicates: int, skipped_untrusted: int, skipped_system: int, skipped_non_ticket: int, approvals_processed: int, cursor_updated: bool, warnings: string[]}
*/
private function emptyResult(): array
{
return [
'ingested' => 0,
'duplicates' => 0,
'skipped_untrusted' => 0,
'skipped_system' => 0,
'skipped_non_ticket' => 0,
'approvals_processed' => 0,
'cursor_updated' => false,
'warnings' => [],
Expand Down
29 changes: 29 additions & 0 deletions app/Services/Support/Gmail/SupportGmailIngestFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace App\Services\Support\Gmail;

use App\Services\Support\SupportEmailAddress;

/**
* Decides which Gmail messages are tickets, approvals, or system noise.
*/
final class SupportGmailIngestFilter
{
public function isSystemOutbound(GmailMessage $message): bool
{
$from = SupportEmailAddress::fromHeader((string) $message->from);
$notify = SupportEmailAddress::normalize((string) config('support_gmail.notify_email'));

return $from !== null && $notify !== null && $from === $notify;
}

public function isTicketSubject(?string $subject): bool
{
$prefix = strtolower(trim((string) config('support_gmail.subject_prefix', '')));
if ($prefix === '') {
return false;
}

return str_contains(strtolower((string) $subject), $prefix);
}
}
13 changes: 12 additions & 1 deletion app/Services/Support/Gmail/SupportGmailPollQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ public static function resolve(): string
// Ingest new tickets (codeweek-support) and APPROVE replies (Re: [CW-SUPPORT #…]).
$subjectFilters = ['subject:'.self::quoteGmailSearchTerm($prefix)];
if ($approvalPrefix !== '' && !self::sameSearchTerm($prefix, $approvalPrefix)) {
$subjectFilters[] = 'subject:'.self::quoteGmailSearchTerm($approvalPrefix);
$approvalSubject = 'subject:'.self::quoteGmailSearchTerm($approvalPrefix);
$notify = trim((string) config('support_gmail.notify_email'));
if ($notify !== '') {
// Exclude our own dry-run / completion emails from the approval poll.
$approvalSubject .= ' -from:'.self::quoteGmailFromAddress($notify);
}
$subjectFilters[] = $approvalSubject;
}
$parts[] = count($subjectFilters) === 1
? $subjectFilters[0]
Expand Down Expand Up @@ -56,4 +62,9 @@ private static function sameSearchTerm(string $a, string $b): bool
{
return strcasecmp(trim($a), trim($b)) === 0;
}

private static function quoteGmailFromAddress(string $email): string
{
return self::quoteGmailSearchTerm(trim($email));
}
}
57 changes: 43 additions & 14 deletions app/Services/Support/SupportApprovalEmailService.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,29 +49,58 @@ public function sendActionCompletion(
return SupportJson::ok('support_completion_email', ['case_id' => $case->id], ['skipped' => true]);
}

$to = SupportEmailAddress::normalize(
(string) ($approval->notify_email ?: $case->forwarded_by_email ?: config('support_gmail.notify_email')),
);
if ($to === null) {
$recipients = $this->completionRecipients($case, $approval);
if ($recipients === []) {
return SupportJson::fail('support_completion_email', ['case_id' => $case->id], 'no_recipient_email');
}

$body = $this->buildCompletionBody($case, $action, $result, $succeeded, (string) ($approval->approved_by ?? ''));
$subject = $this->completionSubject($case, $succeeded);
$sentTo = [];

foreach ($recipients as $to) {
$sent = $this->gmail->sendPlainText(
to: $to,
subject: $subject,
body: $body,
threadId: $approval->gmail_thread_id,
);
$sentTo[] = ['to' => $to, 'gmail_message_id' => $sent['id'] ?? null];
}

$sent = $this->gmail->sendPlainText(
to: $to,
subject: $this->completionSubject($case, $succeeded),
body: $body,
threadId: $approval->gmail_thread_id,
);

return SupportJson::ok('support_completion_email', ['case_id' => $case->id, 'to' => $to], [
return SupportJson::ok('support_completion_email', ['case_id' => $case->id], [
'succeeded' => $succeeded,
'gmail_message_id' => $sent['id'] ?? null,
'gmail_thread_id' => $sent['thread_id'] ?? $approval->gmail_thread_id,
'sent_to' => $sentTo,
'gmail_thread_id' => $approval->gmail_thread_id,
]);
}

/**
* @return list<string>
*/
private function completionRecipients(SupportCase $case, SupportApproval $approval): array
{
$candidates = [
SupportEmailAddress::normalize((string) config('support_gmail.notify_email')),
SupportEmailAddress::normalize((string) $approval->notify_email),
SupportEmailAddress::normalize((string) $approval->approved_by),
SupportEmailAddress::normalize((string) $case->forwarded_by_email),
];

$out = [];
foreach ($candidates as $email) {
if ($email === null || $email === '') {
continue;
}
if (!$this->allowlist->isAllowed($email)) {
continue;
}
$out[$email] = $email;
}

return array_values($out);
}

/**
* Send dry-run summary and open a pending approval for email reply.
*/
Expand Down
Loading
Loading