From b46550e2bac461563aa61b73d47bba69df41ef96 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Sat, 2 May 2026 19:51:50 +0200 Subject: [PATCH] feat(digest): inline file thumbnails in the daily activity digest email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily activity digest currently lists each event with the provider's small icon URL. For file-related events that's a generic mime-type icon, which makes the digest feel like a server log rather than a "things that happened to your stuff today" summary. Show the actual file preview instead, when one is available. `DigestSender::getPreviewDataUri()` resolves the file as the affected user (so access mirrors what the recipient sees in the web UI), generates a 96×96 preview via `IPreview`, and returns a base64 data URI inlined directly into the email — no public preview endpoint needed, and the digest renders fully offline. Returns null on any failure (not a file event, file deleted, no read permission, preview generation unsupported, anything thrown). The existing `$event->getIcon()` is the seamless fallback, so existing behaviour is preserved for non-file or no-preview events. The 96px cap keeps each thumbnail around 3-5 KB; even at the 20-event ACTIVITY_LIMIT the digest stays well under common message-size thresholds. `IRootFolder` and `IPreview` are auto-wired through standard DI; `DigestSender` is not explicitly registered in `Application.php`, so no AppInfo change is needed. Signed-off-by: Frank Karlitschek --- lib/DigestSender.php | 62 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/lib/DigestSender.php b/lib/DigestSender.php index 0dfe4d380..39d2339c2 100644 --- a/lib/DigestSender.php +++ b/lib/DigestSender.php @@ -11,8 +11,13 @@ use OCP\Activity\IEvent; use OCP\Activity\IManager; use OCP\Defaults; +use OCP\Files\File; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; use OCP\IConfig; use OCP\IDateTimeFormatter; +use OCP\IPreview; use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; @@ -24,6 +29,14 @@ class DigestSender { public const ACTIVITY_LIMIT = 20; + /** + * Maximum size in pixels for embedded preview thumbnails. Kept small so that + * the resulting base64-encoded payload doesn't bloat the email — a 96px PNG + * is typically a few KB, and 20 of them stay well under common message-size + * limits. + */ + private const PREVIEW_PIXELS = 96; + public function __construct( private IConfig $config, private Data $data, @@ -37,6 +50,8 @@ public function __construct( private IFactory $l10nFactory, private IDateTimeFormatter $dateTimeFormatter, private LoggerInterface $logger, + private IRootFolder $rootFolder, + private IPreview $previewManager, ) { } @@ -181,7 +196,8 @@ public function sendDigestForUser(IUser $user, int $now, string $timezone, strin $l10n ); - $template->addBodyListItem($this->getHTMLSubject($event), $relativeDateTime, $event->getIcon(), $event->getParsedSubject()); + $icon = $this->getPreviewDataUri($event, $uid) ?? $event->getIcon(); + $template->addBodyListItem($this->getHTMLSubject($event), $relativeDateTime, $icon, $event->getParsedSubject()); } if ($skippedCount) { @@ -215,6 +231,50 @@ public function sendDigestForUser(IUser $user, int $now, string $timezone, strin } } + /** + * Returns a data: URI with a base64-encoded PNG preview of the file the + * activity is about, or null if no preview is available — for any reason + * (not a file event, file deleted, no read permission, preview generation + * unsupported, etc.). The caller falls back to the regular event icon. + * + * Resolved as the affected user, so the resulting access mirrors what the + * recipient could see in the web UI. + */ + private function getPreviewDataUri(IEvent $event, string $uid): ?string { + if ($event->getObjectType() !== 'files' || $event->getObjectId() <= 0) { + return null; + } + + try { + $userFolder = $this->rootFolder->getUserFolder($uid); + $node = $userFolder->getFirstNodeById($event->getObjectId()); + if (!$node instanceof File) { + return null; + } + + if (!$this->previewManager->isAvailable($node)) { + return null; + } + + $preview = $this->previewManager->getPreview($node, self::PREVIEW_PIXELS, self::PREVIEW_PIXELS, true); + $content = $preview->getContent(); + if ($content === '') { + return null; + } + $mime = $preview->getMimeType() ?: 'image/png'; + return 'data:' . $mime . ';base64,' . base64_encode($content); + } catch (NotFoundException|NotPermittedException) { + return null; + } catch (\Throwable $e) { + $this->logger->debug('Could not generate digest preview', [ + 'app' => 'activity', + 'exception' => $e, + 'object_id' => $event->getObjectId(), + ]); + return null; + } + } + /** * @param IEvent $event * @return string