From a5518dd5f9f7e5ec201ec9ef8dbc8a7cf36ee9e9 Mon Sep 17 00:00:00 2001 From: JN <56563426+junxit@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:51:59 -0500 Subject: [PATCH 1/9] chore: tidy .gitignore and ignore script output - Drop /shelf/ and /workspace.xml: JetBrains' own .idea/.gitignore entries, dead here because .idea/ is already ignored - Ignore .DS_Store and the Python/uv caches (__pycache__/, .venv/, .ruff_cache/) - Ignore the files the backup scripts write when run inside the repo; they hold personal data such as alias recipients and DNS records --- .gitignore | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 6299786..2c62381 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,22 @@ -# Default ignored files -/shelf/ -/workspace.xml +# Editors .idea/ .vscode/ + +# macOS +.DS_Store + +# Python and uv +__pycache__/ +.venv/ +.ruff_cache/ + +# Files the scripts write when run from inside the repo. They hold personal +# data (alias recipients, DNS records), so they must never be committed. +/*.json +/*.txt +*.usage.txt +*.before-delete.txt +*.scan.txt +forwardemail/*.json +cloudflare/*.json +cloudflare/*.txt From a0b03ed28d9400d06a39dacaf82063409265c7a1 Mon Sep 17 00:00:00 2001 From: JN <56563426+junxit@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:51:59 -0500 Subject: [PATCH 2/9] chore: manage Python dependencies with uv - Pin the runtime (requests 2.34.2, dnspython 2.9.0) and dev tools (ruff 0.17.1, shellcheck-py 0.11.0.1) exactly in pyproject.toml and uv.lock; Python 3.15.0 is pinned in .python-version - dnspython is for the DNS tools added later in this branch - Ruff: add S113 (HTTP calls need a timeout) and BLE001 (no catch-all except) to the defaults, and turn off LOG015, since these standalone scripts log through the root logger on purpose --- .python-version | 1 + pyproject.toml | 31 +++++++++ uv.lock | 175 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 .python-version create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..f02113f --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.15.0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9d00351 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "junxit-tools" +version = "0.1.0" +description = "Small command-line utilities for Cloudflare, Forward Email, DNS, and Claude Code." +readme = "README.md" +# A lower bound is the only form requires-python takes; the exact interpreter +# is pinned in .python-version, and uv.lock resolves against it. +requires-python = ">=3.15" +dependencies = [ + "dnspython==2.9.0", + "requests==2.34.2", +] + +[dependency-groups] +dev = [ + "ruff==0.17.1", + "shellcheck-py==0.11.0.1", +] + +[tool.uv] +package = false + +[tool.ruff] +target-version = "py315" + +[tool.ruff.lint] +# Ruff's defaults, plus: every HTTP call needs a timeout, and no catch-all excepts. +extend-select = ["S113", "BLE001"] +# These are standalone scripts: logging through the root logger that +# logging.basicConfig() sets up is intended, not a library anti-pattern. +ignore = ["LOG015"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a11e399 --- /dev/null +++ b/uv.lock @@ -0,0 +1,175 @@ +version = 1 +revision = 5 +requires-python = ">=3.15" + +[[package]] +name = "certifi" +version = "2026.10.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/eb/a2aa1ac3ea30dd3f30ca57f40232138b6b0040719316ecb85daf61167771/certifi-2026.10.18.tar.gz", hash = "sha256:b9995d97b64d0431a5af837d8ca8cd6238104d1f319f04bd93ea43124d6c861e", size = 135227, upload-time = "2026-10-18T16:21:07.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/ba/95dd616042484f7c83a06974388b37b837732740ee70bc5dc715b5fcd55a/certifi-2026.10.18-py3-none-any.whl", hash = "sha256:73191c47aaa79da394e8fee2dbc2ec827432cab56311122c9e47abf79e390f62", size = 134093, upload-time = "2026-10-18T16:21:06.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "dnspython" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz", hash = "sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1", size = 423560, upload-time = "2026-10-09T00:07:24.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl", hash = "sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9", size = 354822, upload-time = "2026-10-09T00:07:22.622Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "junxit-tools" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "dnspython" }, + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, + { name = "shellcheck-py" }, +] + +[package.metadata] +requires-dist = [ + { name = "dnspython", specifier = "==2.9.0" }, + { name = "requests", specifier = "==2.34.2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "ruff", specifier = "==0.17.1" }, + { name = "shellcheck-py", specifier = "==0.11.0.1" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" }, + { url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" }, + { url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" }, + { url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" }, + { url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" }, +] + +[[package]] +name = "shellcheck-py" +version = "0.11.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/55/455b097417b3df3d330eff029c72c32f08b25739e3010acb30ad06d268ef/shellcheck_py-0.11.0.1.tar.gz", hash = "sha256:5c620c88901e8f1d3be5934b31ea99e3310065e1245253741eafd0a275c8c9cc", size = 3139, upload-time = "2025-08-09T17:53:42.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/27/d75b03e5458cefdb6d3b674566cd20476c3e4d3fe6cc9d68b7e3b854b296/shellcheck_py-0.11.0.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b6a3fee28efda2e16e38d6e6d59faf7224300256456639727370d404730849e8", size = 6774472, upload-time = "2025-08-09T17:53:34.573Z" }, + { url = "https://files.pythonhosted.org/packages/61/ac/2a84c37171c0cf5a10ea4b0a27d43eb0a1d29bd98b49c2c5ffe17ad24bbe/shellcheck_py-0.11.0.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:6b88d0a244c82ed07e06a53e444da841f69330ca59ae15d4a66c391655dae7a0", size = 11381835, upload-time = "2025-08-09T17:53:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/96/55/250e0e3367613a5c22bd82e33b16b889287d81ab0f7dda67e6514a4cccf4/shellcheck_py-0.11.0.1-py2.py3-none-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b274df81de5b000ff78db433e7328b87e52e3c38481c60f8e488c3095beef05", size = 3800600, upload-time = "2025-08-09T17:53:38.643Z" }, + { url = "https://files.pythonhosted.org/packages/15/5b/bb14c0a7474463b1aa3c09e866cb172dffc66ed2993b7ea8f1db581e86ee/shellcheck_py-0.11.0.1-py2.py3-none-win_amd64.whl", hash = "sha256:784156289ecb17e91c692cd783ab5152333309588cabb10032a047331c63e759", size = 8027541, upload-time = "2025-08-09T17:53:40.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] From 0429ee822ef1a71044d8bbf50f5abd26b7658598 Mon Sep 17 00:00:00 2001 From: JN <56563426+junxit@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:52:00 -0500 Subject: [PATCH 3/9] fix: restore every Forward Email alias setting - import sent only name and recipients, so updating an existing alias cleared its description and labels, and recreating one lost its quota, disabled state, IMAP, and vacation settings. It now sends every field the API accepts, with the quota as a "10 GB"-style string and the vacation responder flattened - Match existing aliases by name and update them by ID, so catch-all and regex names never land in a URL path - Clear recipients again after creating an IMAP-only alias; the API fills empty recipients with the account owner's address - Paginate with limit=1000 and X-Page-Count in both scripts - Time out every request after 30 seconds, exit non-zero on failures and a missing API_KEY, and stop the wrappers at the first failing domain (set -euo pipefail); run them through uv from any directory - README: drop the CSV backup/restore claim (no such tool exists), fix typos, and document the restore behavior --- forwardemail/README.md | 56 +++++-- forwardemail/backup_emails.sh | 10 +- forwardemail/check-usage.py | 117 ++++++++++---- forwardemail/check_usage.sh | 13 +- forwardemail/fe-full.py | 277 ++++++++++++++++++++++++++-------- 5 files changed, 358 insertions(+), 115 deletions(-) diff --git a/forwardemail/README.md b/forwardemail/README.md index 7b0f3b9..1160aab 100644 --- a/forwardemail/README.md +++ b/forwardemail/README.md @@ -1,45 +1,73 @@ # Overview -These are tools to back up `ForwardEmail.net`'s alias configurations locally and restore them at will. This enables you -to edit the configs offline then push them back quickly, if desired. +These are tools to back up [Forward Email](https://forwardemail.net)'s alias configurations locally and restore them +at will. This enables you to edit the configs offline then push them back quickly, if desired. -This assumes the proper dependencies were installed using `pip install` or `pip3 install`. +They back up alias *settings* (recipients, labels, quotas, and so on), not the mail stored in mailboxes. -# Python Scripts +The Python scripts read your API key from the `API_KEY` environment variable and run through uv, which provides the +pinned dependencies (see the [root README](../README.md)): + +```bash +export API_KEY=... +uv run forwardemail/fe-full.py export example.com example.com.json +``` -The easiest way to run these scripts is to use `python3 script.py`. +Every request times out after 30 seconds, and each script exits non-zero if anything fails, so a wrapper or cron job +notices. + +# Python Scripts ## check-usage.py Checks the usage by each `alias` in a specified `domain`. +```bash +uv run forwardemail/check-usage.py example.com +``` + ## fe-full.py -Can do a full export/import to/from `json` files, or partialß backup/restore to/from `csv` files. +Does a full export/import of a domain's aliases to/from a `json` file. + +```bash +uv run forwardemail/fe-full.py export example.com example.com.json +uv run forwardemail/fe-full.py import example.com example.com.json +``` + +- `export` follows the API's pagination, so every alias is saved however many there are. +- `import` creates aliases that don't exist and updates the ones that do, matching them by name. It restores every + setting the export captured: recipients, description, labels, enabled status (and the error code used when + disabled), recipient verification, IMAP, OpenPGP and S/MIME settings, storage quota, retention, SMTP limit, and the + vacation responder. +- An IMAP-only alias (one with no recipients) is restored without recipients. When an alias is created with no + recipients, the API adds the account owner's address, so the script removes it again straight away. # Shell Scripts ## backup_emails.sh -Calls `fe-full export` for each `domain` specified. Allows you to put the API key in one place (if you choose to place -the key in the file vs. your environment). +Calls `fe-full.py export` for each `domain` specified, writing `.json` to the current directory. Allows you to +put the API key in one place (if you choose to place the key in the file vs. your environment). It stops at the first +domain that fails. ## check_usage.sh -Checks the usage of `aliases` for a `domain`. +Checks the usage of `aliases` for each `domain` specified, saving each report to `.usage.txt`. It stops at the +first domain that fails. ## reset_pop.sh Deletes all emails for an account. This is useful if you only access your email via `POP3` (and not `IMAP`). -When your email client deletes the emails via `POP3`, `ForwardEmail.net`, at the time of publishing this tool, would +When your email client deletes the emails via `POP3`, Forward Email, at the time of publishing this tool, would simply move the POP3 deleted emails to the `Trash` folder for that alias (as can be confirmed via `IMAP`). This means -your storage will continue to grow. While ForwardEmail.net likely will clear an email from `Trash` after 30 days, this +your storage will continue to grow. While Forward Email likely will clear an email from `Trash` after 30 days, this can help you reset the mailbox if needed. -Please note this deletes ***ALL YOUR MAL*** including emails in your `Inbox`. +Please note this deletes ***ALL YOUR MAIL*** including emails in your `Inbox`. # References -1. [ForwardEmail API Documentation](https://forwardemail.net/en/email-api) -2. [ForwardEmail's Landing Page](https://forwardemail.net/) \ No newline at end of file +1. [Forward Email API Documentation](https://forwardemail.net/en/email-api) +2. [Forward Email's Landing Page](https://forwardemail.net/) diff --git a/forwardemail/backup_emails.sh b/forwardemail/backup_emails.sh index df57d6b..5683232 100755 --- a/forwardemail/backup_emails.sh +++ b/forwardemail/backup_emails.sh @@ -1,7 +1,11 @@ #!/bin/zsh +# Back up the alias settings (not the mail) of each domain to .json. +set -euo pipefail export API_KEY="your_key_here" +script_dir=$(cd "$(dirname "$0")" && pwd) + # List of domains domains=("example1.com" "example2.com") # Add your domains here @@ -9,9 +13,9 @@ domains=("example1.com" "example2.com") # Add your domains here for domain in "${domains[@]}" do echo "Processing domain: $domain" - + # Run the export command - python3 fe-full.py export "$domain" "$domain.json" - + uv run --project "$script_dir/.." "$script_dir/fe-full.py" export "$domain" "$domain.json" + echo "Finished processing domain: $domain" done diff --git a/forwardemail/check-usage.py b/forwardemail/check-usage.py index 4c1a198..08a824c 100644 --- a/forwardemail/check-usage.py +++ b/forwardemail/check-usage.py @@ -1,66 +1,131 @@ +"""Report how much storage each Forward Email alias on a domain uses. + +Typical usage example: + + export API_KEY=... + uv run forwardemail/check-usage.py example.com + +Attributes: + API_BASE_URL: Root of the Forward Email REST API. + TIMEOUT: Seconds to wait for each HTTP request. + PAGE_LIMIT: Aliases requested per page (the API's maximum). +""" + import argparse import logging import os +import sys import requests -from requests.auth import HTTPBasicAuth API_BASE_URL = "https://api.forwardemail.net/v1" +TIMEOUT = 30 +PAGE_LIMIT = 1000 # Set up logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) -def get_aliases_with_storage(domain): - """Fetch the list of aliases with storage details for a domain.""" - url = f"{API_BASE_URL}/domains/{domain}/aliases" +def make_session(): + """Create an HTTP session authenticated with the API_KEY environment variable. + + Returns: + A requests.Session that sends the key as the Basic-auth username. + + Raises: + SystemExit: If API_KEY is not set. + """ api_key = os.getenv("API_KEY") if not api_key: - logging.error("API_KEY environment variable not set.") - raise EnvironmentError("API_KEY environment variable not set.") + sys.exit("API_KEY environment variable not set.") + session = requests.Session() + session.auth = (api_key, "") + return session - auth = HTTPBasicAuth(api_key, "") - logging.info(f"GET {url}") - response = requests.get(url, auth=auth) - logging.info(f"Response Status Code: {response.status_code}") - response.raise_for_status() - return response.json() + +def get_aliases_with_storage(session, domain): + """Fetch every alias, with storage details, following the API's pagination headers. + + Args: + session: An authenticated session from make_session(). + domain: The domain whose aliases to list. + + Returns: + A list of alias objects as returned by the API. + """ + url = f"{API_BASE_URL}/domains/{domain}/aliases" + aliases = [] + page = 1 + while True: + logging.info(f"GET {url} (page {page})") + response = session.get( + url, params={"page": page, "limit": PAGE_LIMIT}, timeout=TIMEOUT + ) + logging.info(f"Response Status Code: {response.status_code}") + response.raise_for_status() + batch = response.json() + aliases.extend(batch) + if not batch or page >= int(response.headers.get("X-Page-Count", page)): + return aliases + page += 1 def human_readable_size(size_bytes): """Convert bytes to a human-readable format (bytes, KB, MB).""" if size_bytes < 1024: return f"{size_bytes} bytes" - elif size_bytes < 1024 ** 2: + elif size_bytes < 1024**2: return f"{size_bytes / 1024:.2f} KB" else: - return f"{size_bytes / 1024 ** 2:.2f} MB" + return f"{size_bytes / 1024**2:.2f} MB" + +def report_storage(session, domain): + """Report storage usage for each alias under the domain. -def report_storage(domain): - """Report storage usage for each alias under the domain.""" - aliases = get_aliases_with_storage(domain) + Args: + session: An authenticated session from make_session(). + domain: The domain to report on. + """ + aliases = get_aliases_with_storage(session, domain) for alias in aliases: - alias_name = alias['name'] - storage_used = alias.get('storage_used', 0) - max_quota = alias.get('max_quota', 0) + alias_name = alias["name"] + storage_used = alias.get("storage_used", 0) + max_quota = alias.get("max_quota", 0) storage_str = human_readable_size(storage_used) max_quota_str = human_readable_size(max_quota) - logging.info(f"Alias: {alias_name}, Storage Used: {storage_str}, Max Quota: {max_quota_str}") + logging.info( + f"Alias: {alias_name}, Storage Used: {storage_str}, Max Quota: {max_quota_str}" + ) print(f"Alias: {alias_name}") print(f" Storage Used: {storage_str}") print(f" Max Quota: {max_quota_str}") def main(): - parser = argparse.ArgumentParser(description="Report storage usage for email aliases with forwardemail.net API.") - parser.add_argument('domain', help='The domain to report aliases for') + """Parse the command line and print the storage report. + + Returns: + The process exit status: 0 on success, 1 if a request failed. + """ + parser = argparse.ArgumentParser( + description="Report storage usage for email aliases with forwardemail.net API." + ) + parser.add_argument("domain", help="The domain to report aliases for") args = parser.parse_args() - report_storage(args.domain) + session = make_session() + try: + report_storage(session, args.domain) + except requests.RequestException as e: + logging.error(f"Request failed: {e}") + return 1 + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/forwardemail/check_usage.sh b/forwardemail/check_usage.sh index d61e4ea..282de65 100755 --- a/forwardemail/check_usage.sh +++ b/forwardemail/check_usage.sh @@ -1,9 +1,12 @@ #!/bin/zsh -# This scripts retrieves the diskspace usage information for all aliases in the given list of `domains` and stores those in the corresponding .usage.txt file. +# This script retrieves the disk space usage of every alias in the given list of `domains` and stores it in the corresponding .usage.txt file. +set -euo pipefail export API_KEY="your_key_here" +script_dir=$(cd "$(dirname "$0")" && pwd) + # List of domains domains=("example1.com" "example2.com") # Add your domains here @@ -11,9 +14,9 @@ domains=("example1.com" "example2.com") # Add your domains here for domain in "${domains[@]}" do echo "Processing domain: $domain" - - # Run the export command - python3 check-usage.py "$domain" | tee "$domain.usage.txt" - + + # Run the usage report + uv run --project "$script_dir/.." "$script_dir/check-usage.py" "$domain" | tee "$domain.usage.txt" + echo "Finished processing domain: $domain" done diff --git a/forwardemail/fe-full.py b/forwardemail/fe-full.py index 1aa245b..8ed1420 100644 --- a/forwardemail/fe-full.py +++ b/forwardemail/fe-full.py @@ -1,72 +1,183 @@ +"""Back up and restore Forward Email aliases as JSON. + +An export captures every alias on a domain; an import recreates missing aliases +and updates existing ones with every setting the export captured. + +Typical usage example: + + export API_KEY=... + uv run forwardemail/fe-full.py export example.com example.com.json + uv run forwardemail/fe-full.py import example.com example.com.json + +Attributes: + API_BASE_URL: Root of the Forward Email REST API. + TIMEOUT: Seconds to wait for each HTTP request. + PAGE_LIMIT: Aliases requested per page (the API's maximum). + RESTORABLE_FIELDS: Alias fields the create/update endpoints accept as-is. + VACATION_FIELDS: Keys of an export's nested vacation_responder object. +""" + import argparse import json import logging import os +import sys import requests -from requests.auth import HTTPBasicAuth API_BASE_URL = "https://api.forwardemail.net/v1" -# Access the API key from the environment variable -API_KEY = os.getenv('API_KEY') +TIMEOUT = 30 +PAGE_LIMIT = 1000 +RESTORABLE_FIELDS = ( + "name", + "recipients", + "description", + "labels", + "has_recipient_verification", + "is_enabled", + "error_code_if_disabled", + "has_imap", + "has_pgp", + "public_key", + "has_smime", + "smime_certificate", + "has_wkd_disabled", + "retention", + "smtp_limit", +) +VACATION_FIELDS = ("is_enabled", "start_date", "end_date", "subject", "message") # Set up logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) + + +def make_session(): + """Create an HTTP session authenticated with the API_KEY environment variable. + + Returns: + A requests.Session that sends the key as the Basic-auth username. + + Raises: + SystemExit: If API_KEY is not set. + """ + api_key = os.getenv("API_KEY") + if not api_key: + sys.exit("API_KEY environment variable not set.") + session = requests.Session() + session.auth = (api_key, "") + return session -def get_aliases(domain): - """Fetch the list of aliases for a domain.""" +def get_aliases(session, domain): + """Fetch every alias for a domain, following the API's pagination headers. + + Args: + session: An authenticated session from make_session(). + domain: The domain whose aliases to list. + + Returns: + A list of alias objects as returned by the API. + """ url = f"{API_BASE_URL}/domains/{domain}/aliases" - auth = HTTPBasicAuth(API_KEY, "") - logging.info(f"GET {url}") - response = requests.get(url, auth=auth) - logging.info(f"Response Status Code: {response.status_code}") - response.raise_for_status() - return response.json() - - -def export_aliases_to_json(domain, json_file): - """Export aliases to a JSON file.""" - aliases = get_aliases(domain) - with open(json_file, 'w') as jf: + aliases = [] + page = 1 + while True: + logging.info(f"GET {url} (page {page})") + response = session.get( + url, params={"page": page, "limit": PAGE_LIMIT}, timeout=TIMEOUT + ) + logging.info(f"Response Status Code: {response.status_code}") + response.raise_for_status() + batch = response.json() + aliases.extend(batch) + if not batch or page >= int(response.headers.get("X-Page-Count", page)): + return aliases + page += 1 + + +def export_aliases_to_json(session, domain, json_file): + """Export every alias on a domain to a JSON file. + + Args: + session: An authenticated session from make_session(). + domain: The domain to export. + json_file: Path of the JSON file to write. + """ + aliases = get_aliases(session, domain) + with open(json_file, "w") as jf: json.dump(aliases, jf, indent=4) logging.info(f"Total aliases exported: {len(aliases)}") -def create_or_update_alias(domain, alias_name, recipients): - """Create or update an alias using the API.""" - url = f"{API_BASE_URL}/domains/{domain}/aliases" - auth = HTTPBasicAuth(API_KEY, "") - data = { - "name": alias_name, - "recipients": recipients +def quota_string(size_bytes): + """Express a byte count in the form the API expects for max_quota. + + Args: + size_bytes: The quota in bytes, as an export reports it. + + Returns: + A string such as "10 GB", or the plain byte count when no unit divides it evenly. + + Example: + >>> quota_string(10 * 1024**3) + '10 GB' + """ + for unit, size in (("TB", 1024**4), ("GB", 1024**3), ("MB", 1024**2), ("KB", 1024)): + if size_bytes >= size and size_bytes % size == 0: + return f"{size_bytes // size} {unit}" + return str(size_bytes) + + +def alias_payload(alias): + """Build a create/update request body that restores everything an export captured. + + Args: + alias: One alias object from an export. + + Returns: + A dict of API input fields. The quota is converted to a unit string and the nested + vacation responder is flattened into vacation_responder_* fields. + """ + payload = { + field: alias[field] + for field in RESTORABLE_FIELDS + if alias.get(field) is not None } - logging.info(f"POST {url}") - response = requests.post(url, json=data, auth=auth) - - if response.status_code == 400: - if response.json().get('message') == 'Alias already exists for domain.': - update_url = f"{url}/{alias_name}" - logging.info(f"PUT {update_url}") - update_response = requests.put(update_url, json=data, auth=auth) - logging.info(f"Response Status Code: {update_response.status_code}") - update_response.raise_for_status() - return 'updated' - else: - logging.info(f"Response Status Code: {response.status_code}") - response.raise_for_status() - else: - logging.info(f"Response Status Code: {response.status_code}") - response.raise_for_status() - return 'created' + max_quota = alias.get("max_quota") + if max_quota: + payload["max_quota"] = ( + quota_string(max_quota) if isinstance(max_quota, int) else max_quota + ) + vacation = alias.get("vacation_responder") or {} + for field in VACATION_FIELDS: + if vacation.get(field) is not None: + payload[f"vacation_responder_{field}"] = vacation[field] + return payload -def import_aliases_from_json(domain, json_file): - """Import aliases from a JSON file and create/update them.""" - with open(json_file, 'r') as jf: +def import_aliases_from_json(session, domain, json_file): + """Create missing aliases and update existing ones from a JSON export. + + Existing aliases are matched by name and updated by ID, so catch-all and regex + aliases never end up in a URL path. + + Args: + session: An authenticated session from make_session(). + domain: The domain to restore into. + json_file: Path of a JSON file written by the export command. + + Returns: + The number of aliases that failed to import. + """ + with open(json_file) as jf: aliases = json.load(jf) + url = f"{API_BASE_URL}/domains/{domain}/aliases" + existing = {alias["name"]: alias["id"] for alias in get_aliases(session, domain)} + total_added = 0 total_updated = 0 total_failed = 0 @@ -74,14 +185,31 @@ def import_aliases_from_json(domain, json_file): logging.info(f"Total records to import: {len(aliases)}") for alias in aliases: + name = alias["name"] + payload = alias_payload(alias) try: - result = create_or_update_alias(domain, alias["name"], alias["recipients"]) - if result == 'created': - total_added += 1 - elif result == 'updated': + if name in existing: + logging.info(f"PUT {url}/{existing[name]} ({name})") + response = session.put( + f"{url}/{existing[name]}", json=payload, timeout=TIMEOUT + ) + response.raise_for_status() total_updated += 1 - except Exception as e: - logging.error(f"Failed to process alias {alias['name']}: {str(e)}") + else: + logging.info(f"POST {url} ({name})") + response = session.post(url, json=payload, timeout=TIMEOUT) + response.raise_for_status() + if not payload.get("recipients"): + # On create, the API replaces empty recipients with the account's own + # address; an update is the only way to clear them again. + alias_url = f"{url}/{response.json()['id']}" + session.put( + alias_url, json={"recipients": []}, timeout=TIMEOUT + ).raise_for_status() + total_added += 1 + except requests.RequestException as e: + detail = e.response.text if e.response is not None else "" + logging.error(f"Failed to process alias {name}: {e} {detail}".rstrip()) total_failed += 1 total_processed = total_added + total_updated + total_failed @@ -89,31 +217,46 @@ def import_aliases_from_json(domain, json_file): logging.info(f"Total added: {total_added}") logging.info(f"Total updated: {total_updated}") logging.info(f"Total failed: {total_failed}") + return total_failed def main(): - parser = argparse.ArgumentParser(description="Manage email aliases with forwardemail.net API using JSON.") - subparsers = parser.add_subparsers(dest="command") + """Parse the command line and run the export or import command. + + Returns: + The process exit status: 0 on success, 1 if any request failed. + """ + parser = argparse.ArgumentParser( + description="Manage email aliases with forwardemail.net API using JSON." + ) + subparsers = parser.add_subparsers(dest="command", required=True) # Subcommand for exporting aliases to JSON - export_parser = subparsers.add_parser('export', help='Export aliases to JSON') - export_parser.add_argument('domain', help='The domain to export aliases from') - export_parser.add_argument('json_file', help='Output JSON file') + export_parser = subparsers.add_parser("export", help="Export aliases to JSON") + export_parser.add_argument("domain", help="The domain to export aliases from") + export_parser.add_argument("json_file", help="Output JSON file") # Subcommand for importing aliases from JSON - import_parser = subparsers.add_parser('import', help='Import aliases from JSON and create/update them') - import_parser.add_argument('domain', help='The domain to create/update aliases for') - import_parser.add_argument('json_file', help='Input JSON file') + import_parser = subparsers.add_parser( + "import", help="Import aliases from JSON and create/update them" + ) + import_parser.add_argument("domain", help="The domain to create/update aliases for") + import_parser.add_argument("json_file", help="Input JSON file") args = parser.parse_args() + session = make_session() - if args.command == 'export': - export_aliases_to_json(args.domain, args.json_file) - elif args.command == 'import': - import_aliases_from_json(args.domain, args.json_file) - else: - parser.print_help() + try: + if args.command == "export": + export_aliases_to_json(session, args.domain, args.json_file) + return 0 + return ( + 1 if import_aliases_from_json(session, args.domain, args.json_file) else 0 + ) + except requests.RequestException as e: + logging.error(f"Request failed: {e}") + return 1 if __name__ == "__main__": - main() + sys.exit(main()) From a8511342e65b6ccc3e2eceb8d784799b46cf0e1d Mon Sep 17 00:00:00 2001 From: JN <56563426+junxit@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:52:22 -0500 Subject: [PATCH 4/9] fix: make zonetool import, restore and delete safe - import sent the BIND file as a raw JSON-typed body; Cloudflare only accepts a multipart upload with a "file" field - restore waited for HTTP 409, but Cloudflare reports duplicates as 400 with error 81057/81058, so existing records were logged as failures. It now detects those codes, strips read-only fields, and updates the record matching on type, name, and value, so several MX or TXT records on one name can't overwrite each other - list, restore, and delete_all read only the first 100 records; all listing now follows pagination - delete_all writes a timestamped BIND backup before deleting - Time out every request, exit non-zero on failures, a missing token, or an unknown zone, and require a filename where one is needed - README: fix the section order and document every command --- cloudflare/README.md | 28 ++- cloudflare/zonetool.py | 498 +++++++++++++++++++++++++++-------------- 2 files changed, 355 insertions(+), 171 deletions(-) diff --git a/cloudflare/README.md b/cloudflare/README.md index 33a0b22..ae61eb8 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -14,7 +14,29 @@ such. `flush_cache.sh` takes your API token with `purge:cache` permissions and lets you specify your domain on a command line for which you want to purge all cache entries (and force Cloudflare to refresh its cache). -`zonetool.py` is a tool to export and import DNS records from a Cloudflare zone. - It is a quick way to flush/purge the cache without logging in to your Cloudflare portal, especially if you have many -domains that you copy content to from the command line like I do (I use [rsync](https://en.wikipedia.org/wiki/Rsync)) \ No newline at end of file +domains that you copy content to from the command line like I do (I use [rsync](https://en.wikipedia.org/wiki/Rsync)) + +## Back Up and Restore DNS Records + +`zonetool.py` exports and imports the DNS records of a Cloudflare zone. It reads an API token from the `CF_TOKEN` +environment variable: `export` and `list` need Zone:Read and DNS:Read permissions on the zone, while `import`, +`restore`, and `delete_all` need DNS:Edit. + +```bash +export CF_TOKEN=... +uv run cloudflare/zonetool.py example.com export example.com.txt # BIND file: every record, Cloudflare's own format +uv run cloudflare/zonetool.py example.com import example.com.txt # load a BIND file back in +uv run cloudflare/zonetool.py example.com list example.com.json # JSON snapshot, including comments and tags +uv run cloudflare/zonetool.py example.com restore example.com.json # recreate records from a snapshot +uv run cloudflare/zonetool.py example.com delete_all # back up, then delete every record +``` + +- `export` and `import` are the complete round trip. Cloudflare limits imports to 256 KiB files and 3 requests a + minute. +- `restore` recreates the records in a snapshot. When Cloudflare reports that a record already exists, the record with + the same type, name, and value gets the snapshot's TTL, proxy status, comment, and tags. Records that share a name + (several MX or TXT records, say) are matched by value, so the wrong one is never overwritten. +- `delete_all` asks twice, then writes a timestamped `.-