From 6ed90b6c5e2a950bc9b3d31b84d3ea7535b70ef7 Mon Sep 17 00:00:00 2001 From: joamag Date: Wed, 4 Mar 2026 17:56:35 +0000 Subject: [PATCH 1/7] feat: add resend endpoint to re-trigger notification emails Adds GET /admin/resend?since= endpoint that lists all files in the monitored Dropbox folder modified after the given timestamp and re-sends the notification email with those files as attachments. Co-Authored-By: Claude Opus 4.6 --- src/dropbox_notifier/controllers/admin.py | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/src/dropbox_notifier/controllers/admin.py b/src/dropbox_notifier/controllers/admin.py index 7af79b3..91a4410 100644 --- a/src/dropbox_notifier/controllers/admin.py +++ b/src/dropbox_notifier/controllers/admin.py @@ -4,6 +4,8 @@ import appier import appier_extras +from typing import cast + from .root import RootController @@ -27,3 +29,101 @@ def email_test(self, owner=None): ], ) return dict(email=email) + + @appier.route("/admin/resend", "GET", json=True) + @appier.ensure(token="admin", context="admin") + def resend(self, owner=None): + owner = owner or appier.get_app() + owner = cast(appier.App, owner) + + since = self.field("since", None) + if not since: + raise appier.OperationalError(message="No since timestamp defined") + + email = cast(str, appier.conf("NOTIFIER_EMAIL", None)) + receivers = cast(list, appier.conf("NOTIFIER_RECEIVERS", [], cast=list)) + cc = cast(list, appier.conf("NOTIFIER_CC", [], cast=list)) + bcc = cast(list, appier.conf("NOTIFIER_BCC", [], cast=list)) + reply_to = cast(list, appier.conf("NOTIFIER_REPLY_TO", [], cast=list)) + folder_path = cast(str, appier.conf("NOTIFIER_FOLDER", None)) + + if not folder_path: + raise appier.OperationalError(message="No notifier folder defined") + if not email and not receivers: + raise appier.OperationalError(message="No email or receivers defined") + + api = self.get_api() + + folder_meta = api.metadata_file(folder_path) + folder_path = folder_meta["path_display"] + prefix_size = len(folder_path) + + share = api.list_shared_links(folder_path) + share_links = share.get("links", []) + + # in case valid share links exist then we should re-use the + # best of them to create the appropriate links + if share_links: + shared = share_links[0] + + # loops trying to find the best possible share link + # for the folder, keeping in mind that using extended sharing + # controls will allows deep shared folder + for share_link in share_links: + link_permissions = share_link.get("link_permissions", {}) + if not link_permissions.get("can_use_extended_sharing_controls", False): + continue + shared = share_link + + # creates a shared link to the folder so that it can be used + # for the URL creation in no shared link already exists + else: + shared = api.create_shared_link(folder_path) + + shared_url = appier.legacy.urlparse(shared["url"]) + shared_base = f"{shared_url.scheme}://{shared_url.netloc}{shared_url.path}" + shared_query = shared_url.query + + contents = api.list_folder_file(folder_path, recursive=True) + entries = contents.get("entries", []) + + # filters the entries to only include files that have been + # modified after the provided since timestamp value + added_entries = [ + entry + for entry in entries + if entry.get(".tag") == "file" + and entry.get("server_modified", "") >= since + and entry.get("path_display", None) + ] + + added_files = [] + + for added_entry in added_entries: + contents, result = api.download_file(added_entry["id"]) + content_type = appier.FileTuple.guess(result["name"]) + file_tuple = appier.FileTuple.from_data( + contents, + name=added_entry["path_display"][prefix_size:], + mime=content_type or "application/octet-stream", + ) + added_files.append(file_tuple) + + appier_extras.admin.Base.send_email_g( + owner, + "email/updated.html.tpl", + receivers=receivers if receivers else [email], + cc=cc, + bcc=bcc, + reply_to=reply_to, + subject=owner.to_locale(f"Dropbox folder {folder_path} updated"), + attachments=added_files, + added_entries=added_entries, + removed_entries=[], + folder_path=folder_path, + folder_url=shared_base, + folder_query=shared_query, + prefix_size=prefix_size, + ) + + return dict(since=since, resent=len(added_files)) From 474fa58d4edf2f3212ad0a4771bff16c81aaef07 Mon Sep 17 00:00:00 2001 From: joamag Date: Wed, 4 Mar 2026 18:00:13 +0000 Subject: [PATCH 2/7] refactor: extract resend business logic into _resend method Isolates the Dropbox folder scanning, shared link resolution, file filtering and download logic into a private _resend method, keeping the route handler focused on validation and email sending. Co-Authored-By: Claude Opus 4.6 --- src/dropbox_notifier/controllers/admin.py | 43 +++++++++++++---------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/dropbox_notifier/controllers/admin.py b/src/dropbox_notifier/controllers/admin.py index 91a4410..6d10a1f 100644 --- a/src/dropbox_notifier/controllers/admin.py +++ b/src/dropbox_notifier/controllers/admin.py @@ -52,6 +52,30 @@ def resend(self, owner=None): if not email and not receivers: raise appier.OperationalError(message="No email or receivers defined") + added_entries, added_files, folder_path, shared_base, shared_query, prefix_size = ( + self._resend(folder_path, since=since) + ) + + appier_extras.admin.Base.send_email_g( + owner, + "email/updated.html.tpl", + receivers=receivers if receivers else [email], + cc=cc, + bcc=bcc, + reply_to=reply_to, + subject=owner.to_locale(f"Dropbox folder {folder_path} updated"), + attachments=added_files, + added_entries=added_entries, + removed_entries=[], + folder_path=folder_path, + folder_url=shared_base, + folder_query=shared_query, + prefix_size=prefix_size, + ) + + return dict(since=since, resent=len(added_files)) + + def _resend(self, folder_path, since=None): api = self.get_api() folder_meta = api.metadata_file(folder_path) @@ -109,21 +133,4 @@ def resend(self, owner=None): ) added_files.append(file_tuple) - appier_extras.admin.Base.send_email_g( - owner, - "email/updated.html.tpl", - receivers=receivers if receivers else [email], - cc=cc, - bcc=bcc, - reply_to=reply_to, - subject=owner.to_locale(f"Dropbox folder {folder_path} updated"), - attachments=added_files, - added_entries=added_entries, - removed_entries=[], - folder_path=folder_path, - folder_url=shared_base, - folder_query=shared_query, - prefix_size=prefix_size, - ) - - return dict(since=since, resent=len(added_files)) + return added_entries, added_files, folder_path, shared_base, shared_query, prefix_size From 9a9912c2591a5cf51afa597e7a93912c27905646 Mon Sep 17 00:00:00 2001 From: joamag Date: Wed, 4 Mar 2026 18:01:57 +0000 Subject: [PATCH 3/7] docs: add resend endpoint usage example to README Co-Authored-By: Claude Opus 4.6 --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 4ca3d90..c19f8c5 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ The development of this project was sponsored by [Hive Solutions](http://www.hiv | **NOTIFIER_REPLY_TO** | `list` | `[]` | Sames as `NOTIFIER_RECEIVERS` but for the Reply-to. | | **NOTIFIER_FOLDER** | `str` | `None` | The Dropbox path or ID of the folder to be scanned for changes (eg: `"id:CtYjakofsdAAAAAPyEg"`). | +### Resend Notifications + +If notification emails were missed (e.g., due to a race condition), you can resend them for files modified after a given timestamp using the admin endpoint: + +```bash +GET /admin/resend?since=2026-03-01T00:00:00Z +``` + +The `since` parameter should be an ISO 8601 UTC timestamp matching the Dropbox `server_modified` format (eg: `2026-03-01T00:00:00Z`). + +The endpoint will scan the monitored folder, download all files modified after the given timestamp, and resend the notification email with those files as attachments. + ## License Dropbox Notifier is currently licensed under the [Apache License, Version 2.0](http://www.apache.org/licenses/). From b3848aaa91c0e8234ba2b03e66d7da56610ddb87 Mon Sep 17 00:00:00 2001 From: joamag Date: Wed, 4 Mar 2026 18:03:22 +0000 Subject: [PATCH 4/7] docs: update changelog with resend endpoint Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a36c8..3053d26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -* +* Support for resending notification emails via `GET /admin/resend?since=` ### Changed From e148896a4432cc07c86bebebf25b01e8787c3283 Mon Sep 17 00:00:00 2001 From: joamag Date: Wed, 4 Mar 2026 18:03:33 +0000 Subject: [PATCH 5/7] chore: new AI files --- AGENTS.md | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 5 +++ 2 files changed, 119 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..92c7f4e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,114 @@ +# AGENTS.md file + +## Python Virtual Environment (venv) + +The Python virtual environment for this repository is typically located in `.venv`. + +## Formatting + +Always format the code before commiting using, making sure that the Python code is properly formatted using: + +```bash +pip install black +black . +``` + +## Testing + +To run the custom suite of unit test for Dropbox Notifier use the following sequence of commands that will install dependencies +and run the appropriate test suite (last command). + +Try to run the unit tests whenever making changes to the codebase, before commiting new code. + +```bash +pip install -r requirements.txt +pip install -r extra.txt +python setup.py test +``` + +## Style Guide + +- Always update `CHANGELOG.md` according to semantic versioning, mentioning your changes in the unreleased section. +- Write commit messages using [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). +- Never bump the internal package version in `setup.py`. This is handled automatically by the release process. +- Python files use CRLF as the line ending. +- The implementation should be done in Python 2.7+ and compatible with Python 3.13. +- No type annotations should exist in the `.py` files and if the exist they should isolated in th `.pyi` files. +- The style should respect the black formatting. +- The implementation should be done in a way that is compatible with the existing codebase. +- Prefer `item not in list` over `not item in list`. +- Prefer `item == None` over `item is None`. +- The commenting style of the project is unique, try to keep commenting style consistent. +- Use Python docstrings with the `:type:`, `:args:`, `:rtype:`, `:return:`, etc. structure and with a newline after the docstring end (`"""`). + +## Commit Messages + +This project follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) with the following structure: + +```text +: + + +``` + +### Commit Types + +| Type | Description | +| ---------- | ------------------------------------------------------- | +| `feat` | A new feature or functionality | +| `fix` | A bug fix | +| `docs` | Documentation only changes | +| `refactor` | Code change that neither fixes a bug nor adds a feature | +| `chore` | Maintenance tasks, dependency updates, build changes | +| `test` | Adding or updating tests | +| `version` | Version bump commits (reserved for releases) | + +### Guidelines + +- Use lowercase for the type prefix. +- Use imperative mood in the description (e.g., "Add feature" not "Added feature"). +- Keep the first line under 50 characters. +- Reference issue/PR numbers when applicable using `(#123)` at the end. +- For version releases, use the format `version: X.Y.Z`. +- Add an extra newline between subject and body. +- Make the body a series of bullet points about the commit. +- Be descriptive always making use of the body of the message. + +### Examples + +```text +feat: Add user authentication with OAuth 2.0 support (#138) +fix: Resolve race condition in database connection pool +docs: Add API endpoint documentation for v2 routes +refactor: Extract validation logic into reusable utility module +chore: Update dependencies to latest stable versions +test: Add integration tests for payment processing flow +version: 1.8.0 +``` + +## Pre-Commit Checklist + +Before committing, ensure that the following operations items check: + +- [ ] Code is formatted with `black .` +- [ ] Tests pass: `python setup.py test` +- [ ] CHANGELOG.md is updated in [Unreleased] section +- [ ] No debugging print statements or commented-out code +- [ ] CRLF line endings are preserved +- [ ] No type annotations in .py files (use .pyi if needed) + +## New Release + +To create a new release follow the following steps: + +- Make sure that both the tests pass and the code formatting are valid. +- Increment (look at `CHANGELOG.md` for semver changes) the `version` value in `setup.py`, and then the `def _version(self):` return value in `src/dropbox_notifier/main.py`. +- Move all the `CHANGELOG.md` Unreleased items that have at least one non empty item the into a new section with the new version number and date, and then create new empty sub-sections (Added, Changed and Fixed) for the Unreleased section with a single empty item. +- Create a commit with the following message `version: $VERSION_NUMBER`. +- Push the commit. +- Create a new tag with the value fo the new version number `$VERSION_NUMBER`. +- Create a new release on the GitHub repo using the Markdown from the corresponding version entry in `CHANGELOG.md` as the description of the release and the version number as the title. Do not include the title of the release (version and date) in the description. + +## License + +Dropbox Notifier is licensed under the [Apache License, Version 2.0](http://www.apache.org/licenses/). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..227c09e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +**Instructions for AI Agents can be found in the [AGENTS.md](AGENTS.md) file.** From 59e57e5fe24d867783e550f10075388892e50d5a Mon Sep 17 00:00:00 2001 From: joamag Date: Wed, 4 Mar 2026 18:04:55 +0000 Subject: [PATCH 6/7] chore: new black usage --- src/dropbox_notifier/controllers/admin.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/dropbox_notifier/controllers/admin.py b/src/dropbox_notifier/controllers/admin.py index 6d10a1f..34a0420 100644 --- a/src/dropbox_notifier/controllers/admin.py +++ b/src/dropbox_notifier/controllers/admin.py @@ -52,9 +52,14 @@ def resend(self, owner=None): if not email and not receivers: raise appier.OperationalError(message="No email or receivers defined") - added_entries, added_files, folder_path, shared_base, shared_query, prefix_size = ( - self._resend(folder_path, since=since) - ) + ( + added_entries, + added_files, + folder_path, + shared_base, + shared_query, + prefix_size, + ) = self._resend(folder_path, since=since) appier_extras.admin.Base.send_email_g( owner, @@ -133,4 +138,11 @@ def _resend(self, folder_path, since=None): ) added_files.append(file_tuple) - return added_entries, added_files, folder_path, shared_base, shared_query, prefix_size + return ( + added_entries, + added_files, + folder_path, + shared_base, + shared_query, + prefix_size, + ) From ea156a278e4bde29665ad925453297e907a4de4e Mon Sep 17 00:00:00 2001 From: joamag Date: Wed, 4 Mar 2026 18:05:57 +0000 Subject: [PATCH 7/7] fix: grammat --- src/dropbox_notifier/controllers/admin.py | 2 +- src/dropbox_notifier/scheduler.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dropbox_notifier/controllers/admin.py b/src/dropbox_notifier/controllers/admin.py index 34a0420..d93a314 100644 --- a/src/dropbox_notifier/controllers/admin.py +++ b/src/dropbox_notifier/controllers/admin.py @@ -97,7 +97,7 @@ def _resend(self, folder_path, since=None): # loops trying to find the best possible share link # for the folder, keeping in mind that using extended sharing - # controls will allows deep shared folder + # controls will allow deep shared folder for share_link in share_links: link_permissions = share_link.get("link_permissions", {}) if not link_permissions.get("can_use_extended_sharing_controls", False): diff --git a/src/dropbox_notifier/scheduler.py b/src/dropbox_notifier/scheduler.py index f6c06d2..755594d 100644 --- a/src/dropbox_notifier/scheduler.py +++ b/src/dropbox_notifier/scheduler.py @@ -80,7 +80,7 @@ def scan_folder( # loops trying to find the best possible share link # for the folder, keeping in mind that using extended sharing - # controls will allows deep shared folder + # controls will allow deep shared folder for share_link in share_links: link_permissions = share_link.get("link_permissions", {}) if not link_permissions.get("can_use_extended_sharing_controls", False):