fec7d62acb
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>
418 lines
17 KiB
PHP
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"> </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>
|
|
 · 
|
|
<a href="{$site}/terms" style="color:{$muted};text-decoration:underline">Terms of Use</a>
|
|
 · 
|
|
<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">
|
|
© {$year} {$entity}. All rights reserved.<br/>
|
|
Automated message — 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);
|
|
}
|
|
}
|