/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: