+ * Widening these selectors would restyle the whole instance. + * + * UPSTREAM: patched at source in src/ExternalApp.vue (lines 34 and 46) and + * submitted to LibreSign. Once that lands and this instance runs a release + * containing it, this file can be deleted. + */ + +#body-public #content.app-public { + height: 100dvh; +} + +@media (max-width: 512px) { + #body-public #app-sidebar { + height: 100dvh; + } +} diff --git a/ansible/roles/podman/files/skudakmail/img/skudak-wordmark.png b/ansible/roles/podman/files/skudakmail/img/skudak-wordmark.png new file mode 100644 index 0000000000000000000000000000000000000000..200e8df542050f4dc5a1a6eb44f3a3b8e4d33f1a GIT binary patch literal 10374 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 literal 0 HcmV?d00001 diff --git a/ansible/roles/podman/files/skudakmail/lib/AppInfo/Application.php b/ansible/roles/podman/files/skudakmail/lib/AppInfo/Application.php new file mode 100644 index 0000000..06c211f --- /dev/null +++ b/ansible/roles/podman/files/skudakmail/lib/AppInfo/Application.php @@ -0,0 +1,41 @@ +registerEventListener(BeforeMessageSent::class, SkudakMailListener::class); + + // BeforeTemplateRenderedEvent is dispatched from + // lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php:35 and + // lib/private/Template/TemplateManager.php:82 -- the latter covers public + // (unauthenticated) pages, which is the case that matters here since the + // LibreSign signing page is a #[PublicPage]. + $context->registerEventListener(BeforeTemplateRenderedEvent::class, SkudakStyleListener::class); + } + + public function boot(IBootContext $context): void { + } +} diff --git a/ansible/roles/podman/files/skudakmail/lib/Listener/SkudakMailListener.php b/ansible/roles/podman/files/skudakmail/lib/Listener/SkudakMailListener.php new file mode 100644 index 0000000..8827e87 --- /dev/null +++ b/ansible/roles/podman/files/skudakmail/lib/Listener/SkudakMailListener.php @@ -0,0 +1,121 @@ +. The fix is a cid: reference backed by an inline MIME part, and + * that part must be attached to the MESSAGE. An IEMailTemplate subclass has no + * reference to the message, so it physically cannot do this; the template emits + * the , this listener supplies the bytes and rewrites the src. + * + * BeforeMessageSent is the sanctioned hook -- "Emitted before a system mail is + * sent. It can be used to alter the message." (lib/public/Mail/Events/ + * BeforeMessageSent.php). It fires at lib/private/Mail/Mailer.php:186, after + * useTemplate() has already rendered subject/plain/html onto the message and + * before setRecipients() and the transport, so a body rewrite here takes + * effect. No core patch, no LibreSign fork. + * + * FAILURE POSTURE: every step is defensive. If the asset is missing, the body + * is not ours, or anything throws, the listener leaves the message untouched + * and mail still goes out with a remote -- degraded, never blocked. Mail + * that carries signature requests must not fail to send because branding + * broke. + */ + +namespace OCA\Skudakmail\Listener; + +use OC\Mail\Message; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Mail\Events\BeforeMessageSent; +use Psr\Log\LoggerInterface; + +/** @template-implements IEventListener */ +class SkudakMailListener implements IEventListener { + /** Must match SkudakEMailTemplate::LOGO_PATH. */ + private const LOGO_PATH_FRAGMENT = '/custom_apps/skudakmail/img/skudak-wordmark.png'; + + /** Content-ID. Symfony emits this as . */ + private const CID = 'skudak-wordmark.png'; + + public function __construct( + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + if (!$event instanceof BeforeMessageSent) { + return; + } + + try { + $this->embedWordmark($event->getMessage()); + } catch (\Throwable $e) { + // Never let branding break delivery of a signature request. + $this->logger->warning('skudakmail: inline logo embed skipped', [ + 'exception' => $e, + ]); + } + } + + private function embedWordmark(\OCP\Mail\IMessage $message): void { + // Mailer::send() guards `instanceof Message` before dispatching this + // event, so the concrete type is guaranteed -- but getSymfonyEmail() + // is not on the interface, so narrow explicitly rather than assume. + if (!$message instanceof Message) { + return; + } + + $email = $message->getSymfonyEmail(); + $html = $email->getHtmlBody(); + if (!is_string($html) || $html === '') { + return; + } + + // Only touch mail that actually renders our wordmark. Anything else -- + // password resets, share notifications, other apps -- passes through. + if (!str_contains($html, self::LOGO_PATH_FRAGMENT)) { + return; + } + + $asset = $this->assetPath(); + if ($asset === null) { + return; + } + + $bytes = @file_get_contents($asset); + if ($bytes === false || $bytes === '') { + return; + } + + // Rewrite the absolute URL to a cid: reference. Matched on the path + // fragment with an optional query string so a cachebuster or a change + // of host still resolves. + $rewritten = preg_replace( + '#https?://[^"\']*' . preg_quote(self::LOGO_PATH_FRAGMENT, '#') . '(\?[^"\']*)?#', + 'cid:' . self::CID, + $html, + ); + + if (!is_string($rewritten) || $rewritten === $html) { + return; + } + + $email->embed($bytes, self::CID, 'image/png'); + $message->setHtmlBody($rewritten); + } + + /** + * Resolves img/skudak-wordmark.png relative to this file, so the app works + * from whatever apps directory Nextcloud has it in. + */ + private function assetPath(): ?string { + $path = dirname(__DIR__, 2) . '/img/skudak-wordmark.png'; + return is_readable($path) ? $path : null; + } +} diff --git a/ansible/roles/podman/files/skudakmail/lib/Listener/SkudakStyleListener.php b/ansible/roles/podman/files/skudakmail/lib/Listener/SkudakStyleListener.php new file mode 100644 index 0000000..321d134 --- /dev/null +++ b/ansible/roles/podman/files/skudakmail/lib/Listener/SkudakStyleListener.php @@ -0,0 +1,51 @@ + */ +class SkudakStyleListener implements IEventListener { + public function handle(Event $event): void { + if (!$event instanceof BeforeTemplateRenderedEvent) { + return; + } + + // Never let a styling concern break page rendering. A signing page that + // loads unstyled is recoverable; one that 500s is not. + try { + Util::addStyle(Application::APP_ID, 'libresign-mobile'); + } catch (\Throwable $e) { + // Intentionally swallowed -- no logger dependency is worth adding + // for a stylesheet, and a failure here has no user-visible effect + // beyond the override not applying. + } + } +} diff --git a/ansible/roles/podman/files/skudakmail/lib/Mail/SkudakEMailTemplate.php b/ansible/roles/podman/files/skudakmail/lib/Mail/SkudakEMailTemplate.php new file mode 100644 index 0000000..b463fd2 --- /dev/null +++ b/ansible/roles/podman/files/skudakmail/lib/Mail/SkudakEMailTemplate.php @@ -0,0 +1,417 @@ +/img/ is served publicly without auth (verified). + * + * Note that remote images are blocked by default in Apple Mail, Gmail and + * Outlook, and Apple Mail renders its own placeholder box rather than styled + * alt text -- so alt styling cannot rescue it. Surviving that requires a CID + * inline part via IMessage::attachInline(), which lives on the MESSAGE and is + * unreachable from a template subclass. Mitigated instead by dropping the + * band: a blocked logo now leaves plain white space, not a black slab. + * + * IMPLEMENTATION NOTE: font restyling is done by string-substitution against + * the PARENT's own markup rather than by redefining it. Those properties are + * large inline-CSS blobs with positional sprintf placeholders; copying them + * wholesale would mean re-auditing every placeholder on every upgrade, and a + * mismatch renders broken mail. Substitution degrades safely -- if upstream + * changes markup the replacements no-op and mail still sends, just unstyled. + * The header IS replaced wholesale, deliberately, because "no band" cannot be + * expressed as a substitution; its placeholder order is documented at its + * definition and must be kept in sync with upstream. + */ + +namespace OCA\Skudakmail\Mail; + +use OC\Mail\EMailTemplate; + +class SkudakEMailTemplate extends EMailTemplate { + /** Stock Nextcloud font stack, replaced wholesale. Must match exactly. */ + private const STOCK_FONTS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',Arial,sans-serif"; + + /** --font-sans, with the stock stack retained as fallback. */ + private const SKUDAK_FONTS = "Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',Arial,sans-serif"; + + private const ACCENT = '#2563EB'; // --color-accent + private const ON_ACCENT = '#FAFAFA'; // --color-white + private const INK = '#0A0A0A'; // --color-gray-900 + private const MUTED = '#525252'; // --color-gray-500 + private const FAINT = '#A3A3A3'; // --color-gray-300 + private const RULE = '#E5E5E5'; // --color-gray-100 + + private const ENTITY = 'Skudak LLP'; + private const SITE = 'https://skudak.com'; + private const LOGO_PATH = '/custom_apps/skudakmail/img/skudak-wordmark.png'; + + /** Displayed width in px. The asset is 600px wide for retina. */ + private const LOGO_DISPLAY_WIDTH = 190; + + /** + * LibreSign's l10n wraps document names in German guillemets -- "Sign + * »contract«" -- regardless of locale. Mapped to US curly quotes, matching + * the ``...'' convention in the LaTeX document templates. + */ + private const QUOTE_MAP = ['»' => "\u{201C}", '«' => "\u{201D}"]; + + /** + * LibreSign subject -> Skudak subject. Keys are the exact English msgids + * from custom_apps/libresign/lib/Service/MailService.php (lines 51, 87, + * 121, 150, 172). Anything unmatched passes through untouched, so an + * upstream string change degrades to the original subject rather than a + * blank one. + */ + private const SUBJECT_MAP = [ + 'LibreSign: There is a file for you to sign' => 'Document for your signature', + 'LibreSign: Changes into a file for you to sign' => 'Updated document for your signature', + 'LibreSign: A file has been signed' => 'A document has been signed', + 'LibreSign: A signature request has been canceled' => 'Signature request cancelled', + 'LibreSign: Code to sign file' => 'Your signing verification code', + ]; + + /** + * LibreSign heading -> Skudak heading. Exact English msgids from + * MailService.php lines 53/89, 123, 152. + */ + private const HEADING_MAP = [ + 'File to sign' => 'Review and sign', + 'File signed' => 'Document signed', + 'Signature request canceled' => 'Signature request cancelled', + ]; + + /** + * LibreSign body copy -> Skudak body copy (MailService.php lines 60, 96, + * 174). Only the strings with NO %s interpolation are mapped; the two that + * carry a name or filename (lines 125, 154) arrive already substituted and + * so cannot be matched exactly -- they pass through unchanged. + */ + private const BODY_MAP = [ + 'There is a document for you to sign. Access the link below:' + => 'Skudak LLP has sent you a document that requires your signature. Review it and sign using the link below.', + 'Changes have been made in a file that you have to sign. Access the link below:' + => 'A document awaiting your signature has been updated by Skudak LLP. Review the current version and sign using the link below.', + 'Use this code to sign the document:' + => 'Use this verification code to complete your signature:', + ]; + + /** + * Template properties carrying the font stack. Listed explicitly rather + * than discovered reflectively so an upstream rename fails loudly in + * testing instead of silently skipping a block. + */ + private const STYLED_PARTS = [ + 'head', 'tail', 'heading', 'bodyBegin', 'bodyText', + 'listBegin', 'listItem', 'listEnd', 'buttonGroup', 'button', + 'bodyEnd', 'footer', + ]; + + /** + * Own flag, deliberately NOT the parent's $footerAdded. + * + * Message::useTemplate() (lib/private/Mail/Message.php:289-296) calls + * renderText() at :291 BEFORE renderHtml() at :293, and renderText() sets + * $footerAdded = true. Guarding footer injection on !$footerAdded therefore + * never fires on the real send path -- the footer silently vanished from + * every mail while a renderHtml()-only test passed. Both renderers below + * call inject() and this flag makes the second call inert. + */ + private bool $skudakFooterInjected = false; + + public function __construct( + \OCP\Defaults $themingDefaults, + \OCP\IURLGenerator $urlGenerator, + \OCP\L10N\IFactory $l10nFactory, + ?int $logoWidth, + ?int $logoHeight, + string $emailId, + array $data, + ) { + $this->applySkudakStyling(); + + // Must run AFTER the substitutions: the parent constructor copies + // $this->head into $htmlBody as its first act, so restyling head + // afterwards would leave the already-emitted copy untouched. + parent::__construct( + $themingDefaults, + $urlGenerator, + $l10nFactory, + $logoWidth, + $logoHeight, + $emailId, + $data, + ); + } + + private function applySkudakStyling(): void { + foreach (self::STYLED_PARTS as $part) { + if (!property_exists($this, $part)) { + continue; + } + $this->$part = str_replace(self::STOCK_FONTS, self::SKUDAK_FONTS, $this->$part); + } + + // Site headings are --font-weight-light with tightened tracking. + $this->heading = str_replace( + 'font-size:24px;font-weight:400', + 'font-size:26px;font-weight:300;letter-spacing:-0.02em', + $this->heading, + ); + } + + /** + * Rewrites LibreSign's subjects. Called by LibreSign on the TEMPLATE + * (MailService.php:51 etc.), not on the message, which is what makes this + * interceptable at all -- Message::useTemplate() later pulls the result via + * renderSubject(). Prefixed with the entity so the sender is unambiguous in + * an inbox list. + */ + public function setSubject(string $subject): void { + $mapped = self::SUBJECT_MAP[$subject] ?? null; + parent::setSubject( + $mapped === null ? $subject : self::ENTITY . ' — ' . $mapped, + ); + } + + /** + * Replaces the stock header wholesale: no coloured band, wordmark centred + * on white. + * + * Does NOT use the parent's $header property or its placeholder order -- + * this is independent markup, so upstream changes to $header cannot break + * it (and equally cannot improve it). $logoWidth/$logoHeight from the + * Mailer are ignored on purpose: they are clamped to MAX_LOGO_SIZE = 105 + * (lib/private/Mail/Mailer.php:60), which is too small for a wordmark to + * be legible. + */ + public function addHeader(): void { + if ($this->headerAdded) { + return; + } + $this->headerAdded = true; + + $logoUrl = $this->urlGenerator->getAbsoluteURL(self::LOGO_PATH); + $alt = htmlspecialchars(self::ENTITY, ENT_QUOTES, 'UTF-8'); + $w = self::LOGO_DISPLAY_WIDTH; + $fonts = self::SKUDAK_FONTS; + $ink = self::INK; + + $this->htmlBody .= << + + + {$alt} + + + +HTML; + } + + /** + * Both renderers inject the footer -- see $skudakFooterInjected. + * + * Mirrors the parent's own guard structure (renderHtml at + * lib/private/Mail/EMailTemplate.php:643, renderText at :656): close the + * body, append $tail, flip $footerAdded. The Skudak block goes in before + * $tail. + */ + public function renderHtml(): string { + $this->injectSkudakFooter(); + return parent::renderHtml(); + } + + public function renderText(): string { + $this->injectSkudakFooter(); + return parent::renderText(); + } + + private function injectSkudakFooter(): void { + if ($this->skudakFooterInjected || $this->footerAdded) { + return; + } + $this->skudakFooterInjected = true; + + // Close the body ourselves so the footer lands INSIDE the layout + // rather than after it. The parent's render methods are then a no-op + // for body closing and only append $tail. + $this->ensureBodyIsClosed(); + $this->htmlBody .= $this->skudakFooterHtml(); + $this->plainBody .= $this->skudakFooterText(); + } + + private function skudakFooterHtml(): string { + $year = date('Y'); + $entity = htmlspecialchars(self::ENTITY, ENT_QUOTES, 'UTF-8'); + $fonts = self::SKUDAK_FONTS; + $site = self::SITE; + [$muted, $faint, $rule, $ink] = [self::MUTED, self::FAINT, self::RULE, self::INK]; + + // Table-based and fully inline-styled: