From 01bbb3ba9ac302a543e78039156389223fc605f4 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 25 Oct 2026 22:40:36 +0100 Subject: [PATCH] feat(erinnerungen): mailserver in der app einrichten SMTP-Server, Port, Verschluesselung, Zugang und Absender unter Mehr -> Erinnerungen, gespeichert in der neuen Tabelle app_setting. Das Passwort liegt mit AES-256-GCM verschluesselt (Schluessel aus BETTER_AUTH_SECRET), wird nie angezeigt und nicht exportiert. Die .env hat weiter Vorrang. Der Zeitplan laeuft jetzt immer und prueft je Durchlauf, ob ein Server da ist. Co-Authored-By: Claude Opus 5.5 --- drizzle/0004_modern_agent_brand.sql | 5 + drizzle/meta/0004_snapshot.json | 2125 +++++++++++++++++ drizzle/meta/_journal.json | 7 + .../(app)/verwaltung/erinnerungen/actions.ts | 43 +- .../(app)/verwaltung/erinnerungen/page.tsx | 17 +- .../verwaltung/mail-settings-form.tsx | 230 ++ .../verwaltung/reminder-settings.tsx | 11 +- src/db/schema/index.ts | 1 + src/db/schema/settings.ts | 14 + src/lib/mail-settings.ts | 129 + src/lib/mailer.ts | 47 +- src/lib/reminder-job.ts | 14 +- src/lib/secret-box.ts | 41 + src/lib/validation.ts | 33 + tests/unit/mail-settings.test.ts | 58 + 15 files changed, 2731 insertions(+), 44 deletions(-) create mode 100644 drizzle/0004_modern_agent_brand.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 src/components/verwaltung/mail-settings-form.tsx create mode 100644 src/db/schema/settings.ts create mode 100644 src/lib/mail-settings.ts create mode 100644 src/lib/secret-box.ts create mode 100644 tests/unit/mail-settings.test.ts diff --git a/drizzle/0004_modern_agent_brand.sql b/drizzle/0004_modern_agent_brand.sql new file mode 100644 index 0000000..ddb0b9c --- /dev/null +++ b/drizzle/0004_modern_agent_brand.sql @@ -0,0 +1,5 @@ +CREATE TABLE `app_setting` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `updated_at` integer NOT NULL +); diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..9e25cca --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,2125 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b34f5e0c-ed89-4b05-9d8e-5f2c7c4b2b81", + "prevId": "f27f67e4-d146-4d63-b7cc-78bf5b0c686d", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_idx": { + "name": "account_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_by_user_id": { + "name": "used_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_token_hash_unique": { + "name": "invite_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "invite_created_by_user_id_user_id_fk": { + "name": "invite_created_by_user_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invite_used_by_user_id_user_id_fk": { + "name": "invite_used_by_user_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "used_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey": { + "name": "passkey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "columns": [ + "credential_id" + ], + "isUnique": true + }, + "passkey_user_idx": { + "name": "passkey_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rate_limit": { + "name": "rate_limit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_user_idx": { + "name": "session_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instruction": { + "name": "instruction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_size_bytes": { + "name": "file_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "instruction_set_idx": { + "name": "instruction_set_idx", + "columns": [ + "set_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "instruction_set_id_set_id_fk": { + "name": "instruction_set_id_set_id_fk", + "tableFrom": "instruction", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "instruction_source": { + "name": "instruction_source", + "value": "(\"instruction\".\"kind\" = 'pdf_upload' AND \"instruction\".\"file_path\" IS NOT NULL AND \"instruction\".\"url\" IS NULL)\n OR (\"instruction\".\"kind\" = 'external_url' AND \"instruction\".\"url\" IS NOT NULL AND \"instruction\".\"file_path\" IS NULL)" + } + } + }, + "manufacturer": { + "name": "manufacturer", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "manufacturer_slug_unique": { + "name": "manufacturer_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "set": { + "name": "set", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "manufacturer_id": { + "name": "manufacturer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "set_number": { + "name": "set_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "piece_count": { + "name": "piece_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "minifig_count": { + "name": "minifig_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ean": { + "name": "ean", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "age_range": { + "name": "age_range", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_path": { + "name": "image_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retail_price_cents": { + "name": "retail_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_ids": { + "name": "external_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "set_manufacturer_number_unique": { + "name": "set_manufacturer_number_unique", + "columns": [ + "manufacturer_id", + "set_number" + ], + "isUnique": true + }, + "set_name_idx": { + "name": "set_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "set_ean_idx": { + "name": "set_ean_idx", + "columns": [ + "ean" + ], + "isUnique": false + }, + "set_theme_idx": { + "name": "set_theme_idx", + "columns": [ + "theme_id" + ], + "isUnique": false + }, + "set_year_idx": { + "name": "set_year_idx", + "columns": [ + "year" + ], + "isUnique": false + } + }, + "foreignKeys": { + "set_manufacturer_id_manufacturer_id_fk": { + "name": "set_manufacturer_id_manufacturer_id_fk", + "tableFrom": "set", + "tableTo": "manufacturer", + "columnsFrom": [ + "manufacturer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "set_theme_id_theme_id_fk": { + "name": "set_theme_id_theme_id_fk", + "tableFrom": "set", + "tableTo": "theme", + "columnsFrom": [ + "theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "set_tag": { + "name": "set_tag", + "columns": { + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "set_tag_set_id_set_id_fk": { + "name": "set_tag_set_id_set_id_fk", + "tableFrom": "set_tag", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "set_tag_tag_id_tag_id_fk": { + "name": "set_tag_tag_id_tag_id_fk", + "tableFrom": "set_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "set_tag_set_id_tag_id_pk": { + "columns": [ + "set_id", + "tag_id" + ], + "name": "set_tag_set_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tag_slug_unique": { + "name": "tag_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "theme": { + "name": "theme", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "manufacturer_id": { + "name": "manufacturer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_theme_id": { + "name": "parent_theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "theme_manufacturer_idx": { + "name": "theme_manufacturer_idx", + "columns": [ + "manufacturer_id" + ], + "isUnique": false + }, + "theme_unique_name": { + "name": "theme_unique_name", + "columns": [ + "manufacturer_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "theme_manufacturer_id_manufacturer_id_fk": { + "name": "theme_manufacturer_id_manufacturer_id_fk", + "tableFrom": "theme", + "tableTo": "manufacturer", + "columnsFrom": [ + "manufacturer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "theme_parent_theme_id_theme_id_fk": { + "name": "theme_parent_theme_id_theme_id_fk", + "tableFrom": "theme", + "tableTo": "theme", + "columnsFrom": [ + "parent_theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collector": { + "name": "collector", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'blue'" + }, + "birthday": { + "name": "birthday", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collector_sort_idx": { + "name": "collector_sort_idx", + "columns": [ + "sort_order", + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "price_point": { + "name": "price_point", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "observed_at": { + "name": "observed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "price_point_set_idx": { + "name": "price_point_set_idx", + "columns": [ + "set_id", + "observed_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "price_point_set_id_set_id_fk": { + "name": "price_point_set_id_set_id_fk", + "tableFrom": "price_point", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "price_point_positive": { + "name": "price_point_positive", + "value": "\"price_point\".\"price_cents\" >= 0" + } + } + }, + "collection_item": { + "name": "collection_item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'owned'" + }, + "completeness": { + "name": "completeness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acquired_at": { + "name": "acquired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acquired_price_cents": { + "name": "acquired_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acquired_from": { + "name": "acquired_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gifted_by": { + "name": "gifted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lent_out_to": { + "name": "lent_out_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collection_item_set_idx": { + "name": "collection_item_set_idx", + "columns": [ + "set_id" + ], + "isUnique": false + }, + "collection_item_collector_idx": { + "name": "collection_item_collector_idx", + "columns": [ + "collector_id" + ], + "isUnique": false + }, + "collection_item_status_idx": { + "name": "collection_item_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "collection_item_location_idx": { + "name": "collection_item_location_idx", + "columns": [ + "location_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collection_item_set_id_set_id_fk": { + "name": "collection_item_set_id_set_id_fk", + "tableFrom": "collection_item", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_item_collector_id_collector_id_fk": { + "name": "collection_item_collector_id_collector_id_fk", + "tableFrom": "collection_item", + "tableTo": "collector", + "columnsFrom": [ + "collector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_item_location_id_location_id_fk": { + "name": "collection_item_location_id_location_id_fk", + "tableFrom": "collection_item", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "location": { + "name": "location", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_location_id": { + "name": "parent_location_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "location_parent_location_id_location_id_fk": { + "name": "location_parent_location_id_location_id_fk", + "tableFrom": "location", + "tableTo": "location", + "columnsFrom": [ + "parent_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "missing_part": { + "name": "missing_part", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_item_id": { + "name": "collection_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_number": { + "name": "part_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color_name": { + "name": "color_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "missing_part_item_idx": { + "name": "missing_part_item_idx", + "columns": [ + "collection_item_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "missing_part_collection_item_id_collection_item_id_fk": { + "name": "missing_part_collection_item_id_collection_item_id_fk", + "tableFrom": "missing_part", + "tableTo": "collection_item", + "columnsFrom": [ + "collection_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "photo": { + "name": "photo", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collection_item_id": { + "name": "collection_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnail_path": { + "name": "thumbnail_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "photo_set_idx": { + "name": "photo_set_idx", + "columns": [ + "set_id" + ], + "isUnique": false + }, + "photo_item_idx": { + "name": "photo_item_idx", + "columns": [ + "collection_item_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "photo_set_id_set_id_fk": { + "name": "photo_set_id_set_id_fk", + "tableFrom": "photo", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "photo_collection_item_id_collection_item_id_fk": { + "name": "photo_collection_item_id_collection_item_id_fk", + "tableFrom": "photo", + "tableTo": "collection_item", + "columnsFrom": [ + "collection_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "photo_single_owner": { + "name": "photo_single_owner", + "value": "(\"photo\".\"set_id\" IS NOT NULL) <> (\"photo\".\"collection_item_id\" IS NOT NULL)" + } + } + }, + "wishlist": { + "name": "wishlist", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occasion": { + "name": "occasion", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "event_date": { + "name": "event_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "surprise_mode": { + "name": "surprise_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reminder_at": { + "name": "last_reminder_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "wishlist_share_token_unique": { + "name": "wishlist_share_token_unique", + "columns": [ + "share_token" + ], + "isUnique": true + }, + "wishlist_owner_idx": { + "name": "wishlist_owner_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wishlist_owner_user_id_user_id_fk": { + "name": "wishlist_owner_user_id_user_id_fk", + "tableFrom": "wishlist", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wishlist_collector_id_collector_id_fk": { + "name": "wishlist_collector_id_collector_id_fk", + "tableFrom": "wishlist", + "tableTo": "collector", + "columnsFrom": [ + "collector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wishlist_item": { + "name": "wishlist_item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "wishlist_id": { + "name": "wishlist_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reserved_by_name": { + "name": "reserved_by_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reserved_at": { + "name": "reserved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reservation_token_hash": { + "name": "reservation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fulfilled_at": { + "name": "fulfilled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "wishlist_item_unique": { + "name": "wishlist_item_unique", + "columns": [ + "wishlist_id", + "set_id" + ], + "isUnique": true + }, + "wishlist_item_list_idx": { + "name": "wishlist_item_list_idx", + "columns": [ + "wishlist_id", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wishlist_item_wishlist_id_wishlist_id_fk": { + "name": "wishlist_item_wishlist_id_wishlist_id_fk", + "tableFrom": "wishlist_item", + "tableTo": "wishlist", + "columnsFrom": [ + "wishlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wishlist_item_set_id_set_id_fk": { + "name": "wishlist_item_set_id_set_id_fk", + "tableFrom": "wishlist_item", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "wishlist_item_priority": { + "name": "wishlist_item_priority", + "value": "\"wishlist_item\".\"priority\" BETWEEN 1 AND 3" + } + } + }, + "app_setting": { + "name": "app_setting", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f5de118..280fb6d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1788765478965, "tag": "0003_powerful_sally_floyd", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1792963951232, + "tag": "0004_modern_agent_brand", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/(app)/verwaltung/erinnerungen/actions.ts b/src/app/(app)/verwaltung/erinnerungen/actions.ts index 5d37cb1..ad10b67 100644 --- a/src/app/(app)/verwaltung/erinnerungen/actions.ts +++ b/src/app/(app)/verwaltung/erinnerungen/actions.ts @@ -1,9 +1,23 @@ 'use server'; import { revalidatePath } from 'next/cache'; -import { actionData, actionError, type ActionResult } from '@/lib/action-result'; +import { + actionData, + actionError, + actionOk, + formToObject, + parseForm, + type ActionResult, +} from '@/lib/action-result'; import { env } from '@/env'; +import { + deleteMailSettings, + getMailConfig, + isMailConfigured, + saveMailSettings, +} from '@/lib/mail-settings'; import { sendMail, verifyMailConnection } from '@/lib/mailer'; +import { mailSettingsSchema } from '@/lib/validation'; import { runReminderJob, type ReminderRun } from '@/lib/reminder-job'; import { requireAdmin } from '@/lib/session'; @@ -17,7 +31,7 @@ export async function testMailConnection(): Promise { export async function sendTestMail(): Promise { const admin = await requireAdmin(); - if (!env.isMailConfigured) { + if (!isMailConfigured()) { return actionError('Es ist kein SMTP-Server konfiguriert.'); } @@ -41,7 +55,7 @@ export async function sendTestMail(): Promise { export async function runRemindersNow(): Promise> { await requireAdmin(); - if (!env.isMailConfigured) { + if (!isMailConfigured()) { return actionError('Es ist kein SMTP-Server konfiguriert.'); } @@ -49,3 +63,26 @@ export async function runRemindersNow(): Promise> { revalidatePath('/verwaltung/erinnerungen'); return actionData(run); } + +/** Stores the SMTP server set up on the reminders page. */ +export async function saveMailSettingsAction(formData: FormData): Promise { + await requireAdmin(); + + if (getMailConfig()?.source === 'env') { + return actionError('Der Mailserver kommt aus der .env. Änderungen bitte dort vornehmen.'); + } + + const parsed = parseForm(mailSettingsSchema, formToObject(formData)); + if (!parsed.ok) return parsed; + + saveMailSettings(parsed.data); + revalidatePath('/verwaltung/erinnerungen'); + return actionOk(); +} + +export async function deleteMailSettingsAction(): Promise { + await requireAdmin(); + deleteMailSettings(); + revalidatePath('/verwaltung/erinnerungen'); + return actionOk(); +} diff --git a/src/app/(app)/verwaltung/erinnerungen/page.tsx b/src/app/(app)/verwaltung/erinnerungen/page.tsx index f382a16..9998d1e 100644 --- a/src/app/(app)/verwaltung/erinnerungen/page.tsx +++ b/src/app/(app)/verwaltung/erinnerungen/page.tsx @@ -3,10 +3,12 @@ import { eq, sql } from 'drizzle-orm'; import { db } from '@/db'; import { wishlist } from '@/db/schema'; import { env } from '@/env'; +import { MailSettingsForm } from '@/components/verwaltung/mail-settings-form'; import { ReminderSettings, type ReminderListRow, } from '@/components/verwaltung/reminder-settings'; +import { getMailSettingsView } from '@/lib/mail-settings'; import { daysUntil } from '@/lib/reminders'; import { requireAdmin } from '@/lib/session'; @@ -41,11 +43,16 @@ export default async function ErinnerungenPage() { })) .sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0)); + const mail = getMailSettingsView(); + return ( - +
+ + +
); } diff --git a/src/components/verwaltung/mail-settings-form.tsx b/src/components/verwaltung/mail-settings-form.tsx new file mode 100644 index 0000000..e622092 --- /dev/null +++ b/src/components/verwaltung/mail-settings-form.tsx @@ -0,0 +1,230 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Loader2, Save, Server, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { + deleteMailSettingsAction, + saveMailSettingsAction, +} from '@/app/(app)/verwaltung/erinnerungen/actions'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; + +export type MailSettingsFormView = { + source: 'env' | 'app' | null; + host: string; + port: number; + security: 'starttls' | 'tls' | 'none'; + user: string; + from: string; + hasPassword: boolean; + passwordUnreadable: boolean; +}; + +const SECURITY_LABELS = { + starttls: 'STARTTLS (meist Port 587)', + tls: 'SSL/TLS (meist Port 465)', + none: 'Unverschlüsselt (nur im eigenen Netz)', +} as const; + +/** The SMTP server for reminders, set up right here instead of in the .env. */ +export function MailSettingsForm({ view }: { view: MailSettingsFormView }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [security, setSecurity] = useState(view.security); + const [port, setPort] = useState(String(view.port)); + + if (view.source === 'env') { + return ( + + + + + Mailserver + + + Kommt aus der .env und hat dort Vorrang. Ändern lässt er sich nur dort, danach den + Container neu starten. + + + +

+ Server: {view.host}:{view.port} ·{' '} + {SECURITY_LABELS[view.security].split(' (')[0]} +

+

+ Absender: {view.from} +

+
+
+ ); + } + + function changeSecurity(next: 'starttls' | 'tls' | 'none') { + setSecurity(next); + // Follow the usual port while the user has not picked a special one. + if (next === 'tls' && port === '587') setPort('465'); + if (next !== 'tls' && port === '465') setPort('587'); + } + + function submit(event: React.FormEvent) { + event.preventDefault(); + const form = event.currentTarget; + const formData = new FormData(form); + + startTransition(async () => { + const result = await saveMailSettingsAction(formData); + if (!result.ok) { + toast.error(result.error); + return; + } + // The password never stays visible, not even as dots. + const password = form.elements.namedItem('password'); + if (password instanceof HTMLInputElement) password.value = ''; + toast.success('Mailserver gespeichert. Prüf ihn gleich mit „Verbindung prüfen“.'); + router.refresh(); + }); + } + + function remove() { + startTransition(async () => { + const result = await deleteMailSettingsAction(); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Mailserver entfernt. Erinnerungen sind aus.'); + router.refresh(); + }); + } + + return ( + + + + + Mailserver + + + Über diesen Server gehen die Erinnerungen raus. Die Angaben findest du bei deinem + Mail-Anbieter unter „SMTP“ oder „Postausgangsserver“. Das Passwort wird verschlüsselt + gespeichert und nie wieder angezeigt. + + + +
+
+ + + + + setPort(event.target.value)} + required + /> + +
+ + + changeSecurity(event.target.value as typeof security)} + > + {Object.entries(SECURITY_LABELS).map(([value, label]) => ( + + ))} + + + +
+ + + + + + +
+ + {view.hasPassword ? ( + + ) : null} + + + + + +
+ + {view.source === 'app' ? ( + + ) : null} +
+
+
+
+ ); +} diff --git a/src/components/verwaltung/reminder-settings.tsx b/src/components/verwaltung/reminder-settings.tsx index 2aa3346..b63f779 100644 --- a/src/components/verwaltung/reminder-settings.tsx +++ b/src/components/verwaltung/reminder-settings.tsx @@ -98,17 +98,10 @@ export function ReminderSettings({ ) : (

- Es ist kein Mailversand eingerichtet – Erinnerungen sind damit aus, alles andere + Es ist noch kein Mailserver eingerichtet – Erinnerungen sind damit aus, alles andere funktioniert unverändert.

-

Zum Aktivieren in die .env eintragen und den Container neu starten:

-
-{`SMTP_HOST=mail.example.de
-SMTP_PORT=587
-SMTP_USER=bricktrack@example.de
-SMTP_PASSWORD=…
-SMTP_FROM=Bricktrack `}
-              
+

Trag ihn unten unter „Mailserver“ ein. Ein Neustart ist nicht nötig.

)} diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 554833b..4dc33fe 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -4,4 +4,5 @@ export * from './collector'; export * from './prices'; export * from './collection'; export * from './wishlist'; +export * from './settings'; export * from './relations'; diff --git a/src/db/schema/settings.ts b/src/db/schema/settings.ts new file mode 100644 index 0000000..95b1961 --- /dev/null +++ b/src/db/schema/settings.ts @@ -0,0 +1,14 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +/** + * Settings made in the app rather than in the .env - for now the SMTP server + * for reminders. One row per topic, the value as JSON. + * + * Never part of the export: it may hold credentials (the SMTP password is + * stored encrypted, see src/lib/secret-box.ts). + */ +export const appSetting = sqliteTable('app_setting', { + key: text('key').primaryKey(), + value: text('value', { mode: 'json' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); diff --git a/src/lib/mail-settings.ts b/src/lib/mail-settings.ts new file mode 100644 index 0000000..8e3a7e6 --- /dev/null +++ b/src/lib/mail-settings.ts @@ -0,0 +1,129 @@ +import 'server-only'; +import { eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { appSetting } from '@/db/schema'; +import { env } from '@/env'; +import { openSecret, sealSecret } from '@/lib/secret-box'; +import type { MailSettingsInput } from '@/lib/validation'; + +/** + * Where the SMTP server comes from. + * + * The .env wins when it names a server: an admin who configured it there + * expects exactly that. Otherwise the settings made on the reminders page + * apply. Without either, reminders stay off. + */ + +export type MailSecurity = 'starttls' | 'tls' | 'none'; + +export type MailConfig = { + source: 'env' | 'app'; + host: string; + port: number; + security: MailSecurity; + user: string | null; + password: string | null; + from: string; +}; + +type StoredMail = { + host: string; + port: number; + security: MailSecurity; + user: string | null; + /** Encrypted, see secret-box.ts. */ + password: string | null; + from: string; +}; + +const KEY = 'smtp'; + +function readStored(): StoredMail | null { + const row = db.select().from(appSetting).where(eq(appSetting.key, KEY)).get(); + return (row?.value as StoredMail | undefined) ?? null; +} + +export function getMailConfig(): MailConfig | null { + if (env.isMailConfigured) { + return { + source: 'env', + host: env.SMTP_HOST!, + port: env.SMTP_PORT, + security: env.SMTP_SECURE || env.SMTP_PORT === 465 ? 'tls' : 'starttls', + user: env.SMTP_USER ?? null, + password: env.SMTP_PASSWORD ?? null, + from: env.SMTP_FROM!, + }; + } + + const stored = readStored(); + if (!stored) return null; + return { + source: 'app', + host: stored.host, + port: stored.port, + security: stored.security, + user: stored.user, + password: stored.password ? openSecret(stored.password, env.BETTER_AUTH_SECRET) : null, + from: stored.from, + }; +} + +export function isMailConfigured(): boolean { + return getMailConfig() !== null; +} + +/** What the settings form may show - never the password itself. */ +export type MailSettingsView = { + source: 'env' | 'app' | null; + host: string; + port: number; + security: MailSecurity; + user: string; + from: string; + hasPassword: boolean; + /** A password is stored but cannot be decrypted (BETTER_AUTH_SECRET changed). */ + passwordUnreadable: boolean; +}; + +export function getMailSettingsView(): MailSettingsView { + const config = getMailConfig(); + const stored = config?.source === 'env' ? null : readStored(); + + return { + source: config?.source ?? null, + host: config?.host ?? '', + port: config?.port ?? 587, + security: config?.security ?? 'starttls', + user: config?.user ?? '', + from: config?.from ?? '', + hasPassword: Boolean(config?.password), + passwordUnreadable: Boolean(stored?.password && !config?.password), + }; +} + +export function saveMailSettings(input: MailSettingsInput): void { + const previous = readStored(); + + let password = previous?.password ?? null; + if (input.clearPassword) password = null; + if (input.password) password = sealSecret(input.password, env.BETTER_AUTH_SECRET); + + const value: StoredMail = { + host: input.host, + port: input.port, + security: input.security, + user: input.user, + password, + from: input.from, + }; + + db.insert(appSetting) + .values({ key: KEY, value, updatedAt: new Date() }) + .onConflictDoUpdate({ target: appSetting.key, set: { value, updatedAt: new Date() } }) + .run(); +} + +export function deleteMailSettings(): void { + db.delete(appSetting).where(eq(appSetting.key, KEY)).run(); +} diff --git a/src/lib/mailer.ts b/src/lib/mailer.ts index 553e623..95a3c13 100644 --- a/src/lib/mailer.ts +++ b/src/lib/mailer.ts @@ -1,32 +1,39 @@ import 'server-only'; import nodemailer, { type Transporter } from 'nodemailer'; -import { env } from '@/env'; +import { getMailConfig, type MailConfig } from '@/lib/mail-settings'; /** * Outgoing mail. * - * Entirely optional: without SMTP_HOST and SMTP_FROM nothing is scheduled and + * Entirely optional: without a server - in the .env or on the reminders page - * nothing is sent. The app never depends on mail working. */ -let transporter: Transporter | null = null; +let cached: { key: string; transporter: Transporter; from: string } | null = null; -function getTransporter(): Transporter | null { - if (!env.isMailConfigured) return null; - if (transporter) return transporter; +function connectionKey(config: MailConfig): string { + return JSON.stringify([config.host, config.port, config.security, config.user, config.password]); +} - transporter = nodemailer.createTransport({ - host: env.SMTP_HOST, - port: env.SMTP_PORT, +function getTransport(): { transporter: Transporter; from: string } | null { + const config = getMailConfig(); + if (!config) return null; + + // Settings can change at runtime now; a new connection follows them. + const key = connectionKey(config); + if (cached?.key === key) return { transporter: cached.transporter, from: config.from }; + + const transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, // Implicit TLS on 465, STARTTLS on 587 - the usual pair. - secure: env.SMTP_SECURE || env.SMTP_PORT === 465, - auth: - env.SMTP_USER && env.SMTP_PASSWORD - ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD } - : undefined, + secure: config.security === 'tls', + ignoreTLS: config.security === 'none', + auth: config.user && config.password ? { user: config.user, pass: config.password } : undefined, }); - return transporter; + cached = { key, transporter, from: config.from }; + return { transporter, from: config.from }; } export type MailResult = { ok: true } | { ok: false; error: string }; @@ -36,14 +43,14 @@ export async function sendMail(input: { subject: string; text: string; }): Promise { - const transport = getTransporter(); + const transport = getTransport(); if (!transport) { return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' }; } try { - await transport.sendMail({ - from: env.SMTP_FROM, + await transport.transporter.sendMail({ + from: transport.from, to: input.to, subject: input.subject, text: input.text, @@ -60,13 +67,13 @@ export async function sendMail(input: { /** Checks the connection without sending anything. */ export async function verifyMailConnection(): Promise { - const transport = getTransporter(); + const transport = getTransport(); if (!transport) { return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' }; } try { - await transport.verify(); + await transport.transporter.verify(); return { ok: true }; } catch (error) { return { diff --git a/src/lib/reminder-job.ts b/src/lib/reminder-job.ts index e19c6fa..d5296f9 100644 --- a/src/lib/reminder-job.ts +++ b/src/lib/reminder-job.ts @@ -3,6 +3,7 @@ import { eq, sql } from 'drizzle-orm'; import { db } from '@/db'; import { user, wishlist } from '@/db/schema'; import { env } from '@/env'; +import { isMailConfigured } from './mail-settings'; import { sendMail } from './mailer'; import { reminderSubject, @@ -59,7 +60,7 @@ function loadCandidates(): Candidate[] { export async function runReminderJob(now = new Date()): Promise { const result: ReminderRun = { checked: 0, sent: 0, skipped: 0, failures: [] }; - if (!env.isMailConfigured) return result; + if (!isMailConfigured()) return result; const candidates = loadCandidates(); result.checked = candidates.length; @@ -129,13 +130,12 @@ export function ensureReminderScheduler(): void { * cooldown keeps that from turning into repeated mail. */ export function startReminderScheduler(): void { - if (!env.isMailConfigured) { - console.log('[reminders] Kein SMTP konfiguriert - Erinnerungen sind aus.'); - return; - } - + // Always scheduled: the mail server can be set up in the app at any time, + // and each run checks whether one is configured. console.log( - `[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.`, + isMailConfigured() + ? `[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.` + : '[reminders] Noch kein SMTP-Server eingerichtet - Erinnerungen starten, sobald es einen gibt.', ); const tick = () => { diff --git a/src/lib/secret-box.ts b/src/lib/secret-box.ts new file mode 100644 index 0000000..8938432 --- /dev/null +++ b/src/lib/secret-box.ts @@ -0,0 +1,41 @@ +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'; + +/** + * Encrypts secrets that settings must keep, such as the SMTP password. + * + * AES-256-GCM with a key derived from BETTER_AUTH_SECRET: a copy of the + * database alone does not reveal the password. Changing BETTER_AUTH_SECRET + * makes stored secrets unreadable - `openSecret` then returns null and the + * password has to be entered again. Pure apart from node:crypto, so tested. + */ + +const VERSION = 'v1'; + +function deriveKey(masterSecret: string): Buffer { + return Buffer.from(hkdfSync('sha256', masterSecret, 'bricktrack', 'app-setting-secret', 32)); +} + +export function sealSecret(plain: string, masterSecret: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', deriveKey(masterSecret), iv); + const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return [VERSION, iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':'); +} + +export function openSecret(sealed: string, masterSecret: string): string | null { + const [version, iv, tag, encrypted] = sealed.split(':'); + if (version !== VERSION || !iv || !tag || encrypted === undefined) return null; + + try { + const decipher = createDecipheriv('aes-256-gcm', deriveKey(masterSecret), Buffer.from(iv, 'base64')); + decipher.setAuthTag(Buffer.from(tag, 'base64')); + return Buffer.concat([ + decipher.update(Buffer.from(encrypted, 'base64')), + decipher.final(), + ]).toString('utf8'); + } catch { + // Wrong key or tampered value: treat as "no password stored". + return null; + } +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 30b7408..1fdf30e 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -219,6 +219,39 @@ export const reservationSchema = z.object({ export type ManufacturerInput = z.infer; export type ThemeInput = z.infer; +// --------------------------------------------------------------------------- +// Mail (reminders) +// --------------------------------------------------------------------------- + +export const mailSecurityEnum = z.enum(['starttls', 'tls', 'none']); + +export const mailSettingsSchema = z.object({ + host: z + .string() + .trim() + .min(1, 'Bitte den SMTP-Server angeben.') + .max(255) + .regex(/^[a-z0-9.-]+$/i, 'Nur der Servername, z. B. mail.example.de – ohne https:// und Port.'), + port: z.coerce.number().int().min(1, 'Ungültiger Port.').max(65535, 'Ungültiger Port.').default(587), + security: mailSecurityEnum.default('starttls'), + user: optionalText(255), + /** Empty keeps the stored password; see clearPassword to remove it. Not trimmed. */ + password: z + .string() + .max(500) + .nullish() + .transform((value) => (value ? value : null)), + clearPassword: checkbox, + from: z + .string() + .trim() + .min(3, 'Bitte einen Absender angeben.') + .max(255) + .refine((value) => /@[^@\s>]+\.[^@\s>]+/.test(value), 'Der Absender braucht eine E-Mail-Adresse.'), +}); + +export type MailSettingsInput = z.infer; + export type LocationInput = z.infer; export type SetInput = z.infer; export type CollectionItemInput = z.infer; diff --git a/tests/unit/mail-settings.test.ts b/tests/unit/mail-settings.test.ts new file mode 100644 index 0000000..7cc3d95 --- /dev/null +++ b/tests/unit/mail-settings.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { openSecret, sealSecret } from '@/lib/secret-box'; +import { mailSettingsSchema } from '@/lib/validation'; + +const SECRET = 'test-secret-0123456789-abcdefghijklmnop'; + +describe('Passwort verschluesselt speichern', () => { + it('kommt mit demselben Schluessel unveraendert zurueck', () => { + const sealed = sealSecret('geheim mit Leerzeichen ', SECRET); + expect(sealed).not.toContain('geheim'); + expect(openSecret(sealed, SECRET)).toBe('geheim mit Leerzeichen '); + }); + + it('verschluesselt dasselbe Passwort jedes Mal anders', () => { + expect(sealSecret('x', SECRET)).not.toBe(sealSecret('x', SECRET)); + }); + + it('liefert nichts bei anderem Schluessel oder veraendertem Wert', () => { + const sealed = sealSecret('geheim', SECRET); + expect(openSecret(sealed, 'ein-anderes-secret-0123456789-abcdefgh')).toBeNull(); + + const [version, iv, tag, data] = sealed.split(':'); + const flipped = Buffer.from(data!, 'base64'); + flipped[0] = flipped[0]! ^ 0xff; + expect(openSecret([version, iv, tag, flipped.toString('base64')].join(':'), SECRET)).toBeNull(); + expect(openSecret('kaputt', SECRET)).toBeNull(); + }); +}); + +describe('SMTP-Eingaben pruefen', () => { + const valid = { + host: 'mail.example.de', + port: '587', + security: 'starttls', + user: 'bricktrack@example.de', + password: ' mit Rand ', + from: 'Bricktrack ', + }; + + it('nimmt gueltige Angaben und laesst das Passwort, wie es ist', () => { + const parsed = mailSettingsSchema.parse(valid); + expect(parsed.port).toBe(587); + expect(parsed.password).toBe(' mit Rand '); + expect(parsed.clearPassword).toBe(false); + }); + + it('macht aus einem leeren Passwort "unveraendert"', () => { + expect(mailSettingsSchema.parse({ ...valid, password: '' }).password).toBeNull(); + }); + + it('lehnt URLs, Ports und Absender ohne Adresse ab', () => { + expect(mailSettingsSchema.safeParse({ ...valid, host: 'https://mail.example.de' }).success).toBe(false); + expect(mailSettingsSchema.safeParse({ ...valid, host: 'mail.example.de:587' }).success).toBe(false); + expect(mailSettingsSchema.safeParse({ ...valid, port: '70000' }).success).toBe(false); + expect(mailSettingsSchema.safeParse({ ...valid, from: 'Bricktrack' }).success).toBe(false); + expect(mailSettingsSchema.safeParse({ ...valid, security: 'ssl3' }).success).toBe(false); + }); +});