build the app, and ship it on Linux and Windows as well as macOS

The client itself: the sync engine and mirror, the Gmail and IMAP providers,
the screener, the three boxes and two piles, compose and send, the sanitiser
and tracker stripping, contacts, clips, backup, notifications and the guide.

The pipeline that ships it. Three runners now build in parallel and the
publish gate wants all four platform keys in latest.json before a release
leaves draft. Linux gets two more packages Tauri does not build: a flatpak
repackaged from the deb against GNOME 48, and a Nix package that relinks the
deb against nixpkgs so it runs as a native Wayland client rather than through
Xwayland. CI runs the Rust suite on all three desktops rather than on Linux
alone, and rebuilds the flatpak manifest on every push to main.

Two things that were only ever exercised on macOS and were wrong everywhere
else. The menu bar was built by adjusting the submenus macOS is given, so on
a platform whose default menu has no File or View the new File menu landed
after Edit, Check for Updates landed nowhere, and the three places and the
reading pane had no menu at all. The deb declared libwebkit2gtk-4.1-0 and
libgtk-3-0 twice, because tauri.conf.json named the dependencies Tauri
already emits.

The updater key exists now, so releases can sign their artifacts.
This commit is contained in:
pj committed 2026-09-06 12:15:30 +05:30
1 parent 088ec9c6e4
commit eb59cb5f8d
423 files changed
+93212 -246

No files matched your search

