+ * 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 0000000..200e8df Binary files /dev/null and b/ansible/roles/podman/files/skudakmail/img/skudak-wordmark.png differ 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: