Skip to content

Security: digitalhabits/dh-mail

Security

SECURITY.md

Security and privacy

Digital Habits: Mail is a desktop app for Mac and Windows that reads your mail directly from Gmail, Outlook or your organisation's Exchange server. This page says what it asks for, where it keeps things, and what leaves the machine — with the file to read for each, so none of it has to be taken on trust.

It describes the standalone app, the one distributed as a .dmg or a Windows installer.

What leaves your machine

Your mail goes between your computer and your provider, and nowhere else. There is no server of ours in the path — nothing to run, nothing to breach, and no copy of your mail anywhere we could read it.

The app talks to these hosts:

Host What for Where
imap.gmail.com Reading Gmail, and filing, archiving and deleting sync.rs, imap.rs
smtp.gmail.com Sending Gmail smtp.rs
gmail.googleapis.com Out-of-office reply, the send-as name, and a mailbox whose local copy is not yet complete lib/gmail/api.ts
people.googleapis.com Google Contacts, for the address book contact-sources.ts
graph.microsoft.com Outlook: mail, contacts and settings lib/outlook/api.ts
Your Exchange server, at the address you give Exchange: mail, folders, the address book and the out-of-office reply, over EWS. Only an https:// address is accepted ews.rs, ntlm.rs
Your organisation's Autodiscover addresses, and its DNS Only when you connect an Exchange account and the app does not know the server: finding the server's address — see "Exchange is different" below ews_autodiscover.rs, ews_srv.rs
accounts.google.com, oauth2.googleapis.com, login.microsoftonline.com Sign-in, and every token refresh after it oauth.rs, oauth-config.ts
plan.digitalhabits.org The anonymous daily usage count — see below. Off in Settings > General usage-ping.ts
github.com, and GitHub's download servers Checking for a new version and downloading it. The direct downloads only — see below team_update.rs
Whatever host a sender put an image on Remote images in HTML mail — see "Reading a message safely" below. On by default; off in Settings images.rs

That is the whole list. Links in mail, calendar invites and the Help menu open in your browser, not in the app.

The window's Content-Security-Policy in tauri.conf.json is what enforces this: the page cannot reach a host the policy does not name, and app-csp.test.mjs checks the policy on every build. It also names www.googleapis.com and docs.googleapis.com, which this app never calls — another build of the same interface uses them, and the two builds share one policy. Nothing here asks for a Calendar or Docs scope, so a token from this app could not read either.

There is no analytics and no crash reporting. Nothing records what you do in the app or reports it anywhere.

The one thing the app sends to us is an anonymous usage count, so we know roughly how many people use it. Only the release builds send it (the Mac and Windows downloads and the Microsoft Store app), at most once a day and only on days you use the app, and never before you accept the welcome screen. It sends three things to plan.digitalhabits.org/api/ping: the word mail, the system (mac or windows), and a random id that the app makes again at the start of each month, so two months of one install cannot be joined. Nothing about your mail, your mailboxes, your contacts or your accounts goes with it, and our server keeps no address and no user agent beside it. Turn it off in Settings > General, "Send anonymous usage count"; with it off, nothing is sent. The code is usage-ping.ts.

Updates. The direct downloads (the .dmg and the Windows installer) look for a new version a short while after they start, and then every two hours. They read latest.json from the newest release of this repository, with no key and nothing about you. A new version is downloaded in the background and installed only if it is signed with the key built into the app; you choose when to restart, or it is installed when you quit. The Microsoft Store version has no updater: the store updates it. See team_update.rs.

You can check the rest: there is no analytics SDK in the dependency lists (apps/mail/package.json, apps/mail/src-tauri/Cargo.toml, products/mail/crates/mail-native/Cargo.toml), and nothing in apps/mail/src, apps/mail/lib, packages/shared, products/mail/packages/mail or products/mail/crates/mail-native/src contacts any host but the ones above.

What it asks your provider for

Google