+107 -1
View File
@@ -50,8 +50,20 @@ jobs:
- run: node scripts/docs-check.mjs
# Every desktop the release ships to, because the parts of this crate that differ by platform are
# the parts nobody runs by hand: the UserNotifications bridge on macOS, the D-Bus one everywhere
# else, the machine id the refresh tokens are sealed against. A break in one of those used to
# surface at release time on a runner nobody was watching.
rust:
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
include:
# Ubuntu 22.04 is the glibc baseline the release builds on, so it is what CI tests on.
- os: ubuntu-22.04
- os: macos-26
- os: windows-latest
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: rust/margin-mail
@@ -61,6 +73,7 @@ jobs:
path: rust/margin-mail
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
@@ -79,14 +92,107 @@ jobs:
- uses: swatinem/rust-cache@v2
with:
workspaces: rust/margin-mail/src-tauri -> target
key: ${{ matrix.os }}
# tauri_build::build() wants a frontendDist that exists, and build.rs wants credentials to
# embed. The example file is what a fresh clone compiles against, so that is what CI uses.
- name: Stub the build inputs
shell: bash
run: |
mkdir -p dist && touch dist/index.html
cp google-credentials.example.json google-credentials.json
# The gate docs/release.md names is the test suites, and that is all this enforces. `cargo fmt
# --check` and `cargo clippy -D warnings` both fail on the tree as it stands; adopting either
# is a cleanup pass to decide on separately, not something to bolt onto CI first.
- name: Test
working-directory: rust/margin-mail/src-tauri
run: cargo test
# The flake at whatever release nix/release.json pins. A broken flake, or a deb that no longer
# patches against current nixpkgs, shows up here rather than at the next release. Before the
# first release there is nothing pinned and nothing to build.
nix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- id: pin
run: echo "version=$(jq -r '.version // ""' nix/release.json)" >> "$GITHUB_OUTPUT"
- uses: cachix/install-nix-action@v31
if: steps.pin.outputs.version != ''
# --impure with the environment variable because the licence is FSL rather than MIT, so
# nixpkgs treats the package as unfree and refuses to build it otherwise.
- if: steps.pin.outputs.version != ''
run: NIXPKGS_ALLOW_UNFREE=1 nix build --impure .#margin-mail --print-build-logs
- if: steps.pin.outputs.version == ''
run: echo "nix/release.json pins no release yet, so there is nothing to build."
# The flatpak manifest is hand-written rather than produced by Tauri, so nothing else would catch
# a runtime that stopped carrying webkit2gtk-4.1 or a permission the app needs and does not ask
# for. The release job repackages the published deb; this one builds the same manifest against a
# deb built here, which is the only difference between them.
#
# On main rather than on every pull request: it is a full release-mode build plus a runtime
# download, the manifest changes about once a year, and a break in it is worth finding within the
# day rather than within the minute.
flatpak:
if: github.event_name == 'push'
runs-on: ubuntu-22.04
defaults:
run:
working-directory: rust/margin-mail
steps:
- uses: actions/checkout@v7
with:
path: rust/margin-mail
- uses: actions/checkout@v7
with:
repository: priyanshujain/margin
path: python/margin
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
libxdo-dev \
libssl-dev \
build-essential \
flatpak \
flatpak-builder
- uses: actions/setup-node@v6
with:
node-version: 26
- uses: pnpm/action-setup@v6
with:
version: 10
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- uses: swatinem/rust-cache@v2
with:
workspaces: rust/margin-mail/src-tauri -> target
key: flatpak
- run: pnpm install --frozen-lockfile
- run: cp google-credentials.example.json google-credentials.json
- run: pnpm tauri build --bundles deb
- name: Build the flatpak
run: |
mv src-tauri/target/release/bundle/deb/*.deb flatpak/margin-mail.deb
flatpak/build.sh
+148 -4
View File
@@ -44,6 +44,12 @@ jobs:
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > "$tmp" && mv "$tmp" src-tauri/tauri.conf.json
jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json
sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" src-tauri/Cargo.toml
# Cargo.lock records margin-mail's own version, so bumping only Cargo.toml leaves the lock
# a release behind and the next build rewrites it under whoever checked it out.
awk -v v="$VERSION" '
/^name = "margin-mail"$/ { print; getline; sub(/^version = ".*"/, "version = \"" v "\""); print; next }
{ print }
' src-tauri/Cargo.lock > "$tmp" && mv "$tmp" src-tauri/Cargo.lock
- name: Commit and tag
env:
@@ -51,7 +57,7 @@ jobs:
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add src-tauri/tauri.conf.json package.json src-tauri/Cargo.toml
git add src-tauri/tauri.conf.json package.json src-tauri/Cargo.toml src-tauri/Cargo.lock
git commit -m "chore(release): $TAG"
for attempt in 1 2 3 4 5; do
git fetch origin main
@@ -93,6 +99,9 @@ jobs:
- os: ubuntu-22.04
args: "--config src-tauri/tauri.release.conf.json"
rust-targets: ""
- os: windows-latest
args: "--config src-tauri/tauri.release.conf.json"
rust-targets: ""
runs-on: ${{ matrix.os }}
defaults:
run:
@@ -109,7 +118,7 @@ jobs:
path: python/margin
- name: Install Linux dependencies
if: startsWith(matrix.os, 'ubuntu')
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
@@ -141,6 +150,7 @@ jobs:
- uses: swatinem/rust-cache@v2
with:
workspaces: rust/margin-mail/src-tauri -> target
key: ${{ matrix.os }}
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
@@ -160,6 +170,42 @@ jobs:
echo "::warning::GOOGLE_CREDENTIALS secret not set, embedding placeholder credentials; this build cannot connect to Gmail."
fi
# macOS shows no notifications from a bundle that is not signed, so tauri.conf.json ad-hoc
# signs at minimum; a Developer ID from the secrets replaces that, and the App Store Connect
# key notarizes on top. Only what is present is exported, because Tauri takes an empty
# APPLE_SIGNING_IDENTITY for an identity and fails the signing step on it.
- name: Provision Apple signing
if: runner.os == 'macOS'
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
if [ -z "$APPLE_CERTIFICATE" ] || [ -z "$APPLE_SIGNING_IDENTITY" ]; then
echo "::warning::APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY not set; the macOS bundle will be ad-hoc signed and not notarized."
exit 0
fi
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_TEAM_ID; do
{ echo "$name<<MARGIN_EOF"; printf '%s\n' "${!name}"; echo "MARGIN_EOF"; } >> "$GITHUB_ENV"
done
echo "Signing as $APPLE_SIGNING_IDENTITY"
if [ -n "$APPLE_API_KEY_P8" ] && [ -n "$APPLE_API_KEY" ] && [ -n "$APPLE_API_ISSUER" ]; then
printf '%s' "$APPLE_API_KEY_P8" > "$RUNNER_TEMP/AuthKey.p8"
{
echo "APPLE_API_KEY=$APPLE_API_KEY"
echo "APPLE_API_ISSUER=$APPLE_API_ISSUER"
echo "APPLE_API_KEY_PATH=$RUNNER_TEMP/AuthKey.p8"
} >> "$GITHUB_ENV"
echo "Notarizing with App Store Connect key $APPLE_API_KEY"
else
echo "::warning::APPLE_API_KEY, APPLE_API_ISSUER or APPLE_API_KEY_P8 not set; the macOS bundle will be signed and not notarized."
fi
- name: Build and upload
uses: tauri-apps/tauri-action@v0
with:
@@ -171,8 +217,45 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
publish:
- name: Verify the bundle is signed and notarized
if: runner.os == 'macOS' && env.APPLE_API_KEY_PATH != ''
run: |
app="src-tauri/target/universal-apple-darwin/release/bundle/macos/Margin Mail.app"
codesign --verify --deep --strict --verbose=2 "$app"
# Gatekeeper only says "accepted" once the notarization ticket is stapled to the bundle,
# so this is the check that somebody double-clicking the dmg will actually get past.
spctl --assess --type execute --verbose=4 "$app"
xcrun stapler validate "$app"
# The flatpak is not a Tauri bundle target, so it is built here from the deb the Linux job just
# published and uploaded to the same draft release. Before publish, so a release never goes out
# with the Linux artifacts half there.
flatpak:
needs: [prepare, build]
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.tag }}
- run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder
- name: Build the flatpak from the published deb
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TAG: ${{ needs.prepare.outputs.tag }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
gh release download "$TAG" --repo "$REPO" \
--pattern "Margin.Mail_${VERSION}_amd64.deb" --output flatpak/margin-mail.deb
flatpak/build.sh "$PWD/Margin.Mail_${VERSION}_amd64.flatpak"
gh release upload "$TAG" --repo "$REPO" "Margin.Mail_${VERSION}_amd64.flatpak" --clobber
publish:
needs: [prepare, build, flatpak]
runs-on: ubuntu-latest
steps:
- name: Verify manifest is complete, then publish
@@ -184,10 +267,71 @@ jobs:
gh release download "$TAG" --repo "$REPO" --pattern latest.json --output latest.json --clobber
echo "Platforms in latest.json:"
jq '.platforms | keys' latest.json
for key in darwin-aarch64 darwin-x86_64 linux-x86_64; do
for key in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do
if ! jq -e ".platforms[\"$key\"].url" latest.json > /dev/null; then
echo "::error::latest.json is missing platform '$key', refusing to publish a partial update manifest. Re-run the release."
exit 1
fi
done
gh release edit "$TAG" --repo "$REPO" --draft=false --latest
# Nix is the other Linux package. The AppImage carries Ubuntu's GTK stack, which cannot talk to a
# modern Wayland compositor and silently falls back to Xwayland; the Nix package relinks the
# published deb against nixpkgs' webkit2gtk and runs as a native Wayland client. Runs after
# publish so the flake can only ever point at a release that survived the manifest check.
nix:
needs: [prepare, publish]
runs-on: ubuntu-latest
steps:
# main rather than the tag: the pin lands on main, and the tag was cut before the artifact
# it needs the hash of existed.
- uses: actions/checkout@v7
with:
ref: main
- name: Pin the flake to this release
env:
VERSION: ${{ needs.prepare.outputs.version }}
REPO: ${{ github.repository }}
run: |
URL="https://github.com/$REPO/releases/download/v$VERSION/Margin.Mail_${VERSION}_amd64.deb"
# From the published asset, so the hash is of the artifact users will actually fetch.
curl -fsSL --retry 3 -o package.deb "$URL"
HASH="sha256-$(openssl dgst -sha256 -binary package.deb | base64)"
jq -n --arg v "$VERSION" --arg h "$HASH" '{version: $v, hash: $h}' > nix/release.json
cat nix/release.json
- uses: cachix/install-nix-action@v31
# Building it is the check: a wrong hash, a library autoPatchelf cannot find or a broken flake
# stops here rather than on someone's machine. --impure and the variable because FSL is not a
# free licence, so nixpkgs refuses to build the package without being told.
- name: Build the package
run: NIXPKGS_ALLOW_UNFREE=1 nix build --impure .#margin-mail --print-build-logs
- name: Commit the pin
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add nix/release.json
# A rerun after the pin already landed has nothing to commit, and the release is done.
if git diff --cached --quiet; then
echo "The flake already points at v$VERSION, nothing to push."
exit 0
fi
git commit -m "point the nix package at v$VERSION"
for attempt in 1 2 3 4 5; do
git fetch origin main
git rebase origin/main
if git push origin HEAD:main; then
break
fi
if [ "$attempt" = "5" ]; then
echo "::error::main kept advancing; could not push the nix pin after 5 attempts. The release is published; rerun this job."
exit 1
fi
echo "main advanced; rebasing and retrying ($attempt)…"
sleep 3
done
+8 -8
View File
@@ -1,20 +1,20 @@
# Margin Mail
A calm, keyboard-first mail client for Gmail on macOS and iPhone, with the parts of HEY and
A calm, keyboard-first mail client for Gmail on macOS, Linux, Windows and iPhone, with the parts of HEY and
Superhuman worth having and none of the tracking. New senders wait at the door until you let them
in; people, newsletters and receipts live in three separate boxes; what you owe and what you need
sit in two piles at the foot of the list. Every decision you make is yours, kept beside the mail
rather than inside Gmail, and it comes with you if you leave.
It is a sibling to [margin](https://github.com/priyanshujain/margin) and
rather than inside Gmail, and it comes with you if you leave. It is a sibling to
[margin](https://github.com/priyanshujain/margin) and
[Margin Calendar](https://github.com/priyanshujain/margin-calendar) and shares their stack and
visual language.
Nothing is built yet. The product is defined in [docs/design.md](docs/design.md), the features in
The product is defined in [docs/design.md](docs/design.md), the features in
[docs/features.md](docs/features.md), the screens in [docs/ui.md](docs/ui.md), the keys in
[docs/keyboard.md](docs/keyboard.md), and how it will be built in
[docs/architecture.md](docs/architecture.md). The research it rests on is in
[docs/research/](docs/research/).
[docs/keyboard.md](docs/keyboard.md) and the settings in [docs/settings.md](docs/settings.md). How
it gets built is [docs/architecture.md](docs/architecture.md) and [docs/plan.md](docs/plan.md), how
it ships is [docs/release.md](docs/release.md), and the research it rests on is in
[docs/research/](docs/research/). To install it, [docs/install.md](docs/install.md).
Licensed FSL-1.1-MIT: use it for anything except building a competing product, and every version
turns MIT two years after its release.
+236 -67
View File
@@ -2,8 +2,9 @@
Tauri 2, React 19, Vite, TypeScript and zustand on the front, Rust behind. The same stack as
margin and Margin Calendar, so OAuth, token sealing, the build and bundle setup, the overlay
title bar and the phone chrome carry over rather than being invented again. The conventions are
the calendar's, in `../margin-caledar/docs/conventions.md`, and apply here unchanged.
title bar and the phone chrome carry over rather than being invented again. The house rules for
writing it are in [conventions.md](conventions.md), which is the calendar's file with its
examples pointed here.
The split is strict. Rust owns authentication, every byte to and from a mail provider, the local
mirror, the sync loop, MIME parsing, HTML sanitising, the portable state database, the journal
@@ -12,29 +13,69 @@ provider, which keeps the content security policy locked to `ipc:` as in the sib
The facts about the Gmail API that this design rests on are in
[research/gmail-api.md](research/gmail-api.md), checked against Google's pages on 3 September
2026.
2026. The milestones, the libraries and the order they land in are in [plan.md](plan.md).
## Two databases, one boundary
There are two SQLite databases per account and the boundary between them is the product.
The **mirror** is a copy of the mailbox: messages, threads, labels, bodies, attachment metadata,
an FTS5 index, the provider's sync cursor, and an outbox. It is derived from the provider and can
be thrown away and rebuilt. Its keys are the provider's ids.
an FTS5 index, the provider's sync cursor, and an outbox. It holds a window of the mailbox rather
than all of it, which the next section explains. It is derived from the provider and can be
thrown away and rebuilt. Its keys are the provider's ids.
The **state** database is everything the user decided: sender rules, pile membership, snoozes,
notes, renames, merges, clips, ignore flags, notification opt-ins, contact notes. It is never
derived, it is never sent to the provider, and its keys are portable: a thread is identified by
the RFC `Message-ID` of its earliest message (the thread key), a message by its own `Message-ID`,
a sender by their address or domain. Provider ids appear in the state database nowhere. When the
user moves to another provider and the same mail arrives through IMAP with the same
`Message-ID`s, every decision reattaches.
derived, it is never sent to the provider, and its keys are portable. A sender is their address
or their domain. A message is its own `Message-ID`. A thread is the **thread key**: the first
entry of the message's `References` header, or its `In-Reply-To` when there is no `References`,
or its own `Message-ID` when it starts the conversation.
That definition matters more than it looks. A key derived from the earliest message the mirror
happens to hold changes when the window moves, so the same thread would carry two keys on two
devices and lose its pile on the one that had less of it. A key read off the headers of any
message in the thread is the same everywhere: on a phone holding a month, on a laptop holding a
year, and in an IMAP mailbox reached two providers from now. Provider ids appear in the state
database nowhere.
A view is the mirror joined to the state. The Inbox is "threads whose sender rule says Inbox, or
that carry a reply to a thread we are in, minus piles, minus snoozes, minus archived", grouped by
seen state. The join is computed in Rust and served to the frontend as a flat, ordered list of
thread summaries; the frontend never sees a label id or a rule.
## The window
Mail on the device is a window, not the mailbox. Each account carries a `window` setting of 30,
90, 180 or 365 days, or everything, and it is 30 days out of the box. Thirty days is what a
mail client is actually used for, it turns a first sync from an afternoon into a few minutes,
and everything outside it is still one search away on the provider.
The mirror holds every thread whose latest message falls inside the window. On top of that it
holds, at any age: every thread that carries app state (a pile, a snooze, a note, a rename, a
merge, a clip, ignore, notify), every starred thread, every draft, and the outbox. A thread the
user has touched is a thread the user expects to find, and the state database would otherwise
point at rows that are not there.
Eviction runs once a day and again whenever the window shrinks. It removes bodies, attachments
and rows for threads that fall outside, leaving the state database untouched; nothing a person
decided is ever evicted. Widening the window is the mirror image: the newly covered range is
backfilled in the background, newest first, through the same hydration path as the first sync, so
there is one code path for filling the mirror and not two.
The arithmetic of the first sync is the reason for all of it. `messages.list` with an `after:`
term returns the ids for the window cheaply, and metadata hydration runs in batches of 50 at 20
units a message, which is about 300 messages a minute inside the per-user budget. A month of a
busy mailbox is minutes. The whole of it, at the same rate, is hours for twenty thousand messages
and most of a working day for a hundred thousand, which is what the setting exists to let someone
choose deliberately rather than discover.
The window is visible in two places and nowhere else. The Everything place ends with one quiet
line, "Showing the last month. Older mail is on Gmail.", with the setting one click away. Search
results end with "Search older mail on Gmail", which runs the provider's search and hydrates the
hits as transient rows; the next eviction pass removes them again unless they gained state in the
meantime. A query carrying a `before:` or `after:` that lands outside the window skips the local
index and goes straight to the provider, because a local answer would be confidently wrong.
## The provider trait
```
@@ -54,10 +95,44 @@ trait Provider {
}
```
Gmail implements it with the REST API. IMAP and SMTP will implement it next with folders mapped
onto the flag set and labels onto `X-GM-LABELS` or keywords, and JMAP after that with its own
change log. The trait is shaped by what every provider can do, and everything the trait cannot do
is done in the state database instead, which is why the state database exists.
Gmail implements it with the REST API. A mailbox reached over IMAP and SMTP with a password is the
second implementation and is what every account that is not Google uses; JMAP is a third if anyone
ever ships it. The trait is shaped by what every provider can do, and everything the trait cannot
do is done in the state database instead, which is why the state database exists.
An account carries a `kind` saying which of the two it is. Almost nothing branches on it: the
places, the piles, the Screener, snoozes, notes and search are all above the trait and cannot tell
the difference. The screens that do differ are the ones about the account itself, because an IMAP
account has no scopes to grant and no Google account page to revoke from, and it has a server and
a port to show that a Google account does not.
IMAP specifics that live only inside that implementation: a per-mailbox cursor of UIDVALIDITY,
UIDNEXT and HIGHESTMODSEQ in place of a change log, CONDSTORE with QRESYNC for the fast path and
a UID scan when the server has neither, mailbox roles read from SPECIAL-USE and XLIST flags before
falling back to names, and folders standing in for labels so a label change is a MOVE. A UIDVALIDITY
change is reported as a full resync rather than remapped, which reuses a recovery path the engine
already had rather than inventing a second one.
Three consequences of IMAP that are worth knowing before they surprise somebody. There is no
server-side snippet, so the preview line on a list row is derived from the body the first time it
is fetched and is empty until then. There is no thread id, so the portable thread key does all the
work; it is computed by the same rule on both sides, so the two agree by construction. And there is
no equivalent of archiving: a mailbox with no `\Archive` special use and no folder named like one
gets an Archive mailbox created on first sync, because a keybinding that means something different
per account is worse than a folder somebody did not ask for.
Sign-in is a password, not OAuth. Both Thunderbird and Mailspring ship their own OAuth client
credentials in the binary for Google and Microsoft, and Mailspring's source says outright that
anyone can extract theirs. Doing OAuth over IMAP would mean registering another client for
`AUTH XOAUTH2`, so a provider that publishes OAuth as its first choice, which Gmail and Fastmail
both do, is offered its own second choice instead: an app password.
Certificates are decided in one place. The loopback is trusted without asking, because a local
bridge listens there with a certificate it generated for itself and nothing sits between this
process and that socket to impersonate anybody. Every other host is verified against the webpki
roots, and a failure becomes a question carrying a fingerprint, remembered per host and port. The
one exception is a certificate naming a different host: that is refused outright and never offered
as a choice, because it is the single failure indistinguishable from an interception.
Gmail specifics that live only inside the Gmail implementation: `history.list` as the change log
and its 404 recovery, the 6,000 units per minute per user budget, `format=metadata` with a fixed
@@ -65,21 +140,74 @@ header list for hydration, `format=raw` for bodies, batch requests of 25 to 50,
exponential backoff on 429, threading rules on send, `CATEGORY_*` labels read as a hint for the
suggestion function, and the People API for contact autocomplete.
## Authentication and distribution
## One client for the whole suite
Loopback OAuth with PKCE from the calendar, unchanged: the consent page opens in the system
browser, never in a webview the app owns, and the code lands on `127.0.0.1`. Refresh tokens are
Margin, Margin Calendar and Margin Mail share one Google Cloud project, `margin-500217`, and one
OAuth client of type installed. A person who opens the third party access page of their Google
account sees a single entry called "Margin", and revoking it revokes the suite. That is the right
shape: three apps by the same author, on the same machine, holding the same person's data, should
not look like three vendors.
Mechanically it is the calendar's arrangement unchanged. `build.rs` copies
`google-credentials.json` from the repository root into `OUT_DIR`, falling back to
`google-credentials.example.json` when the real file is absent, so a fresh clone compiles and
fails at runtime with a readable "not set up yet" rather than at build time with a missing file.
Loopback OAuth with PKCE, also from the calendar: the consent page opens in the system browser,
never in a webview the app owns, and the code comes back on `127.0.0.1`. Refresh tokens are
sealed with XChaCha20-Poly1305 in the app data directory as the calendar does, for the same
reasons (no `keyring` on Android, code-signature churn on macOS, no Secret Service on minimal
Linux). Scopes: `gmail.modify`, `gmail.settings.basic`, `contacts.other.readonly`,
`contacts.readonly`, and `calendar.events` for RSVP. No `mail.google.com` until IMAP is real.
Linux).
Every Gmail scope that reads mail is restricted. The plan is the one in the research: push the
consent screen to production unverified for the friends release (100 lifetime users, no weekly
re-login), file restricted-scope verification at once with the statement that there is no server
and all Google user data stays on the device, ask in writing whether the security assessment
applies, and budget for it anyway. Bring-your-own OAuth client stays as an escape hatch in
settings for the technical.
Connect asks for `openid email`, `gmail.modify`, `gmail.settings.basic`, `contacts.readonly` and
`contacts.other.readonly`. It does not ask for `calendar.events`, and this is the one place where
Google's rules cost the user something: installed apps get no incremental authorization, so a
scope cannot be added to a live token. The first time somebody answers an invite, the app runs
the whole authorization again with `calendar.events` in the list and replaces the stored token.
Backup does the same with `drive.file` when it is turned on. Both say so before they start.
Granular consent is always on for this client, which means the tick boxes on the consent page are
the user's to clear and the app cannot assume it got what it asked for. The `scope` field of the
token response is stored per account and is the truth. Every feature that needs a scope checks it
first and, when it is missing, renders one sentence and a Grant button that re-runs consent for
the full list. Without `gmail.modify` there is no app at all, so the account is not added and the
screen says which permission was declined and what it was for.
### What the shared client costs
`gmail.modify` and `gmail.settings.basic` are restricted scopes. The shared project must pass
restricted scope verification, and until it does, every Margin app shows the unverified screen and
the three of them share one pool of 100 lifetime users. Verification has to be renewed annually,
and a lapse blocks the whole suite rather than one app. That is the price of the single "Margin"
entry, and it is worth stating in the same breath as the benefit.
The plan is the one in the research: push to production unverified for the friends release, file
restricted scope verification immediately with the statement that there is no server and all
Google user data stays on the device, ask in writing whether the security assessment applies, and
budget for it anyway. Bring your own OAuth client stays in settings as the escape hatch for the
technical.
### The hedge
If verification is refused, or priced at a number that is not worth paying, Gmail over IMAP and
SMTP with a Google app password is the way in. It needs no Cloud project, no verification, and it
has no user cap. Gmail's IMAP extensions carry the pieces the REST API was giving us:
`X-GM-LABELS` for labels, `X-GM-THRID` for the provider's thread id, `X-GM-MSGID` for a stable
per-message id, all of which land in the same mirror columns. IDLE and CONDSTORE replace
`history.list` as the change log. RSVP goes out as an iMIP reply by mail instead of through the
Calendar API. Contacts come from the mirror, which is where autocomplete looks first anyway.
This is not a rewrite, it is the `Provider` trait's second implementation, and knowing that is
half the reason the trait is drawn where it is.
Removing an account therefore reaches further than this device. It hands the grant back to Google
first, and because the endpoint acts on the authorization rather than on the string it is handed,
that signs the person out of every Margin app on every machine they own; the confirmation says so
before it runs. Then the token and the registry entry go, and by default the account's mirror and
state database with them. The confirmation carries one box, ticked, for that last part: unticked,
the pair is moved from `accounts/<id>` to `kept/<id>`, where nothing that enumerates accounts can
see it, and it is moved back the day the same account is added again, decisions and all. An IMAP
account has nothing at Google to hand back, so removing one forgets its passwords and stops there.
The two actions used to be two buttons, Remove and Revoke, and read as a choice nobody could make.
## Sync
@@ -88,17 +216,61 @@ grant no desktop app should hold, and a relay is the thing that might drag the a
annual security assessment. `history.list` costs 2 units; polling every 12 seconds in the
foreground and 60 seconds in the background is a rounding error against the budget.
Initial sync is the expensive part after the May 2026 quota change: `messages.get` is 20 units,
so hydration runs at about 300 messages a minute per account. A 20,000 message mailbox takes
about an hour of background work; a 100,000 message mailbox most of a working day. The order is
newest first, the app is usable as soon as the first page lands, and a thin bar in the account
chip says how far back the mirror reaches. Bodies are fetched on open and prefetched for the
last 90 days when idle. Attachments are fetched on open and cached with a size cap. The mirror
is the whole mailbox by decision; a setting caps the age for people who want less on disk.
Initial sync fills the window, newest first, and the app is usable as soon as the first page
lands. A thin bar in the account chip says how far the mirror has got. Attachments are fetched on
open and cached under a size cap. An account that fails repeatedly is paused by a circuit breaker
rather than retried into a rate limit, and the account chip says so.
An account joins the engine the moment it is connected, and its first pass starts then rather
than at the next tick, which can be a minute away while the consent browser has the focus. One
pass at a time per account: a pass somebody asked for while the loop's is running gets the status
the running one is producing, and a first sync is never listed twice. A first sync that a quit or
a tunnel cut short is picked up by the next pass and reported the same way, with the count
carrying on from where it stopped, so an account still arriving never looks idle.
Bodies are the exception, and the rule is worth stating plainly: **opening a thread never waits on
the network.** `thread_view` is a local read. A message whose body has not arrived comes back with
`bodyPending` and the pane draws a placeholder where the text goes, then `thread_hydrate` fetches
what is missing eight at a time and the bodies appear on a `store-changed` of scope `thread`, which
refreshes the open thread and deliberately does not touch the list.
Behind that, the cache warms itself. Once the first sync has finished, each foreground pass takes
the forty newest bodies it does not have, which is about two hundred a minute against Gmail's six
thousand units and leaves room for roughly a hundred explicit opens in the same minute. The phase
is `caching` while it runs and the header says so. Two exclusions keep the queue moving: a body the
provider will not give up is remembered for the life of the process, so a handful of unfetchable
messages at the head cannot stall everything behind them, and transient rows pulled in by a
provider search are skipped, because a body fetched for a row the next eviction pass deletes is
twenty units spent on nothing. Both are excluded from the progress count as well as the fetch: work
nobody is going to do is not work outstanding.
Every write is optimistic: it lands in the mirror, renders, and is pushed behind. Consecutive
flag changes are coalesced into `batchModify`. Offline writes queue in the outbox and drain on
reconnect, with the thread showing "Waiting to send" until a send goes.
reconnect, with the thread showing "Waiting to send" until a send goes. A failure that is about
the connection or the account holds the queue; one the provider raised about the row itself (an
id it no longer has, a label it never had) is retried once and then dropped with its reason said
once, so a dead row never blocks the rows behind it. While a flag or label change is still queued,
the change log's word on those labels is applied under it rather than over it: what this device
decided is the truth about a message until the server has heard it.
Failures are handled the way Mailspring handles them, which is why nobody using Mailspring has seen
a sync error. A connection that dropped under a request (reset, closed before the answer, cut off in
the body) is tried again at once and then after a second, on a fresh connection, at the call site;
the pool keeps its connections alive with HTTP/2 pings so one the machine slept through is found
dead before a request lands on it. A pass that still fails is written down and otherwise kept
quiet: the chip does not move for one failure, because the next poll is twelve seconds away and is
the retry. It moves on the second in a row ("Offline" for the network, "Sync trouble" for the
rest) and the account pauses for five minutes on the fourth. Only three things are ever toasted:
the pause, because pressing sync is the way out of it; a refused token or a missing scope, because
nothing else mends them; and a write the provider refused for good, once, because the change did
not take. Sending a message and creating a draft are the two calls never repeated on a dropped
connection, since the first attempt may have gone through.
Every failure is also appended to `margin-mail.log` in the app data directory, capped at 256 KB:
each failed pass with the provider's sentence, each body that would not come, each command the
frontend called that answered with an error, and each uncaught error in the webview. An app
launched from the Finder has no stderr anybody will read, and a report of "sync failed" with
nothing behind it cannot be debugged.
Seen, starred, archived, trashed and spam are provider flags and go through the trait. Nothing in
the piles, the Screener, snoozes or notes ever touches the provider, so a user who screens out
@@ -112,18 +284,25 @@ payload. The tables are a materialised view of the journal. This is what makes r
without a server and what makes the backup meaningful.
A **backup store** is a trait with three operations: put a blob at a name, get a blob by name,
list names under a prefix. Two implementations ship: Google Drive's app-data folder, which every
Gmail user already has and which margin's backup already uses, and Cloudflare R2 through S3
list names under a prefix. Two implementations ship: Google Drive and Cloudflare R2 through S3
credentials for people who run their own. Each device uploads its own journal segments under its
device id and downloads every other device's. Merging is last-writer-wins per key by timestamp,
which is correct for every kind of state here (a pile toggle, a note, a rule), and a device that
has been offline for a month simply replays what it missed.
The Drive implementation follows margin's, with one thing worth being accurate about: margin does
not use Drive's app-data space. It holds the `drive.file` scope and writes whole unencrypted files
into a visible folder named `margin`, which is deliberate, because a person should be able to see
their own backup. Margin Mail keeps the same scope, adds no new one, and writes encrypted journal
segments under `margin/mail/<account-hash>/<device-id>/`. Files created by the shared client are
visible to every Margin app, which is what makes one folder work for three of them. The HTTP
parts of margin's `gdrive.rs` port across; the encryption, the journal and the merge are new here.
Everything uploaded is encrypted on the device with XChaCha20-Poly1305 under a key that is
generated on first backup, stored sealed like the tokens, and shown once as a recovery phrase.
Drive and R2 hold ciphertext and names; neither can read a note or a rule. The recovery phrase is
the only way to attach a second device or restore after a lost one, and the settings panel says
so in one sentence.
the only way to attach a second device or restore after a lost one, and the Backup section of
settings says so in one sentence.
Version one on macOS alone does not need the merge. The journal shape is there from the first
commit so that the iPhone can join without a migration.
@@ -134,18 +313,21 @@ Message bodies are parsed from raw RFC 2822 with a real MIME parser (`mail-parse
the provider's pre-parsed payload beyond headers, because encoded words, parameter
continuations and legacy charsets appear daily. HTML is sanitised in Rust before it reaches the
webview: scripts, forms, event handlers, `<meta>` refreshes, external stylesheets, `javascript:`
and `data:` navigation are removed; `cid:` references are rewritten to a local resource scheme;
every remote `<img>` is replaced with a placeholder and its source recorded. The body renders in
an iframe with a strict CSP inside the app's webview so the message can never touch the app.
and `data:` navigation are removed; `cid:` references are rewritten to inline data so the body
carries its own images. The body renders in a sandboxed iframe inside the app's webview so the
message can never touch the app, and it renders on the paper surface in both palettes when it
arrived as HTML: mail written for a white page usually sets a text colour and no background, and
inverting it breaks more than it fixes. Plain text is rendered by this app rather than by its
sender, so it follows the theme like everything else.
Tracker stripping happens in the same pass: images with a known tracking host (a maintained
list, shipped with the app and updated with it), images of one pixel or hidden by style, and
images whose URL carries a recipient token are removed and counted, and the vendor is named in
the banner. When the user asks to show images, Rust fetches them without cookies or referrer and
serves them from cache; the user's IP is exposed to the image host at that moment and only then,
which the privacy setting says plainly. Outgoing mail never contains a tracker and the app never
requests a read receipt. Links are rewritten on click to drop known tracking parameters, with a
setting to turn that off.
which the Privacy section of settings says plainly. Outgoing mail never contains a tracker and the
app never requests a read receipt. Links are rewritten on click to drop known tracking parameters,
with a setting to turn that off.
Refresh tokens and the backup key are sealed, never in SQLite. The mirror and the state database
are files in the app data directory and inherit the OS's disk encryption; encrypting them again
@@ -158,31 +340,18 @@ macOS: overlay title bar with the traffic lights on the header's centre line, cl
hides it and Cmd-Q quits, all from the calendar. iOS second: the same code with the phone chrome
from the calendar's `data-phone` and `data-touch` scheme, overlays as bottom sheets, the OAuth
flow through `ASWebAuthenticationSession`, and background app refresh used only to run the
snooze evaluation and a short sync. Linux afterwards: no traffic lights, closing quits, deb and
AppImage.
snooze evaluation and a short sync.
Linux and Windows are the same code with the platform branches taken the other way: no traffic
lights, closing quits, and the menu bar built rather than adjusted, because neither is given the
File and View submenus macOS starts with. Linux ships as a deb, an AppImage, a flatpak and a Nix
package, Windows as an msi and an exe; which of the four a Linux reader should pick is in
[install.md](install.md).
## Order of work
The sync engine and the reading pane are the two hard things and neither proves the other, so the
first milestone is one account, authentication, the mirror, and a read-only Inbox with a
sanitised, tracker-stripped reading pane. Everything the app is for depends on those being
right.
Second, triage on the mirror: seen, archive, star, trash, spam, selection, the keyboard, the
palette, local search. At this point it is a fast Gmail client and nothing more.
Third, the state database and the four places: sender rules, the suggestion function, the
Screener, the first-run pass, Feed and Paper Trail. This is the milestone where it stops being a
Gmail client.
Fourth, the piles and their friends: Reply later, Set aside, Focus & Reply, snooze with lazy
evaluation, notes, rename, merge, clips, All files, ignore, per-thread notifications, the contact
card.
Fifth, writing: reply, compose, drafts, the outbox with undo send, attachments, remind me if no
reply, instant intro, calendar RSVP.
Sixth, more than one account, the unified view, settings, export, and the backup store with the
journal behind it.
Then the iPhone, then Linux, then the IMAP provider, in that order.
first milestone after the scaffold is one account, authentication, the mirror, and a read-only
Inbox with a sanitised, tracker-stripped reading pane. Everything the app is for depends on those
being right, and everything after them is additive. The milestones and their work packages are in
[plan.md](plan.md).
+139
View File
@@ -0,0 +1,139 @@
# Conventions
This project is a sibling to margin and Margin Calendar and follows their conventions deliberately
rather than inventing new ones. When something here is unclear, the answer is almost always "do
what the calendar does", and the file to look at is named below.
## Rust
`Result<T, String>` everywhere. No `anyhow`, no custom error enum except
`provider::ProviderError`, which exists only because the sync engine has to branch: a revoked
token, a missing scope, a 429 to back off from, and a 404 from `history.list` that means the
change log expired and a full list is the answer rather than a failure.
DTOs crossing the IPC boundary live in `src-tauri/src/dto.rs` and are marked
`#[serde(rename_all = "camelCase")]`. That file is the contract and is frozen: implementation
modules add bodies, not fields. Its mirror is `src/ipc.ts`.
Read Google's responses through the ported `read_json`, which takes the body to a `String` first
so the error payload survives into the message rather than becoming "expected value at line 1".
It is the calendar's function and it lives with the Gmail client.
Heavy synchronous work goes behind `#[tauri::command(async)]` on a synchronous fn, which is
margin's trick in `pdf.rs` for getting off the main thread without hand-writing `spawn_blocking`.
Every command that touches SQLite qualifies.
Provider-specific behaviour stays inside the provider's module. Nothing above
`src-tauri/src/provider/` may know what a Gmail label id looks like, and nothing outside the
mirror may know that a thread has a provider id at all. The sync engine talks to the trait, which
is what lets `provider::fake` drive the whole engine in `cargo test` without credentials.
Comments are rare and explain why, never what. Match the density in `lib.rs`.
## TypeScript
One zustand store per domain in `src/store/`. No middleware. One selector call per field
(`useThing((s) => s.field)`, never a destructured object), actions as inline arrow properties, and
`set((s) => ...)` returning `{}` to no-op.
Async actions use a string phase union (`"idle" | "syncing" | "error"`), never boolean loading
flags. Errors stringify with `String(e)` and surface as a toast.
Side effects that touch disk, the DOM or Tauri live in a sibling module, never inside the store.
The OAuth connect flow in `src/store/useAccounts.ts` reuses margin's
promise-holding-its-own-resolver pattern from `useBackup.ts`: `connect()` returns a promise whose
`resolve` is stashed in state for a later Tauri event to settle.
Typed IPC wrappers live in `src/api/`, one module per domain, one thin function per command. They
are written once against the frozen contract; add bodies to Rust, not new wrappers.
## The design system
Three layers, and the rule is that each may only reach down.
Tokens are `src/styles/tokens.css`, which is a seam rather than a list: it imports the set
margin-shared holds for all three apps, then `src/styles/mail.css` for what mail adds on top.
Every colour, radius, size, duration and font stack is in one of those two, and nothing else in
the app may declare a token.
Primitives are `src/ui/`: the button, the keycap, the row, the avatar, the panel, the popover, the
toast. They read tokens and nothing else, and every one of them appears in every state on the Kit
page at `#/kit`, which is how a restyle gets reviewed.
Screens are `src/screens/`. A screen composes primitives and may never write a colour, a radius or
a size. If a screen needs a value that is not available to it, the answer is a new token or a new
primitive, not a literal.
## Places and stages
A `Place` in the frozen contract is a query over threads, and three of the palette's entries are
not that: Contacts lists people, Clips lists passages, All files lists attachments. Focus & Reply is
a fourth, a page over the Reply later pile rather than somewhere you can be. None of them belongs in
`Place`, and none is an overlay either, because an overlay is something you dismiss to get back to
what you were doing and these are somewhere you go.
So they are a stage, in `src/store/useStage.ts`, and the rule is that a stage wins over a place:
opening Contacts leaves the Inbox where it was and Escape puts you back on it. The current place
and the current stage are both on the root element as `data-place` and `data-stage`, which is what
a test reads and what a stylesheet keys off, so neither has to ask the app what it thinks it is
showing.
## CSS
Flat kebab-case class names, not BEM. State is a `data-*` attribute, never an `is-` class.
Every colour, radius and size goes through a token. If a value is not in the token layer, add it
to `src/styles/mail.css` rather than writing a literal.
Dark mode is `data-theme` on `<html>`, with both palettes defining an identical variable set.
Never a media query for theme.
Transitions name explicit properties and use `var(--ease)`. Never `transition: all`.
Responsiveness is JS-driven. `usePhone()` and `useTouch()` in `src/useMedia.ts` write `data-phone`
and `data-touch` on the root, and styles read those attributes rather than adding media queries.
They answer different questions. `data-phone` is a window too narrow for the desktop chrome and it governs
layout; `data-touch` is a coarse pointer and it governs interaction. A tablet is touch and not a
phone, a narrow desktop window is a phone and not touch, and treating either as a proxy for the
other is how a hover-only control ends up unreachable. Both are also set by the boot script in
`index.html`, so the first paint is already the right shape.
A rule that reads "you cannot hover here" belongs on `data-touch`. A rule that reads "there is no
room for this" belongs on `data-phone`.
There is no container query in this repository. The calendar has exactly one, on the event block,
and it earned it: what decides how many lines of a title fit is the block's own width and not the
window's. Nothing here has met that bar yet, and nothing may reach for one without the same kind
of reason.
Overlays follow margin's `.overlay` and `.panel` idiom, which is in `src/styles/app.css`, and
every one of them registers with `useEscapeLayer` from `src/escape.ts` so Escape unwinds the
layers in order.
## Icons
Feather-style 24x24 stroke `d` strings, named in `src/ui/icons.ts` and passed to
`<Icon d={...} />`. The handful of glyphs the whole suite shares are re-exported from
`margin-shared/icons` so a search here and a search in the calendar are the same drawing; the
verbs and the piles are mail's own. There is no icon set and no registry, and there will not be
one. An icon-only button always carries a `title` with its shortcut written in real glyphs.
## Storage keys
Anything in `localStorage` is prefixed `marginmail-`, following margin's convention: the theme is
`marginmail-theme` in `src/theme.ts`, and the fonts, the text size and the reading pane follow the
same shape. Keys read before first paint are restored by the blocking IIFE in `index.html`, which
is why they are flat strings rather than one blob.
## Work packages
A work package owns a set of files and never edits another package's files. When a package needs
something that lives in another one, the answer is to agree the interface up front (which is what
`dto.rs` and `src/ipc.ts` are for) and stub behind it, not to reach across. A package that has to
edit somebody else's file was cut in the wrong place.
## Never
No CSS framework, no component library, no router, no zustand middleware, no directory trees in
any document, and no em dashes anywhere including code comments.
+27 -20
View File
@@ -17,10 +17,10 @@ named. We refused HEY's layout (one column, a page per thread, a round trip for
insistence that routing is per sender only, and its refusal to let you archive.
From Superhuman: speed and the keyboard. One key per verb, the key printed on every button, and a
command palette that is the whole settings and discovery surface, so the app teaches itself. A
list beside a reading pane, so you triage without leaving the list. Remind me if no reply, undo
send, the contact card. We refused the tracking pixels, the AI surface that ships your mail to a
vendor, the inbox-zero streak, the tiny fixed type, and the price.
command palette that reaches every place, every command and every setting, so the app teaches
itself. A list beside a reading pane, so you triage without leaving the list. Remind me if no
reply, undo send, the contact card. We refused the tracking pixels, the AI surface that ships your
mail to a vendor, the inbox-zero streak, the tiny fixed type, and the price.
From neither: the app is the only place your state lives, and that state is yours. Your piles,
screening decisions, notes, renames and clips are keyed on the mail itself (the RFC Message-ID
@@ -34,9 +34,9 @@ There are three boxes and one gate, and every sender has exactly one destination
**Inbox** is for people and for the few services you want to hear from as they arrive. It is a
stream, not a queue: what you have not looked at sits under New for you at the top, and
everything you have opened or sent sinks to Previously seen beneath it. Nothing counts anything.
A reply pulls a thread back up. There is an archive key, because some people need an empty list
to feel finished, but nothing in the design pushes you towards it.
everything you have opened or sent sinks to Previously seen beneath it. Neither group carries a
count. A reply pulls a thread back up. There is an archive key, because some people need an empty
list to feel finished, but nothing in the design pushes you towards it.
**Feed** is for newsletters and long reads. Every item is already open, in one scrolling column,
newest first, with a marker where you left off. There is no read state and no obligation. You
@@ -53,9 +53,10 @@ decision can be per address or per domain, which is the thing HEY's users ask fo
will not give them. Replies to a thread you are already in bypass the whole system and land in
the Inbox, because the sender rule is about first contact, not about conversations.
On first run there is no Screener avalanche. Everyone who has ever written to you is screened in,
routed by the same suggestion rules, and movable later from their contact card. Only genuinely
new senders from that point on are held.
On first run there is no Screener avalanche. Everyone the account already knows is screened in,
routed by the same suggestion rules, and movable later from their contact card: your Google
contacts, everyone in the mail that came down with the first sync, and everyone you wrote to in
it. Only genuinely new senders from that point on are held.
## The two piles
@@ -76,16 +77,20 @@ Every verb is one unmodified key, the same key Gmail and Superhuman use where th
(`j`, `k`, `e`, `r`, `a`, `f`, `c`, `/`, `x`, `u`, `z`) and HEY's letters for HEY's verbs (`l`
Reply Later, `s` Set Aside, `b` snooze, `y` note, `m` ignore). Number keys go to places. There
are no two-key chords, nothing is modal, and the key is printed on every button so the mouse
teaches the keyboard. Cmd-K opens the palette, which also lists every place, every command and
every setting. The full map and the reasoning for each conflict are in [keyboard.md](keyboard.md).
teaches the keyboard. Cmd-K opens the palette, which reaches every place, every command and
every setting, though settings itself is a screen you can sit in rather than a list you pass
through. The full map and the reasoning for each conflict are in [keyboard.md](keyboard.md).
## Quiet by design
No badge, no unread count, no streak, no photograph when the list is empty. No notification
unless you turned it on for that thread or that person. Remote images do not load until you ask,
tracking pixels are removed before the message renders, and the banner tells you whose pixel it
was. Nothing you send carries a tracker and nothing reports when it was opened. Links open with
their tracking parameters removed.
No unread count, no streak, no photograph when the list is empty. No notification unless you
turned it on for that thread or that person. The one number anywhere is the dock badge, and it
counts New for you rather than unread mail: what is waiting for a decision once the Screener, the
Feed and the Paper Trail have taken everything that is not. That is a fact about your Inbox rather
than a reason to open the app, and it turns off in Notifications. Remote images do not load until
you ask, tracking pixels are removed before the message renders, and the banner tells you whose
pixel it was. Nothing you send carries a tracker and nothing reports when it was opened. Links open
with their tracking parameters removed.
There is no AI in this version. Classification is by headers and by your decisions, the way HEY
does it, and it is explainable in a sentence on every Screener card. The design leaves a seat for
@@ -113,15 +118,17 @@ passage with a link back. All of it roams through the backup store, none of it t
Not a team tool: no shared threads, comments, or read statuses. Not a calendar: invites hand off
to Margin Calendar. Not a scheduler: no send later in this version. Not an assistant: nothing is
summarised or drafted for you. Not a Gmail skin: the Gmail web UI is not a consideration, since
the whole point is never opening it.
summarised or drafted for you. Not an archive: the device keeps a window of recent mail, a month
unless you ask for more, and the rest stays on Gmail where a search still reaches it. Not a Gmail
skin: the Gmail web UI is not a consideration, since the whole point is never opening it.
## Visual language
Lifted from margin and the calendar unchanged: warm paper, ink and two softer inks, hairline
borders, a four-step type scale, three radii, one easing curve, light and dark driven by
`data-theme`. Hanken Grotesk for the interface, Literata for the subject line and for message
bodies, because mail is reading and reading deserves a text face. The additions are mail-specific:
bodies, because mail is reading and reading deserves a text face. Both are the default rather than
the law: Appearance offers the six faces the suite bundles and whatever else is on the machine. The additions are mail-specific:
a row hover and a row selection wash, a warmer band for Previously seen, a note surface, a dot
colour for new mail, and the eight muted hues the calendar already uses, here for avatars and
account edges. No CSS framework, no component library, hand-written CSS on tokens.
+143 -34
View File
@@ -21,8 +21,9 @@ reaches all of them.
| `5` | Set aside | The Set aside pile |
| `6` | Screener | First messages from senders with no decision yet |
| `7` | Snoozed | Threads waiting to return, with their return time |
| `0` | Everything | Every thread in the account, including archived, in date order |
| palette | Sent, Drafts, Starred, Screened out, Spam, Trash | The usual folders |
| `0` | Everything | Every thread on the device, including archived, spam and screened out, in date order |
| palette | Sent, Drafts, Starred | The usual folders |
| palette | Screened out, Spam, Trash | Under Other: the places you go looking in rather than read |
| palette | All files, Clips, Contacts | The libraries |
| palette | Labels | The provider's labels or folders, one place each |
@@ -30,6 +31,13 @@ Every place except Feed, Screener and Focus & Reply is a list column beside the
Feed and Screener take the whole stage because their content is inline. A place remembers its
scroll position and selection while the app is open.
Every place is a view over what is on the device, and what is on the device is a window of the
mailbox: the last 30 days by default, or 90, 180, 365 days or everything, set per account. Threads
you have done something to are kept whatever their age. Only Everything says any of this out loud,
in one quiet line at the foot of the list, "Showing the last month. Older mail is on Gmail.", with
the setting one click away. The mechanism is in [architecture.md](architecture.md) and the setting
is in [settings.md](settings.md).
## 2. Routing and the Screener
### Destinations
@@ -43,8 +51,8 @@ Two overrides apply before the sender rule:
- A message whose `In-Reply-To` or `References` points at a thread the account is already in goes
where that thread is, or to the Inbox if the thread was screened. A reply is never held.
- A message from an address in the account's contacts, or one the account has ever sent to, is
screened in on first run and routed by the suggestion rules, never held.
- A message from someone the account already knows is screened in on first run and routed by the
suggestion rules, never held. Who counts as known is settled under First run below.
### The Screener
@@ -60,9 +68,10 @@ subject, snippet, and a one-line reason with the suggested destination. Keys:
sender into the Inbox and opens a reply.
- Clear all screens out every sender currently waiting, after a confirmation.
Screened out mail is kept for 90 days in the Screened out place, then trashed. Reversing a
decision is done from the sender's contact card, and re-screening someone in brings back whatever
they sent in the last 90 days.
Screened out mail sits in the Screened out place for as long as the storage window keeps it and
falls off the device with everything else of that age. There is no second retention rule to
remember, and a wider window means a longer memory. Reversing a decision is done from the sender's
contact card, and re-screening someone in brings back whatever they sent that is still here.
### Suggestions
@@ -85,10 +94,26 @@ wrong suggestion is a one-line fix.
### First run
When an account is added, every sender in the mirror is screened in with a rule set by the same
suggestion function, silently. The user can move any sender from the contact card, and the move
applies to that sender's existing threads immediately. Only senders whose first message arrives
after the account was added are held in the Screener.
When an account is added, everyone it already knows is screened in with a rule set by the same
suggestion function, silently. A month of mail is not by itself a good answer to who a person
knows, so the seed is drawn from three cheap sources at once: every sender and every recipient
inside the storage window, the People API's `connections` and `otherContacts` lists, and the Sent
mail inside the window. Anyone in any of the three is screened in. All three are already fetched
or already on the way, so this costs a pair of extra calls and no waiting.
The pass runs at the end of the first sync rather than when the first-run panel is dismissed, so
the Inbox fills as the mail arrives instead of sitting empty for as long as somebody takes to read
a panel. It runs once per account and is guarded, which is the whole of what makes the Screener a
gate: if it ran again on a later sync it would screen in every new sender the moment they wrote.
The guard cuts the other way too: asked before the crawl has finished, as it is the moment an
account is added from Settings, the seed answers "not yet" rather than marking itself done over a
mirror with nothing in it, and the sync's own idle asks again. A seed on record as having
screened in nobody is run once more when the mirror is ready, which repairs an account that was
marked that way before the rule existed without anybody removing it and connecting it again.
The user can move any sender from the contact card, and the move applies to that sender's existing
threads immediately. Only senders whose first message arrives after the account was added are held
in the Screener.
Alongside this, a first-run panel offers "Start fresh": mark everything older than a chosen age
(default one week) as seen, so New for you holds only what is recent. This is the only bulk
@@ -110,8 +135,12 @@ holds it until it is opened.
- A new message in a Previously seen thread moves the thread to New for you.
- `e` archives: the thread leaves the Inbox and lives in Everything. A new message in an archived
thread brings it back to New for you. Archive is a provider change (Gmail: remove `INBOX`).
- There are no counts on the groups, on the place, or on the app icon. A dock badge for New for
you exists as a setting and is off.
- There are no counts on the groups or on the place. The one count anywhere is the dock badge,
which is the size of New for you across every account, and it is on by default and turns off in
Notifications. It is not the mailbox's unread count: a thread held in the Screener, routed to the
Feed or the Paper Trail, piled, snoozed or ignored is unread and is not waiting for you. Zero
takes the badge off rather than showing a nought. macOS and Linux carry it; Windows would need a
drawn overlay icon and does not have one yet.
- A note on a thread shows as a single line under its row.
Seen state is the provider's read state (Gmail: `UNREAD`), so it is not app state. Everything
@@ -200,12 +229,14 @@ focused message, `Shift+O` expands all. Quoted text is collapsed behind a pill.
- Message bodies render in a sandboxed webview with scripts, forms and external styles removed.
Remote images are blocked by default; a banner says how many trackers were stripped and names
the vendor; Show images loads them for this message, and the contact card can allow them for a
sender always. Attachments are chips; images and PDFs preview inline on demand.
sender always. Attachments are chips; pressing one opens the file with whatever owns its type.
- Attachments are fetched when the thread is opened, not during sync, and cached.
- A calendar invite (`text/calendar` with `METHOD:REQUEST`) renders as a card: date, title,
time, location, organiser, and Accept (`y`), Maybe (`m`), Decline (`n`), plus Open in Margin
Calendar. RSVP goes through the Calendar API on the invited calendar; when the event is not
there yet it is imported first. Without the Calendar scope the card still renders read-only.
there yet it is imported first. The Calendar permission is not asked for when the account is
added, so the first RSVP says it needs it and runs the consent page again; until then the card
renders read-only.
- Links show their real destination on hover and open with known tracking parameters removed.
- Read together: select several threads with `x` and press `Enter`; the pane shows them one
after another with a heading each.
@@ -290,15 +321,26 @@ focused. Built from the local index; nothing is fetched until you open one.
### Ignore
`m` on a thread. New messages still arrive and append, but the thread never returns to New for
you and never notifies. A banner on the thread says "You are ignoring this thread" with Stop
ignoring. Local.
`m` on a thread. New messages still arrive and append, and the thread rises with them because the
Inbox is in time order, but it never reads as new, never counts on the badge and never notifies. A
banner on the thread says "You are ignoring this thread" with Stop ignoring. Local.
### Notifications
Off by default everywhere. `Shift+N` on a thread turns them on for that thread; the contact card
turns them on for a person. A notification shows the sender and subject, and opening it opens the
thread. There is no badge unless the setting is turned on. Local.
turns them on for a person; Settings turns them on for a place, and has one switch over all of it
for the machine, which is what "nothing on this laptop" means without touching a single thread. The
sync pass that brings a message in is what posts the notification, and only for mail that arrived
after the app came up, so a first sync, a rebuild or a week away says nothing about the backlog; one
message is three lines, the app's name, the sender and the subject, and several in one pass are one
notification counting them and naming the senders. On macOS the system asks once whether the app
may notify at all, the first time anything here is turned on or the test button is pressed, and a
refusal is undone in System Settings rather than here. Clicking one brings the app to the front and
opens the thread in the list it shows in; a click on the grouped one opens the account's Inbox, and
a click on the sample from Settings only brings the app to the front. The dock badge is a setting
in the same section rather than a notification, it counts New for you, and it is the one thing here
that is on. Local.
## 11. Contacts and the contact card
@@ -319,8 +361,38 @@ State: notes, delivery, notify are local. Provider: nothing.
the app POSTs and confirms; with a `mailto:` header it sends the message; otherwise it opens the
link. Either way it offers "and trash everything from them" and "and screen them out".
- Screen out from the contact card is the block: future mail goes to Screened out. Nothing is sent.
- `!` marks spam (provider), `#` trashes (provider), both with undo. Trash empties after 30 days
on the provider's schedule; the Trash place has an Empty button.
- `!` marks spam and `#` trashes, both provider changes, both with an undo toast.
### Screened out, Spam and Trash
Three places under Other in the palette, below the daily ones and below the labels, with no number
keys of their own. Where a place sits is the honest statement of how often you should be in it, and
these are the ones you go looking in rather than the ones you read.
None of them is a filter of ours. Gmail's spam filter runs on Gmail's side before the app sees a
message, and the mirror takes what the mailbox holds: every list call sets `includeSpamTrash`, so a
junked message is already on the device with its body indexed whether or not anything shows it.
Screened out is the one we own, and it is a routing destination rather than a folder, so its rules
are in section 2.
Getting mail back out is the point of all three, and the verb that puts it back is the verb that
put it there, the way the piles already work. `#` in Trash puts a thread back, `!` in Spam takes
the spam mark off. Gmail restores a message's labels when the `TRASH` label comes off, so a thread
put back lands where it was rather than in the Inbox. A thread taken out of Spam is routed like any
other: to its sender's box if that sender has a rule, and to the Screener if they do not, which is
the decision you still owe them.
There are three ways back, in the order you will want them. A wrong keystroke is the toast that is
already up, "Trashed · Undo" and `z`. A rescue a week later is the place and the verb. After thirty
days Gmail empties its own trash, the message leaves the mirror with it, and nothing local changes
that.
There is no Empty button. Permanently deleting through the Gmail API needs the
`https://mail.google.com/` scope, which is total access to the mailbox, and asking every account
for that so a button can destroy things thirty days earlier than Gmail will anyway is a bad trade.
Each place states the rule instead, in one line at the foot of the list: "Gmail empties this after
30 days." Screened out carries no such line, because it falls off with the storage window like
everything else of its age and there is no second retention rule to learn.
## 13. Selection and bulk actions
@@ -332,16 +404,28 @@ Enter for Read together. Every bulk action is one undo.
## 14. Search
`/` focuses search. Results replace the list column and the reading pane works as usual. Search
is local over the full mirror: subject, participants, snippet and body text, with operators
is local over what is on the device: subject, participants, snippet and body text, with operators
`from:`, `to:`, `subject:`, `has:attachment`, `filename:`, `in:` (any place), `before:` and
`after:`, `label:`. When the query touches mail that is not yet hydrated, the provider's search
runs as a second pass and its results append with a note. Results open in place and `Esc`
returns to the previous place.
`after:`, `label:`.
Because the device holds a window, a local result set is a partial answer and says so. Every list
of results ends with "Search older mail on Gmail", which runs the provider's search, appends the
hits and hydrates them as they arrive. Those rows behave like any other row, and the next eviction
pass takes them away again unless they picked up a pile, a note or some other decision in the
meantime. A query whose `before:` or `after:` falls outside the window skips the local index
entirely and goes to the provider, because a local answer to that question would be wrong rather
than merely short. Results open in place and `Esc` returns to the previous place.
Search reaches Spam, Trash and Screened out, and names the place on the row when it does. The
message you most need to find is the one something else decided you should not see, and a search
that skipped those three would be one you had to already know the answer to use. `in:` narrows to
a single place when that is what you meant.
## 15. Accounts
Add as many Gmail accounts as you like. Each has its own places, sender rules, piles and
Screener. The account chip in the title bar switches (`Ctrl+1` to `Ctrl+9`) and offers All
Add as many Gmail accounts as you like. Each has its own places, sender rules, piles, Screener,
storage window and granted permissions, so a work account can keep a year while a personal one
keeps a month. The account chip in the title bar switches (`Ctrl+1` to `Ctrl+9`) and offers All
accounts (`Ctrl+0`), which merges every account's version of the current place into one list with
a coloured edge on each row. Compose picks the account from the thread you are replying to, or
the account you are looking at, and the From field switches it. Sent mail goes through the
@@ -355,13 +439,38 @@ are the provider's, they roam with the mailbox, and they are not how Margin orga
## 17. Settings and export
`Cmd+,` opens settings as a panel: Accounts (add, remove, signature, aliases), Backup (Google
Drive or Cloudflare R2, and the recovery phrase), Appearance (theme, reading pane, row density
for the phone), Sending (undo delay, reply-all default, instant intro text), Privacy (remote
images, link cleaning, per-sender allowances), Notifications (badge, sound), Keyboard (the keymap
file), Data (export mail as mbox per account, export app state as JSON, import app state).
Settings is a place, not a panel. `Cmd+,`, the palette, the account chip and the app menu all lead
to the same full-stage screen, with a rail of sections down the left and one section at a time on
the right. Twelve sections cover the accounts and their permissions, appearance, the storage
window, privacy, the Screener, the piles and snooze, writing, notifications, the keymap, backup and
the recovery phrase, every export the app offers, and the version. Each is specified in
[settings.md](settings.md), including which of them live on the device and which roam.
## 18. Not in version one
## 18. Help
The app is unlike the mail clients people arrive from, and none of the differences are
discoverable by poking at the interface, so there is a tour, a question mark in the corner, and a
guide behind it. Each is specified in [help.md](help.md).
The tour is nine slides in a sheet, over the Inbox, run once for every account that is added and
skipped with one key. It follows the first-run panel above and names the Screener, the three
boxes, the two piles, snooze, the keyboard, the palette, the things kept beside the mail, what is
off by default, and undo.
The corner button opens the tour again, the guide, and the keyboard shortcuts. It is not drawn
while the compose card is open, while an overlay is up, in the guide itself, or on a phone.
The guide is a panel over the whole window: a search field, a rail of sections, and one article at
a time. How to do each thing the app does, and last, the questions people actually ask. Every
article leads with its answer and shows it in a drawn figure, a screenshot or a table of keys, and
a search nothing answers offers to file the question against the repository. Closing it puts back
the place, the open thread and the scroll. Its pictures come from the dev fixture through
`just guide-shots` and are committed, because they ship in the bundle.
State: which accounts have had their first run, and therefore their tour, is a device fact in
localStorage beside the first-run panel's own flag. Provider: nothing.
## 19. Not in version one
Send later. Snippets. Any AI. Shared threads, team comments, read statuses. Workflows, collections,
cover art. A calendar sidebar. Unified search across accounts (search is per account until the
+103
View File
@@ -0,0 +1,103 @@
# Help
Three things, and they are separate on purpose: a tour that runs once when an account is added, a
question mark in the corner that is there for good, and a guide behind it that answers the
questions this app raises by not working like the last one. Behaviour that is being explained is
specified in [features.md](features.md); the keys are in [keyboard.md](keyboard.md) and every
keycap printed anywhere here is generated from the same binding table, so a remapped key is what
the help says.
The premise is that this app is unusual and that pretending otherwise is what makes people leave.
Mail from somebody new does not arrive. There are three boxes rather than one. Flags are two piles
with keys on them. None of that is discoverable by poking at it, and none of it is a reason to
turn the first hour into a wizard either.
## The tour
Nine slides in a sheet, over the Inbox it is talking about. It follows the first-run panel, which
is the moment the account is set up and the mail is in, and it ends where the app expects you to
carry on: the Screener, the three boxes, the two piles, snooze, the keyboard, the palette, the
things kept beside the mail, what is quiet by default, and undo.
Skip is the first control on it and Escape is the same answer, so it costs one keystroke to
refuse. The arrows and the return key move through it, the dots at the foot say how far in you are
and can be pressed to jump, and the last slide says where to find all of this again.
It runs for every account that is added rather than only the first. The panel it follows is per
account too: a second mailbox on a shared machine is somebody else's first look at the app, and
the flag that remembers is on the device rather than in the state that roams. Somebody adding
their own second account has seen it before and skips it, which is one key.
The slides state facts and nothing else. No animation beyond a fade, nothing bouncing, no
illustration that is not a diagram of the real thing, and no screenshot: the app is behind the
sheet, so a picture of it would be a picture of what is already there.
## The corner
A small round question mark fixed in the bottom right, and the only permanent chrome the app has
outside the header. It opens three rows: the tour again, the guide, and the keyboard shortcuts
with its key printed.
It gets out of the way rather than floating over everything. It is not drawn while the compose
card is open, because they share that corner and compose is the thing you are doing; not while any
overlay is up, because it would be under the scrim; not in the guide, which is where it goes; and
not on a phone, where the tab bar owns that corner and the palette behind the places button is
what reaches all three.
The same three are in the native Help menu, above Report an Issue, because that is the first place
a Mac user looks and a menu bar item costs nothing.
## The guide
A panel over the whole window, with the app dimmed behind it. It is read about the app rather than
instead of it, so nothing behind it can be pressed while it is up and the close control puts back
exactly what was there: the place, the open thread, the scroll position. Escape is the same answer.
Inside, a search field across the whole width, then a rail of sections down the left and one
article at a time on the right at a reading measure.
Search is the first thing in the panel and it takes the keyboard the moment the guide opens,
because somebody who came here has a question rather than an appetite for a table of contents. It
reads what the articles say and not only what they are called. A query nothing answers is the one
moment the guide has failed, so that is where it offers to open a question against the repository,
labelled `question` and titled with what was typed, and it says out loud that the page it opens is
public.
Articles are written to be looked at before they are read. The answer is the first paragraph, a
drawn figure or a screenshot carries the idea under it, and a verb that would otherwise be a
sentence in a list of six is a table of the key and what it does, generated from the binding table.
The rule is enforced rather than hoped for: `guide.test.ts` fails on a paragraph over seventy five
words, and on an article over a hundred and twenty words with nothing in it to look at.
The figures are drawn from the app's own parts rather than exported from a drawing program, in
`src/screens/guide/Figures.tsx`. A picture is the app photographed and a figure is an idea drawn,
and the ideas are the things no camera can be pointed at: where mail goes, what a pile does, how
long a send is held.
It was a stage first, and that was wrong twice over. A stage wins over a place, so the header sat
above it with three box buttons that changed a place nobody could see; and a library about the app
is not somewhere you go instead of your mail, it is something you hold up in front of it. Making it
a panel found the second half of the same bug: the title bar carries a stacking order of its own so
that a popover can hang off the account chip, and it was above every scrim in the app, which left
the palette, the shortcut sheet and the tour all reachable past their own scrim at the top of the
window. Overlays now sit above it, which is the rule the phone stylesheet had already worked out
for its tab bar.
The sections run in the order somebody meets the problem: getting started, the Screener, reading,
triage, writing, organising, accounts, and then the questions. An article is prose with steps
where there are steps and the key printed where there is a key. The questions are the last section
and they are the ones people actually ask: where a newsletter went, whether a screened-out sender
is told (they are not), why mail from last year is not here, why there is no Empty Trash button,
whether anything reads the mail with AI (nothing does), and what happens to the mailbox if the app
is deleted.
The pictures come from the browser suite rather than from anybody's mailbox. `just guide-shots`
drives the dev fixture and writes ten PNGs into `public/guide/`, at twice the display size for a
retina screen, and they are committed because they ship inside the bundle. The recipe is not part
of `just test-ui`: an ordinary run of the suite must not rewrite files that are in the tree.
## Not here
No coach marks over the interface, no tooltip that follows you around, no "did you know" after the
third launch, no checklist of things to finish, no progress bar over your own mailbox. The tour is
one sheet you can refuse, and after that help is somewhere you go and find rather than something
that arrives while you are reading your mail.
+157
View File
@@ -0,0 +1,157 @@
# Installing Margin Mail
Every build comes from the same release. Pick the file for your machine from
[the latest release](https://github.com/priyanshujain/margin-mail/releases/latest) and follow the
section below for it. The version number in the file names changes with every release; `0.1.0`
stands in for it throughout.
Whatever you install, the app keeps its mirror of your mail, your accounts and every decision
you have ever made about a sender in one directory, and uninstalling never touches it. Where that
directory is, and how to move it, is at the end.
## macOS
Apple Silicon and Intel are the same file: the dmg carries a universal binary.
1. Download `Margin.Mail_0.1.0_universal.dmg`.
2. Open it and drag **Margin Mail** onto the Applications folder in the same window.
3. Eject the disk image and open the app from Applications or Spotlight.
The bundle is signed with a Developer ID and notarized, so it opens on the first double click with
no right-click-and-Open dance and no trip to System Settings.
macOS asks about notifications the first time the app has something to tell you rather than at
launch. If you say no and change your mind, it is under Notifications in System Settings, and the
app's Settings has a button that takes you straight there.
The minimum is macOS 10.15.
## Linux
Four packages, and they are not equal. If you are on Wayland, or on NixOS, use Nix. Otherwise the
deb on Debian and Ubuntu, the flatpak on anything else, and the AppImage when you want no install
at all.
### deb, on Debian and Ubuntu
```
sudo apt install ./Margin.Mail_0.1.0_amd64.deb
```
`apt` rather than `dpkg -i`, because it pulls `libwebkit2gtk-4.1-0` and `libgtk-3-0` in for you.
The app then appears in your launcher. Upgrading is the same command with the newer file, and
`sudo apt remove margin-mail` reverses it.
Built on Ubuntu 22.04, so 22.04 is the oldest release it runs on. Anything older has a glibc the
binary was not linked against and will refuse to start.
### flatpak, on everything else
```
flatpak install ./Margin.Mail_0.1.0_amd64.flatpak
flatpak run studio.margin.mail
```
A single self-contained file: it brings its own GTK and WebKit, so it does not care what your
distribution ships. It is not on Flathub and will not be, because a Flathub build has to come from
source and this app's Google client is embedded at compile time from a file that is deliberately
not in the repository.
The sandbox is narrow on purpose. The app gets the network, the notification service and your
downloads directory, and nothing else. That means saved attachments and exports land in
`~/Downloads` and can go nowhere else, and the app's data lives under
`~/.var/app/studio.margin.mail/` rather than in the usual place, so a flatpak install and a deb
install do not see each other's mail.
`flatpak uninstall studio.margin.mail` removes it, and add `--delete-data` to take the mail with
it.
### Nix
The package relinks the published deb against nixpkgs' own GTK and WebKit, which is the only build
here that runs as a native Wayland client rather than falling back to Xwayland.
```
NIXPKGS_ALLOW_UNFREE=1 nix run --impure github:priyanshujain/margin-mail#margin-mail
```
`NIXPKGS_ALLOW_UNFREE` because the licence is FSL rather than MIT, so nix asks first. To keep it,
add the flake as an input and the overlay to your configuration:
```nix
{
inputs.margin-mail.url = "github:priyanshujain/margin-mail";
# In your nixpkgs configuration:
nixpkgs.overlays = [ inputs.margin-mail.overlays.default ];
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "margin-mail" ];
environment.systemPackages = [ pkgs.margin-mail ];
}
```
A Nix install does not update itself. The store is read only, so the app reports the new version
and tells you to update the flake instead of trying to replace its own binary.
### AppImage, when you want no install
```
chmod +x Margin.Mail_0.1.0_amd64.AppImage
./Margin.Mail_0.1.0_amd64.AppImage
```
Nothing is written outside your home directory and there is nothing to uninstall. It carries
Ubuntu's GTK stack, which cannot talk to a modern Wayland compositor, so on Wayland it runs through
Xwayland and looks slightly soft on a HiDPI screen. That is the trade for a single portable file.
To get it into your launcher rather than running it from a terminal, `just install` from a clone
does the whole thing, or by hand:
```
install -Dm755 Margin.Mail_0.1.0_amd64.AppImage ~/.local/bin/margin-mail
```
and write a `.desktop` file pointing `Exec` at it.
## Windows
Two installers, and either is fine. The `.exe` is the friendlier one; the `.msi` is what you want
if you are deploying by policy.
1. Download `Margin.Mail_0.1.0_x64-setup.exe`.
2. Run it. Windows SmartScreen will say it does not recognise the publisher, because the installer
is not code-signed. Click **More info**, then **Run anyway**.
3. The app appears in the Start menu.
Uninstalling is through Apps in Settings, the same as anything else.
Windows 10 1803 or newer, and WebView2, which every supported Windows already has.
## Updating
Every install except Nix and the flatpak updates itself. The app checks the release feed on launch,
tells you when something newer is out, and installs it when you say so. **Check for Updates** in
the File menu, or the application menu on macOS, asks immediately.
Nix updates with the flake. The flatpak updates with `flatpak update studio.margin.mail` once you
have installed the newer bundle file, since a single-file bundle carries no remote to check.
## Where your mail lives
One directory per platform, and it survives uninstalling:
- macOS: `~/Library/Application Support/studio.margin.mail`
- Linux: `~/.local/share/studio.margin.mail`, or `~/.var/app/studio.margin.mail/data/studio.margin.mail` under flatpak
- Windows: `%APPDATA%\studio.margin.mail`
It holds the mirror of your mail, the state database of every decision you have made, and your
sealed refresh tokens. Copying it to another machine moves everything except the tokens, which are
sealed against the machine that stored them, so the accounts ask to be connected again and nothing
else changes.
To remove the app and its mail together, uninstall and then delete that directory.
## Building it yourself
`just install` from a clone builds for the machine you are sitting at and puts it where that
machine expects to find applications. [release.md](release.md) has what a build needs and how a
release is cut.
+2 -2
View File
@@ -34,8 +34,8 @@ app. Bindings live in a keymap file the user can edit; the defaults are what fol
| `e` | Archive | yes |
| `u` | Toggle seen | yes |
| `Shift+S` | Toggle star | yes |
| `#` | Trash | yes |
| `!` | Spam | yes |
| `#` | Trash, and put back in Trash (toggle) | yes |
| `!` | Spam, and not spam in Spam (toggle) | yes |
| `l` | Reply later (toggle) | no |
| `s` | Set aside (toggle) | no |
| `b` | Snooze… | no |
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

+128
View File
@@ -0,0 +1,128 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<title>Margin Mail: Connect</title>
<link rel="stylesheet" href="mail.css">
<style>
/* The welcome screen is the only place in the app that is not a list, a pane or an overlay, so
these are its own pieces. Every value is a token. */
.welcome {
margin: auto;
padding: 0 24px 56px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.welcome-mark {
margin-bottom: 24px;
}
.welcome-title {
margin: 0;
font-family: var(--font-heading);
font-weight: 500;
font-size: 32px;
letter-spacing: -0.02em;
}
.welcome-line {
margin: 9px 0 28px;
max-width: 30em;
color: var(--ink-soft);
font-size: var(--t-4);
}
/* One field and one button, which is the whole of the first step. The button is larger than a
button anywhere else in the app for the same reason it is the only one here. */
.imap-address {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
width: 100%;
max-width: 24em;
text-align: left;
}
.imap-address .field {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
color: var(--ink-faint);
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.field-input {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--raised);
font: inherit;
font-size: var(--t-4);
color: var(--ink);
}
.imap-address .button {
min-height: 38px;
padding: 0 20px;
border-radius: var(--r-md);
font-size: var(--t-4);
}
.welcome-privacy {
margin: 22px 0 0;
max-width: 42em;
color: var(--ink-faint);
font-size: var(--t-2);
line-height: 1.6;
}
</style>
</head>
<body>
<div class="app">
<header class="titlebar">
<div class="traffic"><i></i><i></i><i></i></div>
<div class="lead"></div>
<div></div>
<div class="trail"></div>
</header>
<div class="stage">
<div class="welcome">
<div class="welcome-mark">
<svg width="54" height="54" viewBox="0 0 54 54" fill="none">
<rect width="54" height="54" rx="13" style="fill: var(--accent)"/>
<rect x="14" y="18.5" width="26" height="17" rx="3.2" style="stroke: var(--accent-contrast)" stroke-width="2"/>
<path d="m14.8 20.5 10.5 7.6a3 3 0 0 0 3.4 0l10.5-7.6" style="stroke: var(--accent-contrast)" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<h1 class="welcome-title">Margin Mail</h1>
<p class="welcome-line">A quiet, keyboard-first client for your mail, where every decision you make stays on your own machine.</p>
<form class="imap-address">
<div class="field">
<label class="field-label" for="address">Email address</label>
<input class="field-input" id="address" type="email" value="[email protected]">
</div>
<button class="button" data-variant="primary" type="button">Continue</button>
</form>
<p class="welcome-privacy">Any mailbox works here: Google, Fastmail, iCloud, Yahoo, Proton through Bridge, a mailbox where you work, or anything else that speaks IMAP. Margin works out the servers from the address, and nothing is stored until the account is added.</p>
</div>
</div>
</div>
</body>
</html>
+294
View File
@@ -0,0 +1,294 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<title>Margin Mail: First run</title>
<link rel="stylesheet" href="mail.css">
<style>
/* The first-run panel. Everything else on this page is the Inbox as it already is. */
.onboard-lead {
margin: 0;
font-size: var(--t-4);
line-height: 1.5;
}
.onboard-sub {
margin: 0;
color: var(--ink-soft);
line-height: 1.5;
}
.field .onboard-sub {
margin: -1px 0 3px;
}
.choice {
display: flex;
align-items: center;
gap: 6px;
}
.choice-label {
margin-right: 2px;
color: var(--ink-faint);
font-size: var(--t-2);
}
.choice-option {
padding: 4px 11px;
border: 1px solid var(--line-strong);
border-radius: var(--r-pill);
background: var(--raised);
color: var(--ink-soft);
font-size: var(--t-2);
transition: background 120ms var(--ease);
}
.choice-option[data-on] {
border-color: var(--accent);
background: var(--accent);
color: var(--accent-contrast);
}
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute">
<defs>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></symbol>
<symbol id="i-places" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></symbol>
<symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></symbol>
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-reply" viewBox="0 0 24 24"><path d="M9 17 4 12l5-5"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></symbol>
<symbol id="i-later" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
<symbol id="i-aside" viewBox="0 0 24 24"><path d="M12 17v4"/><path d="M8 3h8l-1 7 3 3H6l3-3z"/></symbol>
<symbol id="i-snooze" viewBox="0 0 24 24"><path d="M5 3 2 6"/><path d="m22 6-3-3"/><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/></symbol>
<symbol id="i-archive" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="4" rx="1"/><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8"/><path d="M10 12h4"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="19" cy="12" r="1.2"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 3 4 6v6c0 5 3.5 8 8 9 4.5-1 8-4 8-9V6z"/><path d="m9 12 2 2 4-4"/></symbol>
<symbol id="i-clip" viewBox="0 0 24 24"><path d="m21 11-8.5 8.5a5 5 0 0 1-7-7L14 4a3.3 3.3 0 0 1 4.7 4.7L10.5 17a1.7 1.7 0 0 1-2.4-2.4L16 6.7"/></symbol>
<symbol id="i-note" viewBox="0 0 24 24"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></symbol>
<symbol id="i-bolt" viewBox="0 0 24 24"><path d="M13 2 4 14h7l-1 8 9-12h-7z"/></symbol>
</defs>
</svg>
<div class="app">
<header class="titlebar">
<div class="traffic"><i></i><i></i><i></i></div>
<div class="lead">
<button class="account-chip">
<span class="avatar" data-hue="4">PJ</span>
[email protected]
<svg class="chev" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-chev"/></svg>
</button>
</div>
<div class="view-switch">
<button class="view-option" data-active="true"><span class="key">1</span>Inbox</button>
<button class="view-option"><span class="key">2</span>Feed</button>
<button class="view-option"><span class="key">3</span>Paper Trail</button>
</div>
<div class="trail">
<button class="icon-button" title="Search /"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><use href="#i-search"/></svg></button>
<button class="icon-button" title="Places ⌘K"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><use href="#i-places"/></svg></button>
<button class="write-button"><svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-pen"/></svg>Write<span class="key">c</span></button>
</div>
</header>
<div class="stage">
<section class="list-col">
<div class="list-head">
<h1 class="list-title">Inbox</h1>
</div>
<div class="list">
<div class="section">New for you</div>
<div class="row" data-new>
<div class="row-gutter"><span class="row-dot"></span></div>
<span class="avatar" data-hue="2">MR</span>
<div class="row-main">
<div class="row-top"><span class="row-sender">Maya Raghunathan</span><span class="row-count">3</span><span class="row-time">11:42</span></div>
<div class="row-bottom"><span class="row-subject">Dinner on Thursday?</span><span class="row-snippet">Priya said the place on Church Street takes bookings now, want me to</span></div>
</div>
</div>
<div class="row" data-new data-selected>
<div class="row-gutter"><span class="row-dot"></span></div>
<span class="avatar" data-hue="6">AK</span>
<div class="row-main">
<div class="row-top"><span class="row-sender">Arun Kulkarni</span><span class="row-time">10:15</span></div>
<div class="row-bottom"><span class="row-subject">Re: Lease renewal for the studio</span><span class="row-snippet">Attached the revised draft. The only change is clause 7, which now</span></div>
</div>
</div>
<div class="row" data-new>
<div class="row-gutter"><span class="row-dot"></span></div>
<span class="avatar" data-brand>Ai</span>
<div class="row-main">
<div class="row-top"><span class="row-sender">Airbnb</span><span class="row-time">09:03</span></div>
<div class="row-bottom"><span class="row-subject">Your reservation in Lisbon is confirmed</span><span class="row-snippet">Check-in Friday 12 September after 15:00. Your host Inês will send</span></div>
</div>
</div>
<div class="row" data-new>
<div class="row-gutter"><span class="row-dot"></span></div>
<span class="avatar" data-hue="7">SO</span>
<div class="row-main">
<div class="row-top"><span class="row-sender">Sam Okafor</span><span class="row-time">Yesterday</span></div>
<div class="row-bottom"><span class="row-subject">Piano lessons, the form you sent</span><span class="row-snippet">Got it, thank you. Wednesdays at five work for us. Is there a</span></div>
</div>
</div>
<div class="seen">
<div class="section">Previously seen</div>
<div class="row">
<div class="row-gutter"></div>
<span class="avatar" data-hue="5">LB</span>
<div class="row-main">
<div class="row-top"><span class="row-sender">Lena Brandt</span><span class="row-count">7</span><span class="row-time">Yesterday</span></div>
<div class="row-bottom"><span class="row-subject">Kitchen bench quote</span><span class="row-snippet">Sounds good, Julie. Any afternoon next week works for me.</span></div>
</div>
</div>
<div class="row">
<div class="row-gutter"></div>
<span class="avatar" data-hue="3">DP</span>
<div class="row-main">
<div class="row-top"><span class="row-sender">Dev Patel</span><span class="row-time">Mon</span></div>
<div class="row-bottom"><span class="row-subject">Slides from the talk</span><span class="row-snippet">Here they are, plus the reading list I mentioned. The paper on</span></div>
</div>
</div>
<div class="row">
<div class="row-gutter"></div>
<span class="avatar" data-brand>Ds</span>
<div class="row-main">
<div class="row-top"><span class="row-sender">DocuSign</span><span class="row-time">Mon</span></div>
<div class="row-bottom"><span class="row-subject">Completed: Studio lease 2026</span><span class="row-snippet">All parties have completed the envelope. You can access the</span></div>
</div>
</div>
<div class="row">
<div class="row-gutter"></div>
<span class="avatar" data-hue="8">HW</span>
<div class="row-main">
<div class="row-top"><span class="row-sender">Hannah Weiss</span><span class="row-count">2</span><span class="row-time">Sun</span></div>
<div class="row-bottom"><span class="row-subject">Photos from the Hawaii trip</span><span class="row-snippet">Finally went through them all. The ones from the ridge walk are</span></div>
</div>
</div>
<div class="row">
<div class="row-gutter"></div>
<span class="avatar" data-hue="1">RY</span>
<div class="row-main">
<div class="row-top"><span class="row-sender">Russell Young</span><span class="row-time">30 Aug</span></div>
<div class="row-bottom"><span class="row-subject">Pumpkin bread recipe</span><span class="row-snippet">From my mother's card, transcribed as best I could. Bake at 175</span></div>
</div>
</div>
</div>
</div>
<div class="piles">
<button class="pile" data-empty>
<div class="pile-card" data-depth="0">
<div class="pile-top"><svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-later"/></svg>Reply later<span class="key">4</span></div>
<div class="pile-subject">Nothing yet</div>
</div>
</button>
<button class="pile" data-empty>
<div class="pile-card" data-depth="0">
<div class="pile-top"><svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-aside"/></svg>Set aside<span class="key">5</span></div>
<div class="pile-subject">Nothing yet</div>
</div>
</button>
</div>
</section>
<section class="pane">
<div class="pane-bar">
<button class="verb"><svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><use href="#i-reply"/></svg>Reply<span class="key">r</span></button>
<button class="verb"><svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><use href="#i-later"/></svg>Reply later<span class="key">l</span></button>
<button class="verb"><svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><use href="#i-aside"/></svg>Set aside<span class="key">s</span></button>
<button class="verb"><svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><use href="#i-snooze"/></svg>Snooze<span class="key">b</span></button>
<span class="spacer"></span>
<button class="verb"><svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><use href="#i-archive"/></svg>Archive<span class="key">e</span></button>
<button class="icon-button" title="More ."><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><use href="#i-more"/></svg></button>
</div>
<div class="thread">
<div class="thread-inner">
<div class="thread-head">
<h1 class="thread-subject">Lease renewal for the studio</h1>
<div class="thread-meta">
<span class="avatars"><span class="avatar" data-hue="6">AK</span><span class="avatar" data-hue="4">PJ</span></span>
<span>Arun Kulkarni and you</span>
<span>·</span>
<span>2 messages</span>
</div>
</div>
<div class="banner">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><use href="#i-shield"/></svg>
<span>Blocked <b>1 tracker</b> from HubSpot. Remote images are off for this sender.</span>
<button class="banner-action">Show images</button>
</div>
<div class="msg" data-collapsed>
<div class="msg-head">
<span class="avatar" data-hue="4">PJ</span>
<div class="msg-who"><div class="msg-name">You</div><div class="msg-preview">Hi Arun, thanks for sending the renewal over. Two things before I sign: the notice period in clause 7 and</div></div>
<div class="msg-time">Mon 14:20</div>
</div>
</div>
<div class="msg">
<div class="msg-head">
<span class="avatar" data-hue="6">AK</span>
<div class="msg-who"><div class="msg-name">Arun Kulkarni<span class="addr">[email protected]</span></div><div class="msg-to">to you</div></div>
<div class="msg-time">Today 10:15</div>
</div>
<div class="msg-body">
<p>Hi Priyanshu,</p>
<p>Attached the revised draft. The only change is clause 7, which now reads three months either side rather than six. The rent review in clause 12 stays as it was, tied to the index and capped at four percent.</p>
<p>If that works, sign when you get a moment and I will countersign the same day. Happy to walk through anything on a call, Thursday afternoon is open.</p>
<p>Best,<br>Arun</p>
<span class="quoted">··· Show quoted text</span>
<div class="attachments">
<div class="attachment"><span class="ext">PDF</span>Studio-lease-2026-v2.pdf<span class="size">412 KB</span></div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<div class="overlay">
<div class="panel">
<div class="panel-head">
<h2>You are set up</h2>
</div>
<div class="panel-body">
<p class="onboard-lead"><b>1,284 senders</b> were screened in already, because you have written to them or they are in your contacts: 96 to the Inbox, 71 to the Feed and 1,117 to the Paper Trail.</p>
<p class="onboard-sub">From here on, anyone new waits in the Screener until you say where their mail goes. Nobody is told either way.</p>
<div class="field">
<span class="field-label">Start fresh</span>
<p class="onboard-sub">Optional. Mark older mail as seen, so New for you holds only what is recent. Reversible for seven days.</p>
<div class="choice">
<span class="choice-label">Older than</span>
<button class="choice-option">a day</button>
<button class="choice-option" data-on>a week</button>
<button class="choice-option">a month</button>
<button class="choice-option">three months</button>
</div>
</div>
</div>
<div class="panel-foot">
<button class="button">Start fresh</button>
<button class="button" data-variant="primary">Done</button>
</div>
</div>
</div>
</div>
</body>
</html>
+1 -1
View File
@@ -105,7 +105,7 @@
</div>
</div>
<p class="screen-note">Screened-out mail is kept for 90 days under Screened out, then deleted. Change your mind from a sender's contact card at any time.</p>
<p class="screen-note">Screened-out mail sits under Screened out for as long as this account keeps mail on the device. Change your mind from a sender's contact card at any time.</p>
</div>
</div>
</section>
+371
View File
@@ -0,0 +1,371 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<title>Margin Mail: Settings</title>
<link rel="stylesheet" href="mail.css">
<style>
/* Settings is a place, so it takes the stage: a rail of sections on the left, one section on the
right. These are its own pieces; every value is a token. */
.settings {
flex: 1;
min-width: 0;
display: flex;
}
.settings-rail {
flex: none;
width: 210px;
display: flex;
flex-direction: column;
gap: 1px;
padding: 12px 10px;
border-right: 1px solid var(--line);
background: var(--sidebar);
}
.settings-rail h1 {
margin: 4px 8px 10px;
font-family: var(--font-heading);
font-weight: 500;
font-size: 17px;
letter-spacing: -0.01em;
}
.settings-tab {
padding: 7px 10px;
border-radius: var(--r-sm);
color: var(--ink-soft);
font-size: var(--t-3);
text-align: left;
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.settings-tab:hover {
background: var(--accent-wash);
}
.settings-tab[data-active] {
background: var(--accent-wash);
color: var(--ink);
font-weight: 600;
}
.settings-panel {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 24px 36px 40px;
}
.settings-inner {
max-width: 640px;
}
.settings-title {
margin: 0;
font-family: var(--font-heading);
font-weight: 500;
font-size: 22px;
letter-spacing: -0.01em;
}
.settings-note {
margin: 6px 0 20px;
color: var(--ink-soft);
}
.acct {
margin-bottom: 12px;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--raised);
}
.acct-head {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: 12px;
align-items: center;
}
.acct-head .avatar {
width: 36px;
height: 36px;
font-size: 12px;
}
.acct-name {
font-weight: 600;
font-size: var(--t-4);
}
.acct-addr {
color: var(--ink-faint);
font-size: var(--t-2);
}
.swatches {
display: flex;
gap: 6px;
}
.swatch {
width: 15px;
height: 15px;
border-radius: var(--r-pill);
}
.swatch[data-hue="1"] { background: var(--hue-1); }
.swatch[data-hue="2"] { background: var(--hue-2); }
.swatch[data-hue="3"] { background: var(--hue-3); }
.swatch[data-hue="4"] { background: var(--hue-4); }
.swatch[data-hue="5"] { background: var(--hue-5); }
.swatch[data-hue="6"] { background: var(--hue-6); }
.swatch[data-hue="7"] { background: var(--hue-7); }
.swatch[data-hue="8"] { background: var(--hue-8); }
.swatch[data-on] {
box-shadow: 0 0 0 2px var(--raised), 0 0 0 3px var(--ink-soft);
}
.set-rows {
display: flex;
flex-direction: column;
gap: 7px;
margin-top: 12px;
padding-top: 11px;
border-top: 1px solid var(--line);
}
.set-row {
display: flex;
align-items: center;
gap: 10px;
font-size: var(--t-2);
}
.set-row .lab {
flex: none;
width: 84px;
color: var(--ink-faint);
}
.set-row .val {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scopes {
margin-top: 12px;
padding-top: 6px;
border-top: 1px solid var(--line);
}
.scopes .section {
padding: 4px 0 4px;
}
.scope {
display: flex;
align-items: center;
gap: 9px;
min-height: 26px;
font-size: var(--t-2);
}
.scope .tick {
flex: none;
color: var(--ink-faint);
}
.scope .what {
flex: 1;
min-width: 0;
}
.scope .state {
color: var(--ink-faint);
}
.scope[data-missing] .what {
color: var(--ink);
}
.scope-why {
margin: 1px 0 5px 25px;
color: var(--ink-faint);
font-size: var(--t-2);
}
.acct-foot {
display: flex;
gap: 8px;
margin-top: 12px;
}
.settings-quiet {
margin: 18px 0 0;
color: var(--ink-faint);
font-size: var(--t-2);
}
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute">
<defs>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></symbol>
<symbol id="i-places" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></symbol>
<symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></symbol>
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 5 5L19 7"/></symbol>
<symbol id="i-minus" viewBox="0 0 24 24"><path d="M6 12h12"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 6v12M6 12h12"/></symbol>
</defs>
</svg>
<div class="app">
<header class="titlebar">
<div class="traffic"><i></i><i></i><i></i></div>
<div class="lead">
<button class="account-chip">
<span class="avatar" data-hue="4">PJ</span>
[email protected]
<svg class="chev" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-chev"/></svg>
</button>
</div>
<div class="view-switch">
<button class="view-option"><span class="key">1</span>Inbox</button>
<button class="view-option"><span class="key">2</span>Feed</button>
<button class="view-option"><span class="key">3</span>Paper Trail</button>
</div>
<div class="trail">
<button class="icon-button" title="Search /"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><use href="#i-search"/></svg></button>
<button class="icon-button" title="Places ⌘K"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><use href="#i-places"/></svg></button>
<button class="write-button"><svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-pen"/></svg>Write<span class="key">c</span></button>
</div>
</header>
<div class="stage">
<section class="settings">
<nav class="settings-rail">
<h1>Settings</h1>
<button class="settings-tab" data-active>Accounts</button>
<button class="settings-tab">Appearance</button>
<button class="settings-tab">Mail</button>
<button class="settings-tab">Privacy</button>
<button class="settings-tab">Screener</button>
<button class="settings-tab">Piles and snooze</button>
<button class="settings-tab">Writing</button>
<button class="settings-tab">Notifications</button>
<button class="settings-tab">Keyboard</button>
<button class="settings-tab">Backup</button>
<button class="settings-tab">Data</button>
<button class="settings-tab">About</button>
</nav>
<div class="settings-panel">
<div class="settings-inner">
<h2 class="settings-title">Accounts</h2>
<p class="settings-note">Two accounts, each with its own places, rules and piles. The colour is the edge on a row in All accounts.</p>
<div class="acct">
<div class="acct-head">
<span class="avatar" data-hue="4">PJ</span>
<div>
<div class="acct-name">Priyanshu Jain</div>
<div class="acct-addr">[email protected]</div>
</div>
<div class="swatches">
<span class="swatch" data-hue="1"></span>
<span class="swatch" data-hue="2"></span>
<span class="swatch" data-hue="3"></span>
<span class="swatch" data-hue="4" data-on></span>
<span class="swatch" data-hue="5"></span>
<span class="swatch" data-hue="6"></span>
<span class="swatch" data-hue="7"></span>
<span class="swatch" data-hue="8"></span>
</div>
</div>
<div class="set-rows">
<div class="set-row"><span class="lab">Signature</span><span class="val">Priyanshu Jain · 73ai</span></div>
<div class="set-row"><span class="lab">Aliases</span><span class="val">[email protected], [email protected]</span></div>
</div>
<div class="scopes">
<div class="section">Permissions</div>
<div class="scope">
<svg class="tick" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-check"/></svg>
<span class="what">Read and change your mail</span><span class="state">Granted</span>
</div>
<div class="scope">
<svg class="tick" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-check"/></svg>
<span class="what">Read your signature and aliases</span><span class="state">Granted</span>
</div>
<div class="scope">
<svg class="tick" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-check"/></svg>
<span class="what">Read your contacts</span><span class="state">Granted</span>
</div>
<div class="scope">
<svg class="tick" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-check"/></svg>
<span class="what">Read the people you have written to</span><span class="state">Granted</span>
</div>
<div class="scope">
<svg class="tick" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-check"/></svg>
<span class="what">Keep a backup in your Drive</span><span class="state">Granted</span>
</div>
<div class="scope" data-missing>
<svg class="tick" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-minus"/></svg>
<span class="what">Answer calendar invitations</span>
<button class="button">Grant</button>
</div>
<p class="scope-why">Calendar is not connected, so an invitation renders but Accept, Maybe and Decline do nothing. Granting reopens the Google consent page.</p>
</div>
</div>
<div class="acct">
<div class="acct-head">
<span class="avatar" data-hue="2">PS</span>
<div>
<div class="acct-name">Personal</div>
<div class="acct-addr">[email protected]</div>
</div>
<div class="swatches">
<span class="swatch" data-hue="1"></span>
<span class="swatch" data-hue="2" data-on></span>
<span class="swatch" data-hue="3"></span>
<span class="swatch" data-hue="4"></span>
<span class="swatch" data-hue="5"></span>
<span class="swatch" data-hue="6"></span>
<span class="swatch" data-hue="7"></span>
<span class="swatch" data-hue="8"></span>
</div>
</div>
<div class="set-rows">
<div class="set-row"><span class="lab">Signature</span><span class="val">Not set</span></div>
<div class="set-row"><span class="lab">Aliases</span><span class="val">None on the provider</span></div>
<div class="set-row"><span class="lab">Permissions</span><span class="val">All granted</span></div>
</div>
</div>
<div class="acct-foot">
<button class="button"><svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-plus"/></svg>Add account</button>
<button class="button" data-variant="ghost">Remove an account</button>
</div>
<p class="settings-quiet">Margin Mail, Margin and Margin Calendar share one Google client, so your Google account lists them once, as Margin, and revoking it revokes all three.</p>
<div class="acct-foot"><button class="button">Bring your own OAuth client</button></div>
</div>
</div>
</section>
</div>
</div>
</body>
</html>
+193
View File
@@ -0,0 +1,193 @@
# Plan
How the app gets built, in the order it gets built, and what it is built out of. The product is
specified in [design.md](design.md) and [features.md](features.md); this document is about the
work rather than the result, and it exists so that somebody picking the repository up in six
months can see why the pieces landed in the order they did.
Work is cut into milestones, and a milestone into work packages. A package is a unit somebody can
finish: it owns a set of files, it ends with something that runs, and it never edits another
package's files. The package names below are the ones the source comments already use, so a
placeholder that says "the contract lands in F3" means the work package named here.
## The milestones
**M0, foundations.** F1 is the scaffold: the Tauri crate, the Vite front end, the icons, the
justfile and CI. F2 is the design system, which is `src/styles/tokens.css` and the primitives that
sit on it, reviewed through the Kit page. F3 is the contracts: `src-tauri/src/dto.rs` and its
mirror `src/ipc.ts`, frozen before anything implements them. F4 is the documentation, this file
among it. None of M0 sends a byte to Google, and that is the point: the shape is settled while it
is still cheap to change.
**M1, read.** The milestone that proves the two hard things. R1 is authentication and accounts:
the loopback flow, the sealed token, the granted scopes. R2 is the Gmail client behind the
`Provider` trait, with the quota arithmetic and the backoff in it. R3 is the mirror and the sync
loop, including the storage window and eviction. R4 is the message pipeline: the MIME parse, the
sanitiser, the tracker stripper. R5 is the shell, which is the header, the list column, the
reading pane and the keyboard. R6 is connect and onboarding, the first thing a new user meets and
the last thing built in this milestone, because it cannot be designed honestly until the sync it
narrates exists. At the end of M1 the app reads mail and does nothing else.
**M2, triage.** T1 is flags and undo: seen, star, archive, trash, spam, each optimistic and each
reversible. T2 is selection and bulk actions. T3 is search, local over the window with the
provider as the second pass. T4 is labels. At the end of M2 it is a fast Gmail client and nothing
more, which is worth having in the hands of one user for a week before the next milestone changes
what it is.
**M3, places.** P1 is the state database and its journal, the schema that everything after this
depends on. P2 is routing: sender rules, the suggestion function, the overrides. P3 is the
Screener and the first-run pass. P4 is the Feed and the Paper Trail. P5 is contacts, the contact
card and autocomplete. This is the milestone where it stops being a Gmail client.
**M4, piles.** L1 is Reply later, Set aside and Focus & Reply. L2 is snooze with lazy evaluation.
L3 is notes, rename and merge. L4 is the rest of what the state database makes possible: clips,
All files, ignore, per-thread notifications and unsubscribe.
**M5, writing.** W1 is the editor and drafts. W2 is the send pipeline: the outbox, the undo delay,
attachments, threading headers. W3 is instant intro, remind me if no reply, and calendar invites
including the re-authorization that RSVP needs.
**M6, accounts, settings and backup.** A1 is more than one account and the unified view. A2 is the
settings screen, specified in [settings.md](settings.md). A3 is the backup store, the encryption
and the recovery phrase, with the journal merge behind it.
**M7, ship.** S1 is the release pipeline: signing, notarisation, the updater. S2 is the
verification materials Google's restricted scope review wants, which is a privacy policy that is
true, a demo video, and a written justification for each scope. Then the platforms in order: S3
the iPhone, S4 Linux, S5 the IMAP provider. They are last because each one is a second copy of a
problem already solved once, and solving it twice before it is solved once is how a project stalls.
## What comes from the siblings
Very little here is new, and that is deliberate. Margin Mail sits beside margin and Margin
Calendar on disk and takes from both.
From the calendar: the OAuth loopback flow with PKCE and its Google-specific handling, the sealed
token store, the deep-link path that mobile needs instead of a loopback listener, the overlay
title bar and the macOS window behaviour, the `data-phone` and `data-touch` scheme with the boot
script that sets them before first paint, the escape-layer stack, the palette, the zustand store
idiom, the release workflow's shape, and the `build.rs` trick that embeds
`google-credentials.json` with the example file as a fallback.
From margin: the HTTP half of `gdrive.rs`, which is folder lookup, upload and download against
Drive's v3 API; the trick of putting heavy synchronous work behind `#[tauri::command(async)]`; and
`margin-shared`, which is a real dependency rather than a copy. The tokens, the icon strings and
the font catalogue come from that package through a relative path in `package.json`, which is why
CI checks out both repositories side by side.
From neither: the mirror, the state database, the journal, the sanitiser and everything to do with
mail. Those are this repository's own work.
## The libraries
Every version below was checked against crates.io and npm on 3 September 2026.
On the Rust side, `mail-parser` 0.11 with `full_encoding` parses bodies from the raw RFC 2822
bytes; the feature is not optional, because the charsets it adds are the ones that still turn up
in real mail every day. `mail-builder` 0.5 builds outgoing MIME. `css-inline` 0.21 folds the
editor's stylesheet into the markup before the message is built, so a client that drops `<style>`
still renders what was written. `ammonia` 4.1 is the sanitiser, and two of its hooks do the work
that matters: `attribute_filter` rewrites `img src`, which is where tracker stripping and `cid:`
substitution happen, and `filter_style_properties` narrows inline CSS to an allowlist of
properties that can never take a `url()`, which closes the last route a message has to fetch
something.
`rusqlite` 0.40 with `bundled`, so FTS5 is compiled in rather than depending on what the
platform's libsqlite3 happened to be built with, and so several accounts sharing one process share
one predictable SQLite. `reqwest` 0.13 with `gzip`, `json` and `http2`: Gmail's JSON compresses by
an order of magnitude and hydration is thousands of responses, and a batch of fifty shares a
connection with the poll loop. Note that its TLS feature is now called `rustls` rather than
`rustls-tls`; the old name is a build error, not a warning. `chacha20poly1305` 0.11 seals tokens
and backup segments, `argon2` 0.6 derives the backup key from the recovery phrase slowly enough to
matter, `bip39` 2.2 produces the phrase, and `chrono` and `fontdb` do the obvious.
Two deliberate omissions. There is no `oauth2` crate: the calendar's hand-rolled flow is already
tested against Google's actual behaviour, including the parts that do not match the spec, and
replacing working code with a dependency that has to be taught the same lessons is not a trade.
There is no `tokio-rusqlite`: it pins an older rusqlite than the one above, and the synchronous
command trick makes it unnecessary anyway.
On the front end, `@tiptap/react` 3.31 with StarterKit is the editor, as in margin. `react-virtuoso`
4.18 renders the grouped list, because a mailbox list is long, its rows are two heights, and it has
group headers, which is exactly the case hand-rolled virtualisation gets wrong. Beyond those,
React, zustand and the Tauri API, and nothing else.
One consequence worth writing down. Message bodies render in an iframe with `srcdoc`, which
inherits the app's content security policy rather than escaping it, so the frame cannot fetch
anything: inline `cid:` images are rewritten to `data:` URIs by the sanitiser, and a remote image
the user has chosen to allow is fetched by Rust, without cookies or referrer, and handed to the
frame the same way. Every byte a message displays has been through Rust first.
The sandbox attribute is `allow-same-origin` rather than empty, and the reason is worth keeping.
An empty sandbox gives the frame an opaque origin, and a document the parent cannot reach is a
document the parent cannot measure: there is then no way to size the frame to its content, so every
message carries its own scrollbar, and no way to catch a click on a link, so nothing opens. Keeping
`allow-same-origin` leaves every other restriction in place, forms and top navigation included, and
scripts are still blocked three times over: the sandbox disables them without `allow-scripts`, the
content security policy is `script-src 'self'` which stops inline scripts and inline event handlers
whatever the origin, and the sanitiser removed them before either got a say.
## The shape of the repository
The front end is `src`. Screens live in `src/screens`, the primitives they are built from in
`src/ui`, one zustand store per domain in `src/store`, the typed IPC wrappers in `src/api`, and
the stylesheets in `src/styles`, where `tokens.css` is the only file allowed to hold a raw colour.
`src/ipc.ts` is the frontend half of the contract and `src/screens/Kit.tsx` is the page that
renders every primitive in every state, which is how a restyle gets reviewed.
The backend is `src-tauri/src`. `dto.rs` is the other half of the contract and is frozen once M0
ends. `lib.rs` holds the app setup, the menu and `emit_store_changed`. The Google client, the
mirror, the state database and the sync loop each get a module, and the Gmail specifics stay
inside the Gmail one so that the second provider has somewhere to be.
Documentation is `docs`, with the mockups under `docs/mockups`: HTML sources in `src` beside a
shared `mail.css`, rendered to PNGs by `docs/mockups/render.sh`, which pulls the fonts from the
margin repository and Chromium from the calendar's `node_modules` so nothing binary is vendored
here. The prose gate is `scripts/docs-check.mjs`. Local builds and installs are the `justfile`;
CI and releases are the two workflows in `.github/workflows`.
## How the work is verified
Four gates, all of them runnable on a laptop before anything is handed over.
`just test` runs the Vitest suites and `cargo test`. Both must be green; there are no retries
anywhere in this repository, because a test that passes on the second attempt is lying about
something.
`just test-ui` runs Playwright against the real UI, with `src/ipc.ts` routed to the dev fixture so
no browser ever talks to Google. The viewport is pinned to 1440 by 900, the same size the mockups
were rendered at, because half of what the suite asserts is geometry: the 420 px list column, the
row height, whether the piles are on screen. It also writes screenshots as it goes, starting with
the Kit page in both palettes under `screenshots/`, so a visual regression shows up as a diff
rather than as a complaint two weeks later, and it greps the stylesheets for a hex literal outside
the token layer, which is the one rule a reviewer will not reliably catch by eye.
`just docs` is the prose gate: no em dashes, no directory trees, no relative link that goes
nowhere.
`pnpm build` is `tsc` then Vite, so the typecheck and the bundle are one step, and
`pnpm fonts:check` catches the vendored font copy under `public/fonts` drifting from the
`margin-shared` package. CI runs all of it, checking out this repository and margin side by side
because the shared package is reached by a relative path.
Beyond the gates, the mockups in [ui.md](ui.md) are the target rather than an impression of it.
A screen is finished when it looks like its render, not when it looks reasonable.
## What was learned from reading Mailspring
Mailspring is GPL-3.0 and was read, not copied. Five things in it are worth having and are in this
design because of it. Bodies live in their own table with a `fetched_at` column, prefetched inside
a window and evicted outside it, which is where the storage window in
[architecture.md](architecture.md) comes from. The engine hands the UI a typed delta stream rather
than telling it to refetch, which here is the `store-changed` event with a scope in it, so a note
landing does not make the list reload its bodies. The provider's thread id and the `References`
threading are kept as separate columns rather than collapsed into a hash of the headers, because
they disagree often enough to matter. An account that fails to sync repeatedly is paused by a
circuit breaker instead of being retried into a rate limit. And SQLite gets a busy timeout,
because several accounts share one process and one connection.
The list of what to avoid is just as useful. Nothing may depend on the app running at a particular
wall-clock time, which is why snooze is evaluated lazily. No metadata is held in a cloud service.
No tracking pixels, no rewritten links, and no contact lookup that sends a correspondent's address
to a server to find out who they are. And no header that never expires: everything cached has a
rule for when it goes.
+148
View File
@@ -0,0 +1,148 @@
# Releasing
## Installing locally
`just install` builds the app for whatever machine you are sitting at and puts it where that
machine expects to find applications: `/Applications` on macOS, or the package manager on Linux.
It is the same command whether or not the app is already installed, so it doubles as the update.
On macOS it asks a running copy to quit first, because replacing a bundle under a live process
leaves it half old and half new, and starts the new one once it is in place, so the copy on screen
is never older than the copy installed. `just uninstall` reverses it and leaves the data directory
alone, which matters more here than it does in the siblings: that directory holds the state
database, and the state database is every decision you have ever made about a sender.
The local build skips the dmg and builds only the `.app`, since nothing about copying a bundle into
place needs a disk image and building one is the slowest part of a mac bundle. A locally installed
app has no updater artifacts, so it will not update itself. Rerun `just install`.
## Signing
macOS shows no notifications from an app whose bundle is not signed, and `tauri build` on its own
leaves only the linker's signature on the binary, which does not count: the app never appears under
Notifications in System Settings and is never asked. So `tauri.conf.json` names `-` as the signing
identity and every macOS build is at least ad-hoc signed as a bundle, which is enough for
notifications. A real identity replaces that wherever one is available. `just build` sources
`~/.margin-signing/studio.margin.app.env` when it exists (another directory with
`MARGIN_SIGNING_DIR`), which exports `APPLE_SIGNING_IDENTITY`, and Tauri takes that over the config.
The release workflow does the same from repository secrets and notarizes when the App Store Connect
key is there too:
- `APPLE_CERTIFICATE` and `APPLE_CERTIFICATE_PASSWORD`, the Developer ID Application certificate as
a base64 `.p12` and its password. Tauri imports it into a temporary keychain for the build.
- `APPLE_SIGNING_IDENTITY` and `APPLE_TEAM_ID`, the identity's name and the team behind it.
- `APPLE_API_KEY`, `APPLE_API_ISSUER` and `APPLE_API_KEY_P8`, the App Store Connect API key id, its
issuer and the contents of the `.p8`. Without these three the bundle is signed and not notarized,
which Gatekeeper minds on a download and notifications do not.
From the signing directory that is:
```
cd ~/.margin-signing
gh secret set APPLE_CERTIFICATE < <(base64 -i developer-id.p12)
gh secret set APPLE_CERTIFICATE_PASSWORD < developer-id.p12.pass
gh secret set APPLE_SIGNING_IDENTITY --body "Developer ID Application: <name> (<team>)"
gh secret set APPLE_TEAM_ID --body "<team>"
gh secret set APPLE_API_KEY --body "<key id>"
gh secret set APPLE_API_ISSUER --body "<issuer>"
gh secret set APPLE_API_KEY_P8 < AuthKey.p8
```
## Before the first release
Two things have to exist that do not yet.
**The updater key.** Done. The pair was generated with
`pnpm tauri signer generate -w ~/.tauri/margin_mail_updater.key`, the public half is in
`src-tauri/tauri.release.conf.json` and the private half and its password are the repository's
`TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` secrets. The private half and
its password sit beside the siblings' in `~/.tauri`, and only there. The public half is baked into
every build, so the private half can never be rotated without stranding everyone who has not
updated yet: back it up somewhere that is not the machine that made it.
**The verification.** `gmail.modify` and `gmail.settings.basic` are restricted scopes, so the shared
Cloud project has to pass restricted scope verification before this app is anything other than a
hundred lifetime users behind an unverified consent screen. That is a form, a privacy policy that is
true, a demo video and a written justification per scope, and it is per project rather than per
client, so it covers margin and Margin Calendar too and a lapse blocks all three. The hedge is in
[architecture.md](architecture.md): Gmail over IMAP and SMTP with an app password needs no project,
no verification and has no cap.
## Cutting a release
Releases are manual: run the **Release** workflow from the Actions tab. Leave the version empty to
bump the patch number, or give one to set it. The workflow bumps `tauri.conf.json`, `package.json`
and `Cargo.toml` together, commits that to main, tags it, and builds the tag rather than whatever
main happens to be by then.
Three runners build in parallel: a universal macOS bundle, an x86_64 Linux one and an x86_64
Windows one. Nothing is published until all three have landed. The publish job downloads
`latest.json` and refuses to take the release out of draft unless `darwin-aarch64`,
`darwin-x86_64`, `linux-x86_64` and `windows-x86_64` are all in it. A half-populated manifest is
worse than no release: the updater would offer an update to the platforms that made it and error on
the ones that did not.
Linux builds on Ubuntu 22.04 on purpose. The bundle will not run on anything older than the glibc it
was linked against, so it is built on the oldest release that is supported.
The Windows installers are not code-signed, so SmartScreen warns on the first download until the
app has built up reputation. A certificate would go in as `WINDOWS_CERTIFICATE` and
`WINDOWS_CERTIFICATE_PASSWORD` and needs nothing else changed.
Phones do not come from this pipeline at all. The store is their update channel.
## The two Linux packages Tauri does not build
Tauri produces the deb and the AppImage. The flatpak and the Nix package are built from the deb
afterwards, by two more jobs, and [install.md](install.md) says which of the four a reader should
actually pick.
The **flatpak** job runs between the build and the publish, so a release never goes out with the
Linux artifacts half there. It downloads the deb from the draft release, runs
`flatpak/build.sh` over the manifest in the same directory, and uploads a single-file bundle
beside it. The manifest is hand-written because Tauri has no flatpak bundler, and it pins
`org.gnome.Platform` 48, which is the newest runtime that still carries the GTK3 WebKit wry links
against. The sandbox gets the network, the notification service and the downloads directory, and
nothing else. CI builds the same manifest on every push to main, against a deb built there, which
is the only way a break in it gets found before a release.
The **nix** job runs after the publish, so the flake can only ever point at a release that survived
the manifest check. It hashes the published deb into `nix/release.json`, builds the package to
prove the pin works, and commits the pin to main. `NIXPKGS_ALLOW_UNFREE` and `--impure` are in that
command because FSL is not a free licence and `nix/package.nix` says so honestly rather than
claiming MIT to dodge the prompt.
Adding a Homebrew cask is the one distribution route the siblings have and this does not. It is a
job at the end of the release workflow, a `Casks/margin-mail.rb` in `priyanshujain/homebrew-margin`
and a `HOMEBREW_TAP_DEPLOY_KEY` secret; margin's `release.yml` has the job to copy.
## What the build needs
Both jobs check out this repository and the public margin repository side by side, because
`package.json` depends on `margin-shared` through a relative path. A token edited there is meant to
show up in every Margin app at once, and a copy vendored here would defeat that.
Ten repository secrets, all of them set. The three below, and the seven Apple ones under Signing
above:
- `GOOGLE_CREDENTIALS`, the contents of the real `google-credentials.json`. The build writes it to
the repository root and `build.rs` embeds it. Without it the build falls back to the example file
and warns, which produces an app that runs and then says it is not set up yet. It is the same
value in all three Margin repositories, because it is the same OAuth client.
- `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, which sign the updater
artifacts.
The OAuth client secret ends up inside the shipped binary. That is how installed apps work and
Google does not treat it as confidential: an installed client cannot keep a secret, which is why the
flow uses PKCE and why the token exchange is safe without one.
## Updates
Installed copies check
`https://github.com/priyanshujain/margin-mail/releases/latest/download/latest.json` and update
themselves from it. `--latest` on the publish step is what moves that pointer, so a release that
fails the manifest check stays a draft and nobody is offered a broken update.
A packaged install does not update itself. The Nix wrapper in the siblings sets a
`PACKAGED_BY` variable and the app checks for it before it would touch its own binary, which in a
read-only store it could not replace anyway; this app reads `MARGIN_MAIL_PACKAGED_BY` for the same
reason, and reports the newer version and the upgrade command instead.
+201
View File
@@ -0,0 +1,201 @@
# Settings
Settings is a place, not a panel. `Cmd+,` opens it, so does the palette, so does the account chip,
so does the app menu, and all four land on the same screen: the whole stage, a rail of section
names down the left, one section on the right. No tabs, no modal, no nested pages. `Esc` goes back
to the place you came from, `j` and `k` walk the rail, and the screen is in
[mockups/settings.png](mockups/settings.png).
The reason it is a place is that a mail client accumulates decisions. Twelve sections of them is
too much for an overlay that steals the window and too much for a palette that shows one row at a
time, and both shapes make a person who is looking for the storage window read the whole list
twice. A rail is boring and it works.
What follows is the section list, in rail order, with what each one holds. Behaviour that is not
about the control itself is specified in [features.md](features.md).
## Accounts
The first section and the one people arrive at. Each linked account is a card: its colour (one of
the eight muted hues, and the same colour the account's rows carry in All accounts), the display
name, the signature for that address, and its aliases. The colour is pickable, the name and
signature are editable, and the aliases are read rather than edited here. An IMAP account says
plainly that it has none: neither IMAP nor SMTP has a way to publish them, so such an account only
ever sends as its own address.
What sits under the card depends on what kind of account it is, because the two have nothing in
common below the name.
A Google account shows the permissions it granted, one line each: mail, mail settings, contacts,
other contacts, calendar, backup. Granular consent means any of them can be missing, so each line
either reads as granted or carries a Grant button that runs the consent page again for the full
list and replaces the stored token. The wording of a missing line says what it costs in plain
terms, not what the scope is called: "Calendar is not connected, so invites are read-only."
An IMAP account has no permissions to show, because nothing was granted: it shows the two servers
it is actually using, incoming and outgoing, with the port and the security for each, the username
it logs in with, and where the password is kept. A permission list on such an account would be six
lines of Google vocabulary and a Grant button that opened a consent page for an account that has no
Google behind it. The footnote about the three Margin apps sharing one Google client is likewise
only shown when one of the accounts is Google.
Remove an account lists the accounts with one button each. The confirmation says what removing
costs, which for a Google account is Margin's access at Google for all three Margin apps on every
machine, because they share one client, and carries a single box, ticked, to delete the account's
mail and decisions from this computer as well. Unticked, they are set aside on disk and come back
if the account is added again. There is no separate Revoke: removing always revokes, and the box
is the only choice left.
Add account asks for the address and nothing else, and runs the flow the welcome screen runs, in a
sheet: a Google address hands over to the browser and the strip above the list waits for it, any
other address gets its sign-in step in the same sheet, and an Outlook address is told why it cannot
be added rather than given a button that fails. Once the account is written, a panel takes the
window: first the storage window, the same five spans as the row below with a month chosen
already, because the first sync reads it; then the sync itself, the engine's own line, a bar the
width of the panel and the count of messages in. Nothing closes it. When the pass ends it opens that account's Inbox, and the first-run
panel over it says how many senders were screened in. A first pass that stops shows the provider's
sentence with Try again and Go in anyway. The flow is specified in [ui.md](ui.md). Remove
account asks once, then deletes that
account's mirror, its state database and its stored secret, and says that the mail itself is
untouched on the provider. Last on the section, quiet and collapsed, is bring your own OAuth client: a
field for a client id and secret for someone who would rather not share the suite's, with one
sentence saying what it is for and that nothing else changes.
## Appearance
Theme: light, dark, or follow the system. Interface font and text font, from the catalogue
described at the foot of this document. Text size, which scales the reading pane's body copy and
nothing else, because the chrome is already at the size it wants to be. Reading pane on or off,
which is the same switch as `Cmd+\`. Density, which only appears on a phone and only chooses
between the comfortable row and a tighter one.
## Mail
The storage window, per account, as a row of choices: 30 days, 90 days, 180 days, a year,
everything. Under it, one line saying what the account currently holds and how far back it
reaches. Shrinking the window says how many threads will be evicted before it does it; widening
starts a backfill and shows the same thin bar the first sync uses. Threads carrying a pile, a
note, a snooze or any other decision are kept regardless, and the section says so.
Then two smaller things: the attachment cache cap, with the space currently used beside it, and
whether to prefetch message bodies inside the window when the app is idle. Both are about disk and
neither changes what is in a list.
## Privacy
Remote images: never, ask per message, or always. Link cleaning on or off. A list of the senders
allowed to load images, added from the contact card and removable here.
This section carries the one sentence the app owes the reader about what showing an image means:
loading a remote image tells the server that hosts it that you opened the message, from your IP
address, at that moment. Nothing else in the app reveals that, and the sentence sits under the
control rather than in a help page.
## Screener
The gate on or off. With it off, a sender with no rule is routed by the suggestion function and
nothing is held; with it on, first contact waits. Whether replies to threads you are already in
are held (they are not, by default, and the switch exists for the small number of people who want
absolutely everything screened). Suggestions on or off: with them off, the Screener card shows the
three destinations and no recommendation.
## Piles and snooze
The times behind the snooze choices: later today, tomorrow morning, the weekend, next week. Each
is a time of day or an offset the user can set once and forget. On a phone, the two swipe actions
and which direction each is on. And the Feed's automatic trash age, which is off by default and
can be set here for every Feed sender or from a contact card for one of them.
## Writing
The undo delay: five, ten, twenty or thirty seconds, ten by default. Whether `r` means reply or
reply all. The instant intro text, as an editable template with the placeholder it uses. And the
signature per account, which is the same field as the one on the account card in Accounts, shown
here because this is where somebody writing a signature will look for it.
## Notifications
New mail can arrive as a push notification on the machine the app is running on: a banner from the
system with the app's name, the sender and the subject, and a click on it opens the thread. Off by
default and the section says so first, with one
exception it names: the dock badge, which is on. One switch, Allow notifications, is the system's
permission and the app's own preference together, because nobody cares which of the two is saying
no. Turning it on asks the system when it has never been asked, and a yes turns the Inbox on with
it so the switch does something; a no leaves it off over one line saying notifications are turned
off for the app in System Settings and one button that opens them there, since the answer lives
there and not here. Only while the switch is on does the rest show: the three places, so somebody
who wants to be told about the Inbox and never about the Feed can say that once rather than thread
by thread, and a Send a test notification button. There is no sound setting. A notification comes
with the system's sound, and the system's own pane is where that is muted. The first place turned
on in the first run panel asks the same question. Last is the dock badge, with a note that it is
not a notification and needs no permission: it counts unseen Inbox threads across every account,
which is what is waiting for a decision rather than what is unread, and turning it off here takes
it off the dock at once.
## Keyboard
The keymap is a file, not a table of pickers. This section says where it is, opens it in the
system editor, and resets it to the defaults in [keyboard.md](keyboard.md). A file that fails to
parse is reported here with the line number and the defaults stay in force until it is fixed.
## Backup
Which store, if any: Google Drive or Cloudflare R2 with S3 credentials. The status of the
connection and when the last backup completed. The recovery phrase, shown once when backup is
first turned on and never again, with the sentence saying that it is the only way to attach a
second device or restore after a lost one, and that writing it down now is the whole of the
arrangement. Restore takes a recovery phrase and pulls the journal back down.
Never again is not a policy, it is the mechanism. The phrase is generated, the key it derives is
sealed, and the phrase itself is dropped; a device that could show it again would be a device that
had kept it, and then the phrase would protect nothing that the disk did not already give away.
The section says that in a line rather than apologising for it.
The store holds ciphertext and file names and nothing else, which the section says in a line
rather than a paragraph.
## Data
Export mail as mbox, per account, from the window that is on the device. Export app state as JSON
and import it back, which is the portable form of every decision the app holds. Storage used,
broken down into the mirror, the attachment cache and the state database. Clear the mirror, which
deletes the local copy of the mail, keeps every decision, and resyncs the window from scratch; it
is the answer to a corrupt database and it asks once.
## About
The version, the licence (FSL-1.1-MIT, with the line about each release turning MIT after two
years), check for updates on the desktop, and the "packaged by" note that names who built the
binary. Nothing else. This section is where somebody files a bug from, so the version string is
selectable.
## Where a setting lives
Two places, and the split is not arbitrary.
**Device settings** live in a `settings.json` in the app data directory, written atomically by
replacing the file rather than editing it, so a crash mid-write leaves the old file rather than
half a new one. Theme, both fonts, text size, the storage window, the cache caps, notifications,
the keymap path and the backup store's configuration are all device settings. They describe this
machine: the window that suits a laptop with a small disk is not the window that suits a desktop,
and a notification preference that roamed to a phone would be wrong on arrival.
**Roaming settings** are per account decisions that should follow the person rather than the
hardware: sender rules, signatures, the instant intro text. They live in the state database and go
through its journal like every other decision, so they reach a second device through the backup
store and survive a reinstall. The rule of thumb is that anything a person would be annoyed to
retype on a new machine roams, and anything about this machine's screen or disk does not.
## Fonts
The two font controls reuse margin-shared's catalogue rather than defining one here, because a
face named in one Margin app and missing in another is the bug that catalogue exists to prevent. A
`FontRef` is stored, not a family name, so a bundled face and a system face that happen to share a
name stay distinct.
Six families are bundled: Hanken Grotesk, Literata, EB Garamond, Lora, Source Serif 4 and
Fraunces. Alongside them the picker lists every family `fontdb` finds on the machine. Interface
font writes `--font-ui` on the root and text font writes `--font-heading`, which is the same pair
of variables the stylesheet already reads, so changing a face is a token change and touches
nothing else.
+197 -26
View File
@@ -5,6 +5,89 @@ HTML mockups in [mockups/src/](mockups/src/), built on the real token set, so th
target rather than an impression of it; `mockups/render.sh` regenerates them. Behaviour is
specified in [features.md](features.md), keys in [keyboard.md](keyboard.md).
## Connecting
![The welcome screen](mockups/connect.png)
The first screen, and the only one that is not the app. The wordmark, a sentence under it, one
field for the address, and Continue. No provider is named as a button, because a person knows
their address and does not always know who runs the mailbox behind it, and a screen that opens with
a Google button and an "anything else" button under it has already decided who it was built for.
Under the field, in the faint ink, the sentence that says what works: Google, Fastmail, iCloud,
Yahoo, Proton through Bridge, a mailbox where you work, or anything else that speaks IMAP.
Continue reads the domain and works the rest out. The button says "Looking up northgate.example"
while it does, the field is held, and Stop sits beside it, so a press never looks like nothing
happened and a lookup that was never going to answer is not a wait anybody is held to. Under the
faint sentence, quieter still, is Enter the servers myself, for somebody who already knows nobody
publishes theirs. Four things can come next.
A Google address, whether gmail.com or a work domain whose mail is delivered to Google, goes to the
browser. The screen becomes Waiting for Google, with Open link again and Copy link below it,
because the browser that opened is not always the browser in front of you, and the sentence that
says what the sign-in means sits under them:
> Sign-in happens in your browser with Google. Margin never sees your password. The key Google
> hands back is stored only on this device, and you can revoke it any time from your Google
> account.
The consent page opens on the address that was typed rather than on Google's chooser. Closing it
comes quietly back to the address, still in its field.
Every other address goes to a sign-in step headed with the provider's own name: "Sign in to
Fastmail". Its first sentence says where the servers came from, before the password is typed into
them, because a configuration the provider publishes and one guessed by trying the usual server
names are different promises. Under it, the two servers in one line each, then Your name and
Password. Where the provider is one that refuses the password you sign in with, the hint under the
field says so and names the place in that provider's settings where an app password is made; that
is the single most common way a mail setup fails, and it is said before it can. Add account tests
both servers and, if they answer, adds the account and goes straight to the mail. A refusal is
explained on the same panel, with the server's own sentence and what to do about it. Change the
servers opens the sheet with both halves editable; a certificate nobody vouches for is a question
of its own, with the fingerprint to check it by.
An address nobody publishes settings for lands on the same sign-in step with a different first
sentence and Enter the servers in place of Add account, which opens the sheet with the usual names
already typed in as a starting point.
Between the account being written and its mail arriving there is one question, on the welcome
stage and in the panel alike: how far back this device holds. The same five spans as the Storage
window row in Settings, a month chosen already, one Start button. It is asked rather than assumed
because the first sync reads the answer, and a year of a busy mailbox is a wait somebody should
have chosen. The progress that follows is named after the answer: "Bringing in the last month",
"Bringing in the last year", "Bringing in everything".
An account added while the app is already up, from Settings or from the sign-in step for a
password account, does not land quietly. The moment it is written, "Bringing in the address" takes
the window as a panel: the question first, then the engine's line ("Listing your mail", "Fetching
the newest mail first"), a bar the width of the panel, and "1,204 of 4,812 messages" under it.
There is no close control that works, no Escape and no way through the scrim, because there is
nothing to do but wait and the wait is short. When the pass ends the panel goes, Settings goes
with it, and the window is that account's Inbox with the first-run panel over it. A first pass
that stops turns the panel into the provider's own sentence with Try again and Go in anyway, the
same two ways on the welcome screen offers.
An Outlook, Hotmail or Microsoft 365 address, recognised from the domain or from where the mail is
delivered, is told plainly that Microsoft turned off password sign-in and that the sign-in it wants
instead needs a registration Margin does not have yet. A HEY or Tuta address is told that there is
no IMAP behind it at all. Neither asks for a password that would only fail, and both offer the way
back to the address.
Then the screen becomes progress: "Bringing in the last month", a thin bar, and a count of what
has arrived. Nobody should have to watch it, so it turns into the Inbox as soon as the first page
of threads lands and the rest fills in behind.
![The first-run panel over the new Inbox](mockups/onboarding.png)
The first-run panel arrives over that Inbox once the pass over senders has finished. It says how
many senders were screened in and where they went, offers Start fresh with its age picker (a week
by default), and ends with Done. Skipping is Done. Start fresh is the only bulk write the app ever
proposes, and the panel says so in a line rather than in a warning.
The tour follows it, whichever way it ended: nine slides on what this app does that the last one
did not, with Skip as the first control on them. It runs for every account that is added, and
[help.md](help.md) says why that is not the same as running it at every launch.
## The window
![The Inbox with the reading pane and the two piles](mockups/inbox.png)
@@ -13,20 +96,34 @@ One header row, then the stage. The header carries the account chip on the left
chevron, the switcher and All accounts behind it), the three boxes as a segmented switch in the
centre with their number keys printed, and on the right search, the places button (which opens
the palette on its Places group) and Write. On macOS the traffic lights float over the left lane.
Nothing else is persistent: no sidebar, no folder tree, no toolbar. Sync status is not shown
unless something is wrong, in which case the account chip carries a small note ("Offline",
"Signed out").
Nothing else is persistent: no sidebar, no folder tree, no toolbar. Sync says what it is doing in
the account chip's lane and nowhere else: a note when something is wrong ("Offline", "Signed out",
"Sync trouble", "Paused"), in the engine's own word since only it knows the kind of failure,
and otherwise one faint line naming the work and its progress while there is any ("Caching recent
mail, 340 of 1,412"). A toast is raised only for the pause, for a refused token or a missing
permission, and for a write the provider refused for good; everything else the chip carries and the
next poll answers. It goes when the work does. Nothing about it can be pressed, and it is in the
chip's lane rather than the centre so that a line growing and shrinking never moves the boxes.
A mailbox is readable long before it is complete, so the alternative to that line is an app that
looks finished while it is still working, and a first impression of mail that is missing rather
than mail that is on its way.
The stage is the list column, 420 px, and the reading pane. The list column has a head (the
place's name in the text face and, in the Inbox, the Screener pill), the list, and the two piles
at the foot. With the pane hidden the list takes the width and a thread opens in place.
at the foot. With the pane hidden the list takes the width and a thread opens in place. A list
that has run out of what is on the device closes with one faint line saying so: in Everything it
reads "Showing the last month. Older mail is on Gmail.", and in search results it is the button
that runs the provider's search instead.
### Rows
Two lines at 58 px. Left gutter for the new-mail dot, a 30 px avatar (initials on one of the
Two lines at 46 px. Left gutter for the new-mail dot, a 30 px avatar (initials on one of the
eight muted hues, or a bordered brand mark for companies), then sender and time on the first
line and subject and snippet on the second. New mail: a dot and a 600-weight sender. Seen mail:
no dot, subject in the soft ink. A message count sits between sender and time when the thread
line and subject and snippet on the second. Unseen mail: sender and subject at 600 weight in the
full ink. Seen mail: regular weight, subject in the soft ink. That weight is the only read
indicator there is, no dot, no band and no count, which is Superhuman's rule and the one signal that
cannot lag behind another. A message count sits between sender and time when the thread
has more than one. A note shows as one line under the row on the note surface. In All accounts
the row has a 2 px coloured left edge for its account. The selected row has a 2 px accent edge
and a wash; hover is a lighter wash. There are no chips, no icons on hover, and no checkboxes
@@ -34,9 +131,12 @@ until `x` is pressed, at which point the gutter shows one.
### Groups
New for you, Previously seen (on a slightly warmer band), and Back when a snooze has returned.
Group headings are small uppercase labels with a rule, the same as every section label in the
family. The New for you heading carries "Mark all as seen" at its right; nothing carries a count.
The Inbox is one list in time order, with Back above it when a snooze has returned. There is no New
for you and no Previously seen: HEY splits by read state and Superhuman does not, and this app went
with Superhuman once it had tried carrying both. Other places group by age (Today, This week, This
month, Earlier) and Sent by recipient. Group headings are small uppercase labels with a rule, the
same as every section label in the family; nothing carries a count. Mark all as seen is a palette
row and a key.
### The piles
@@ -48,7 +148,8 @@ selection exists the piles give way to the action bar.
### The reading pane
A bar of verbs with keys (Reply, Reply later, Set aside, Snooze, then Archive and More on the
right), then the thread: subject in Literata at 22 px, a participants line with stacked avatars,
right), then the thread. More, or `.`, drops the rest of the thread's verbs with their keys in the
order the keyboard table lists them, and leaves out any that mean nothing where you are. Then the thread: subject in Literata at 22 px, a participants line with stacked avatars,
the tracker banner when anything was stripped, then messages. Each message has an avatar, the
sender's name and address, "to you", and the time; older messages collapse to a preview line.
Bodies are Literata at 15 px on a 46 em measure for text mail, and the sanitised HTML for
@@ -60,9 +161,39 @@ key, Remind me if no reply, and attach and note icons.
![The same Inbox in dark](mockups/inbox-dark.png)
Driven by `data-theme` with the shared dark palette. Message bodies stay on the paper surface
in dark mode too, because HTML mail is written for a light background and inverting it breaks
more than it fixes; the pane's own chrome goes dark around it.
Driven by `data-theme` with the shared dark palette, and the one thing that needs stating
precisely is what happens to a message body.
A message renders on a light page when the sender painted one, and on the app's own surface when
they did not. That is the whole rule, and what decides it is paint rather than `Content-Type`. A
newsletter lays out a page: a wash behind a 600 px card, a header band, a footer in grey. It keeps
that page in both palettes, because it is a design and taking it apart loses more than it saves.
A colleague's mail composed in a client that happens to send HTML carries bold runs, a list, four
links and a signature, and no colour and no surface at all. There is nothing in it that wants a
white page, so it reads like the rest of the app. Either way the pane's own chrome goes dark
around the body.
This used to say "a message that arrived as HTML stays on the paper surface", and that was wrong.
Most HTML mail is somebody typing to you. Pinning all of it to white put a slab of white in the
middle of a dark window for every ordinary message anybody was sent, which is a much bigger loss
than the newsletter case it was protecting.
The hazard the old rule existed for is real, and it is handled on the other side. A sender who
sets a text colour without setting a background is dark ink on a dark page, so on the theme
surface Rust takes off, before the body is ever cached, any author colour that would not read on
our own paper: what survives is the mid-tone band a brand red or a heading grey lives in, and what
goes is the near-black that would vanish on our dark page and the near-white that would vanish on
our light one. A background the sender painted under their own text is judged with it, and it is
what gives way first.
The document inside the frame is told which of the two it is, with `color-scheme`. On a painted
page that is `only light`, without which the browser puts its own dark canvas and its own dark
form controls under a page we have just finished painting white.
The decision is made once, in the sanitiser, and stored on the body row, because opening a thread
is a local read and is not allowed to grow a walk over the markup. Every heuristic misses, so a
message head in dark carries one quiet control that overrules it for that message, for as long as
the app is open.
## The Screener
@@ -71,8 +202,8 @@ more than it fixes; the pane's own chrome goes dark around it.
The whole stage. A centred title and one sentence of explanation, then a card per sender:
avatar, name and address, subject, snippet, and a pill with the reason and the suggested box.
On the right, three buttons with their keys: Yes to the suggestion, Elsewhere, No. The focused
card has a faint ring. Under the list, one faint sentence about the 90-day retention and how to
reverse a decision. Clear all is a ghost button at the top right.
card has a faint ring. Under the list, one faint sentence about how long screened-out mail stays
and how to reverse a decision. Clear all is a ghost button at the top right.
## The Feed
@@ -120,9 +251,42 @@ Replies do not use the card; they are the box at the end of the thread in the pa
![The palette](mockups/palette.png)
`Cmd+K`. A panel at 12 vh from the top over a scrim, with a single field and a list grouped into
Places, Actions, People, Settings. Every row prints its key. Typing filters across groups. It is
the only menu in the app and the way every setting is reached; the places button in the header
opens it on the Places group. The shortcut sheet behind `?` is generated from the same data.
Places, Labels, Other, Actions, People, Settings. Every row prints its key. Typing filters across
groups. It is the only menu in the app and the way every setting is reached; the places button in
the header opens it on the Places group. The shortcut sheet behind `?` is generated from the same
data.
Other is Screened out, Spam and Trash, and it is last of the three place groups on purpose: none
of them has a number key, and a menu that put them beside the Inbox would be claiming they are
somewhere you go daily. Their rules are in [features.md](features.md) section 12.
## Settings
![Settings, on the Accounts section](mockups/settings.png)
The whole stage, and a place rather than an overlay. A rail of section names down the left with
one selected, and that section on the right at a 640 px measure. No tabs and no nesting: a section
is a column of labelled controls with a sentence under any control that needs one.
Accounts is the section it opens on. Each account is a card with its colour, its name and address,
its signature and aliases, and under them the permissions it granted, one line each. A permission
that is missing says what it costs in a sentence and carries a Grant button. Add account sits at
the foot of the list. Every section is specified in [settings.md](settings.md).
## Help
A small round question mark fixed in the bottom right corner, the only permanent chrome outside
the header, opening three rows: the tour, the guide, and the keyboard shortcuts with its key. It is
not drawn while the compose card is open, since they share that corner, nor under an overlay, nor
on a phone.
The guide is a panel over the whole window: a search field across the full width, then a rail of
sections down the left and one article on the right at a reading measure. Search has the keyboard
the moment it opens, and a query nothing answers offers to file the question against the
repository. An article leads with its answer and then shows it: a drawn figure, a screenshot, or a
table of the key beside the verb, and the last section is the questions. The app is dimmed behind
it and none of it can be pressed, and closing puts back the place, the open thread and the scroll.
[help.md](help.md) has the whole of it.
## Thread details
@@ -151,10 +315,17 @@ later, left for Set aside, both changeable in settings.
## Empty states
Every empty list says one quiet thing in the text face and nothing else: "Nothing new for you"
above Previously seen; "Nothing here" in an empty place; "No one is waiting" in the Screener;
Every empty list says one quiet thing in the text face and nothing else: "Nothing here" in an
empty place; "No one is waiting" in the Screener;
"Nothing due" in Snoozed. No illustration, no photograph, no streak.
A place that is empty because its account has not arrived yet is not empty, and does not say it
is. While the first sync is bringing the mailbox in, from the welcome screen or from Settings, the
list shows the engine's own line ("Listing your mail", "Fetching the newest mail first"), the thin
bar the welcome screen uses, and the count under it. "Nothing here" waits until the crawl has
finished, and so does the Screener pill in the Inbox, because until the seed has run the number on
it is every sender the account has met so far.
## Motion
Transitions name explicit properties and use the family's one easing curve, 120 ms for hovers
@@ -163,8 +334,8 @@ the toast is the acknowledgement. The compose card slides up; the palette fades.
## Not in the mockups
Settings (a standard panel with the sections listed in features.md), search results (the list
column with a query in the head), All files (a card grid with a filter row), Clips (a list of
passages), Contacts (a list with the same row anatomy), the snooze picker (a small popover with
the six choices and their keys), and the selection action bar (the piles' footprint filled with
verbs). All of them are compositions of the pieces above and need no new visual vocabulary.
Search results (the list column with a query in the head and the provider's search offered at
its foot), All files (a card grid with a filter row), Clips (a list of passages), Contacts (a list
with the same row anatomy), the snooze picker (a small popover with the six choices and their
keys), and the selection action bar (the piles' footprint filled with verbs). All of them are
compositions of the pieces above and need no new visual vocabulary.
+22
View File
@@ -0,0 +1,22 @@
{
description = "Margin Mail, a calm, keyboard-first mail client";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs =
{ self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
overlays.default = final: prev: {
margin-mail = final.callPackage ./nix/package.nix { };
};
packages.${system} = {
margin-mail = pkgs.callPackage ./nix/package.nix { };
default = self.packages.${system}.margin-mail;
};
};
}
+5
View File
@@ -0,0 +1,5 @@
margin-mail.deb
build/
repo/
*.flatpak
.flatpak-builder/
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Build the flatpak from the deb sitting beside this script as margin-mail.deb, and write a
# single-file bundle next to it. Used by both CI jobs and by hand; the only difference between the
# two is where the deb came from.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
cd "$here"
id=studio.margin.mail
deb=margin-mail.deb
bundle=${1:-$here/margin-mail.flatpak}
[ -f "$deb" ] || { echo "flatpak/build.sh: no $deb beside this script." >&2; exit 1; }
runtime_version=$(sed -n "s/^runtime-version: *'\(.*\)'/\1/p" "$id.yml")
# --user so nothing here needs root, and --if-not-exists so a second run is free.
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install --user --noninteractive flathub \
"org.gnome.Platform//$runtime_version" \
"org.gnome.Sdk//$runtime_version"
rm -rf build repo
flatpak-builder --user --disable-rofiles-fuse --force-clean --repo=repo build "$id.yml"
flatpak build-bundle repo "$bundle" "$id"
echo "Wrote $bundle"
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>studio.margin.mail</id>
<name>Margin Mail</name>
<summary>A calm, keyboard-first mail client for Gmail</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>LicenseRef-proprietary=https://github.com/priyanshujain/margin-mail/blob/main/LICENSE</project_license>
<developer id="studio.margin">
<name>Margin</name>
</developer>
<description>
<p>
New senders wait at the door until you let them in, people and newsletters and receipts live
in three separate boxes, and every decision you make is kept beside the mail rather than
inside it.
</p>
<p>
Trackers are stripped from what you read and never sent from what you write. The mirror of
your mail and every decision you have made about a sender are files on your own disk, so
changing provider costs you nothing.
</p>
</description>
<launchable type="desktop-id">studio.margin.mail.desktop</launchable>
<url type="homepage">https://github.com/priyanshujain/margin-mail</url>
<url type="bugtracker">https://github.com/priyanshujain/margin-mail/issues</url>
<categories>
<category>Office</category>
<category>Email</category>
</categories>
<content_rating type="oars-1.1"/>
</component>
+54
View File
@@ -0,0 +1,54 @@
# The flatpak, built by repackaging the deb rather than from source. Tauri has no flatpak bundler,
# and building from source here would produce an app that runs and then says Google is not set up:
# the OAuth client is embedded at compile time from a file that is deliberately not in the repo.
#
# GNOME 48 is the runtime because it is the newest that still carries webkit2gtk-4.1, which is the
# GTK3 WebKit wry links against. When that stops being true the answer is a GTK4 wry, not an older
# runtime.
id: studio.margin.mail
runtime: org.gnome.Platform
runtime-version: '48'
sdk: org.gnome.Sdk
command: margin-mail
finish-args:
- --socket=wayland
- --socket=fallback-x11
- --share=ipc
- --device=dri
# Gmail, IMAP, SMTP, and the loopback listener that catches Google's OAuth redirect. The redirect
# comes back to 127.0.0.1 inside the sandbox, which is where the app is listening, so this is the
# whole of what the connect flow needs; the consent page itself opens in the host's browser
# through the OpenURI portal, which needs no permission of its own.
- --share=network
- --talk-name=org.freedesktop.Notifications
# Saved attachments and exports go to the downloads directory and nowhere else.
- --filesystem=xdg-download
modules:
- name: margin-mail
buildsystem: simple
build-commands:
- mkdir -p deb
- ar x margin-mail.deb --output deb
- tar -C deb -xf deb/data.tar.gz
- install -Dm755 deb/usr/bin/margin-mail /app/bin/margin-mail
# Flatpak wants the desktop file and every icon named for the app id, and the deb names them
# after the product and the binary, so both are renamed on the way in.
- install -Dm644 "deb/usr/share/applications/Margin Mail.desktop" /app/share/applications/studio.margin.mail.desktop
- desktop-file-edit --set-icon=studio.margin.mail /app/share/applications/studio.margin.mail.desktop
- |
for src in deb/usr/share/icons/hicolor/*/apps/margin-mail.png; do
size=$(basename "$(dirname "$(dirname "$src")")")
install -Dm644 "$src" "/app/share/icons/hicolor/$size/apps/studio.margin.mail.png"
done
- install -Dm644 studio.margin.mail.metainfo.xml /app/share/metainfo/studio.margin.mail.metainfo.xml
sources:
- type: file
path: margin-mail.deb
- type: file
path: studio.margin.mail.metainfo.xml
+17
View File
@@ -24,6 +24,11 @@ test:
test-ui:
pnpm test:ui
# Recapture the pictures the guide ships with, from the fixture rather than from anybody's mailbox.
# They are committed, so this is not part of `test-ui`: an ordinary run must not rewrite the tree.
guide-shots:
GUIDE_SHOTS=1 pnpm exec playwright test tests/guide-shots.spec.ts
# The prose gate: no em dashes, no broken relative links, no directory trees.
docs:
node scripts/docs-check.mjs
@@ -35,6 +40,18 @@ build:
# The .app on macOS, the .deb and AppImage on Linux. No dmg: nothing here needs a disk image to
# copy a bundle into place, and building one is the slowest part of a mac bundle.
pnpm install
# macOS shows no notifications from a bundle that is not signed, so tauri.conf.json ad-hoc signs
# at minimum and a real identity replaces that when this machine has one: the signing directory
# holds an env file naming it, and Tauri reads APPLE_SIGNING_IDENTITY over the config.
if [ "$(uname -s)" = Darwin ]; then
signing="${MARGIN_SIGNING_DIR:-$HOME/.margin-signing}/studio.margin.app.env"
if [ -f "$signing" ]; then
set -a; . "$signing"; set +a
echo "Signing as $APPLE_SIGNING_IDENTITY"
else
echo "No $signing; the bundle will be ad-hoc signed."
fi
fi
case "$(uname -s)" in
Darwin) pnpm tauri build --bundles app ;;
Linux) pnpm tauri build --bundles deb,appimage ;;
+125
View File
@@ -0,0 +1,125 @@
# The Linux package: the deb the release workflow published, relinked against nixpkgs' own GTK and
# WebKit so it runs as a native Wayland client. It is a binary package by necessity: the Google
# OAuth client is embedded at compile time from a file that is deliberately not in the repo, so
# anything built from source here would run and then say Google is not set up.
#
# Ported from Margin Calendar's nix/package.nix, which is the sibling to read when this needs work.
{
lib,
stdenv,
fetchurl,
dpkg,
autoPatchelfHook,
wrapGAppsHook3,
cairo,
dbus,
gdk-pixbuf,
glib,
glib-networking,
gsettings-desktop-schemas,
gtk3,
libsoup_3,
mesa,
runtimeShell,
webkitgtk_4_1,
xdg-utils,
}:
let
release = lib.importJSON ./release.json;
in
stdenv.mkDerivation {
pname = "margin-mail";
inherit (release) version;
src = fetchurl {
url = "https://github.com/priyanshujain/margin-mail/releases/download/v${release.version}/Margin.Mail_${release.version}_amd64.deb";
inherit (release) hash;
};
unpackPhase = ''
runHook preUnpack
dpkg-deb -x $src .
runHook postUnpack
'';
nativeBuildInputs = [
dpkg
autoPatchelfHook
wrapGAppsHook3
];
buildInputs = [
cairo
dbus
gdk-pixbuf
glib
glib-networking
gsettings-desktop-schemas
gtk3
libsoup_3
webkitgtk_4_1
];
# The binary sits under lib/ so wrapGAppsHook wraps the launcher in bin/ and nothing else.
#
# Outside NixOS there is no /run/opengl-driver, so the libglvnd this build links finds no EGL
# driver and WebKit aborts its web process on the spot. The launcher points it at nixpkgs' Mesa
# instead, unless something like nixGL already did. The xdg-open shim strips that again for the
# browser the app opens for the Google consent page, which has a Mesa of its own.
installPhase = ''
runHook preInstall
mkdir -p $out/bin $out/lib/margin-mail/bin
cp usr/bin/margin-mail $out/lib/margin-mail/
cp -r usr/share $out/
mv "$out/share/applications/Margin Mail.desktop" $out/share/applications/margin-mail.desktop
cat > $out/bin/margin-mail <<LAUNCHER
#!${runtimeShell}
if [ ! -d /run/opengl-driver ]; then
: "\''${__EGL_VENDOR_LIBRARY_FILENAMES:=$(echo ${mesa}/share/glvnd/egl_vendor.d/*.json)}"
: "\''${LIBGL_DRIVERS_PATH:=${mesa}/lib/dri}"
: "\''${GBM_BACKENDS_PATH:=${mesa}/lib/gbm}"
export __EGL_VENDOR_LIBRARY_FILENAMES LIBGL_DRIVERS_PATH GBM_BACKENDS_PATH
fi
exec $out/lib/margin-mail/margin-mail "\$@"
LAUNCHER
cat > $out/lib/margin-mail/bin/xdg-open <<SHIM
#!${runtimeShell}
unset __EGL_VENDOR_LIBRARY_FILENAMES LIBGL_DRIVERS_PATH GBM_BACKENDS_PATH
exec ${xdg-utils}/bin/xdg-open "\$@"
SHIM
chmod +x $out/bin/margin-mail $out/lib/margin-mail/bin/xdg-open
runHook postInstall
'';
# The variable is how the app knows the store owns the binary, so "Check for updates" reports the
# new version and the upgrade command instead of trying to replace a file it cannot write.
preFixup = ''
gappsWrapperArgs+=(
--prefix PATH : $out/lib/margin-mail/bin
--set MARGIN_MAIL_PACKAGED_BY nix
)
'';
meta = {
description = "A calm, keyboard-first mail client for Gmail";
longDescription = "New senders wait at the door until you let them in, people and newsletters and receipts live in three separate boxes, and every decision you make is kept beside the mail rather than inside it.";
homepage = "https://github.com/priyanshujain/margin-mail";
# Not one of lib.licenses: FSL is source available rather than free, and it turns MIT two years
# after each release. `free = false` is what makes nix ask before building it, which is correct
# and is why the workflows pass NIXPKGS_ALLOW_UNFREE.
license = {
shortName = "FSL-1.1-MIT";
fullName = "Functional Source License, Version 1.1, MIT Future License";
url = "https://github.com/priyanshujain/margin-mail/blob/main/LICENSE";
free = false;
redistributable = true;
};
sourceProvenance = [ lib.sourceTypes.binaryNativeCode ];
platforms = [ "x86_64-linux" ];
mainProgram = "margin-mail";
};
}
+4
View File
@@ -0,0 +1,4 @@
{
"version": "",
"hash": ""
}
+1
View File
@@ -33,6 +33,7 @@
"devDependencies": {
"@playwright/test": "^1.62.1",
"@tauri-apps/cli": "^2",
"@types/node": "^24.0.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
+1 -1
View File
@@ -8,7 +8,7 @@
import { defineConfig } from "@playwright/test";
const PORT = 1440;
const PORT = 1450;
const BASE_URL = `http://localhost:${PORT}`;
export default defineConfig({
+32 -14
View File
@@ -54,6 +54,9 @@ importers:
'@tauri-apps/cli':
specifier: ^2
version: 2.11.4
'@types/node':
specifier: ^24.0.0
version: 24.13.3
'@types/react':
specifier: ^19.1.8
version: 19.2.18
@@ -62,16 +65,16 @@ importers:
version: 19.2.7(@types/[email protected])
'@vitejs/plugin-react':
specifier: ^4.6.0
version: 4.7.0([email protected])
version: 4.7.0([email protected](@types/[email protected]))
typescript:
specifier: ~5.8.3
version: 5.8.3
vite:
specifier: ^7.0.4
version: 7.3.6
version: 7.3.6(@types/[email protected])
vitest:
specifier: ^3.2.4
version: 3.2.7
version: 3.2.7(@types/[email protected])
packages:
@@ -738,6 +741,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/[email protected]':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/[email protected]':
resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==}
peerDependencies:
@@ -1072,6 +1078,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
[email protected]:
resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
hasBin: true
@@ -1766,6 +1775,10 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
undici-types: 7.18.2
'@types/[email protected](@types/[email protected])':
dependencies:
'@types/react': 19.2.18
@@ -1776,7 +1789,7 @@ snapshots:
'@types/[email protected]': {}
'@vitejs/[email protected]([email protected])':
'@vitejs/[email protected]([email protected](@types/[email protected]))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/[email protected])
@@ -1784,7 +1797,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 7.3.6
vite: 7.3.6(@types/[email protected])
transitivePeerDependencies:
- supports-color
@@ -1796,13 +1809,13 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/[email protected]([email protected])':
'@vitest/[email protected]([email protected](@types/[email protected]))':
dependencies:
'@vitest/spy': 3.2.7
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.6
vite: 7.3.6(@types/[email protected])
'@vitest/[email protected]':
dependencies:
@@ -2128,6 +2141,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]([email protected]):
dependencies:
browserslist: 4.28.8
@@ -2138,13 +2153,13 @@ snapshots:
dependencies:
react: 19.2.8
[email protected]:
[email protected](@types/[email protected]):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.6
vite: 7.3.6(@types/[email protected])
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -2159,7 +2174,7 @@ snapshots:
- tsx
- yaml
[email protected]:
[email protected](@types/[email protected]):
dependencies:
esbuild: 0.28.2
fdir: 6.5.0([email protected])
@@ -2168,13 +2183,14 @@ snapshots:
rollup: 4.63.1
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 24.13.3
fsevents: 2.3.3
[email protected]:
[email protected](@types/[email protected]):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
'@vitest/mocker': 3.2.7([email protected])
'@vitest/mocker': 3.2.7([email protected](@types/[email protected]))
'@vitest/pretty-format': 3.2.7
'@vitest/runner': 3.2.7
'@vitest/snapshot': 3.2.7
@@ -2192,9 +2208,11 @@ snapshots:
tinyglobby: 0.2.17
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.6
vite-node: 3.2.4
vite: 7.3.6(@types/[email protected])
vite-node: 3.2.4(@types/[email protected])
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
transitivePeerDependencies:
- jiti
- less
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+127
View File
@@ -0,0 +1,127 @@
/* The stylesheet inside a message's iframe.
*
* It is served raw out of public/ rather than bundled, because the frame is a document of its own
* and cannot see the app's stylesheets. Every value it uses is a custom property that
* src/screens/MessageBody.tsx writes onto the frame's root from the app's own computed tokens, so
* this file holds the shape of a message body and no colour, no face and no size of its own.
*
* The two faces are declared again for the same reason: a child document does not inherit the
* parent's @font-face rules, and without these a message renders in Georgia. They are the two
* default slots; a face chosen in settings needs its declaration here as well. */
@font-face {
font-family: "Hanken Grotesk";
src: url("/fonts/HankenGrotesk-VF.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Literata";
src: url("/fonts/Literata-VF.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Literata";
src: url("/fonts/Literata-Italic-VF.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: italic;
font-display: block;
}
html {
/* The parent measures scrollHeight to size the frame, so nothing in here may scroll itself. */
overflow: hidden;
/* What this document is, in its own words: `only light` for a page the sender painted and ours
for everything else. It decides the canvas the browser paints before the body does, the colours
it gives anything it draws itself, and what a rule inside the message would be told about
`prefers-color-scheme`. Written from MessageBody.tsx the way every other value here is.
The `only light` half is the one no mail client ships. A body pinned to a white page in a dark
window is still a document, and a document with nothing to say about its own scheme gets the
machine's, which puts a dark canvas and dark user agent colours under a page we just painted
white. */
color-scheme: var(--m-color-scheme);
}
body {
margin: 0;
max-width: var(--m-measure);
/* Painted rather than left transparent, because a message that painted its own page is pinned to
the light surface in both palettes and has to bring that surface with it. On the theme the
value is the app's own paper and this rule is invisible. */
background: var(--m-paper);
color: var(--m-ink);
font-family: var(--m-font-text);
font-size: var(--m-size);
/* 1.5 rather than the 1.6 this was drawn at. At 15px that is 22.5px a line against 24px, which
is worth more on a long message than every margin in the pane put together, and it is still
prose rather than a spreadsheet. Anything tighter than 1.45 and the text face stops being
worth having. */
line-height: 1.5;
overflow-wrap: break-word;
-webkit-font-smoothing: antialiased;
}
/* Transactional mail is data rather than prose, so it reads in the interface face. */
body[data-plain] {
font-family: var(--m-font-ui);
font-size: var(--m-size-plain);
line-height: 1.55;
}
p {
margin: 0 0 12px;
}
p:last-child {
margin-bottom: 0;
}
a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
/* A sender's image is whatever size the sender made it, and the pane is not that wide. */
img {
max-width: 100%;
height: auto;
}
table {
max-width: 100%;
border-collapse: collapse;
}
blockquote {
margin: 0 0 12px;
padding-left: 14px;
border-left: 2px solid var(--m-line);
color: var(--m-faint);
}
pre,
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.92em;
}
pre {
padding: 10px 12px;
border-radius: 5px;
background: var(--m-wash);
overflow-x: auto;
}
hr {
height: 1px;
margin: 16px 0;
border: 0;
background: var(--m-line);
}
+547 -15
View File
@@ -102,18 +102,68 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror 2.0.20",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener 5.4.2",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
dependencies = [
"concurrent-queue",
"event-listener 2.5.3",
"futures-core",
]
[[package]]
name = "async-channel"
version = "2.5.0"
@@ -152,6 +202,29 @@ dependencies = [
"slab",
]
[[package]]
name = "async-imap"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a6728e0f7931b36d725ac234fcb02539e9f7888dbeaaa8a18d9ea5792181570"
dependencies = [
"async-channel 2.5.0",
"async-compression",
"base64 0.22.1",
"bytes",
"chrono",
"futures",
"imap-proto",
"log",
"nom",
"pin-project",
"pin-utils",
"self_cell",
"stop-token",
"thiserror 1.0.69",
"tokio",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -176,7 +249,7 @@ version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener 5.4.2",
"event-listener-strategy",
"pin-project-lite",
]
@@ -187,14 +260,14 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-channel 2.5.0",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"event-listener 5.4.2",
"futures-lite",
"rustix",
]
@@ -315,6 +388,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -415,7 +494,7 @@ version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
dependencies = [
"async-channel",
"async-channel 2.5.0",
"async-task",
"futures-io",
"futures-lite",
@@ -804,6 +883,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-channel"
version = "0.5.16"
@@ -989,6 +1074,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dbus"
version = "0.9.12"
@@ -1031,6 +1122,20 @@ dependencies = [
"thiserror 2.0.20",
]
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1324,6 +1429,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "event-listener"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]]
name = "event-listener"
version = "5.4.2"
@@ -1340,7 +1451,7 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"event-listener 5.4.2",
"pin-project-lite",
]
@@ -1426,7 +1537,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
"roxmltree 0.20.0",
]
[[package]]
@@ -1484,6 +1595,21 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
@@ -1559,6 +1685,7 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -1980,6 +2107,76 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "hickory-net"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "084e7bd6a377435d568f652153e571b50970d7ccc1d1eeec0519f834632287e1"
dependencies = [
"async-trait",
"cfg-if",
"data-encoding",
"futures-channel",
"futures-io",
"futures-util",
"hickory-proto",
"idna",
"ipnet",
"jni 0.22.4",
"rand 0.10.2",
"thiserror 2.0.20",
"tinyvec",
"tokio",
"tracing",
"url",
]
[[package]]
name = "hickory-proto"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d"
dependencies = [
"data-encoding",
"idna",
"ipnet",
"jni 0.22.4",
"once_cell",
"prefix-trie",
"rand 0.10.2",
"ring",
"thiserror 2.0.20",
"tinyvec",
"tracing",
"url",
]
[[package]]
name = "hickory-resolver"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e4f9f4603319422d482738f3f6fe5aac03157fdbfed1cd85a3ff45adb09072f"
dependencies = [
"cfg-if",
"futures-util",
"hickory-net",
"hickory-proto",
"ipconfig",
"ipnet",
"jni 0.22.4",
"moka",
"ndk-context",
"once_cell",
"parking_lot",
"rand 0.10.2",
"resolv-conf",
"smallvec",
"system-configuration",
"thiserror 2.0.20",
"tokio",
"tracing",
]
[[package]]
name = "html5ever"
version = "0.38.0"
@@ -2254,6 +2451,15 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "imap-proto"
version = "0.16.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f6af35c6a517aea5c72314abe90134980d2ae6a763809b50c208b3e429d71f"
dependencies = [
"nom",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2295,11 +2501,27 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "ipconfig"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2",
"widestring",
"windows-registry 0.6.1",
"windows-result 0.4.1",
"windows-sys 0.61.2",
]
[[package]]
name = "ipnet"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
dependencies = [
"serde",
]
[[package]]
name = "is-docker"
@@ -2418,6 +2640,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.20",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -2500,6 +2752,12 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libappindicator"
version = "0.9.0"
@@ -2644,6 +2902,24 @@ dependencies = [
"hashify",
]
[[package]]
name = "mail-send"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8355326d14f08c9de5e9badfe606d9ba8fea2febb60fe4318ecbac33cfb7b41e"
dependencies = [
"base64 0.23.1",
"gethostname",
"md5",
"rand 0.9.5",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier 0.7.0",
"smtp-proto",
"tokio",
"tokio-rustls",
]
[[package]]
name = "maplit"
version = "1.0.2"
@@ -2656,6 +2932,7 @@ version = "0.0.1"
dependencies = [
"ammonia",
"argon2",
"async-imap",
"base64 0.22.1",
"bip39",
"block2",
@@ -2663,14 +2940,21 @@ dependencies = [
"chrono",
"css-inline",
"fontdb",
"futures",
"hickory-resolver",
"mail-builder",
"mail-parser",
"mail-send",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"objc2-user-notifications",
"rand 0.8.8",
"reqwest 0.13.1",
"roxmltree 0.21.1",
"rusqlite",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"sha2",
@@ -2684,7 +2968,11 @@ dependencies = [
"tauri-plugin-updater",
"tempfile",
"tokio",
"tokio-rustls",
"tokio-util",
"url",
"webpki-roots",
"x509-parser",
]
[[package]]
@@ -2709,6 +2997,12 @@ dependencies = [
"web_atoms",
]
[[package]]
name = "md5"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
[[package]]
name = "memchr"
version = "2.8.3"
@@ -2739,6 +3033,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
@@ -2776,6 +3076,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moka"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9"
dependencies = [
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"equivalent",
"parking_lot",
"portable-atomic",
"smallvec",
"tagptr",
"uuid",
]
[[package]]
name = "muda"
version = "0.19.3"
@@ -2812,6 +3129,12 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
@@ -2839,6 +3162,16 @@ dependencies = [
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "notify-rust"
version = "4.18.0"
@@ -2853,12 +3186,31 @@ dependencies = [
"zbus",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3080,6 +3432,8 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"bitflags 2.13.1",
"block2",
"objc2",
"objc2-foundation",
]
@@ -3098,11 +3452,24 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"critical-section",
"portable-atomic",
]
[[package]]
name = "open"
@@ -3311,12 +3678,38 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.5"
@@ -3442,6 +3835,17 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "prefix-trie"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7"
dependencies = [
"either",
"ipnet",
"num-traits",
]
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
@@ -3835,7 +4239,7 @@ dependencies = [
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"rustls-platform-verifier 0.6.2",
"serde",
"serde_json",
"sync_wrapper",
@@ -3853,6 +4257,12 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "resolv-conf"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
[[package]]
name = "ring"
version = "0.17.14"
@@ -3873,6 +4283,15 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
@@ -3923,6 +4342,15 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustix"
version = "1.1.4"
@@ -3943,6 +4371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -3981,7 +4410,28 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"jni 0.21.1",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
@@ -4160,6 +4610,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "self_cell"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
[[package]]
name = "semver"
version = "1.0.28"
@@ -4374,6 +4830,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -4401,6 +4873,12 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "smtp-proto"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e365d0fff6ae5036364ab62f236fa669ce05acbd5878df32d3755d48cfc1491b"
[[package]]
name = "socket2"
version = "0.6.5"
@@ -4477,6 +4955,18 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stop-token"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af91f480ee899ab2d9f8435bfdfc14d08a5754bd9d3fef1f1a1c23336aad6c8b"
dependencies = [
"async-channel 1.9.0",
"cfg-if",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -4619,6 +5109,12 @@ dependencies = [
"version-compare",
]
[[package]]
name = "tagptr"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "tao"
version = "0.35.3"
@@ -4637,7 +5133,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -4704,7 +5200,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -4949,7 +5445,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -4972,7 +5468,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -5187,9 +5683,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -5208,6 +5716,7 @@ checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-io",
"futures-sink",
"libc",
"pin-project-lite",
@@ -5857,6 +6366,12 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -6377,7 +6892,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -6424,6 +6939,23 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"rusticata-macros",
"thiserror 2.0.20",
"time",
]
[[package]]
name = "xattr"
version = "1.6.1"
@@ -6473,7 +7005,7 @@ dependencies = [
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"event-listener 5.4.2",
"futures-core",
"futures-lite",
"hex",
+63 -1
View File
@@ -30,7 +30,7 @@ sha2 = "0.10"
rand = "0.8"
url = "2"
chrono = { version = "0.4", features = ["serde"] }
tokio = { version = "1", features = ["sync", "time"] }
tokio = { version = "1", features = ["sync", "time", "net", "io-util", "rt"] }
# gzip because Gmail's JSON compresses by an order of magnitude and hydration is thousands of
# responses; http2 because a batch of 50 and the poll loop share one connection.
@@ -69,6 +69,68 @@ bip39 = { version = "2.2", features = ["rand"] }
# The system families the Appearance section offers alongside the six bundled ones.
fontdb = "0.24"
# The second provider: a mailbox reached over IMAP and SMTP with a password, which is every
# account that is not Google. Proton is one of these, through Bridge on the loopback.
#
# `async-imap` defaults to async-std, so both its default features are off and the tokio pair is
# named instead: there is one runtime in this process and it is Tauri's.
async-imap = { version = "0.11", default-features = false, features = ["runtime-tokio", "tokio"] }
# `async-imap` speaks futures-io and `tokio-rustls` speaks tokio-io, so the stream is bridged.
tokio-util = { version = "0.7", features = ["compat"] }
# `async-imap` returns its responses as futures streams, so the combinators come with it.
futures = "0.3"
# TLS for both, and the seam where a self-signed certificate is decided about.
#
# Every `ClientConfig` in this crate must name `rustls::crypto::ring::default_provider()`
# explicitly. Both crypto providers are in the tree because of what reqwest pulls, so rustls has
# no process default to fall back on and building a config without one panics at runtime rather
# than failing to compile.
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-pki-types = "1"
webpki-roots = "1"
# The fingerprint shown in the certificate question, and the hostname check that decides whether
# there is a question at all rather than a refusal.
x509-parser = "0.18"
# Sending. Same author as mail-parser and mail-builder, and pinned to ring for the same reason as
# above. No `dkim`: nothing here signs outgoing mail.
mail-send = { version = "0.6", default-features = false, features = ["ring", "tls12", "cram-md5", "digest-md5", "md5", "rand"] }
# Autodiscovery. `hickory-resolver` is for the MX rung, which is what recognises a vanity domain
# sitting on Google Workspace or Fastmail; `roxmltree` reads the clientConfig documents, which are
# small, read-once and never written.
hickory-resolver = { version = "0.26", default-features = false, features = ["system-config", "tokio"] }
roxmltree = "0.21"
# Notifications on macOS are posted through UserNotifications directly (notify/macos.rs says why the
# plugin's own path shows nothing on macOS 26). These are the versions wry already resolves, so the
# build keeps a single copy of objc2.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-foundation = { version = "0.3", default-features = false, features = [
"std",
"NSDictionary",
"NSError",
"NSObject",
"NSString",
] }
objc2-user-notifications = { version = "0.3", default-features = false, features = [
"std",
"block2",
"UNUserNotificationCenter",
"UNNotification",
"UNNotificationContent",
"UNNotificationRequest",
"UNNotificationResponse",
"UNNotificationSettings",
"UNNotificationSound",
"UNNotificationTrigger",
] }
block2 = "0.6"
# There is no auto-updater and no process to restart on a phone: the store is the update channel.
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-process = "2"
@@ -0,0 +1,28 @@
Return-Path: <[email protected]>
MIME-Version: 1.0
Date: Mon, 31 Aug 2026 18:41:07 +0000
Message-ID: <[email protected]>
Subject: Completed: Studio lease 2026
From: DocuSign NA3 System <[email protected]>
To: Priyanshu Jain <[email protected]>
X-Mailer: Microsoft Outlook 16.0
Content-Type: multipart/mixed; boundary="_004_DM6PR11MB4491_"
--_004_DM6PR11MB4491_
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: quoted-printable
All parties have completed the envelope. You can access the signed copy
from the link below or from the attached PDF.
--_004_DM6PR11MB4491_
Content-Type: application/pdf; name="=?utf-8?Q?Studio_lease_2026_=28sign=C3=A9=29.pdf?="
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="=?utf-8?Q?Studio_lease_2026_=28sign=C3=A9=29.pdf?="
JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBv
Ymo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlw
ZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNTk1IDg0Ml0+PmVuZG9iagp0cmFpbGVy
PDwvUm9vdCAxIDAgUj4+CiUlRU9GCg==
--_004_DM6PR11MB4491_--
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="MessagingEngine">
<domain>fastmail.com</domain>
<displayName>Fastmail</displayName>
<displayShortName>Fastmail</displayShortName>
<incomingServer type="imap">
<hostname>imap.fastmail.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<authentication>OAuth2</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<incomingServer type="pop3">
<hostname>pop.fastmail.com</hostname>
<port>995</port>
<socketType>SSL</socketType>
<authentication>OAuth2</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.fastmail.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<authentication>OAuth2</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<instruction url="https://www.fastmail.help/hc/en-us/articles/1500000278342">
<descr lang="en">Server Names and Ports</descr>
</instruction>
<instruction url="https://www.fastmail.help/hc/en-us/articles/360058753054">
<descr lang="en">Using Fastmail with Mozilla Thunderbird</descr>
</instruction>
</emailProvider>
<webMail>
<loginPage url="https://app.fastmail.com/login/?domain=%EMAILDOMAIN%" />
</webMail>
</clientConfig>
+77
View File
@@ -0,0 +1,77 @@
<clientConfig version="1.1">
<emailProvider id="googlemail.com">
<domain>gmail.com</domain>
<domain>googlemail.com</domain>
<!-- MX, for Google Apps -->
<domain>google.com</domain>
<domain>jazztel.es</domain>
<domain>nyu.edu</domain>
<displayName>Google Mail</displayName>
<displayShortName>GMail</displayShortName>
<incomingServer type="imap">
<hostname>imap.gmail.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>OAuth2</authentication>
<authentication>password-cleartext</authentication>
</incomingServer>
<incomingServer type="pop3">
<hostname>pop.gmail.com</hostname>
<port>995</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>OAuth2</authentication>
<authentication>password-cleartext</authentication>
<pop3>
<leaveMessagesOnServer>true</leaveMessagesOnServer>
</pop3>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.gmail.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>OAuth2</authentication>
<authentication>password-cleartext</authentication>
</outgoingServer>
<documentation url="http://mail.google.com/support/bin/answer.py?answer=13273">
<descr>How to enable IMAP/POP3 in GMail</descr>
</documentation>
<documentation url="http://mail.google.com/support/bin/topic.py?topic=12806">
<descr>How to configure email clients for IMAP</descr>
</documentation>
<documentation url="http://mail.google.com/support/bin/topic.py?topic=12805">
<descr>How to configure email clients for POP3</descr>
</documentation>
<documentation url="http://mail.google.com/support/bin/answer.py?answer=86399">
<descr>How to configure TB 2.0 for POP3</descr>
</documentation>
</emailProvider>
<oAuth2>
<issuer>accounts.google.com</issuer>
<!-- https://developers.google.com/identity/protocols/oauth2/scopes -->
<scope>https://mail.google.com/ https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/carddav</scope>
<authURL>https://accounts.google.com/o/oauth2/auth</authURL>
<tokenURL>https://www.googleapis.com/oauth2/v3/token</tokenURL>
</oAuth2>
<enable visiturl="https://mail.google.com/mail/?ui=2&amp;shva=1#settings/fwdandpop">
<instruction>You need to enable IMAP access</instruction>
</enable>
<webMail>
<loginPage url="https://accounts.google.com/ServiceLogin?service=mail&amp;continue=http://mail.google.com/mail/"/>
<loginPageInfo url="https://accounts.google.com/ServiceLogin?service=mail&amp;continue=http://mail.google.com/mail/">
<username>%EMAILADDRESS%</username>
<usernameField id="Email"/>
<passwordField id="Passwd"/>
<loginButton id="signIn"/>
</loginPageInfo>
</webMail>
</clientConfig>
+99
View File
@@ -0,0 +1,99 @@
<clientConfig version="1.1">
<emailProvider id="posteo.de">
<domain>posteo.de</domain>
<domain>posteo.at</domain>
<domain>posteo.be</domain>
<domain>posteo.ca</domain>
<domain>posteo.ch</domain>
<domain>posteo.cl</domain>
<domain>posteo.co</domain>
<domain>posteo.co.uk</domain>
<domain>posteo.com</domain>
<domain>posteo.com.br</domain>
<domain>posteo.cr</domain>
<domain>posteo.cz</domain>
<domain>posteo.dk</domain>
<domain>posteo.ee</domain>
<domain>posteo.es</domain>
<domain>posteo.eu</domain>
<domain>posteo.fi</domain>
<domain>posteo.gl</domain>
<domain>posteo.gr</domain>
<domain>posteo.hn</domain>
<domain>posteo.hr</domain>
<domain>posteo.hu</domain>
<domain>posteo.ie</domain>
<domain>posteo.in</domain>
<domain>posteo.is</domain>
<domain>posteo.it</domain>
<domain>posteo.jp</domain>
<domain>posteo.la</domain>
<domain>posteo.li</domain>
<domain>posteo.lt</domain>
<domain>posteo.lu</domain>
<domain>posteo.me</domain>
<domain>posteo.mx</domain>
<domain>posteo.my</domain>
<domain>posteo.net</domain>
<domain>posteo.nl</domain>
<domain>posteo.no</domain>
<domain>posteo.nz</domain>
<domain>posteo.org</domain>
<domain>posteo.pe</domain>
<domain>posteo.pl</domain>
<domain>posteo.pm</domain>
<domain>posteo.pt</domain>
<domain>posteo.ro</domain>
<domain>posteo.se</domain>
<domain>posteo.sg</domain>
<domain>posteo.si</domain>
<domain>posteo.tn</domain>
<domain>posteo.uk</domain>
<domain>posteo.us</domain>
<displayName>Posteo</displayName>
<displayShortName>Posteo</displayShortName>
<incomingServer type="imap">
<hostname>posteo.de</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<incomingServer type="imap">
<hostname>posteo.de</hostname>
<port>143</port>
<socketType>STARTTLS</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<incomingServer type="pop3">
<hostname>posteo.de</hostname>
<port>995</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<incomingServer type="pop3">
<hostname>posteo.de</hostname>
<port>110</port>
<socketType>STARTTLS</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>posteo.de</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</outgoingServer>
<outgoingServer type="smtp">
<hostname>posteo.de</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</outgoingServer>
<documentation url="https://posteo.de/hilfe/wie-richte-ich-posteo-in-einem-mailprogramm-ein-pop3-imap-und-smtp"/>
</emailProvider>
</clientConfig>
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="protonmail.com">
<domain>protonmail.com</domain>
<displayName>ProtonMail</displayName>
<displayShortName>ProtonMail</displayShortName>
<incomingServer type="imap">
<hostname>127.0.0.1</hostname>
<port>1143</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>127.0.0.1</hostname>
<port>1025</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<documentation url="https://protonmail.com/support">
<descr lang="en">Support page</descr>
</documentation>
</emailProvider>
</clientConfig>
+106
View File
@@ -0,0 +1,106 @@
Return-Path: <[email protected]>
MIME-Version: 1.0
Date: Wed, 2 Sep 2026 15:40:11 +0530
Message-ID: <[email protected]>
Subject: Invitation: Piano lesson: Cooper @ Wed 9 Sep 2026 17:00 - 17:45 (IST)
([email protected])
From: Sam Okafor <[email protected]>
To: Priyanshu Jain <[email protected]>
Content-Type: multipart/mixed; boundary="mixed-piano-0001"
--mixed-piano-0001
Content-Type: multipart/alternative; boundary="alt-piano-0001"
--alt-piano-0001
Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes
Content-Transfer-Encoding: 7bit
You have been invited to the following event.
Piano lesson: Cooper
When: Wed 9 Sep 2026 17:00 to 17:45 India Standard Time
Where: Sunny Day Music, Bandra
Who: Sam Okafor, Priyanshu Jain
--alt-piano-0001
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<html><body><div style=3D"padding:16px">
<p>You have been invited to the following event.</p>
<h3>Piano lesson: Cooper</h3>
<p><b>When</b> Wed 9 Sep 2026 17:00 to 17:45 India Standard Time<br>
<b>Where</b> Sunny Day Music, Bandra<br>
<b>Who</b> Sam Okafor, Priyanshu Jain</p>
</div></body></html>
--alt-piano-0001
Content-Type: text/calendar; charset=UTF-8; method=REQUEST
Content-Transfer-Encoding: 7bit
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VTIMEZONE
TZID:Asia/Kolkata
X-LIC-LOCATION:Asia/Kolkata
BEGIN:STANDARD
TZOFFSETFROM:+0530
TZOFFSETTO:+0530
TZNAME:IST
DTSTART:19700101T000000
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Asia/Kolkata:20260909T170000
DTEND;TZID=Asia/Kolkata:20260909T174500
DTSTAMP:20260902T101500Z
ORGANIZER;CN=Sunny Day Music:mailto:[email protected]
UID:[email protected]
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=T
RUE;CN=Priyanshu Jain;X-NUM-GUESTS=0:mailto:[email protected]
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=Sam Oka
for;X-NUM-GUESTS=0:mailto:[email protected]
CREATED:20260902T101500Z
DESCRIPTION:First lesson for Cooper. Nothing to prepare\, and there is a pia
no here\, so nothing to carry either.
LAST-MODIFIED:20260902T101500Z
LOCATION:Sunny Day Music\, Bandra
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Piano lesson: Cooper
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR
--alt-piano-0001--
--mixed-piano-0001
Content-Type: application/ics; name="invite.ics"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="invite.ics"
QkVHSU46VkNBTEVOREFSDQpQUk9ESUQ6LS8vR29vZ2xlIEluYy8vR29vZ2xlIENhbGVuZGFyIDcw
LjkwNTQvL0VODQpWRVJTSU9OOjIuMA0KQ0FMU0NBTEU6R1JFR09SSUFODQpNRVRIT0Q6UkVRVUVT
VA0KQkVHSU46VlRJTUVaT05FDQpUWklEOkFzaWEvS29sa2F0YQ0KWC1MSUMtTE9DQVRJT046QXNp
YS9Lb2xrYXRhDQpCRUdJTjpTVEFOREFSRA0KVFpPRkZTRVRGUk9NOiswNTMwDQpUWk9GRlNFVFRP
OiswNTMwDQpUWk5BTUU6SVNUDQpEVFNUQVJUOjE5NzAwMTAxVDAwMDAwMA0KRU5EOlNUQU5EQVJE
DQpFTkQ6VlRJTUVaT05FDQpCRUdJTjpWRVZFTlQNCkRUU1RBUlQ7VFpJRD1Bc2lhL0tvbGthdGE6
MjAyNjA5MDlUMTcwMDAwDQpEVEVORDtUWklEPUFzaWEvS29sa2F0YToyMDI2MDkwOVQxNzQ1MDAN
CkRUU1RBTVA6MjAyNjA5MDJUMTAxNTAwWg0KT1JHQU5JWkVSO0NOPVN1bm55IERheSBNdXNpYzpt
YWlsdG86bGVzc29uc0BzdW5ueWRheW11c2ljLmV4YW1wbGUNClVJRDo0YzliMmY3YS1waWFuby1j
b29wZXJAc3VubnlkYXltdXNpYy5leGFtcGxlDQpBVFRFTkRFRTtDVVRZUEU9SU5ESVZJRFVBTDtS
T0xFPVJFUS1QQVJUSUNJUEFOVDtQQVJUU1RBVD1ORUVEUy1BQ1RJT047UlNWUD1UDQogUlVFO0NO
PVByaXlhbnNodSBKYWluO1gtTlVNLUdVRVNUUz0wOm1haWx0bzpwakA3M2FpLm9yZw0KQVRURU5E
RUU7Q1VUWVBFPUlORElWSURVQUw7Uk9MRT1SRVEtUEFSVElDSVBBTlQ7UEFSVFNUQVQ9QUNDRVBU
RUQ7Q049U2FtIE9rYQ0KIGZvcjtYLU5VTS1HVUVTVFM9MDptYWlsdG86c2FtQHN1bm55ZGF5bXVz
aWMuZXhhbXBsZQ0KQ1JFQVRFRDoyMDI2MDkwMlQxMDE1MDBaDQpERVNDUklQVElPTjpGaXJzdCBs
ZXNzb24gZm9yIENvb3Blci4gTm90aGluZyB0byBwcmVwYXJlXCwgYW5kIHRoZXJlIGlzIGEgcGlh
DQogbm8gaGVyZVwsIHNvIG5vdGhpbmcgdG8gY2FycnkgZWl0aGVyLg0KTEFTVC1NT0RJRklFRDoy
MDI2MDkwMlQxMDE1MDBaDQpMT0NBVElPTjpTdW5ueSBEYXkgTXVzaWNcLCBCYW5kcmENClNFUVVF
TkNFOjANClNUQVRVUzpDT05GSVJNRUQNClNVTU1BUlk6UGlhbm8gbGVzc29uOiBDb29wZXINClRS
QU5TUDpPUEFRVUUNCkVORDpWRVZFTlQNCkVORDpWQ0FMRU5EQVINCg==
--mixed-piano-0001--
@@ -0,0 +1,17 @@
Return-Path: <[email protected]>
MIME-Version: 1.0
Date: Tue, 1 Sep 2026 14:07:33 +0900
Message-ID: <[email protected]>
Subject: =?ISO-2022-JP?B?GyRCMnE1RCROO3FOQSRyQXckaiReJDkbKEI=?=
From: =?ISO-2022-JP?B?GyRCOjRGIxsoQiAbJEIyVjtSGyhC?= <[email protected]>
To: Priyanshu Jain <[email protected]>
X-Mailer: Becky! ver. 2.75
Content-Type: text/plain; charset=ISO-2022-JP
Content-Transfer-Encoding: 7bit
$B$*@$OC$K$J$C$F$*$j$^$9!#:4F#$G$9!#(B
$BMh=5$N2q5D$N;qNA$r$*Aw$j$7$^$9!#$43NG'$N$&$(!"$40U8+$r$$$?$@$1$^$9$H(B
$B9,$$$G$9!#(B
$B$h$m$7$/$*4j$$$$$?$7$^$9!#(B
+21
View File
@@ -0,0 +1,21 @@
Return-Path: <[email protected]>
MIME-Version: 1.0
Date: Sun, 30 Aug 2026 19:38:12 +0200
Message-ID: <[email protected]>
Subject: =?iso-8859-1?Q?Votre_r=E9servation_est_confirm=E9e?=
From: =?iso-8859-1?Q?Th=E9=E2tre_du_Ch=E2telet?= <[email protected]>
To: [email protected]
Content-Type: text/plain; charset=iso-8859-1
Content-Transfer-Encoding: 8bit
Bonjour,
Votre réservation est confirmée pour la deuxième soirée. Les places sont
au rang H, côté jardin, et le théâtre ouvre à dix-neuf heures.
Merci de présenter ce message à l'entrée. Aucun billet papier ne sera
envoyé.
Bien cordialement,
La billetterie
Théâtre du Châtelet
+16
View File
@@ -0,0 +1,16 @@
Return-Path: <[email protected]>
MIME-Version: 1.0
Date: Tue, 1 Sep 2026 08:15:33 +0000
Message-ID: <[email protected]>
Subject: Fwd: Dock schedule for the week
From: Harbour Line Ops <[email protected]>
To: Priyanshu Jain <[email protected]>
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
<html><body>
<p>Forwarding the schedule. The chart was in the original.</p>
<p><img src="cid:[email protected]" width="480" height="320"
alt="Dock schedule chart"></p>
<p>Nothing else changed this week.</p>
</body></html>
@@ -0,0 +1,19 @@
Return-Path: <[email protected]>
MIME-Version: 1.0
Date: Mon, 24 Aug 2026 17:55:30 -0400
Message-ID: <[email protected]>
Subject: Pumpkin bread recipe
From: "Young, Russell \"Russ\"" <[email protected]>
To: "Jain, Priyanshu" <[email protected]>,
"Weiss, Hannah (nee Fischer)" <[email protected]>
Reply-To: "Young, Russell \"Russ\"" <[email protected]>
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
From my mother's card, transcribed as best I could. Bake at 175 for
fifty minutes, and do not open the oven before forty.
Two loaves, or one loaf and twelve muffins. The muffins take half the
time and are better the next day.
Russ
@@ -0,0 +1,20 @@
Return-Path: <[email protected]>
MIME-Version: 1.0
Date: Thu, 3 Sep 2026 09:03:20 +0530
Message-ID: <[email protected]>
Subject: =?UTF-8?B?WW91ciByZXNlcnZhdGlvbiBpbiBMaXNib24gaXMgY29uZmlybWVkLCBJbsM=?=
=?UTF-8?B?qnMgaXMgZXhwZWN0aW5nIHlvdQ==?=
From: Airbnb <[email protected]>
Reply-To: =?UTF-8?Q?In=C3=AAs?= <[email protected]>
To: [email protected]
X-Auto-Response-Suppress: All
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Check-in Friday 12 September after 15:00. Your host Inês will send the
door code the evening before.
Rua da Bica de Duarte Belo 42, Lisboa. The tram stops at the top of the
street and the walk down is five minutes.
Airbnb
@@ -0,0 +1,15 @@
Return-Path: <[email protected]>
MIME-Version: 1.0
Date: Sun, 30 Aug 2026 21:04:55 +0100
Message-ID: <[email protected]>
Subject: =?UTF-8?B?TGVzIE1pc8OpcmFibGVzIHRpY2tldHMsIHNlY29uZCBuaWdodA==?=
From: Caroline Bauhaus <[email protected]>
To: Priyanshu Jain <[email protected]>, Maya Raghunathan <[email protected]>
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: base64
SSBnb3QgZm91ciBmb3IgdGhlIHNlY29uZCBuaWdodCByYXRoZXIgdGhhbiB0aGUgZmlyc3QsIHRo
ZSBzZWF0cyBhcmUKYmV0dGVyIGFuZCBpdCBpcyB0ZW4gcG91bmRzIGxlc3MgZWFjaC4gUm93IEgs
IHNsaWdodGx5IGxlZnQgb2YgY2VudHJlLgoKTm9ib2R5IGhhcyB0byBwYXkgbWUgYmFjayB1bnRp
bCBhZnRlciwgYW5kIGlmIEthcnRoaWsgZHJvcHMgb3V0IEkga25vdwp0d28gcGVvcGxlIHdobyB3
b3VsZCB0YWtlIGhpcy4KCkNhcm9saW5lCg==
@@ -0,0 +1,17 @@
Return-Path: <[email protected]>
MIME-Version: 1.0
Date: Wed, 2 Sep 2026 17:26:41 +0200
Message-ID: <[email protected]>
Subject: =?utf-8?Q?Angebot=3A_K=C3=BCchenarbeitsplatte_in_Eiche_massi?=
=?utf-8?Q?v_=28Nachtrag=29?=
From: =?iso-8859-1?Q?Lena_Brandt_=28Tischlerei_M=FCnchen=29?= <[email protected]>
To: Priyanshu Jain <[email protected]>
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sounds good, Julie. Any afternoon next week works for me.
Der Nachtrag betrifft nur die Oberflaeche: geoelt statt lackiert, wie
besprochen. Die Kante bleibt gerade.
Lena
@@ -0,0 +1,29 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: DocuSign NA3 System <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788201667000
subject: Completed: Studio lease 2026
is-html: false
surface: theme
snippet: All parties have completed the envelope. You can access the signed copy from the link below or from the attached PDF.
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
attachment: part=2 name="Studio lease 2026 (signé).pdf" type=application/pdf size=193 inline=false cid=- sha256=e5a636ac00ea3c17
--- html ---
<p>All parties have completed the envelope. You can access the signed copy from the link below or from the attached PDF.</p>
--- quoted: none ---
--- text ---
All parties have completed the envelope. You can access the signed copy
from the link below or from the attached PDF.
@@ -0,0 +1,47 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Sam Okafor <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788343811000
subject: Invitation: Piano lesson: Cooper @ Wed 9 Sep 2026 17:00 - 17:45 (IST) ([email protected])
is-html: true
surface: theme
snippet: You have been invited to the following event. Piano lesson: Cooper When Wed 9 Sep 2026 17:00 to 17:45 India Standard Time Where Sunny Day Music, Bandra Who Sam Okafor, Priyanshu Jain
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
invite-uid: [email protected]
invite-summary: Piano lesson: Cooper
invite-start-ms: 1788953400000
invite-end-ms: 1788956100000
invite-all-day: false
invite-location: Sunny Day Music, Bandra
invite-organizer: Sunny Day Music <[email protected]>
invite-my-response: NeedsAction
invite-description: First lesson for Cooper. Nothing to prepare, and there is a piano here, so nothing to carry either.
--- html ---
<div style="padding:16px">
<p>You have been invited to the following event.</p>
<h3>Piano lesson: Cooper</h3>
<p><b>When</b> Wed 9 Sep 2026 17:00 to 17:45 India Standard Time<br>
<b>Where</b> Sunny Day Music, Bandra<br>
<b>Who</b> Sam Okafor, Priyanshu Jain</p>
</div>
--- quoted: none ---
--- text ---
You have been invited to the following event.
Piano lesson: Cooper
When: Wed 9 Sep 2026 17:00 to 17:45 India Standard Time
Where: Sunny Day Music, Bandra
Who: Sam Okafor, Priyanshu Jain
@@ -0,0 +1,35 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: 佐藤 花子 <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788239253000
subject: 会議の資料を送ります
is-html: false
surface: theme
snippet: お世話になっております。佐藤です。 来週の会議の資料をお送りします。ご確認のうえ、ご意見をいただけますと 幸いです。 よろしくお願いいたします。
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>お世話になっております。佐藤です。</p>
<p>来週の会議の資料をお送りします。ご確認のうえ、ご意見をいただけますと<br>
幸いです。</p>
<p>よろしくお願いいたします。</p>
--- quoted: none ---
--- text ---
お世話になっております。佐藤です。
来週の会議の資料をお送りします。ご確認のうえ、ご意見をいただけますと
幸いです。
よろしくお願いいたします。
@@ -0,0 +1,42 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Théâtre du Châtelet <[email protected]>
to: <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788111492000
subject: Votre réservation est confirmée
is-html: false
surface: theme
snippet: Bonjour, Votre réservation est confirmée pour la deuxième soirée. Les places sont au rang H, côté jardin, et le théâtre ouvre à dix-neuf heures. Merci de présenter ce message à l'entrée. Aucun billet…
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>Bonjour,</p>
<p>Votre réservation est confirmée pour la deuxième soirée. Les places sont au rang H, côté jardin, et le théâtre ouvre à dix-neuf heures.</p>
<p>Merci de présenter ce message à l'entrée. Aucun billet papier ne sera envoyé.</p>
<p>Bien cordialement,<br>
La billetterie<br>
Théâtre du Châtelet</p>
--- quoted: none ---
--- text ---
Bonjour,
Votre réservation est confirmée pour la deuxième soirée. Les places sont
au rang H, côté jardin, et le théâtre ouvre à dix-neuf heures.
Merci de présenter ce message à l'entrée. Aucun billet papier ne sera
envoyé.
Bien cordialement,
La billetterie
Théâtre du Châtelet
@@ -0,0 +1,30 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Harbour Line Ops <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788250533000
subject: Fwd: Dock schedule for the week
is-html: true
surface: theme
snippet: Forwarding the schedule. The chart was in the original. Nothing else changed this week.
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>Forwarding the schedule. The chart was in the original.</p>
<p><img alt="Dock schedule chart" width="480" height="320"></p>
<p>Nothing else changed this week.</p>
--- quoted: none ---
--- text ---
Forwarding the schedule. The chart was in the original. Nothing else changed this week.
@@ -0,0 +1,35 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Young, Russell "Russ" <[email protected]>
to: Jain, Priyanshu <[email protected]>, Weiss, Hannah (nee Fischer) <[email protected]>
cc: -
bcc: -
reply-to: Young, Russell "Russ" <[email protected]>
date-ms: 1787608530000
subject: Pumpkin bread recipe
is-html: false
surface: theme
snippet: From my mother's card, transcribed as best I could. Bake at 175 for fifty minutes, and do not open the oven before forty. Two loaves, or one loaf and twelve muffins. The muffins take half the time…
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>From my mother's card, transcribed as best I could. Bake at 175 for fifty minutes, and do not open the oven before forty.</p>
<p>Two loaves, or one loaf and twelve muffins. The muffins take half the time and are better the next day.</p>
<p>Russ</p>
--- quoted: none ---
--- text ---
From my mother's card, transcribed as best I could. Bake at 175 for
fifty minutes, and do not open the oven before forty.
Two loaves, or one loaf and twelve muffins. The muffins take half the
time and are better the next day.
Russ
@@ -0,0 +1,35 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Airbnb <[email protected]>
to: <[email protected]>
cc: -
bcc: -
reply-to: Inês <[email protected]>
date-ms: 1788406400000
subject: Your reservation in Lisbon is confirmed, Inês is expecting you
is-html: false
surface: theme
snippet: Check-in Friday 12 September after 15:00. Your host Inês will send the door code the evening before. Rua da Bica de Duarte Belo 42, Lisboa. The tram stops at the top of the street and the walk down…
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>Check-in Friday 12 September after 15:00. Your host Inês will send the door code the evening before.</p>
<p>Rua da Bica de Duarte Belo 42, Lisboa. The tram stops at the top of the street and the walk down is five minutes.</p>
<p>Airbnb</p>
--- quoted: none ---
--- text ---
Check-in Friday 12 September after 15:00. Your host Inês will send the
door code the evening before.
Rua da Bica de Duarte Belo 42, Lisboa. The tram stops at the top of the
street and the walk down is five minutes.
Airbnb
@@ -0,0 +1,35 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Caroline Bauhaus <[email protected]>
to: Priyanshu Jain <[email protected]>, Maya Raghunathan <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788120295000
subject: Les Misérables tickets, second night
is-html: false
surface: theme
snippet: I got four for the second night rather than the first, the seats are better and it is ten pounds less each. Row H, slightly left of centre. Nobody has to pay me back until after, and if Karthik drops…
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>I got four for the second night rather than the first, the seats are better and it is ten pounds less each. Row H, slightly left of centre.</p>
<p>Nobody has to pay me back until after, and if Karthik drops out I know two people who would take his.</p>
<p>Caroline</p>
--- quoted: none ---
--- text ---
I got four for the second night rather than the first, the seats are
better and it is ten pounds less each. Row H, slightly left of centre.
Nobody has to pay me back until after, and if Karthik drops out I know
two people who would take his.
Caroline
@@ -0,0 +1,34 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Lena Brandt (Tischlerei München) <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788362801000
subject: Angebot: Küchenarbeitsplatte in Eiche massiv (Nachtrag)
is-html: false
surface: theme
snippet: Sounds good, Julie. Any afternoon next week works for me. Der Nachtrag betrifft nur die Oberflaeche: geoelt statt lackiert, wie besprochen. Die Kante bleibt gerade. Lena
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>Sounds good, Julie. Any afternoon next week works for me.</p>
<p>Der Nachtrag betrifft nur die Oberflaeche: geoelt statt lackiert, wie besprochen. Die Kante bleibt gerade.</p>
<p>Lena</p>
--- quoted: none ---
--- text ---
Sounds good, Julie. Any afternoon next week works for me.
Der Nachtrag betrifft nur die Oberflaeche: geoelt statt lackiert, wie
besprochen. Die Kante bleibt gerade.
Lena
@@ -0,0 +1,38 @@
render-version: 2
message-id: [email protected]
in-reply-to: [email protected]
references-first: [email protected]
from: Jeff Wolfe <[email protected]>
to: <[email protected]>, <[email protected]>, Bauhaus, Caroline <[email protected]>
cc: <[email protected]>
bcc: -
reply-to: -
date-ms: 1788382927000
subject: Re: Cooper's parent-teacher conference
is-html: false
surface: theme
snippet: Mr. and Mrs. Young, this is just a reminder to schedule your parent-teacher conference with me. You can feel free to do that here. If you would prefer a video call instead of meeting at school, I am…
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>Mr. and Mrs. Young, this is just a reminder to schedule your parent-teacher conference with me. You can feel free to do that here.</p>
<p>If you would prefer a video call instead of meeting at school, I am happy to do that as well. Please let me know what date and time works best for you.</p>
<p>Jeff Wolfe<br>
Year 4, Oakridge</p>
--- quoted: none ---
--- text ---
Mr. and Mrs. Young, this is just a reminder to schedule your
parent-teacher conference with me. You can feel free to do that here.
If you would prefer a video call instead of meeting at school, I am happy
to do that as well. Please let me know what date and time works best for
you.
Jeff Wolfe
Year 4, Oakridge
@@ -0,0 +1,31 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Ledger Lines <[email protected]>
to: <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788067838000
subject: The bond market, again
is-html: false
surface: theme
snippet: Rates did the one thing nobody had written a note about, which is nothing at all. Here is what that means for the long end. The full piece is on the site. This mail is the summary.
list-id: weekly.ledgerlines.example
auto-submitted: -
precedence: -
unsubscribe: one-click=false mailto=mailto:[email protected] url=-
blocked-images: 0
--- html ---
<p>Rates did the one thing nobody had written a note about, which is nothing at all. Here is what that means for the long end.</p>
<p>The full piece is on the site. This mail is the summary.</p>
--- quoted: none ---
--- text ---
Rates did the one thing nobody had written a note about, which is
nothing at all. Here is what that means for the long end.
The full piece is on the site. This mail is the summary.
@@ -0,0 +1,43 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Discount Tire <[email protected]>
to: <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1787752800000
subject: Your air pressure reminder
is-html: true
surface: theme
snippet: It has been 30 days since your last check. Stop by any store for a free air pressure check, no appointment needed. Book now Find a store near you
list-id: -
auto-submitted: -
precedence: -
unsubscribe: one-click=false mailto=- url=https://discounttire.example/u/4471
blocked-images: 0
--- html ---
<div>
<p>It has been 30 days since your last check.
Stop by any store for a free air pressure check, no appointment needed.</p>
</div>
<p><a target="_blank" rel="noopener noreferrer">Book
now</a></p>
<p><a href="https://discounttire.example/stores" target="_blank" rel="noopener noreferrer">Find a store near you</a></p>
--- quoted: none ---
--- text ---
It has been 30 days since your last check. Stop by any store for a free air pressure check, no appointment needed. Book now Find a store near you
@@ -0,0 +1,30 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Clare Dunn <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788182527000
subject: The signature block, and one photo
is-html: true
surface: theme
snippet: The photo you asked for is below. Clare
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>The photo you asked for is below.</p>
<p><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" width="600" height="400" alt="The site"></p>
<p>Clare</p>
--- quoted: none ---
--- text ---
The photo you asked for is below. Clare
@@ -0,0 +1,36 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Hannah Weiss <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788085180000
subject: Photos from the Hawaii trip
is-html: true
surface: theme
snippet: Finally went through them all. The ones from the ridge walk are the best of the lot, two of them are below and the rest are in the folder. Hannah
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<div>
<p>Finally went through them all. The ones from the ridge walk are the
best of the lot, two of them are below and the rest are in the folder.</p>
<p><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAACJ0lEQVR42g3Loc6GIBQA0P9xvmg0EAgEAoFgYMxwA2MEAmOGm5jBwEwkRjA4EsnkA/6efv5+mkyazppRzbkWUkulF9DK6TVoQG127bL2RYemt1vj3w/IBHQGRoFzEBKkggVAOVgDAILZwWXwBUKD7YYvWDJZOltGLedWSCuVXcAqZ9dgAa3ZrcvWFxua3W77BU8mT2fPqOfcC+ml8gt45fwaPKA3u3fZ++JD89vtvxDJFOkcGY2cRyGjVHGBqFxcQwSMZo8uR19iaHG74xeQTEhnZBQ5RyFRKlwAlcM1ICCaHV1GXzA03G78QiJTonNiNHGehExSpQWScmkNCTCZPbmcfEmhpe1OXzjIdND5YPTg/BDykOpY4FDuWMMBeJj9cPnw5Qjt2O7jCyeZTjqfjJ6cn0KeUp0LnMqdazgBT7OfLp++nKGd231+oZCp0LkwWjgvQhapygJFubKGAljMXlwuvpTQynaXL1QyVTpXRivnVcgqVV2gKlfXUAGr2avL1ZcaWt3u+oWLTBedL0Yvzi8hL6muBS7lrjVcgJfZL5cvX67Qru2+vtDJ1OncGe2cdyG7VH2BrlxfQwfsZu8ud196aH27+xcGmQadB6OD8yHkkGosMJQbaxiAw+zD5eHLCG1s9/jCQ6aHzg+jD+ePkI9UzwKPcs8aHsDH7I/Ljy9PaM92P194yfTS+WX05fwV8pXqXeBV7l3DC/ia/XX59eUN7d3uF/8BbBpFEE55jaYAAAAASUVORK5CYII=" alt="The ridge at seven" width="16" height="16"></p>
<p><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAACJ0lEQVR42g3Loc6GIBQA0P9xvmg0EAgEAoFgYMxwA2MEAmOGm5jBwEwkRjA4EsnkA/6efv5+mkyazppRzbkWUkulF9DK6TVoQG127bL2RYemt1vj3w/IBHQGRoFzEBKkggVAOVgDAILZwWXwBUKD7YYvWDJZOltGLedWSCuVXcAqZ9dgAa3ZrcvWFxua3W77BU8mT2fPqOfcC+ml8gt45fwaPKA3u3fZ++JD89vtvxDJFOkcGY2cRyGjVHGBqFxcQwSMZo8uR19iaHG74xeQTEhnZBQ5RyFRKlwAlcM1ICCaHV1GXzA03G78QiJTonNiNHGehExSpQWScmkNCTCZPbmcfEmhpe1OXzjIdND5YPTg/BDykOpY4FDuWMMBeJj9cPnw5Qjt2O7jCyeZTjqfjJ6cn0KeUp0LnMqdazgBT7OfLp++nKGd231+oZCp0LkwWjgvQhapygJFubKGAljMXlwuvpTQynaXL1QyVTpXRivnVcgqVV2gKlfXUAGr2avL1ZcaWt3u+oWLTBedL0Yvzi8hL6muBS7lrjVcgJfZL5cvX67Qru2+vtDJ1OncGe2cdyG7VH2BrlxfQwfsZu8ud196aH27+xcGmQadB6OD8yHkkGosMJQbaxiAw+zD5eHLCG1s9/jCQ6aHzg+jD+ePkI9UzwKPcs8aHsDH7I/Ljy9PaM92P194yfTS+WX05fwV8pXqXeBV7l3DC/ia/XX59eUN7d3uF/8BbBpFEE55jaYAAAAASUVORK5CYII=" alt="Looking back down" width="16" height="16"></p>
<p>Hannah</p>
</div>
--- quoted: none ---
--- text ---
Finally went through them all. The ones from the ridge walk are the best
of the lot, two of them are below and the rest are in the folder.
Hannah
@@ -0,0 +1,37 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Nadia Osei <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788343491000
subject: Invitation: Quarterly review @ Thu 15 Oct 2026 14:00 - 15:00 (BST)
is-html: false
surface: theme
snippet: You have been invited to Quarterly review on Thursday 15 October 2026, 14:00 to 15:00 London time.
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
invite-uid: [email protected]
invite-summary: Quarterly review
invite-start-ms: 1792069200000
invite-end-ms: 1792072800000
invite-all-day: false
invite-location: Westferry, meeting room 2
invite-organizer: Nadia Osei <[email protected]>
invite-my-response: NeedsAction
invite-description: The quarterly numbers, then the roadmap. Bring the deck.
--- html ---
<p>You have been invited to Quarterly review on Thursday 15 October 2026, 14:00 to 15:00 London time.</p>
--- quoted: none ---
--- text ---
You have been invited to Quarterly review on Thursday 15 October 2026,
14:00 to 15:00 London time.
@@ -0,0 +1,44 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: City Power <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788383091000
subject: Bill payment pending
is-html: true
surface: theme
snippet: Dear Customer, Your one-time electronic payment of 98.26 for your City Power bill has been received and is pending. Your payment will be posted within two business days. Confirmation number…
list-id: -
auto-submitted: auto-generated
precedence: -
unsubscribe: -
blocked-images: 0
attachment: part=2 name="Statement-August.pdf" type=application/pdf size=193 inline=false cid=- sha256=e5a636ac00ea3c17
--- html ---
<p>Dear Customer,</p>
<p>Your one-time electronic payment of <b>98.26</b> for your City Power bill has been received and is pending. Your payment will be posted within two business days.</p>
<p>Confirmation number 116400046977232</p>
<p>Thank you,<br>City Power Customer Care</p>
--- quoted: none ---
--- text ---
Dear Customer,
Your one-time electronic payment of 98.26 for your City Power bill has
been received and is pending. Your payment will be posted within two
business days.
Confirmation number 116400046977232
Please keep this message for your records. If you did not make this
payment, call us on the number printed on your statement.
Thank you,
City Power Customer Care
@@ -0,0 +1,42 @@
render-version: 2
message-id: 01000198a2f4c1b2-9b0c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e@thebrowser.example
in-reply-to: -
references-first: -
from: The Browser <[email protected]>
to: <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788411603000
subject: Five things worth reading this weekend
is-html: true
surface: theme
snippet: Good morning. It is a long weekend in some places and a wet one in most, so this edition leans toward pieces you can settle into. The pencil, at length. Henry Petroski wrote a whole book about the…
list-id: the-browser.list.thebrowser.example
auto-submitted: -
precedence: bulk
unsubscribe: one-click=true mailto=mailto:[email protected]?subject=unsub url=https://thebrowser.example/unsubscribe?u=91827&id=8f3a1c
blocked-images: 0
--- html ---
<p>Good morning. It is a long weekend in some places and a wet one in most, so this edition leans toward pieces you can settle into.</p>
<p><b>The pencil, at length.</b> Henry Petroski wrote a whole book about the pencil and this essay is the argument for why that was a reasonable thing to do. Graphite, cedar, the Napoleonic wars and a factory in Nuremberg that still runs.</p>
<p><b>Why bridges hum.</b> A short explanation of vortex shedding from an engineer who spent a career listening to cables.</p>
<p><b>The last typewriter repairman in Mumbai.</b> A profile that is really about what it means to keep a trade going after the trade has gone.</p>
<p><a href="https://thebrowser.example/unsubscribe?u=91827&amp;id=8f3a1c" target="_blank" rel="noopener noreferrer">Unsubscribe</a></p>
--- quoted: none ---
--- text ---
Good morning. It is a long weekend in some places and a wet one in most,
so this edition leans toward pieces you can settle into. A long piece on
the history of the pencil, a short one on why bridges hum, and three more.
THE PENCIL, AT LENGTH. Henry Petroski wrote a whole book about the pencil
and this essay is the argument for why that was a reasonable thing to do.
WHY BRIDGES HUM. A short explanation of vortex shedding from an engineer
who spent a career listening to cables.
Unsubscribe: https://thebrowser.example/unsubscribe?u=91827&id=8f3a1c
@@ -0,0 +1,32 @@
render-version: 2
message-id: -
in-reply-to: -
references-first: -
from: Sunny Day Music <[email protected]>
to: <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1786512739000
subject: Enrolment received for Cooper
is-html: false
surface: theme
snippet: Enrolment received for Cooper. Term starts the week of 8 September and your teacher will be in touch to fix a weekly slot. Nothing further is needed from you today. The term dates and the studio…
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>Enrolment received for Cooper. Term starts the week of 8 September and your teacher will be in touch to fix a weekly slot.</p>
<p>Nothing further is needed from you today. The term dates and the studio address are on the enrolment form you signed.</p>
--- quoted: none ---
--- text ---
Enrolment received for Cooper. Term starts the week of 8 September and
your teacher will be in touch to fix a weekly slot.
Nothing further is needed from you today. The term dates and the studio
address are on the enrolment form you signed.
@@ -0,0 +1,48 @@
render-version: 2
message-id: AM0PR07MB5218F1C0D9E2A4E1B@AM0PR07MB5218.eurprd07.prod.outlook.com
in-reply-to: [email protected]
references-first: [email protected]
from: Young, Russell <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788277269000
subject: RE: The lease, clause 14
is-html: true
surface: theme
snippet: Clause 14 is the standard form. I would not sign it as drafted. Russell
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>Clause 14 is the standard form. I would not sign it as drafted.</p>
<p>Russell</p>
<div style="border:none;border-top:solid #E1E1E1 1.0pt;padding:3.0pt 0cm 0cm 0cm">
</div>
--- quoted ---
<hr>
<div>
<p><b>From:</b> Priyanshu Jain &lt;[email protected]&gt;<br>
<b>Sent:</b> Tuesday, 1 September 2026 12:00<br>
<b>Subject:</b> The lease, clause 14</p>
<p>Russell, is clause 14 negotiable or is it the landlord's standard form?</p>
</div>
--- text ---
Clause 14 is the standard form. I would not sign it as drafted.
Russell
-----Original Message-----
From: Priyanshu Jain <[email protected]>
Sent: Tuesday, 1 September 2026 12:00
To: Young, Russell <[email protected]>
Subject: The lease, clause 14
Russell, is clause 14 negotiable or is it the landlord's standard form?
+36
View File
@@ -0,0 +1,36 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Maya Raghunathan <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788415929000
subject: Dinner on Thursday?
is-html: false
surface: theme
snippet: Priya said the place on Church Street takes bookings now, want me to put us down for four at eight? Everyone can make Thursday except Karthik, who has the badminton thing and says he will come late…
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>Priya said the place on Church Street takes bookings now, want me to put us down for four at eight? Everyone can make Thursday except Karthik, who has the badminton thing and says he will come late.</p>
<p>If you would rather somewhere quieter there is the Malleswaram place we went to in June. Say by tomorrow and I will call them.</p>
<p>Maya</p>
--- quoted: none ---
--- text ---
Priya said the place on Church Street takes bookings now, want me to put
us down for four at eight? Everyone can make Thursday except Karthik, who
has the badminton thing and says he will come late.
If you would rather somewhere quieter there is the Malleswaram place we
went to in June. Say by tomorrow and I will call them.
Maya
@@ -0,0 +1,34 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Field Notes Dispatch <[email protected]>
to: <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788168600000
subject: September: the notebook that survived a washing machine
is-html: false
surface: theme
snippet: A reader in Tromsø sent us a photo of a pocket notebook that went through a full cycle at forty degrees, in a coat, with the coat. Every page is still legible. Also this month: the autumn edition…
list-id: dispatch.fieldnotes.example
auto-submitted: -
precedence: -
unsubscribe: one-click=false mailto=- url=https://fieldnotes.example/u/7712
blocked-images: 0
--- html ---
<p>A reader in Tromsø sent us a photo of a pocket notebook that went through a full cycle at forty degrees, in a coat, with the coat. Every page is still legible.</p>
<p>Also this month: the autumn edition ships on the 15th. Three colours, the usual count. The paper weight is unchanged at 100 g/m², and the price stays at €12 for the three pack.</p>
<p>The equals sign is written = when it stands alone, which is the part of this encoding that catches parsers out. Trailing whitespace is kept by encoding it, like the space at the end of the previous line.</p>
--- quoted: none ---
--- text ---
A reader in Tromsø sent us a photo of a pocket notebook that went through a full cycle at forty degrees, in a coat, with the coat. Every page is still legible.
Also this month: the autumn edition ships on the 15th. Three colours, the usual count. The paper weight is unchanged at 100 g/m², and the price stays at €12 for the three pack.
The equals sign is written = when it stands alone, which is the part of this encoding that catches parsers out. Trailing whitespace is kept
by encoding it, like the space at the end of the previous line.
@@ -0,0 +1,47 @@
render-version: 2
message-id: [email protected]
in-reply-to: [email protected]
references-first: [email protected]
from: Dev Patel <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788191095000
subject: Re: Slides from the talk
is-html: true
surface: theme
snippet: Here they are, plus the reading list I mentioned. The paper on cache oblivious layouts is the one to start with.
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<div dir="ltr">Here they are, plus the reading list I mentioned. The paper
on cache oblivious layouts is the one to start with.</div>
--- quoted ---
<br>
<div>
<div dir="ltr">On Mon, 31 Aug 2026 at 18:40, Priyanshu
Jain &lt;<a href="mailto:[email protected]" target="_blank" rel="noopener noreferrer">[email protected]</a>&gt; wrote:</div>
<blockquote style="margin:0 0 0 0.8ex;border-left:1px solid #ccc;padding-left:1ex">
<div dir="ltr">Good talk. Could you send the slides, and the reading list
you put up at the end?</div>
<br>
<div>
<div dir="ltr">On Fri, 28 Aug 2026 at 12:02, Dev Patel
&lt;<a href="mailto:[email protected]" target="_blank" rel="noopener noreferrer">[email protected]</a>&gt;
wrote:</div>
<blockquote style="margin:0 0 0 0.8ex;border-left:1px solid #ccc;padding-left:1ex">
<div dir="ltr">Talk is on Monday at six, room 4.02. Come if you are free.</div>
</blockquote>
</div>
</blockquote>
</div>
--- text ---
Here they are, plus the reading list I mentioned. The paper on cache
oblivious layouts is the one to start with.
@@ -0,0 +1,61 @@
render-version: 2
message-id: [email protected]
in-reply-to: [email protected]
references-first: [email protected]
from: Sam Okafor <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788343802000
subject: Re: Fw: (no subject)
is-html: false
surface: theme
snippet: Got it, thank you. Wednesdays at five work for us. The first lesson is on the 9th, nothing to prepare. The invitation is attached, it should land in your calendar. Sam
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>Got it, thank you. Wednesdays at five work for us.</p>
<p>The first lesson is on the 9th, nothing to prepare. The invitation is attached, it should land in your calendar.</p>
<p>Sam</p>
--- quoted ---
<p>On Mon, 31 Aug 2026 at 09:12, Priyanshu Jain &lt;[email protected]&gt; wrote:<br>
&gt; Hi Sam, form signed and attached. Would a weekday after school suit<br>
&gt; Cooper? He finishes at four and we are ten minutes away on foot.<br>
&gt;<br>
&gt; On Wed, 12 Aug 2026 at 11:02, Sunny Day Music<br>
&gt; &lt;[email protected]&gt; wrote:<br>
&gt; &gt; Enrolment received for Cooper. Term starts the week of 8 September<br>
&gt; &gt; and your teacher will be in touch to fix a weekly slot.<br>
&gt; &gt;<br>
&gt; &gt; Nothing further is needed from you today.<br>
&gt;<br>
&gt; Thanks,<br>
&gt; Priyanshu</p>
--- text ---
Got it, thank you. Wednesdays at five work for us.
The first lesson is on the 9th, nothing to prepare. The invitation is
attached, it should land in your calendar.
Sam
On Mon, 31 Aug 2026 at 09:12, Priyanshu Jain <[email protected]> wrote:
> Hi Sam, form signed and attached. Would a weekday after school suit
> Cooper? He finishes at four and we are ten minutes away on foot.
>
> On Wed, 12 Aug 2026 at 11:02, Sunny Day Music
> <[email protected]> wrote:
> > Enrolment received for Cooper. Term starts the week of 8 September
> > and your teacher will be in touch to fix a weekly slot.
> >
> > Nothing further is needed from you today.
>
> Thanks,
> Priyanshu
@@ -0,0 +1,32 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Spotify <[email protected]>
to: <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788417018000
subject: Your receipt
is-html: false
surface: theme
snippet: Thank you for purchasing Spotify Premium. Amount charged 11.99, on the card ending 4417. Your next payment is due on 3 October 2026. This mailbox is not monitored. Manage your subscription from the…
list-id: -
auto-submitted: auto-generated
precedence: bulk
unsubscribe: -
blocked-images: 0
--- html ---
<p>Thank you for purchasing Spotify Premium. Amount charged 11.99, on the card ending 4417. Your next payment is due on 3 October 2026.</p>
<p>This mailbox is not monitored. Manage your subscription from the account page.</p>
--- quoted: none ---
--- text ---
Thank you for purchasing Spotify Premium. Amount charged 11.99, on the
card ending 4417. Your next payment is due on 3 October 2026.
This mailbox is not monitored. Manage your subscription from the account
page.
@@ -0,0 +1,41 @@
render-version: 2
message-id: [email protected]
in-reply-to: [email protected]
references-first: [email protected]
from: Arun Kulkarni <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: Meridian Leasing <[email protected]>
bcc: -
reply-to: -
date-ms: 1788410702000
subject: Re: Lease renewal for the studio
is-html: false
surface: theme
snippet: Hi Priyanshu, Attached the revised draft. The only change is clause 7, which now reads three months either side rather than six. The rent review in clause 12 stays as it was, tied to the index and…
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>Hi Priyanshu,</p>
<p>Attached the revised draft. The only change is clause 7, which now reads three months either side rather than six. The rent review in clause 12 stays as it was, tied to the index and capped at four percent.</p>
<p>If that works, sign when you get a moment and I will countersign the same day. Happy to walk through anything on a call, Thursday afternoon is open.</p>
<p>Best,<br>
Arun</p>
--- quoted: none ---
--- text ---
Hi Priyanshu,
Attached the revised draft. The only change is clause 7, which now reads
three months either side rather than six. The rent review in clause 12
stays as it was, tied to the index and capped at four percent.
If that works, sign when you get a moment and I will countersign the same
day. Happy to walk through anything on a call, Thursday afternoon is open.
Best,
Arun
@@ -0,0 +1,31 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Lena Brandt <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788333120000
subject: Kitchen bench quote
is-html: false
surface: theme
snippet: The quote is attached. Prices hold for thirty days. Lena
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
attachment: part=2 name="Angebot Küchenarbeitsplatte Eiche massiv 2026.pdf" type=application/pdf size=193 inline=false cid=- sha256=e5a636ac00ea3c17
--- html ---
<p>The quote is attached. Prices hold for thirty days.</p>
<p>Lena</p>
--- quoted: none ---
--- text ---
The quote is attached. Prices hold for thirty days.
Lena
@@ -0,0 +1,36 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Todd Markham <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788187692000
subject: Re: Life insurance quote
is-html: false
surface: theme
snippet: Hello Ms. Young, I hope all is well with you. I wanted to touch base on our conversation from a few weeks ago. Following up on the quote I sent through last week for the twenty year term policy. If…
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>Hello Ms. Young, I hope all is well with you. I wanted to touch base on our conversation from a few weeks ago.</p>
<p>Following up on the quote I sent through last week for the twenty year term policy. If the premium looks right, I can start the paperwork whenever you are ready.</p>
<p>Todd</p>
--- quoted: none ---
--- text ---
Hello Ms. Young, I hope all is well with you. I wanted to touch base on
our conversation from a few weeks ago.
Following up on the quote I sent through last week for the twenty year
term policy. If the premium looks right, I can start the paperwork
whenever you are ready.
Todd
@@ -0,0 +1,31 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Evite on behalf of Robyn Madison <[email protected]>
to: <[email protected]>
cc: -
bcc: -
reply-to: Robyn Madison <[email protected]>
date-ms: 1788315761000
subject: You have an invitation from Robyn Madison
is-html: false
surface: theme
snippet: Join us for Jack's 8th birthday! Robyn Madison needs your RSVP. Saturday 19 September, from two in the afternoon, at the park by the lake. Bring nothing but a hat.
list-id: -
auto-submitted: -
precedence: bulk
unsubscribe: one-click=false mailto=- url=https://evite.example/optout/99120
blocked-images: 0
--- html ---
<p>Join us for Jack's 8th birthday! Robyn Madison needs your RSVP.</p>
<p>Saturday 19 September, from two in the afternoon, at the park by the lake. Bring nothing but a hat.</p>
--- quoted: none ---
--- text ---
Join us for Jack's 8th birthday! Robyn Madison needs your RSVP.
Saturday 19 September, from two in the afternoon, at the park by the
lake. Bring nothing but a hat.
@@ -0,0 +1,32 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Lena Brandt <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788188733000
subject: The worktop, and one more thing
is-html: true
surface: theme
snippet: The worktop is oiled rather than lacquered, as we agreed. The delivery slot moved to the fourteenth. Everything else is unchanged. Lena Brandt, Brandt Tischlerei
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<div style="font-family:Arial , sans-serif">
<p>The worktop is oiled rather than lacquered, as we agreed.</p>
<p style="color:#d32f2f">The delivery slot moved to the fourteenth.</p>
<p><font>Everything else is unchanged.</font></p>
<p style="color:#666666">Lena Brandt, Brandt Tischlerei</p>
</div>
--- quoted: none ---
--- text ---
The worktop is oiled rather than lacquered, as we agreed. The delivery slot moved to the fourteenth. Everything else is unchanged. Lena Brandt, Brandt Tischlerei
@@ -0,0 +1,37 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Harbour Market <[email protected]>
to: <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788326100000
subject: The autumn box is back
is-html: true
surface: paper
snippet: The autumn box is back, and it is heavier than last year. See what is in it
list-id: news.harbourmarket.example
auto-submitted: -
precedence: bulk
unsubscribe: -
blocked-images: 0
--- html ---
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tbody><tr><td align="center" style="padding:20px 0">
<table width="600" cellpadding="0" cellspacing="0" border="0">
<tbody><tr><td style="padding:28px;color:#2b2b2b;font-family:Georgia , serif">
<p style="margin:0 0 12px">The autumn box is back, and it is heavier than last year.</p>
<p style="margin:0"><a href="https://harbourmarket.example/boxes" target="_blank" rel="noopener noreferrer">See what is in it</a></p>
</td></tr>
</tbody></table>
</td></tr>
</tbody></table>
--- quoted: none ---
--- text ---
The autumn box is back, and it is heavier than last year. See what is in it
@@ -0,0 +1,43 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: The Long Read <[email protected]>
to: <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788415211000
subject: The weekend edition
is-html: true
surface: paper
snippet: The weekend edition Three pieces to settle into, and one to argue with. The pencil, at length Why bridges hum You are getting this because you asked for it. Unsubscribe
list-id: weekly.thelongread.example
auto-submitted: -
precedence: bulk
unsubscribe: one-click=false mailto=- url=https://thelongread.example/unsubscribe?u=4471
blocked-images: 0
--- html ---
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f4f1ea">
<tbody><tr><td align="center" style="padding:24px 0">
<table width="600" cellpadding="0" cellspacing="0" border="0" bgcolor="#ffffff">
<tbody><tr><td style="padding:32px">
<h1 style="font-family:Georgia , serif;font-size:26px;color:#1a1a1a;margin:0 0 18px">The weekend edition</h1>
<p style="font-family:Georgia , serif;font-size:16px;color:#333333;margin:0 0 14px">Three pieces to settle into, and one to argue with.</p>
<p style="margin:0 0 14px"><a href="https://thelongread.example/pencil" style="color:#8a5a2b" target="_blank" rel="noopener noreferrer">The pencil, at length</a></p>
<p style="margin:0"><a href="https://thelongread.example/bridges" style="color:#8a5a2b" target="_blank" rel="noopener noreferrer">Why bridges hum</a></p>
</td></tr>
<tr><td bgcolor="#efece4" style="padding:18px 32px;font-size:12px;color:#6b6b6b">
You are getting this because you asked for it.
<a href="https://thelongread.example/unsubscribe?u=4471" target="_blank" rel="noopener noreferrer">Unsubscribe</a>
</td></tr>
</tbody></table>
</td></tr>
</tbody></table>
--- quoted: none ---
--- text ---
The weekend edition Three pieces to settle into, and one to argue with. The pencil, at length Why bridges hum You are getting this because you asked for it. Unsubscribe
@@ -0,0 +1,46 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Arun Mehta <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788327842000
subject: Three things before Thursday
is-html: true
surface: theme
snippet: Morning Priyanshu, Three things before Thursday, none of them urgent : The revised lease is with the landlord's solicitor. The service charge reconciliation went out on Monday. Parking bay 12 is…
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<div>
<p>Morning Priyanshu,</p>
<p>Three things before Thursday, none of them <b>urgent</b>:</p>
<ul>
<li>The <b>revised lease</b> is with the landlord's solicitor.</li>
<li>The service charge reconciliation went out on Monday.</li>
<li>Parking bay 12 is yours from the first.</li>
</ul>
<p>Links, in case you want them now rather than Thursday:
<a href="https://meridianproperties.example/lease/8812" target="_blank" rel="noopener noreferrer">the draft</a>,
<a href="https://meridianproperties.example/charges/2026" target="_blank" rel="noopener noreferrer">the reconciliation</a>,
<a href="https://meridianproperties.example/parking" target="_blank" rel="noopener noreferrer">the bay plan</a> and the
<a href="https://meridianproperties.example/contact" target="_blank" rel="noopener noreferrer">office number</a>.</p>
<p>Best,<br>Arun</p>
<div>
<p>Arun Mehta<br>
Meridian Properties<br>
+91 22 6100 4400</p>
</div>
</div>
--- quoted: none ---
--- text ---
Morning Priyanshu, Three things before Thursday, none of them urgent : The revised lease is with the landlord's solicitor. The service charge reconciliation went out on Monday. Parking bay 12 is yours from the first. Links, in case you want them now rather than Thursday: the draft , the reconciliation , the bay plan and the office number . Best, Arun Arun Mehta Meridian Properties +91 22 6100 4400
@@ -0,0 +1,33 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Station House <[email protected]>
to: <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788510600000
subject: Tonight at the Station House
is-html: true
surface: theme
snippet: Doors at seven, the band at eight. Tickets on the door only, cash or card. Sold out on Saturday.
list-id: news.stationhouse.example
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<div>
<p>Doors at seven, the band at eight.</p>
<p>Tickets on the door only, cash or card.</p>
<p style="color:#d32f2f">Sold out on Saturday.</p>
</div>
--- quoted: none ---
--- text ---
Doors at seven, the band at eight. Tickets on the door only, cash or card. Sold out on Saturday.
@@ -0,0 +1,34 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Bramble Coffee <[email protected]>
to: <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788247367000
subject: Your receipt from Bramble Coffee
is-html: true
surface: paper
snippet: Thanks, that is paid. Flat white and a cardamom bun £6.40 on the card ending 4417 Bramble Coffee, 14 Rivington Street
list-id: -
auto-submitted: auto-generated
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<div style="background-color:#faf7f0;padding:28px;font-family:Helvetica , Arial , sans-serif">
<div style="max-width:520px;margin:0 auto">
<p style="font-size:18px;margin:0 0 16px">Thanks, that is paid.</p>
<p style="margin:0 0 8px">Flat white and a cardamom bun</p>
<p style="margin:0 0 16px">£6.40 on the card ending 4417</p>
<p style="font-size:12px;color:#7a7a7a;margin:0">Bramble Coffee, 14 Rivington Street</p>
</div>
</div>
--- quoted: none ---
--- text ---
Thanks, that is paid. Flat white and a cardamom bun £6.40 on the card ending 4417 Bramble Coffee, 14 Rivington Street
@@ -0,0 +1,36 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Arun Kulkarni <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788157964000
subject: The studio, one more thing
is-html: true
surface: theme
snippet: Priyanshu, the floor plan you asked for is below. The measurements are from the survey in June, not the original drawing. Arun
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 1
blocked-url: https://meridianproperties.in/img/studio-floorplan.jpg
tracker: HubSpot https://track.hubspot.com/__ptq.gif?k=8812&r=pj%4073ai.org
tracker: meridianproperties.in https://meridianproperties.in/o/open.png?id=8812
--- html ---
<p>Priyanshu, the floor plan you asked for is below. The measurements are
from the survey in June, not the original drawing.</p>
<p><img alt="Studio floor plan" width="640" height="420"></p>
<p>Arun</p>
<img style="border:0" width="1" height="1" alt="">
<img alt="" width="1" height="1">
--- quoted: none ---
--- text ---
Priyanshu, the floor plan you asked for is below. The measurements are from the survey in June, not the original drawing. Arun
@@ -0,0 +1,37 @@
render-version: 2
message-id: [email protected]
in-reply-to: -
references-first: -
from: Inês Carvalho <[email protected]>
to: Priyanshu Jain <[email protected]>
cc: -
bcc: -
reply-to: -
date-ms: 1788505920000
subject: Instruções para a chegada, com um café à espera
is-html: false
surface: theme
snippet: Olá Priyanshu, O código da porta é 4417 e o elevador está do lado direito. Deixo café e pão na cozinha para a manhã seguinte. Se o comboio atrasar, telefone. Não há problema nenhum. Inês
list-id: -
auto-submitted: -
precedence: -
unsubscribe: -
blocked-images: 0
--- html ---
<p>Olá Priyanshu,</p>
<p>O código da porta é 4417 e o elevador está do lado direito. Deixo café e pão na cozinha para a manhã seguinte.</p>
<p>Se o comboio atrasar, telefone. Não há problema nenhum.</p>
<p>Inês</p>
--- quoted: none ---
--- text ---
Olá Priyanshu,
O código da porta é 4417 e o elevador está do lado direito. Deixo café e
pão na cozinha para a manhã seguinte.
Se o comboio atrasar, telefone. Não há problema nenhum.
Inês
+24
View File
@@ -0,0 +1,24 @@
Return-Path: <[email protected]>
MIME-Version: 1.0
Date: Wed, 2 Sep 2026 16:02:07 -0500
Message-ID: <[email protected]>
In-Reply-To: <[email protected]>
References: <[email protected]>
Subject: Re: Cooper's parent-teacher conference
From: Jeff Wolfe <[email protected]>
To: Year 4 parents: [email protected], [email protected],
"Bauhaus, Caroline" <[email protected]>;,
undisclosed-recipients: ;
Cc: Oakridge Office: [email protected];
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
Mr. and Mrs. Young, this is just a reminder to schedule your
parent-teacher conference with me. You can feel free to do that here.
If you would prefer a video call instead of meeting at school, I am happy
to do that as well. Please let me know what date and time works best for
you.
Jeff Wolfe
Year 4, Oakridge
+28
View File
@@ -0,0 +1,28 @@
Return-Path: <[email protected]>
Received: from a.mx.ledgerlines.example (a.mx.ledgerlines.example
[198.51.100.24]) by mx.73ai.org with ESMTPS id 991ab2
for <[email protected]>; Sun, 30 Aug 2026 11:00:41 +0530 (IST)
Received: by a.mx.ledgerlines.example with SMTP id 22c1f0;
Sun, 30 Aug 2026 05:30:39 +0000 (UTC)
MIME-Version: 1.0
Date: Sun, 30 Aug 2026 05:30:38 +0000 (UTC)
Message-ID: <[email protected]>
Subject: The
bond
market,
again
From: Ledger Lines
<[email protected]>
To:
[email protected]
List-Id: Ledger Lines <weekly.ledgerlines.example>
List-Unsubscribe: <mailto:[email protected]>
X-Campaign-Tags: bonds,rates,quarterly,long-form,subscriber-only,archive
,resend,segment-b
Content-Type: text/plain; charset=us-ascii
Content-Transfer-Encoding: 7bit
Rates did the one thing nobody had written a note about, which is
nothing at all. Here is what that means for the long end.
The full piece is on the site. This mail is the summary.
+36
View File
@@ -0,0 +1,36 @@
Return-Path: <[email protected]>
MIME-Version: 1.0
Date: Wed, 26 Aug 2026 08:00:00 -0600
Message-ID: <[email protected]>
Subject: Your air pressure reminder
From: Discount Tire <[email protected]>
To: [email protected]
List-Unsubscribe: <https://discounttire.example/u/4471>
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
<html>
<head>
<meta http-equiv="refresh" content="5;url=https://discounttire.example/go">
<link rel="stylesheet" href="https://discounttire.example/css/mail.css">
<style>body { font-family: Arial, sans-serif }</style>
<script>window.location = "https://discounttire.example/track?o=1";</script>
</head>
<body onload="fetch('https://discounttire.example/beacon')">
<div style="background-image: url('https://discounttire.example/bg.png')">
<p onclick="alert('opened')">It has been 30 days since your last check.
Stop by any store for a free air pressure check, no appointment needed.</p>
</div>
<form action="https://discounttire.example/book" method="post">
<input type="text" name="postcode" placeholder="Postcode">
<button type="submit">Find a store</button>
</form>
<iframe src="https://discounttire.example/offer" width="600" height="200"
frameborder="0"></iframe>
<p><a href="javascript:void(document.location='https://evil.example')">Book
now</a></p>
<p><a href="https://discounttire.example/stores">Find a store near you</a></p>
<object data="https://discounttire.example/flash.swf"></object>
<embed src="https://discounttire.example/flash.swf">
</body>
</html>
@@ -0,0 +1,29 @@
Return-Path: <[email protected]>
MIME-Version: 1.0
Date: Mon, 31 Aug 2026 14:22:07 +0100
Message-ID: <[email protected]>
Subject: The signature block, and one photo
From: Clare Dunn <[email protected]>
To: Priyanshu Jain <[email protected]>
Content-Type: multipart/related; type="text/html"; boundary="_004_AM6PR03MB4419_"
--_004_AM6PR03MB4419_
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: 7bit
<html><body>
<p>The photo you asked for is below.</p>
<p><img src="cid:[email protected]" width="600" height="400" alt="The site"></p>
<p>Clare</p>
</body></html>
--_004_AM6PR03MB4419_
Content-Type: image/png; name="site.png"
Content-Description: site.png
Content-Disposition: attachment; filename="site.png"; size=70
Content-ID: <[email protected]>
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
--_004_AM6PR03MB4419_--
+75
View File
@@ -0,0 +1,75 @@
Return-Path: <[email protected]>
MIME-Version: 1.0
Date: Sun, 30 Aug 2026 12:19:40 +0200
Message-ID: <[email protected]>
Subject: Photos from the Hawaii trip
From: Hannah Weiss <[email protected]>
To: Priyanshu Jain <[email protected]>
Content-Type: multipart/related; type="multipart/alternative";
boundary="rel-hawaii-04"
--rel-hawaii-04
Content-Type: multipart/alternative; boundary="alt-hawaii-04"
--alt-hawaii-04
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
Finally went through them all. The ones from the ridge walk are the best
of the lot, two of them are below and the rest are in the folder.
Hannah
--alt-hawaii-04
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
<div>
<p>Finally went through them all. The ones from the ridge walk are the
best of the lot, two of them are below and the rest are in the folder.</p>
<p><img src="cid:[email protected]" alt="The ridge at seven"
width="16" height="16"></p>
<p><img src="cid:[email protected]" alt="Looking back down"
width="16" height="16"></p>
<p>Hannah</p>
</div>
--alt-hawaii-04--
--rel-hawaii-04
Content-Type: image/png; name="ridge-walk-1.png"
Content-Transfer-Encoding: base64
Content-ID: <[email protected]>
Content-Disposition: inline; filename="ridge-walk-1.png"
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAACJ0lEQVR42g3Loc6GIBQA0P9xvmg0
EAgEAoFgYMxwA2MEAmOGm5jBwEwkRjA4EsnkA/6efv5+mkyazppRzbkWUkulF9DK6TVoQG127bL2
RYemt1vj3w/IBHQGRoFzEBKkggVAOVgDAILZwWXwBUKD7YYvWDJZOltGLedWSCuVXcAqZ9dgAa3Z
rcvWFxua3W77BU8mT2fPqOfcC+ml8gt45fwaPKA3u3fZ++JD89vtvxDJFOkcGY2cRyGjVHGBqFxc
QwSMZo8uR19iaHG74xeQTEhnZBQ5RyFRKlwAlcM1ICCaHV1GXzA03G78QiJTonNiNHGehExSpQWS
cmkNCTCZPbmcfEmhpe1OXzjIdND5YPTg/BDykOpY4FDuWMMBeJj9cPnw5Qjt2O7jCyeZTjqfjJ6c
n0KeUp0LnMqdazgBT7OfLp++nKGd231+oZCp0LkwWjgvQhapygJFubKGAljMXlwuvpTQynaXL1Qy
VTpXRivnVcgqVV2gKlfXUAGr2avL1ZcaWt3u+oWLTBedL0Yvzi8hL6muBS7lrjVcgJfZL5cvX67Q
ru2+vtDJ1OncGe2cdyG7VH2BrlxfQwfsZu8ud196aH27+xcGmQadB6OD8yHkkGosMJQbaxiAw+zD
5eHLCG1s9/jCQ6aHzg+jD+ePkI9UzwKPcs8aHsDH7I/Ljy9PaM92P194yfTS+WX05fwV8pXqXeBV
7l3DC/ia/XX59eUN7d3uF/8BbBpFEE55jaYAAAAASUVORK5CYII=
--rel-hawaii-04
Content-Type: image/png; name="ridge-walk-2.png"
Content-Transfer-Encoding: base64
Content-ID: <[email protected]>
Content-Disposition: inline; filename="ridge-walk-2.png"
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAACJ0lEQVR42g3Loc6GIBQA0P9xvmg0
EAgEAoFgYMxwA2MEAmOGm5jBwEwkRjA4EsnkA/6efv5+mkyazppRzbkWUkulF9DK6TVoQG127bL2
RYemt1vj3w/IBHQGRoFzEBKkggVAOVgDAILZwWXwBUKD7YYvWDJZOltGLedWSCuVXcAqZ9dgAa3Z
rcvWFxua3W77BU8mT2fPqOfcC+ml8gt45fwaPKA3u3fZ++JD89vtvxDJFOkcGY2cRyGjVHGBqFxc
QwSMZo8uR19iaHG74xeQTEhnZBQ5RyFRKlwAlcM1ICCaHV1GXzA03G78QiJTonNiNHGehExSpQWS
cmkNCTCZPbmcfEmhpe1OXzjIdND5YPTg/BDykOpY4FDuWMMBeJj9cPnw5Qjt2O7jCyeZTjqfjJ6c
n0KeUp0LnMqdazgBT7OfLp++nKGd231+oZCp0LkwWjgvQhapygJFubKGAljMXlwuvpTQynaXL1Qy
VTpXRivnVcgqVV2gKlfXUAGr2avL1ZcaWt3u+oWLTBedL0Yvzi8hL6muBS7lrjVcgJfZL5cvX67Q
ru2+vtDJ1OncGe2cdyG7VH2BrlxfQwfsZu8ud196aH27+xcGmQadB6OD8yHkkGosMJQbaxiAw+zD
5eHLCG1s9/jCQ6aHzg+jD+ePkI9UzwKPcs8aHsDH7I/Ljy9PaM92P194yfTS+WX05fwV8pXqXeBV
7l3DC/ia/XX59eUN7d3uF/8BbBpFEE55jaYAAAAASUVORK5CYII=
--rel-hawaii-04--
Loaded 100 of 423 files, more files were not shown because too many files have changed in this diff. Show more