Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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
<type>: <description>

<body>
```

### 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/).
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<ISO8601>`

### Changed

Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.**
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).
Expand Down
119 changes: 119 additions & 0 deletions src/dropbox_notifier/controllers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import appier
import appier_extras

from typing import cast

from .root import RootController


Expand All @@ -27,3 +29,120 @@ 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")

(
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,
Comment on lines +74 to +76
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar in the comment: "controls will allows deep shared folder" should be "controls will allow deep shared folders".

Copilot uses AI. Check for mistakes.
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)
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 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):
Comment on lines +95 to +103
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filtering with entry.get("server_modified", "") >= since compares ISO8601 timestamps as raw strings. This can yield incorrect results when since uses a different timezone/format (e.g., +00:00 vs Z) or includes fractional seconds, and it also accepts invalid since values silently. Parse/validate since and server_modified as datetimes (normalize to UTC) and raise an OperationalError for invalid since formats before filtering.

Copilot uses AI. Check for mistakes.
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

Comment on lines +105 to +115
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint can download and attach every file modified since an arbitrary timestamp. If since is far in the past (or the folder has many/large files), this may cause long request times, high Dropbox API usage, large memory consumption, and oversized emails. Consider adding a hard cap (max files and/or total bytes), returning a summary when the cap is exceeded, and/or providing a way to send links instead of attachments for large payloads.

Copilot uses AI. Check for mistakes.
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse since as datetime before filtering entries

The filter compares entry["server_modified"] and the query since as raw strings, which is not a reliable chronological comparison for valid ISO8601 inputs that use different representations (for example timezone offsets like -05:00 or fractional seconds). In those cases this endpoint can resend files outside the requested time window or miss files that should be included, even though the input timestamp is valid.

Useful? React with 👍 / 👎.

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)

return (
added_entries,
added_files,
folder_path,
shared_base,
shared_query,
prefix_size,
)
2 changes: 1 addition & 1 deletion src/dropbox_notifier/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down