Files
deploy_home/ansible/roles/podman/files/skudakmail/lib/Mail/SkudakEMailTemplate.php
T
Bastian de Byl fec7d62acb feat(skudak-cloud): repair LibreSign, brand its mail, add Redis
LibreSign had been silently broken since it was first deployed in
January. Every step of the old before-starting hook ended in `|| echo`,
so six months of failures logged nothing.

LibreSign repair
- Root cause was a stale config_path: a valid OpenSSL root CA existed at
  generation 1, a failed CFSSL attempt left an empty generation 2, and
  config_path was left pointing at the empty one. Regenerated as
  "Skudak LLP" (was the pre-rename "Skudak Rennsport LLP").
- Deleted the hook. Java/PDFtk/jSignPdf live under data/appdata_*, a
  persisted volume, so they only ever needed installing once. Install and
  verification are now explicit tasks that actually fail.
- PHP_MEMORY_LIMIT 1024M -- the 512M image default fails opaquely
  mid-signature. LC_ALL/LANG so the JVM is not ANSI_X3.4-1968.
- signature_render_mode=GRAPHIC_ONLY. Any other mode halves the stamp
  width and overlays a name/date block that collides with the drawn mark
  and duplicates what our documents already typeset. The value must be
  exactly GRAPHIC_ONLY; a bare "GRAPHIC" is accepted by occ, matches no
  radio in the UI, and silently reverts to default.
- write_qrcode_on_footer=false, written with --type=boolean because
  FooterHandler reads it via getValueBool and the typed appconfig API
  does not coerce a string "0". The validation URL text is kept.
- identification_documents=0 -- the default gates signing behind an ID
  upload plus admin approval, so signers saw no way to sign.
- shareapi_restrict_user_enumeration_full_match=no, so an email owned by
  an existing account can be added as a signer. Root cause is in core
  (MailPlugin.php:163), not LibreSign. Do NOT set full_match_email=no --
  that disables email signer search entirely.

Mail branding (skudakmail app)
- Two supported extension points, no core patch and no LibreSign fork:
  mail_template_class for layout, subjects, button labels and the footer
  LibreSign never adds; and a BeforeMessageSent listener to embed the
  wordmark as a cid: part so it survives remote-image blocking.
- A third listener adds scoped CSS fixing the signing page being clipped
  on iOS Safari (100vh -> 100dvh). Patched upstream too.
- skudakmail-verify.php.j2 asserts all of the above through the real
  useTemplate() path and fails the play on drift. Every assertion was
  proven to fail when deliberately regressed.

Redis
- memcache.locking was unset, so Nextcloud used DBLockingProvider and
  every file lock became a MariaDB write -- the contention behind the
  intermittent multi-second stalls. Verified after: db locks static,
  redis keys growing.
- requirepass lives in a mounted 0640 conf, not --requirepass, which
  would leak it into podman inspect, the systemd unit and ps. The file is
  chowned to uid 999 because redis-server does not run as root and the
  :ro mount stops the image fixing it itself.
- No maxmemory: cache is evictable, locks are NOT, and evicting a held
  lock permits concurrent writers to one file. No persistence either --
  a restored RDB could reinstate locks whose owner is long dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:54:52 -04:00

418 lines
17 KiB
PHP

<?php
declare(strict_types=1);
/**
* Skudak-branded email template.
*
* Wired in via the `mail_template_class` system config value, which Nextcloud
* checks in lib/private/Mail/Mailer.php::createEMailTemplate(). That is a
* supported extension point -- core is not patched, so Nextcloud upgrades do
* not clobber this.
*
* WHY THIS EXISTS AT ALL: LibreSign's outgoing mail is generic open-source
* boilerplate -- subject "LibreSign: There is a file for you to sign", heading
* "File to sign", button "Sign »filename«", and NO footer whatsoever (it never
* calls addFooter(); verified: zero hits for addFooter in custom_apps/libresign).
* That mail carries partnership instruments to signers, so it needs to read as
* an official Skudak LLP communication.
*
* DESIGN INTENT (mirrors ~/src/skudak/skudak-site/src/styles/variables.css):
* - Light ground, near-black text, NO coloured header band. skudak.com is
* --color-white #FAFAFA with --color-gray-900 #0A0A0A text; transactional
* mail from Stripe/Linear/DocuSign is likewise restrained. A band also
* leaves an ugly empty slab when the logo is blocked (see LOGO note).
* - --color-accent #2563EB on the CTA button only. That is the one place the
* site spends colour, so it is the one place this template does.
* - Inter, matching --font-sans, with the stock stack as fallback.
*
* LOGO: served from this app's own img/ directory rather than the theming app.
* Two reasons. (1) The theming logo is white-on-transparent because the web UI
* and login page are dark; a white mark is invisible on this template's white
* ground. (2) Decoupling means restyling mail can never disturb the web UI.
* /custom_apps/<app>/img/<file> 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
<table align="center" style="border-collapse:collapse;border-spacing:0;margin:0 auto;padding:0;text-align:left;vertical-align:top;width:100%">
<tbody><tr style="padding:0;text-align:left;vertical-align:top">
<td align="center" style="border-collapse:collapse!important;margin:0;padding:40px 30px 28px 30px;text-align:center;vertical-align:top">
<img src="{$logoUrl}" alt="{$alt}" width="{$w}" style="-ms-interpolation-mode:bicubic;border:0;clear:both;display:block;margin:0 auto;outline:0;text-decoration:none;width:{$w}px;max-width:{$w}px;height:auto;color:{$ink};font-family:{$fonts};font-size:22px;font-weight:300;letter-spacing:-0.02em"/>
</td>
</tr></tbody>
</table>
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: <style> blocks, flex and grid
// are stripped or unsupported across Outlook and most webmail.
return <<<HTML
<table align="center" style="border-collapse:collapse;border-spacing:0;margin:0 auto;padding:0;text-align:left;vertical-align:top;width:100%">
<tbody><tr style="padding:0;text-align:left;vertical-align:top">
<td align="center" style="border-collapse:collapse!important;margin:0;padding:0 30px 44px 30px;text-align:center;vertical-align:top">
<table align="center" style="border-collapse:collapse;border-spacing:0;margin:0 auto;padding:0;text-align:center;width:100%;max-width:550px">
<tbody>
<tr><td style="border-collapse:collapse!important;border-top:1px solid {$rule};font-size:0;line-height:0;height:1px;margin:0;padding:0">&#xA0;</td></tr>
<tr><td align="center" style="border-collapse:collapse!important;color:{$muted};font-family:{$fonts};font-size:13px;font-weight:400;line-height:1.6;margin:0;padding:22px 0 0 0;text-align:center">
This is an official document-signing request from <strong style="color:{$ink};font-weight:600">{$entity}</strong>.<br/>
Nothing is signed unless you open the document and complete it yourself. If you were not expecting this, you can safely ignore it.
</td></tr>
<tr><td align="center" style="border-collapse:collapse!important;color:{$muted};font-family:{$fonts};font-size:13px;font-weight:400;line-height:1.6;margin:0;padding:16px 0 0 0;text-align:center">
<a href="{$site}/privacy" style="color:{$muted};text-decoration:underline">Privacy Policy</a>
&#160;&#183;&#160;
<a href="{$site}/terms" style="color:{$muted};text-decoration:underline">Terms of Use</a>
&#160;&#183;&#160;
<a href="{$site}" style="color:{$muted};text-decoration:underline">skudak.com</a>
</td></tr>
<tr><td align="center" style="border-collapse:collapse!important;color:{$faint};font-family:{$fonts};font-size:12px;font-weight:400;line-height:1.6;margin:0;padding:16px 0 0 0;text-align:center">
&copy; {$year} {$entity}. All rights reserved.<br/>
Automated message &mdash; please do not reply to this address.
</td></tr>
</tbody>
</table>
</td>
</tr></tbody>
</table>
HTML;
}
private function skudakFooterText(): string {
$year = date('Y');
$entity = self::ENTITY;
$site = self::SITE;
return <<<TEXT
--
This is an official document-signing request from {$entity}.
Nothing is signed unless you open the document and complete it yourself.
If you were not expecting this, you can safely ignore it.
Privacy Policy: {$site}/privacy
Terms of Use: {$site}/terms
© {$year} {$entity}. All rights reserved.
Automated message — please do not reply to this address.
TEXT;
}
private function tidyQuotes(string $text): string {
return strtr($text, self::QUOTE_MAP);
}
// Signatures below mirror the parent EXACTLY. $plainTitle/$plainText are
// deliberately untyped there (they accept string|bool -- false suppresses
// the plain-text variant), and narrowing a parameter type in an override
// is a fatal error in PHP.
public function addHeading(string $title, $plainTitle = ''): void {
$mapped = self::HEADING_MAP[$title] ?? $this->tidyQuotes($title);
parent::addHeading(
$mapped,
is_string($plainTitle) && $plainTitle !== ''
? (self::HEADING_MAP[$plainTitle] ?? $this->tidyQuotes($plainTitle))
: $plainTitle,
);
}
public function addBodyText(string $text, $plainText = ''): void {
$mapped = self::BODY_MAP[$text] ?? $this->tidyQuotes($text);
parent::addBodyText(
$mapped,
is_string($plainText) && $plainText !== ''
? (self::BODY_MAP[$plainText] ?? $this->tidyQuotes($plainText))
: $plainText,
);
}
/**
* Reimplemented for two reasons: the accent colour, and a fixed label.
*
* LibreSign builds "Sign »%s«" with the raw filename
* (MailService.php:64,100). Real documents here are named things like
* amendment-001-partner-compensation, which makes an ungainly button and
* leaks the document name to anyone who sees the inbox preview. Replaced
* with a fixed call to action; the document is identified on the landing
* page behind the link.
*
* Mirrors the parent's vsprintf argument order exactly:
* [$color, $color, $url, $color, $textColor, $textColor, $text].
* Kept in sync with parent::addBodyButton() -- if that changes upstream,
* this needs revisiting.
*/
public function addBodyButton(string $text, string $url, $plainText = ''): void {
if ($this->footerAdded) {
return;
}
$this->ensureBodyIsOpened();
$this->ensureBodyListClosed();
$label = $this->buttonLabelFor($text);
if ($plainText === '') {
$plainText = $label;
} elseif (is_string($plainText)) {
$plainText = $this->tidyQuotes($plainText);
}
$this->htmlBody .= vsprintf($this->button, [
self::ACCENT,
self::ACCENT,
$url,
self::ACCENT,
self::ON_ACCENT,
self::ON_ACCENT,
htmlspecialchars($label, ENT_QUOTES, 'UTF-8'),
]);
if ($plainText !== false) {
$this->plainBody .= $plainText . ': ';
}
$this->plainBody .= $url . PHP_EOL;
}
/**
* Maps LibreSign's filename-bearing labels onto fixed calls to action.
* Matched on the stable leading verb rather than the whole string, since
* the tail is a filename. Unknown labels pass through with quotes tidied.
*/
private function buttonLabelFor(string $text): string {
if (str_starts_with($text, 'Sign ')) {
return 'Review document';
}
if (str_starts_with($text, 'View signed file')) {
return 'View signed document';
}
return $this->tidyQuotes($text);
}
}