Scope Why
https://mail.google.com/ The full-mailbox scope. It is the only scope Gmail's IMAP and SMTP servers accept, and the app reads and sends over IMAP and SMTP so that a local copy of the mailbox can be kept without a request budget. Google describes it as read, compose, send and permanently delete. The app deletes mail for good in two cases only (actions.rs). One is a draft that you discard. The other is "Delete forever": in Trash or in Junk, you pick one conversation or select several, and the app asks before it deletes them. After you confirm, it waits eight seconds with an Undo before it tells the server. That call is not possible with a narrower scope. "Delete" everywhere else moves mail to Trash, which Gmail empties on its own schedule. The app has no "Empty Trash", on purpose: it never deletes a folder, only the conversations you picked.
openid, email Sign-in only: so Google returns an ID token naming the account. Added in connect-mailbox.ts.
gmail.settings.basic Read and set your out-of-office reply. Setting it accepts only this scope. The same scope reads the name Gmail puts on mail you send; the app only reads that, never changes it.
contacts.readonly Read your Google Contacts, so a name completes to an address

Not requested: gmail.modify and gmail.send, which the full-mailbox scope already contains; nor Drive, Calendar, Docs, or any scope outside mail and contacts.

Microsoft

User.Read, Mail.ReadWrite, Mail.Send, Contacts.Read, MailboxSettings.ReadWrite, plus openid, profile, email and offline_access for sign-in and refresh.

Both lists are in oauth-config.ts; connect-mailbox.ts adds openid and email to the Google request. Nothing else asks for a scope.

Where things are kept

Refresh tokens go in the operating system's credential store, not in the app's own files — the macOS keychain, or Windows Credential Manager — under the service org.digitalhabits.mail. See secrets.rs. The database holds no token, so a copy of it cannot be used to sign in as you.

An Exchange password is kept the same way, in the credential store and nowhere else. Only the app's Rust core reads it, to sign in; it never goes back to the window. See ews.rs.

On a Mac, a build that carries a provisioning profile uses the data-protection keychain, where access is decided by the app's entitlement and macOS never asks the user. A build without one uses the login keychain, where macOS may ask once per item with a password dialog — choose "Always Allow". See KEYCHAIN.md.

Mail is kept in a local SQLite file, mail.sqlite3, in the app's data directory (database_path in db.rs). It holds a copy of every message's headers, the bodies of recent mail and of mail you have opened, and your contacts. An attachment on mail you receive is fetched only when you open it, and is not kept; one you save goes to your Downloads folder. An attachment you add to a draft is different: it is held with the draft until you send it, discard it, or disconnect the account. The file is not encrypted: anyone who can read your user account's files can read the mail in it, as with Apple Mail or Outlook.

Two smaller stores sit beside it, in the window's own storage. One keeps the body of a thread you opened, so it reopens at once (thread-cache.ts). The other keeps a draft you have not sent, with its attachments, so it survives a restart (local-drafts.ts).

Disconnecting an account in Settings removes its token from the credential store, its messages, bodies and sync state from the file (db/accounts.rs accounts_remove, messages/write.rs messages_clear_account), and its cached thread bodies and unsent drafts from the two stores above. A few smaller tables — contacts, snoozes, queued actions, the outbox — keep their rows until the app's data directory is deleted, which removes everything.

Settings stay on the machine — in localStorage for the interface, and in the SQLite file for accounts. localStorage also holds a cache of the thread list (senders, subjects, snippets) so the list draws before the database answers (list-cache.ts), and pinned threads keep their sender and subject. When you disconnect an account, its rows leave this cache and its pins are removed (forgetAccountEverywhere, unpinAccount in standalone-api.ts).

A log file in the operating system's log folder records what the sync worker did. It names the account. For mail you send, it records how many recipients there are, not who they are, and never the subject or the body. When a mail server refuses something, its error is written as the server gave it, and that error can name an address. Where a draft is handed to Outlook, a failure is written as the exit status alone, because the error would otherwise carry the words of the draft. The file is small (at most 40 KB; when it is full, it starts again) and stays on the machine.

How sign-in works

The app opens your browser, you sign in with Google or Microsoft, and the answer comes back to a loopback listener on 127.0.0.1 — see oauth.rs and connect-mailbox.ts.

For Gmail and Outlook, you never type your mail password into this app. It never sees one.

The flow uses PKCE. The Google client secret is compiled into the app, which Google documents as not confidential for an installed app — PKCE is the protection, not the secret.

Exchange is different. Exchange Web Services has no sign-in through the browser, so you type your username and password into the app. The app's Rust core signs in with NTLM, or with Basic only when the server offers nothing else, and always over https://: it refuses an http:// address, so the password never travels unencrypted. After one refused password the account stops and asks you again, because each retry with a wrong password counts against you, and enough of them lock the account on the server. See ews.rs and ntlm.rs.

Finding the server. For a University of Copenhagen address the server is filled in. For another organisation, the app can find it the way Thunderbird does (Autodiscover): it asks autodiscover.<your domain> and then your domain itself, signing in to each over https:// only, and then the DNS record _autodiscover._tcp.<your domain>. It asks one place at a time and signs in at most once at each. It gives your password to no host outside your email's own domain unless you agree first, and it stops at the first refused password, so that no other host gets it. See ews_autodiscover.rs.

Reading a message safely

The app never draws a sender's HTML into its own page. It shows each message in an iframe of its own. That iframe carries a Content-Security-Policy that lets no script run, except one hash-pinned helper of ours. Before the message reaches the iframe, it goes through an allowlist: it keeps only known elements and attributes, and links and pictures only with the schemes http, https, mailto and cid (and data: for a raster picture). Scripts, forms, embeds, event handlers, SVG and MathML do not get through — see email-allowlist.ts. The iframe also keeps the sender's CSS away from the app — see EmailHtmlView.tsx.

Three views show a message this way, because each one can hold words that somebody else wrote:

  • The reading pane.
  • The preview of a message before you send it. It shows the original you quote, and it shows your own text, which starts as an older message when you write from one.
  • The printed document, which names no script source at all — see print-document.ts.

The iframe has its own origin. It is sandboxed without allow-same-origin, so its document has an opaque origin. A script that ran in a message could not read the app's page, and it could not call the desktop bridge. The app and the message frame talk only by postMessage, and the app reads a message only from that frame. See link-bridge.ts.

The app's own page has a policy as well. Scripts can run only from the app itself, and the page can connect only to the hosts in the table above. It is the second guard, for the case where something gets past the first. See csp in tauri.conf.json.

Remote images load by default, as they do in Apple Mail and Outlook. An image fetched from a sender's server can tell them that you opened their mail, and roughly where you are. We accept that trade so mail looks the way it was sent. To change this behaviour, turn off "Load images by default" in Settings. The app then blocks remote images, and you allow them per sender. Pictures carried inside the message are always shown, because fetching those tells nobody anything. A remote image is fetched by the app itself, with no cookies and no referrer, never by the page. It refuses an address on your own network or computer, after each redirect too, so a message cannot use an image to reach your router or another device — see images.rs.

A saved attachment is marked as downloaded. On a Mac it gets the same quarantine mark a browser gives a download, and on Windows the same "from the internet" mark. So the system checks it before it runs. The app does not open a program or a script by itself: it saves it and shows it in Finder or Explorer — see download_safety.rs.

Checking the app you install

Mac. Every release is signed with an Apple Developer ID and notarised by Apple — both the app and the disk image, because Gatekeeper judges the image you open. To check a copy before installing it:

spctl -a -vvv -t install "Digital Habits Mail_<version>_universal.dmg"

accepted and source=Notarized Developer ID mean it is the build we signed.

Windows. Every installer is signed with Azure Trusted Signing. To check a copy, in PowerShell:

Get-AuthenticodeSignature .\Digital-Habits-Mail_<version>_x64-setup.exe |
  Format-List Status, @{n='Subject';e={$_.SignerCertificate.Subject}}

Status: Valid and a subject of CN=Reduce Digital Distraction Ltd mean it is the build we signed. The Microsoft Store copy is re-signed by Microsoft and lists Centre for Digital Habits as the publisher; both names are ours.

The build writes a SHA-256 checksum beside every installer, and we publish it with the download, so you can confirm the file you downloaded is the file we built.

The version the app is running is shown in Settings on both platforms, and in the Help menu on a Mac, so a report can say which build it came from.

This repository

The code here is a snapshot of each release, exported whole from the private repository the app is developed in. One commit is one version. Releases are tagged v<version>, so git diff v0.11.45 v0.11.47 shows everything that changed between two builds.

Reporting something

A security problem: please email team@digitalhabits.org. Do not open a public issue for it. Describe the issue, how to reproduce it, and which version you use (see Settings). We reply within three days, treat security problems as our top priority, and tell you when a fix is released.

Anything else: either reach out at team@digitalhabits.org or open an issue at https://github.com/digitalhabits/dh-mail/issues.

There aren't any published security advisories