feat(debyltech-cloud): add cloud.debyltech.com Nextcloud

A de Byl Technologies LLC Nextcloud cloned from the Skudak instance:
LibreSign signing for people without an account, registration off
(admin-created accounts only), no Group Folders. DNS is a terraform-managed
ALIAS to fulfillr.debyltech.com.

- containers/debyltech/cloud.yml: nextcloud/mariadb/redis on port 8091.
  It installs unattended on the first deploy, sends mail through SES as
  noreply@debyltech.com, and re-asserts the Skudak LibreSign settings.
- files/debyltechmail: skudakmail rebranded, with a new black-and-white
  wordmark and white web-UI logos.
- LibreSign is pinned to 14.2.2 from the GitHub release (sha256-checked)
  rather than `occ app:install`. The app store served a same-day 14.2.3
  whose tarball has no binary-signature metadata. 14.2.x also doesn't
  create its own download dirs, so they're pre-created.
- The backup runs nightly at 04:15 to TrueNAS /mnt/glacier/debyltechcloud and
  reaches personal iDrive via the "iDrive E2 Backup" task; the TrueNAS side
  excludes /debyltechcloud/_backup/config/**.
- Fix the libresign:configure:check gate in both instances: '\berror\b'
  becomes a backspace in Jinja and never matched, so a check reporting three
  errors passed clean. Now '\\berror\\b'.
- vault: cloud_debyltech_* secrets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bastian de Byl
2026-09-28 17:04:04 -04:00
co-authored by Claude Opus 5.5
parent 0eca63d4b7
commit fa5bbf8e54
19 changed files with 1653 additions and 12 deletions
@@ -0,0 +1,44 @@
<?xml version="1.0"?>
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>debyltechmail</id>
<name>de Byl Tech Customisations</name>
<summary>de Byl Technologies-branded email templates and UI overrides for Nextcloud and LibreSign</summary>
<description><![CDATA[
Restyles outgoing Nextcloud and LibreSign mail to match the de Byl
Technologies brand (~/src/debyltech/debyltech-com). Cloned from the Skudak
instance's skudakmail app. Two supported extension points, no core
patch and no LibreSign fork:
1. `OCA\Debyltechmail\Mail\DebyltechEMailTemplate` extends Nextcloud's EMailTemplate
and is wired in via the `mail_template_class` system config value, which
Nextcloud checks in `lib/private/Mail/Mailer.php::createEMailTemplate()`.
It owns layout, typography, subject rewriting, button labels and the
footer LibreSign never adds.
2. `OCA\Debyltechmail\Listener\DebyltechMailListener` listens on
`OCP\Mail\Events\BeforeMessageSent` to embed the wordmark as an inline
(cid:) MIME part, so the logo survives the remote-image blocking that
Apple Mail, Gmail and Outlook apply by default. This cannot be done from
the template class, which has no reference to the message.
3. `OCA\Debyltechmail\Listener\DebyltechStyleListener` listens on
`OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent` and adds
css/libresign-mobile.css, which fixes the LibreSign public signing page
being clipped at the bottom on iOS Safari. Serving it from here rather
than patching LibreSign keeps the app's integrity signature intact and
survives app updates, which wipe the app directory.
The app has no routes, no UI, no settings and no database tables. The id
`debyltechmail` is referenced by the `mail_template_class` system config;
its scope is instance-wide customisation, not mail alone.
]]></description>
<version>1.0.0</version>
<licence>agpl</licence>
<author>de Byl Technologies LLC</author>
<namespace>Debyltechmail</namespace>
<category>customization</category>
<dependencies>
<nextcloud min-version="34" max-version="34"/>
</dependencies>
</info>
@@ -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:
* <body id="body-public" class="layout-base">
* <div id="content" class="app-public" role="main">
* 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;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace OCA\Debyltechmail\AppInfo;
use OCA\Debyltechmail\Listener\DebyltechMailListener;
use OCA\Debyltechmail\Listener\DebyltechStyleListener;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\Mail\Events\BeforeMessageSent;
class Application extends App implements IBootstrap {
public const APP_ID = 'debyltechmail';
public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
}
public function register(IRegistrationContext $context): void {
// BeforeMessageSent fires in Mailer::send() (lib/private/Mail/Mailer.php:186),
// AFTER useTemplate() has flattened the template into subject/plain/html on
// the message, and BEFORE setRecipients() and the Symfony transport. That
// window is the only place an inline (cid:) logo can be attached -- see the
// listener for why the template class alone cannot do it.
$context->registerEventListener(BeforeMessageSent::class, DebyltechMailListener::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, DebyltechStyleListener::class);
}
public function boot(IBootContext $context): void {
}
}
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/**
* Embeds the de Byl Technologies wordmark as an inline (cid:) MIME part.
*
* WHY A LISTENER AND NOT THE TEMPLATE CLASS: Apple Mail, Gmail and Outlook all
* block remote images by default, and Apple Mail draws its own placeholder box
* rather than styled alt text -- so no amount of styling in the HTML rescues a
* remote <img>. 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 <img>, 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 <img> -- degraded, never blocked. Mail
* that carries signature requests must not fail to send because branding
* broke.
*/
namespace OCA\Debyltechmail\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<BeforeMessageSent> */
class DebyltechMailListener implements IEventListener {
/** Must match DebyltechEMailTemplate::LOGO_PATH. */
private const LOGO_PATH_FRAGMENT = '/custom_apps/debyltechmail/img/debyltech-wordmark.png';
/** Content-ID. Symfony emits this as <debyltech-wordmark.png>. */
private const CID = 'debyltech-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('debyltechmail: 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/debyltech-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/debyltech-wordmark.png';
return is_readable($path) ? $path : null;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/**
* Injects de Byl Tech's CSS overrides into rendered Nextcloud pages.
*
* Currently one override: the LibreSign public signing page clips its bottom
* action bar on iOS Safari, because ExternalApp.vue sizes #content to 100vh and
* Safari resolves that against the large viewport (chrome hidden). See
* css/libresign-mobile.css for the full reasoning.
*
* WHY A LISTENER RATHER THAN PATCHING LIBRESIGN: an app-store app carries
* appinfo/signature.json, so editing a single byte of it raises INVALID_HASH in
* the admin security check, and an app update wipes the directory outright
* (Installer::downloadApp() calls Files::rmdirr on it). A stylesheet served
* from our own app survives both, and survives Nextcloud upgrades.
*
* The stylesheet itself is tightly scoped to #body-public / .app-public. This
* listener is deliberately NOT scoped further -- adding a stylesheet is
* idempotent and cheap, and gating on which app is rendering would couple this
* to LibreSign's route structure for no benefit. The CSS decides where it
* applies; this only decides that it is available.
*/
namespace OCA\Debyltechmail\Listener;
use OCA\Debyltechmail\AppInfo\Application;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Util;
/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
class DebyltechStyleListener 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.
}
}
}
@@ -0,0 +1,421 @@
<?php
declare(strict_types=1);
/**
* de Byl Technologies-branded email template. Cloned from the Skudak
* instance's skudakmail app (roles/podman/files/skudakmail); keep fixes to
* the shared mechanics in sync between the two.
*
* 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 customer agreements to signers, so it needs to read as an
* official de Byl Technologies LLC communication.
*
* DESIGN INTENT (palette from ~/src/debyltech/debyltech-com, theme
* assets/scss/_variables.scss):
* - Light ground, near-black text, NO coloured header band, and a pure
* black-and-white wordmark. Transactional mail from Stripe/Linear/DocuSign
* is likewise restrained, and a band leaves an ugly empty slab when the
* logo is blocked (see LOGO note).
* - $primary-color copper #bc804d on the CTA button only -- the one place
* this template spends colour.
* - Open Sans, matching the site's $primary-font, 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\Debyltechmail\Mail;
use OC\Mail\EMailTemplate;
class DebyltechEMailTemplate 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";
/** $primary-font, with the stock stack retained as fallback. */
private const BRAND_FONTS = "'Open Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',Arial,sans-serif";
private const ACCENT = '#BC804D'; // $primary-color (copper)
private const ON_ACCENT = '#FFFFFF';
private const INK = '#0A0A0A';
private const MUTED = '#525252';
private const FAINT = '#A3A3A3';
private const RULE = '#E5E5E5';
private const ENTITY = 'de Byl Technologies LLC';
private const SITE = 'https://debyltech.com';
private const LOGO_PATH = '/custom_apps/debyltechmail/img/debyltech-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 -> de Byl Tech 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 -> de Byl Tech 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 -> de Byl Tech 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:'
=> 'de Byl Technologies LLC 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 de Byl Technologies LLC. 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 $brandFooterInjected = false;
public function __construct(
\OCP\Defaults $themingDefaults,
\OCP\IURLGenerator $urlGenerator,
\OCP\L10N\IFactory $l10nFactory,
?int $logoWidth,
?int $logoHeight,
string $emailId,
array $data,
) {
$this->applyBrandStyling();
// 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 applyBrandStyling(): void {
foreach (self::STYLED_PARTS as $part) {
if (!property_exists($this, $part)) {
continue;
}
$this->$part = str_replace(self::STOCK_FONTS, self::BRAND_FONTS, $this->$part);
}
// Light, tightly tracked headings, carried over from the Skudak template.
$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::BRAND_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 $brandFooterInjected.
*
* 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 brand block goes in before
* $tail.
*/
public function renderHtml(): string {
$this->injectBrandFooter();
return parent::renderHtml();
}
public function renderText(): string {
$this->injectBrandFooter();
return parent::renderText();
}
private function injectBrandFooter(): void {
if ($this->brandFooterInjected || $this->footerAdded) {
return;
}
$this->brandFooterInjected = 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->brandFooterHtml();
$this->plainBody .= $this->brandFooterText();
}
private function brandFooterHtml(): string {
$year = date('Y');
$entity = htmlspecialchars(self::ENTITY, ENT_QUOTES, 'UTF-8');
$fonts = self::BRAND_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}/legal/privacy" style="color:{$muted};text-decoration:underline">Privacy Policy</a>
&#160;&#183;&#160;
<a href="{$site}/legal/tos" style="color:{$muted};text-decoration:underline">Terms of Use</a>
&#160;&#183;&#160;
<a href="{$site}" style="color:{$muted};text-decoration:underline">debyltech.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 brandFooterText(): 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}/legal/privacy
Terms of Use: {$site}/legal/tos
© {$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
* acme-master-services-agreement-rev3, 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);
}
}