diff --git a/backend/handler/notification_channels/apprise_channel.py b/backend/handler/notification_channels/apprise_channel.py index e52e60520..0f7edecd7 100644 --- a/backend/handler/notification_channels/apprise_channel.py +++ b/backend/handler/notification_channels/apprise_channel.py @@ -118,7 +118,7 @@ _HOST: Final = re.compile(r"\[[0-9a-f:.]+\]|[\w.-]+", re.I) # A token that is a URL path, which Apprise wants between slashes. _PATH_TOKEN: Final = "path" -FieldType = Literal["string", "int", "float", "bool", "choice", "list"] +FieldType = Literal["string", "email", "int", "float", "bool", "choice", "list"] FieldValue = str | int | float | bool | list[str] # What Apprise logs about the send running in this context: its warnings, and @@ -420,7 +420,8 @@ def _segment(field: AppriseField, value: FieldValue) -> str: return f"/{quote(path)}/" if path else "/" if isinstance(value, list): return field.delimiter.join(quote(str(item), safe="") for item in value) - return quote(str(value), safe="") + # Apprise reads an address's @ as the URL's own. + return quote(str(value), safe="@" if field.type == "email" else "") def _fill( @@ -569,7 +570,9 @@ def _token_pattern(service: AppriseService, key: str) -> str: if key in _TOKEN_PATTERNS: return _TOKEN_PATTERNS[key] field = service.field(key) - return r".+" if field is not None and field.type == "list" else r"[^/@?#]+" + if field is not None and field.type == "list": + return r".+" + return r"[^/?#]+" if field is not None and field.type == "email" else r"[^/@?#]+" def _template_pattern(service: AppriseService, template: str) -> re.Pattern[str]: diff --git a/backend/tests/handler/notification_channels/test_apprise_channel.py b/backend/tests/handler/notification_channels/test_apprise_channel.py index fdce247a4..34fb54bd8 100644 --- a/backend/tests/handler/notification_channels/test_apprise_channel.py +++ b/backend/tests/handler/notification_channels/test_apprise_channel.py @@ -26,6 +26,7 @@ from .fixtures import make_notification _DISCORD = {"webhook_id": "1234567890", "webhook_token": "abcdefghijklmnop"} _JSON = {"host": "hooks.example.com", "path": "romm"} +_SENDGRID = {"apikey": "abc123", "from_email": "romm@example.com"} def _message(**overrides: Any) -> OutboundMessage: @@ -70,6 +71,16 @@ class TestCatalog: assert targets is not None assert (targets.type, targets.required) == ("list", True) + @pytest.mark.parametrize( + "service,key", + [("sendgrid", "from_email"), ("ses", "from_email"), ("resend", "from_addr")], + ) + def test_offers_an_email_address(self, service, key): + field = find_service(service).field(key) + + assert field is not None + assert (field.type, field.required) == ("email", True) + def test_offers_the_schema_only_when_there_is_a_choice(self): schema = find_service("ntfy").field("schema") @@ -154,6 +165,21 @@ class TestBuildUrl: {"host": "hooks.example.com", "path": "/api/romm"}, "jsons://hooks.example.com/api/romm", ), + ( + "sendgrid", + {**_SENDGRID, "targets": ["me@example.com"]}, + "sendgrid://abc123:romm@example.com/me%40example.com", + ), + ( + "ses", + { + "from_email": "romm@example.com", + "access_key_id": "AKID", + "secret_access_key": "s3cr3t", + "region": "us-east-1", + }, + "ses://romm@example.com/AKID/s3cr3t/us-east-1", + ), ], ) def test_fills_in_the_template_the_fields_make_up(self, service, fields, url): @@ -261,6 +287,11 @@ class TestFieldsFromUrl: "json", {"host": "hooks.example.com", "path": "api/romm"}, ), + ( + "sendgrid://abc123:romm@example.com/me%40example.com", + "sendgrid", + {**_SENDGRID, "targets": ["me@example.com"]}, + ), ], ) def test_reads_a_url_back_into_its_service_and_fields(self, url, service, fields): @@ -269,6 +300,51 @@ class TestFieldsFromUrl: assert (found.id, read) == (service, fields) checked_fields(service, read) + @pytest.mark.parametrize( + "service,fields", + [ + ("sendgrid", {**_SENDGRID, "targets": ["me@example.com"]}), + ("brevo", _SENDGRID), + ("postmark", _SENDGRID), + ("mailersend", _SENDGRID), + ("resend", {"apikey": "abc123", "from_addr": "romm@example.com"}), + ( + "ses", + { + "from_email": "romm@example.com", + "access_key_id": "AKID", + "secret_access_key": "s3cr3t", + "region": "us-east-1", + }, + ), + ( + "octopush", + { + "api_login": "romm@example.com", + "api_key": "k3y", + "targets": ["+15551234567"], + }, + ), + ( + "twist", + {"password": "pw", "email": "romm@example.com", "targets": ["#romm"]}, + ), + ( + "voipms", + { + "password": "pw", + "email": "romm@example.com", + "from_phone": "15551234567", + "targets": ["15557654321"], + }, + ), + ], + ) + def test_reads_back_the_url_an_email_field_makes(self, service, fields): + found, read = fields_from_url(build_url(find_service(service), fields)) + + assert (found.id, read) == (service, checked_fields(service, fields)) + @pytest.mark.parametrize( "url,reason", [ diff --git a/frontend/src/__generated__/models/AppriseFieldSchema.ts b/frontend/src/__generated__/models/AppriseFieldSchema.ts index 6954a3642..464737ef8 100644 --- a/frontend/src/__generated__/models/AppriseFieldSchema.ts +++ b/frontend/src/__generated__/models/AppriseFieldSchema.ts @@ -5,7 +5,7 @@ export type AppriseFieldSchema = { key: string; label: string; - type: 'string' | 'int' | 'float' | 'bool' | 'choice' | 'list'; + type: 'string' | 'email' | 'int' | 'float' | 'bool' | 'choice' | 'list'; required: boolean; private: boolean; advanced: boolean; diff --git a/frontend/src/v2/components/Notifications/AppriseFieldInput.vue b/frontend/src/v2/components/Notifications/AppriseFieldInput.vue index 7bf573eac..a35e58b1b 100644 --- a/frontend/src/v2/components/Notifications/AppriseFieldInput.vue +++ b/frontend/src/v2/components/Notifications/AppriseFieldInput.vue @@ -10,7 +10,7 @@ import { NOTIFICATION_CHANNEL_URL_MAX_LENGTH, appriseFieldLabel, } from "@/v2/utils/notificationChannels"; -import { notBlank } from "@/v2/utils/validation"; +import { email, notBlank } from "@/v2/utils/validation"; const props = defineProps<{ field: AppriseFieldSchema; @@ -32,6 +32,11 @@ const hint = computed(() => const isNumber = computed( () => props.field.type === "int" || props.field.type === "float", ); +const inputType = computed(() => { + if (props.field.private) return "password"; + if (isNumber.value) return "number"; + return props.field.type === "email" ? "email" : "text"; +}); function inRange(text: string): true | string { const { min, max, type } = props.field; @@ -54,6 +59,9 @@ function inRange(text: string): true | string { const rules = computed(() => [ ...(required.value ? [notBlank()] : []), ...(isNumber.value ? [inRange] : []), + ...(props.field.type === "email" + ? [(text: string) => text === "" || email(text)] + : []), ]); const listRules = computed(() => required.value @@ -97,7 +105,7 @@ const listRules = computed(() => v-else :model-value="String(value)" :label="label" - :type="field.private ? 'password' : isNumber ? 'number' : 'text'" + :type="inputType" :rules="rules" :required="required" :hint="hint" diff --git a/frontend/src/v2/components/Notifications/NotificationChannelDialog.test.ts b/frontend/src/v2/components/Notifications/NotificationChannelDialog.test.ts index ba8cf66eb..81f122e41 100644 --- a/frontend/src/v2/components/Notifications/NotificationChannelDialog.test.ts +++ b/frontend/src/v2/components/Notifications/NotificationChannelDialog.test.ts @@ -178,6 +178,52 @@ describe("NotificationChannelDialog", () => { wrapper.unmount(); }); + it("takes an address in an email field and checks it", async () => { + const sendgrid = makeAppriseService({ + id: "sendgrid", + name: "SendGrid", + fields: [ + { + ...makeAppriseService().fields[3]!, + key: "apikey", + label: "API Key", + required: true, + }, + { + ...makeAppriseService().fields[1]!, + key: "from_email", + label: "From Email", + type: "email", + required: true, + }, + ], + }); + api.getAppriseServices.mockResolvedValue({ data: [sendgrid] }); + api.create.mockResolvedValue({ data: channel({ type: "apprise" }) }); + const wrapper = await open(null, { admin: true }); + + await pick(wrapper, "apprise:sendgrid"); + await wrapper.findAll("input.r-text-field__input")[0]!.setValue("Mail"); + await input(wrapper, '[type="password"]').setValue("abc123"); + const address = input(wrapper, '[type="email"]'); + await address.setValue("romm"); + await save(wrapper); + + expect(api.create).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain("Invalid email"); + + await address.setValue("romm@example.com"); + await save(wrapper); + + expect(api.create).toHaveBeenCalledWith( + expect.objectContaining({ + service: "sendgrid", + fields: { apikey: "abc123", from_email: "romm@example.com" }, + }), + ); + wrapper.unmount(); + }); + const ntfyChannel = () => channel({ type: "apprise", diff --git a/pyproject.toml b/pyproject.toml index c0f8c9be2..0e6549279 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "asyncssh ~= 2.24", "alembic ~= 1.16", "anyio ~= 4.4", - "apprise ~= 1.13", + "apprise ~= 2.0", "authlib ~= 1.8.0", "bcrypt ~= 5.0", "certifi >= 2024.7.4", diff --git a/uv.lock b/uv.lock index 7c9ad3b64..c8abfde58 100644 --- a/uv.lock +++ b/uv.lock @@ -154,7 +154,7 @@ wheels = [ [[package]] name = "apprise" -version = "1.13.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -164,10 +164,11 @@ dependencies = [ { name = "requests" }, { name = "requests-oauthlib" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/44/5965c245998c72022297e2b515d9594281a97ed1442b3fa022c2c2324102/apprise-1.13.1.tar.gz", hash = "sha256:e7689dda71aaf739244d6c8690de13cb1361b8d0a79980fb48bb397455ca0bdd", size = 2520230, upload-time = "2026-08-31T02:12:01.472Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/b0/2f2e9b6d9e52f7530b47ba7a9259ebf2378e3953f445222f54412fb27a68/apprise-2.0.0.tar.gz", hash = "sha256:aeb321737f951860d7cb0a9574159090cbdbcb0f1ba01c6b49c69018380adf8d", size = 3268123, upload-time = "2026-09-26T18:49:55.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/7d/6852bd3fc2eb48fa432cb22f5df11d1c902137dcecab93e0d8319b8c8e16/apprise-1.13.1-py3-none-any.whl", hash = "sha256:2ded6d00562dd2ae9eb6f1e6cc8d3fb66e06f7ede6efac0ad029031b633780f4", size = 1826431, upload-time = "2026-08-31T02:11:58.263Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/9d3daa5cd16142b4077fbc3270b146ad965b8f81ffb4b538f781c4a1c858/apprise-2.0.0-py3-none-any.whl", hash = "sha256:945a2500a846b88b3c126a8d6e7c0472f6e3d94d8be52c836d8819f8db50b3d7", size = 2417371, upload-time = "2026-09-26T18:49:52.758Z" }, ] [[package]] @@ -2492,7 +2493,7 @@ requires-dist = [ { name = "aiohttp", specifier = "~=3.14" }, { name = "alembic", specifier = "~=1.16" }, { name = "anyio", specifier = "~=4.4" }, - { name = "apprise", specifier = "~=1.13" }, + { name = "apprise", specifier = "~=2.0" }, { name = "asyncssh", specifier = "~=2.24" }, { name = "authlib", specifier = "~=1.8.0" }, { name = "bcrypt", specifier = "~=5.0" },