diff --git a/ansible/roles/podman/defaults/main.yml b/ansible/roles/podman/defaults/main.yml
index 9660ca9..d8db091 100644
--- a/ansible/roles/podman/defaults/main.yml
+++ b/ansible/roles/podman/defaults/main.yml
@@ -110,6 +110,25 @@ bookstack_server_name_new: wiki.skudak.com
cloud_skudak_server_name_new: cloud.skudak.com
gitea_skudak_server_name: git.skudak.com
+# LibreSign root certificate authority identity for skudak-cloud. This is the
+# issuer name that appears on every signed document, so it must match the
+# entity's legal name -- it was previously generated as "Skudak Rennsport LLP",
+# the pre-rename name. Changing these values does NOT re-issue the CA on its
+# own; see the guarded generate task in containers/skudak/cloud.yml.
+# Skudak brand palette, mirroring ~/src/skudak/skudak-site/src/styles/variables.css.
+# --color-gray-900 for the mail header band and UI chrome; the white signature
+# logo is legible on it. --color-accent is spent only on the CTA button, and
+# lives in the skudakmail app rather than here since theming has no second
+# colour slot.
+theming_skudak_primary: "#0A0A0A"
+
+libresign_skudak_cert_cn: Skudak LLP
+libresign_skudak_cert_o: Skudak LLP
+libresign_skudak_cert_c: US
+libresign_skudak_cert_st: New Hampshire
+libresign_skudak_cert_l: Newbury
+
+
# Legacy nginx/ModSecurity configuration removed - Caddy provides built-in security
# Web server configuration (Caddy is the default)
diff --git a/ansible/roles/podman/files/skudakmail/appinfo/info.xml b/ansible/roles/podman/files/skudakmail/appinfo/info.xml
new file mode 100644
index 0000000..1507808
--- /dev/null
+++ b/ansible/roles/podman/files/skudakmail/appinfo/info.xml
@@ -0,0 +1,44 @@
+
+
+ skudakmail
+ Skudak Customisations
+ Skudak-branded email templates and UI overrides for Nextcloud and LibreSign
+
+ 1.0.0
+ agpl
+ Skudak LLP
+ Skudakmail
+ customization
+
+
+
+
diff --git a/ansible/roles/podman/files/skudakmail/css/libresign-mobile.css b/ansible/roles/podman/files/skudakmail/css/libresign-mobile.css
new file mode 100644
index 0000000..7fd81aa
--- /dev/null
+++ b/ansible/roles/podman/files/skudakmail/css/libresign-mobile.css
@@ -0,0 +1,40 @@
+/*
+ * Mobile fix for the LibreSign public signing page.
+ *
+ * PROBLEM: src/ExternalApp.vue sets `height: 100vh` on `html body #content`
+ * and again on `#app-sidebar` under `@media (max-width: 512px)`. iOS Safari
+ * resolves 100vh against the LARGE viewport -- as though the browser chrome
+ * were hidden -- so the element extends behind the bottom toolbar and the
+ * signing action bar is clipped off-screen. The built `external` chunk uses
+ * 100vh seven times and dvh/svh/safe-area zero times.
+ *
+ * WHY NOT safe-area-inset: the page's viewport meta is
+ * `width=device-width, initial-scale=1.0, minimum-scale=1.0` with no
+ * `viewport-fit=cover`, so env(safe-area-inset-bottom) resolves to 0 here.
+ *
+ * WHY dvh: the dynamic viewport unit tracks the chrome as it shows and hides,
+ * which is exactly the behaviour wanted. Browsers without dvh support drop the
+ * declaration entirely and keep LibreSign's own 100vh -- so this degrades to
+ * today's behaviour rather than to something broken. No @supports needed.
+ *
+ * SCOPING IS LOad-BEARING. `#content` and `#app-sidebar` are Nextcloud-wide
+ * IDs used throughout the authenticated UI. Every rule below is scoped to
+ * `#body-public` + `.app-public`, which the public signing page sets:
+ *
+ *
+ * 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 .= <<
+
+
+
+ |
+
+
+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: