Skip to content

Commit

Permalink
Merge pull request #269 from opcodesio/mail-previews
Browse files Browse the repository at this point in the history
Feature / mail previews straight from the log viewer
  • Loading branch information
arukompas authored Aug 25, 2023
2 parents 9ed2263 + 77f0c2f commit 579d5f4
Show file tree
Hide file tree
Showing 11 changed files with 340 additions and 17 deletions.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
],
"require": {
"php": "^8.0",
"illuminate/contracts": "^8.0|^9.0|^10.0"
"illuminate/contracts": "^8.0|^9.0|^10.0",
"opcodesio/mail-parser": "^0.1.1"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.2",
Expand Down
2 changes: 1 addition & 1 deletion public/app.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/app.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions public/mix-manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"/app.js": "/app.js?id=c8c3c417b14e0e0f6f9d15caea9a1d4a",
"/app.css": "/app.css?id=2644d93568c08a41a0612c7c2c38618e",
"/app.js": "/app.js?id=d5035807188334bb7aa0348c50193ad3",
"/app.css": "/app.css?id=1d3e1405b5f756d27f515e824e61f265",
"/img/log-viewer-128.png": "/img/log-viewer-128.png?id=d576c6d2e16074d3f064e60fe4f35166",
"/img/log-viewer-32.png": "/img/log-viewer-32.png?id=f8ec67d10f996aa8baf00df3b61eea6d",
"/img/log-viewer-64.png": "/img/log-viewer-64.png?id=8902d596fc883ca9eb8105bb683568c6"
Expand Down
52 changes: 51 additions & 1 deletion resources/css/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,58 @@ html.dark {
}
}

.mail-preview-attributes {
@apply text-xs lg:text-sm w-full border border-brand-100 dark:border-brand-800 rounded bg-brand-50/30 dark:bg-brand-900/20 overflow-x-auto lg:overflow-hidden mb-4 lg:mb-6;

table {
@apply w-full;
}
td {
@apply px-2 py-1 lg:px-6 lg:py-2;
}
td:not(:first-child) {
overflow-wrap: anywhere;
}
tr:first-child td {
@apply pt-1.5 lg:pt-3;
}
tr:last-child td {
@apply pb-1.5 lg:pb-3;
}
tr:not(:last-child) td {
@apply border-b border-brand-100 dark:border-brand-900;
}
}

.mail-preview-html {
@apply w-full border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-900 overflow-auto mb-4 lg:mb-6;
}

.mail-attachment-button {
@apply flex items-center justify-between px-2 py-1 lg:px-4 lg:py-2 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded;
max-width: 460px;

&:not(:last-child) {
@apply mb-2;
}

a {
@apply focus:outline-brand-500;
}
}

.tabs-container {
@apply text-xs lg:text-sm;
}

.tabs-container,
.mail-preview,
.log-stack {
@apply px-2 py-1 lg:py-2 lg:px-8;
}

.log-stack {
@apply px-2 lg:px-4 py-1 lg:py-2 lg:px-6 lg:px-8 border-gray-200 dark:border-gray-700 text-[10px] leading-3 lg:text-xs lg:leading-4 whitespace-pre-wrap break-all;
@apply border-gray-200 dark:border-gray-700 text-[10px] leading-3 lg:text-xs lg:leading-4 whitespace-pre-wrap break-all;
}

.log-link {
Expand Down
49 changes: 39 additions & 10 deletions resources/js/components/BaseLogTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,25 @@
<LogCopyButton :log="log" />
</div>
</div>
<pre class="log-stack" v-html="highlightSearchResult(log.full_text, searchStore.query)"></pre>
<template v-if="hasContext(log)">
<p class="mx-2 lg:mx-8 pt-2 border-t font-semibold text-gray-700 dark:text-gray-400 text-xs lg:text-sm">Context:</p>
<pre class="log-stack" v-html="highlightSearchResult(prepareContextForOutput(log.context), searchStore.query)"></pre>
</template>

<div v-if="log.extra && log.extra.log_text_incomplete" class="py-4 px-8 text-gray-500 italic">
The contents of this log have been cut short to the first {{ LogViewer.max_log_size_formatted }}.
The full size of this log entry is <strong>{{ log.extra.log_size_formatted }}</strong>
</div>

<tab-container v-if="logViewerStore.isOpen(index)" :tabs="getTabsForLog(log)">
<tab-content v-if="log.extra && log.extra.mail_preview" tab-value="mail_preview">
<mail-preview :mail="log.extra.mail_preview" />
</tab-content>

<tab-content tab-value="raw">
<pre class="log-stack" v-html="highlightSearchResult(log.full_text, searchStore.query)"></pre>
<template v-if="hasContext(log)">
<p class="mx-2 lg:mx-8 pt-2 border-t font-semibold text-gray-700 dark:text-gray-400 text-xs lg:text-sm">Context:</p>
<pre class="log-stack" v-html="highlightSearchResult(prepareContextForOutput(log.context), searchStore.query)"></pre>
</template>

<div v-if="log.extra && log.extra.log_text_incomplete" class="py-4 px-8 text-gray-500 italic">
The contents of this log have been cut short to the first {{ LogViewer.max_log_size_formatted }}.
The full size of this log entry is <strong>{{ log.extra.log_size_formatted }}</strong>
</div>
</tab-content>
</tab-container>
</td>
</tr>
</tbody>
Expand Down Expand Up @@ -134,6 +143,9 @@ import { useFileStore } from '../stores/files.js';
import LogCopyButton from './LogCopyButton.vue';
import { handleLogToggleKeyboardNavigation } from '../keyboardNavigation';
import { useSeverityStore } from '../stores/severity.js';
import TabContainer from "./TabContainer.vue";
import TabContent from "./TabContent.vue";
import MailPreview from "./MailPreview.vue";
const fileStore = useFileStore();
const logViewerStore = useLogViewerStore();
Expand All @@ -158,6 +170,23 @@ const hasContext = (log) => {
return log.context && Object.keys(log.context).length > 0;
}
const hasPreviews = (log) => {
return getExtraTabsForLog(log).length > 0;
}
const getExtraTabsForLog = (log) => {
return [
log.extra && log.extra.mail_preview ? { name: 'Mail preview', value: 'mail_preview' } : null,
].filter(Boolean);
}
const getTabsForLog = (log) => {
return [
...getExtraTabsForLog(log),
{ name: 'Raw', value: 'raw' },
].filter(Boolean);
}
const prepareContextForOutput = (context) => {
return JSON.stringify(context, function (key, value) {
if (typeof value === 'string') {
Expand Down
85 changes: 85 additions & 0 deletions resources/js/components/MailPreview.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<template>
<div class="mail-preview">
<!-- headers -->
<div class="mail-preview-attributes">
<table>
<tr v-if="mail.from">
<td class="font-semibold">From</td>
<td>{{ mail.from }}</td>
</tr>
<tr v-if="mail.to">
<td class="font-semibold">To</td>
<td>{{ mail.to }}</td>
</tr>
<tr v-if="mail.id">
<td class="font-semibold">Message ID</td>
<td>{{ mail.id }}</td>
</tr>
<tr v-if="mail.subject">
<td class="font-semibold">Subject</td>
<td>{{ mail.subject }}</td>
</tr>
<tr v-if="mail.attachments && mail.attachments.length > 0">
<td class="font-semibold">Attachments</td>
<td>
<div v-for="(attachment, index) in mail.attachments" :key="`mail-${mail.id}-attachment-${index}`"
class="mail-attachment-button"
>
<div class="flex items-center">
<PaperClipIcon class="h-4 w-4 text-gray-500 dark:text-gray-400 mr-1" />
<span>{{ attachment.filename }} <span class="opacity-60">({{ attachment.size_formatted }})</span></span>
</div>
<div>
<a href="#" @click.prevent="downloadAttachment(attachment)"
class="text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
>Download</a>
</div>
</div>
</td>
</tr>
</table>
</div>

<!-- HTML preview -->
<iframe
v-if="mail.html"
class="mail-preview-html"
:style="{height: `${iframeHeight}px`}"
:srcdoc="mail.html"
@load="setIframeHeight"
ref="iframe"
></iframe>
</div>
</template>

<script setup>
import { PaperClipIcon } from '@heroicons/vue/24/outline';
import {computed, ref} from "vue";
const props = defineProps({
mail: {
type: Object,
},
})
const iframe = ref(null);
const iframeHeight = ref(600);
const setIframeHeight = () => {
iframeHeight.value = (iframe.value?.contentWindow?.document?.body?.clientHeight || 580) + 20;
}
const downloadAttachment = (attachment) => {
const blob = new Blob([attachment.content], { type: attachment.content_type || 'application/octet-stream' });
const blobUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href = blobUrl;
downloadLink.download = attachment.filename;
downloadLink.click();
// Clean up the temporary URL after the download
URL.revokeObjectURL(blobUrl);
}
</script>
33 changes: 33 additions & 0 deletions resources/js/components/TabContainer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<template>
<div>
<div class="tabs-container" v-if="tabs && tabs.length > 1">
<div class="border-b border-gray-200 dark:border-gray-800">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<a v-for="tab in tabs" :key="tab.name" href="#" @click.prevent="currentTab = tab"
:class="[isCurrent(tab) ? 'border-brand-500 dark:border-brand-400 text-brand-600 dark:text-brand-500' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:text-gray-700 dark:hover:text-gray-200', 'whitespace-nowrap border-b-2 py-2 px-1 text-sm font-medium focus:outline-brand-500']"
:aria-current="isCurrent(tab) ? 'page' : undefined">{{ tab.name }}</a>
</nav>
</div>
</div>

<slot></slot>
</div>
</template>

<script setup>
import {provide, ref} from "vue";
const props = defineProps({
tabs: {
type: Array,
required: true,
},
})
const currentTab = ref(props.tabs[0]);
provide('currentTab', currentTab);
const isCurrent = (tab) => {
return currentTab.value && currentTab.value.value === tab.value;
}
</script>
22 changes: 22 additions & 0 deletions resources/js/components/TabContent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<template>
<div v-if="isSelected">
<slot></slot>
</div>
</template>

<script setup>
import {computed, inject} from "vue";
const props = defineProps({
tabValue: {
type: String,
required: true,
},
})
const currentTab = inject('currentTab');
const isSelected = computed(() => {
return currentTab.value && currentTab.value.value === props.tabValue;
})
</script>
33 changes: 32 additions & 1 deletion src/Logs/LaravelLog.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Opcodes\LogViewer\Facades\LogViewer;
use Opcodes\LogViewer\LogLevels\LaravelLogLevel;
use Opcodes\LogViewer\Utils\Utils;
use Opcodes\MailParser\Message;

class LaravelLog extends Log
{
Expand Down Expand Up @@ -91,6 +92,7 @@ protected function parseText(array &$matches = []): void
}

$this->text = trim($text);
$this->extractMailPreview();
}

protected function fillMatches(array $matches = []): void
Expand All @@ -105,7 +107,7 @@ protected static function regexPattern(): string
.')?: ?(.*?)( in [\/].*?:[0-9]+)?$/is';
}

public function extractContextsFromFullText(): void
protected function extractContextsFromFullText(): void
{
// The regex pattern to find JSON strings.
$pattern = '/(\{(?:[^{}]|(?R))*\}|\[(?:[^\[\]]|(?R))*\])/';
Expand Down Expand Up @@ -143,4 +145,33 @@ public function extractContextsFromFullText(): void
$this->context = $contexts[0];
}
}

protected function extractMailPreview(): void
{
$isMail = Str::contains($this->text, 'To:')
&& Str::contains($this->text, 'From:')
&& Str::contains($this->text, 'MIME-Version: 1.0');

if (! $isMail) {
return;
}

$message = Message::fromString($this->text);

$this->extra['mail_preview'] = [
'id' => $message->getId() ?: null,
'subject' => $message->getSubject(),
'from' => $message->getFrom(),
'to' => $message->getTo(),
'attachments' => array_map(fn ($attachment) => [
'content' => $attachment->getContent(),
'content_type' => $attachment->getContentType(),
'filename' => $attachment->getFilename(),
'size_formatted' => Utils::bytesForHumans($attachment->getSize()),
], $message->getAttachments()),
'html' => $message->getHtmlPart()?->getContent(),
'text' => $message->getTextPart()?->getContent(),
'size_formatted' => Utils::bytesForHumans($message->getSize()),
];
}
}
Loading

0 comments on commit 579d5f4

Please sign in to comment.