get(\OCP\Mail\IMailer::class); $dispatcher = \OC::$server->get(\OCP\EventDispatcher\IEventDispatcher::class); // Mirrors MailService::notifyUnsignedUser() (custom_apps/libresign/lib/Service/MailService.php:85-116). $template = $mailer->createEMailTemplate('settings.TestEmail'); $template->setSubject('LibreSign: There is a file for you to sign'); $template->addHeader(); $template->addHeading('File to sign', false); $template->addBodyText('There is a document for you to sign. Access the link below:'); $template->addBodyButton('Sign »verify.pdf«', 'https://{{ cloud_skudak_server_name_new }}/verify'); $message = $mailer->createMessage(); $message->setTo(['verify@example.invalid' => 'Verify']); $message->useTemplate($template); // What Mailer::send() does at lib/private/Mail/Mailer.php:186. Nothing is sent. $dispatcher->dispatchTyped(new \OCP\Mail\Events\BeforeMessageSent($message)); $html = $message->getSymfonyEmail()->getHtmlBody() ?? ''; $text = $message->getPlainBody(); $subject = $message->getSubject(); $inlineNames = []; foreach ($message->getSymfonyEmail()->getAttachments() as $part) { $inlineNames[] = (string)$part->getFilename(); } $failures = []; if (!$template instanceof \OCA\Skudakmail\Mail\SkudakEMailTemplate) { $failures[] = 'template class is ' . get_class($template) . ' -- expected SkudakEMailTemplate. Is the skudakmail app enabled, and does ' . 'appinfo/info.xml still allow this Nextcloud major?'; } if (!str_starts_with($subject, 'Skudak LLP')) { $failures[] = 'subject not rewritten: ' . $subject; } if (!str_contains($html, 'official document-signing request')) { $failures[] = 'HTML footer missing (renderText/renderHtml ordering regression?)'; } if (!str_contains($text, 'official document-signing request')) { $failures[] = 'plain-text footer missing'; } if (!str_contains($html, 'skudak.com/privacy') || !str_contains($html, 'skudak.com/terms')) { $failures[] = 'privacy/terms links missing from footer'; } if (preg_match('/[»«]/u', $html)) { $failures[] = 'German guillemets survived into the body'; } if (!str_contains($html, 'Review document')) { $failures[] = 'button label not normalised to "Review document"'; } if (!str_contains($html, 'cid:skudak-wordmark.png')) { $failures[] = 'logo is not a cid: reference -- BeforeMessageSent listener did not fire'; } if (!in_array('skudak-wordmark.png', $inlineNames, true)) { $failures[] = 'inline logo MIME part absent (found: ' . (implode(', ', $inlineNames) ?: 'none') . ')'; } // --------------------------------------------------------------------------- // LibreSign signing settings. These live in oc_appconfig (the database), not on // disk, so they survive container recreation -- but they are re-assertable and // a stray click in the admin UI can change them silently. GRAPHIC in particular // matters: any other mode makes SignatureTextService::getSignatureWidth() // return $current / 2 and stamp a name/date block that duplicates -- and // collides with -- the one our documents already typeset. $appConfig = \OC::$server->get(\OCP\IAppConfig::class); // Must be exactly GRAPHIC_ONLY -- SignerElementsService::RENDER_MODE_GRAPHIC_ONLY. // The valid set is DESCRIPTION_ONLY / SIGNAME_AND_DESCRIPTION / // GRAPHIC_AND_DESCRIPTION / GRAPHIC_ONLY. Anything outside it (a bare 'GRAPHIC', // say) is accepted by occ but matches no radio in the admin UI and falls // through to default behaviour, so this asserts membership, not just non-empty. $renderMode = $appConfig->getValueString('libresign', 'signature_render_mode', ''); if ($renderMode !== 'GRAPHIC_ONLY') { $failures[] = 'libresign signature_render_mode is "' . $renderMode . '" -- expected GRAPHIC_ONLY (signature only). Any other mode halves the ' . 'stamp width and overlays a duplicate name/date block.'; } // Read with getValueBool, exactly as FooterHandler:158 does -- asserting the // string form would pass on a value the app itself reads as true. if ($appConfig->getValueBool('libresign', 'write_qrcode_on_footer', true) !== false) { $failures[] = 'libresign write_qrcode_on_footer is not false -- the validation ' . 'QR block will be stamped on every page and overlaps the document footer ' . 'set by skudak-contract.cls. (Was it written without --type=boolean?)'; } // Signer search for account-owned emails. Both keys are asserted because the // two failure modes are opposite and the second is the more dangerous: // full_match = yes -> account-owned emails silently unselectable // full_match_email = no -> email signer search disabled ENTIRELY // Defaults are 'yes' for both (MailPlugin.php:50-55), so an unset // full_match_email is correct and only an explicit 'no' is a problem. if ($appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_full_match', 'yes') !== 'no') { $failures[] = 'core shareapi_restrict_user_enumeration_full_match is not "no" -- ' . 'emails belonging to an existing Nextcloud account cannot be added as ' . 'LibreSign signers (MailPlugin.php:163 aborts the search).'; } if ($appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_full_match_email', 'yes') === 'no') { $failures[] = 'core shareapi_restrict_user_enumeration_full_match_email is "no" -- ' . 'this disables email signer search ENTIRELY (MailPlugin.php:67). It must be ' . 'unset or "yes"; it is NOT the knob for the account-owned-email problem.'; } $identDocs = $appConfig->getValueString('libresign', 'identification_documents', ''); if ($identDocs !== '0') { $failures[] = 'libresign identification_documents is "' . $identDocs . '" -- expected 0. A non-zero value gates signing behind an ID upload ' . 'plus admin approval, and signers see no way to sign.'; } // --------------------------------------------------------------------------- // Redis: distributed cache + transactional file locking. // // These come from the image's config/redis.config.php drop-in, which only // activates when REDIS_HOST is set on the container. If the env var is lost // (a container recreated from a stale spec, say), Nextcloud silently reverts // to DBLockingProvider and every file lock goes back to being a MariaDB write // -- functional, but the stalls come back with no error anywhere. $sysConfig = \OC::$server->get(\OCP\IConfig::class); foreach (['memcache.locking', 'memcache.distributed'] as $key) { $value = $sysConfig->getSystemValueString($key, ''); if ($value !== '\OC\Memcache\Redis') { $failures[] = $key . ' is "' . $value . '" -- expected \\OC\\Memcache\\Redis. ' . 'Is REDIS_HOST still set on the skudak-cloud container?'; } } // Prove Redis is actually reachable and authenticating, not merely configured. // A wrong password leaves the config looking perfect while every cache and // lock operation fails at runtime. try { $cacheFactory = \OC::$server->get(\OCP\ICacheFactory::class); if (!$cacheFactory->isAvailable()) { $failures[] = 'distributed cache reports unavailable -- redis unreachable or auth failed'; } else { $probe = $cacheFactory->createDistributed('skudakmail-verify'); $probe->set('probe', 'ok', 30); if ($probe->get('probe') !== 'ok') { $failures[] = 'distributed cache round-trip failed (set/get mismatch)'; } $probe->remove('probe'); } } catch (\Throwable $e) { $failures[] = 'distributed cache threw: ' . $e->getMessage(); } if ($failures !== []) { fwrite(STDERR, "skudakmail branding verification FAILED:\n"); foreach ($failures as $f) { fwrite(STDERR, " - $f\n"); } exit(1); } echo "skudakmail branding OK (subject: $subject)\n";