mirror of
https://github.com/priyanshujain/margin-mail.git
synced 2026-10-04 20:17:05 +00:00
940 lines
35 KiB
Rust
940 lines
35 KiB
Rust
// The IPC contract. Every type here has a matching declaration in src/ipc.ts, and both sides are
|
|
// frozen once written: implementation modules add bodies, not fields.
|
|
//
|
|
// Two rules run through the whole file. Nothing that crosses this boundary carries a provider
|
|
// identifier the frontend could act on, because a view is the mirror joined to the state and the
|
|
// frontend is not entitled to know which of the two a value came from. And every list the frontend
|
|
// renders arrives already ordered and already grouped, because the grouping is part of the view
|
|
// and computing it twice in two languages is how the two drift.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
// -------------------------------------------------------------------------------------------
|
|
// People, accounts and places
|
|
// -------------------------------------------------------------------------------------------
|
|
|
|
/// One correspondent. `name` is whatever the header carried, which is often nothing.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Person {
|
|
pub name: Option<String>,
|
|
pub address: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Account {
|
|
pub id: String,
|
|
pub email: String,
|
|
/// Which mailbox is behind this account, and therefore which `Provider` drives it. Read by the
|
|
/// frontend to know whether a scope list means anything here: an IMAP account has none.
|
|
pub kind: AccountKind,
|
|
/// The display name from the provider's profile, editable in settings.
|
|
pub name: String,
|
|
/// One of the eight hues, as a token name (`hue-1`), not a hex. The stylesheet owns the value.
|
|
pub color: String,
|
|
pub connected: bool,
|
|
/// What Google actually granted. Granular consent means a user can untick a scope on the
|
|
/// consent screen, so every feature that needs one checks this rather than assuming.
|
|
pub granted_scopes: Vec<String>,
|
|
/// How far back the mirror keeps this account's mail, in days. Zero means everything.
|
|
pub window_days: u32,
|
|
}
|
|
|
|
/// The two kinds of mailbox, which is the one thing above the `Provider` trait that has to know.
|
|
///
|
|
/// Everything else in the app is written against the trait. This exists because a few screens
|
|
/// genuinely differ: an IMAP account has no scopes to grant, no Google account page to revoke
|
|
/// from, and a server and port to show in settings that a Google account does not have.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum AccountKind {
|
|
Google,
|
|
Imap,
|
|
}
|
|
|
|
impl Default for AccountKind {
|
|
/// Every account that existed before there was a second kind is a Google one.
|
|
fn default() -> Self {
|
|
AccountKind::Google
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------
|
|
// IMAP and SMTP configuration
|
|
// -------------------------------------------------------------------------------------------
|
|
|
|
/// How a socket is protected.
|
|
///
|
|
/// The three names Thunderbird's autoconfig format uses, because every published configuration in
|
|
/// the world is written in that vocabulary and translating it twice is how the meanings drift.
|
|
/// `Tls` is what the format calls SSL: TLS from the first byte, on 993 or 465. `StartTls` is a
|
|
/// plaintext connection upgraded by a command, on 143 or 587.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum Security {
|
|
Plain,
|
|
StartTls,
|
|
Tls,
|
|
}
|
|
|
|
/// Which family of SASL mechanism to authenticate with. The exact mechanism is chosen from what
|
|
/// the server advertises; this only says which list to choose from.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum AuthKind {
|
|
Password,
|
|
/// Reachable from a discovered configuration, which is why it can be represented. Nothing
|
|
/// builds one yet: OAuth over IMAP needs a client registered with that provider.
|
|
OAuth2,
|
|
}
|
|
|
|
/// One end of a mail account: a host, a port, how the socket is protected and who to log in as.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ServerConfig {
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub security: Security,
|
|
pub auth: AuthKind,
|
|
/// Already expanded. `%EMAILADDRESS%` and `%EMAILLOCALPART%` are substituted inside discovery,
|
|
/// so nothing downstream has to know that the format has placeholders in it.
|
|
pub username: String,
|
|
}
|
|
|
|
/// Both ends, and where they came from.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MailConfig {
|
|
pub imap: ServerConfig,
|
|
pub smtp: ServerConfig,
|
|
/// Which rung of the ladder answered: `autoconfig`, `ispdb`, `mx`, `probe` or `manual`. The
|
|
/// connect screen says where the settings came from, because "we found these" and "we guessed
|
|
/// these" are different promises and a person about to type a password should be told which.
|
|
pub source: String,
|
|
/// The provider's own name for itself, when the configuration carried one.
|
|
pub display_name: Option<String>,
|
|
}
|
|
|
|
/// A certificate the user has to decide about before a connection can be made.
|
|
///
|
|
/// Only ever raised for a host that is not the loopback. Proton Bridge listens on 127.0.0.1 with a
|
|
/// certificate it generated itself, and there is nothing between this process and that socket to
|
|
/// impersonate anybody, so loopback is trusted without asking. Every other host is a question.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CertQuestion {
|
|
pub host: String,
|
|
pub port: u16,
|
|
/// SHA-256 of the DER, uppercase hex in colon-separated pairs, which is the form every other
|
|
/// tool prints so it can be compared against one.
|
|
pub fingerprint: String,
|
|
pub subject: String,
|
|
pub issuer: String,
|
|
pub expires_ms: i64,
|
|
/// `self-signed`, `expired` or `unknown-issuer`. A hostname mismatch is never a question: it
|
|
/// is the one failure that looks exactly like an interception, so it is refused outright.
|
|
pub reason: String,
|
|
}
|
|
|
|
/// What a connection test found. Not a `Result`, because "the password was wrong" and "the
|
|
/// certificate needs a decision" are answers the screen renders rather than errors it reports.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ConnectReport {
|
|
pub ok: bool,
|
|
/// What kind of refusal it was: `unreachable`, `auth`, `certificate`, `wrong-host` or `other`.
|
|
///
|
|
/// A separate field rather than something the screen reads back out of `message`, because the
|
|
/// screen has real decisions hanging off it (a loopback that is unreachable means the bridge
|
|
/// is not running, and that is a different sentence from a wrong password) and reading them
|
|
/// out of prose means a regex over whatever the server happened to say that day.
|
|
pub kind: Option<String>,
|
|
/// `imap` or `smtp`, when one leg failed and the other did not.
|
|
pub failed: Option<String>,
|
|
/// One sentence, already fit to read. The server's own words when they are usable.
|
|
pub message: Option<String>,
|
|
pub cert: Option<CertQuestion>,
|
|
/// Set when the server refused the password but named a reason a person can act on, such as
|
|
/// an app password being required.
|
|
pub advice: Option<String>,
|
|
}
|
|
|
|
/// Every view the app can show. `Label` and `Search` carry an argument in `ThreadQuery`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum Place {
|
|
Inbox,
|
|
Feed,
|
|
PaperTrail,
|
|
ReplyLater,
|
|
SetAside,
|
|
Screener,
|
|
Snoozed,
|
|
Everything,
|
|
Sent,
|
|
Drafts,
|
|
Starred,
|
|
ScreenedOut,
|
|
Spam,
|
|
Trash,
|
|
Label,
|
|
Search,
|
|
}
|
|
|
|
/// Where a sender's mail goes. Exactly one per sender, which is the whole of the routing model.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum Destination {
|
|
Inbox,
|
|
Feed,
|
|
PaperTrail,
|
|
ScreenedOut,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum Pile {
|
|
ReplyLater,
|
|
SetAside,
|
|
}
|
|
|
|
/// The surface a message body renders on, decided from what the sender painted rather than from
|
|
/// how the bytes arrived. `sanitize::surface` is where it is worked out and why.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum Surface {
|
|
/// The sender painted nothing, so the body takes the app's own paper and follows the theme.
|
|
#[default]
|
|
Theme,
|
|
/// The sender painted an opaque light page across the layout, so the body keeps it in both
|
|
/// palettes and the pane's chrome goes dark around it.
|
|
Paper,
|
|
}
|
|
|
|
/// What a list asks for. `account_id` of None means every account, which is the All accounts view.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ThreadQuery {
|
|
#[serde(default)]
|
|
pub account_id: Option<String>,
|
|
pub place: Place,
|
|
/// The provider's label id, for `Place::Label`.
|
|
#[serde(default)]
|
|
pub label_id: Option<String>,
|
|
/// The query text, for `Place::Search`.
|
|
#[serde(default)]
|
|
pub query: Option<String>,
|
|
pub limit: u32,
|
|
/// Opaque, from the previous page's `next_cursor`. None starts at the top.
|
|
#[serde(default)]
|
|
pub cursor: Option<String>,
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------
|
|
// Threads
|
|
// -------------------------------------------------------------------------------------------
|
|
|
|
/// One row of a list, already grouped and already ordered.
|
|
///
|
|
/// `key` is the portable thread key: the first entry of the message's `References` header, else its
|
|
/// `In-Reply-To`, else its own `Message-ID`. It does not depend on which messages happen to be
|
|
/// mirrored, so it survives a storage window changing, a second device, and a move to another
|
|
/// provider.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ThreadSummary {
|
|
pub key: String,
|
|
pub account_id: String,
|
|
/// The account's hue token, for the coloured edge in All accounts.
|
|
pub account_color: String,
|
|
/// What to print. The rename when there is one, the real subject otherwise.
|
|
pub subject: String,
|
|
/// Set only when the thread was renamed, so the row can say "renamed · was …".
|
|
pub original_subject: Option<String>,
|
|
/// Whose name goes on the row: the latest correspondent who is not the account.
|
|
pub from: Person,
|
|
/// Everyone in the thread, for the stacked avatars in the pane.
|
|
pub participants: Vec<Person>,
|
|
pub snippet: String,
|
|
/// Epoch milliseconds of the latest message.
|
|
pub date_ms: i64,
|
|
pub message_count: u32,
|
|
/// At least one message the account has not seen. There is no count anywhere in this app.
|
|
pub unseen: bool,
|
|
pub starred: bool,
|
|
/// In the provider's trash, and in its spam. Flags rather than places: a search result carries
|
|
/// them into a list that is not Trash or Spam, and neither the row nor the pane can work them
|
|
/// out from the place it is being shown in.
|
|
pub trashed: bool,
|
|
pub spam: bool,
|
|
pub has_attachment: bool,
|
|
pub has_draft: bool,
|
|
pub pile: Option<Pile>,
|
|
/// Epoch milliseconds: the moment this thread was due back.
|
|
///
|
|
/// In the future while it is still waiting, in the past once it has come back and is sitting in
|
|
/// the Back group, which is how a thread that returned late can say "Due yesterday" rather than
|
|
/// pretending. The group tells the two apart, so a row never has to guess.
|
|
pub snoozed_until: Option<i64>,
|
|
pub ignored: bool,
|
|
pub notify: bool,
|
|
pub merged: bool,
|
|
/// The one line under the row. The whole note is in the pane.
|
|
pub note: Option<String>,
|
|
/// Which group head this row sits under: "back", "new", "seen", "this-week", "earlier" or
|
|
/// "sent-to". The view decides both the grouping and the order the groups come in, and the
|
|
/// frontend renders a head whenever this changes and never sorts again. Sorting twice is how
|
|
/// Back ends up in the middle of the Inbox.
|
|
pub group: String,
|
|
/// Waiting in the outbox, so the row can say "Waiting to send".
|
|
pub sending: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ThreadPage {
|
|
pub threads: Vec<ThreadSummary>,
|
|
/// Feed the next call. None when this is the end of the list.
|
|
pub next_cursor: Option<String>,
|
|
/// The quiet line at the foot: "Showing the last month. Older mail is on Gmail." or the
|
|
/// search's "Search older mail on Gmail". None when there is nothing to say.
|
|
pub footer: Option<String>,
|
|
}
|
|
|
|
/// A thread this one was merged from, for the banner that offers Unmerge.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MergedSource {
|
|
pub key: String,
|
|
pub subject: String,
|
|
}
|
|
|
|
/// The whole thread, for the reading pane.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ThreadView {
|
|
pub key: String,
|
|
pub account_id: String,
|
|
pub subject: String,
|
|
pub original_subject: Option<String>,
|
|
pub participants: Vec<Person>,
|
|
pub messages: Vec<MessageView>,
|
|
pub notes: Vec<Note>,
|
|
pub merged_from: Vec<MergedSource>,
|
|
pub pile: Option<Pile>,
|
|
pub snoozed_until: Option<i64>,
|
|
pub ignored: bool,
|
|
pub notify: bool,
|
|
pub starred: bool,
|
|
/// The same two flags the row carries, so the pane can offer the way back out.
|
|
pub trashed: bool,
|
|
pub spam: bool,
|
|
/// The provider's labels on this thread, for the label list. Never rendered in a row.
|
|
pub labels: Vec<String>,
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------
|
|
// Messages
|
|
// -------------------------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Attachment {
|
|
pub id: String,
|
|
pub message_id: String,
|
|
pub filename: String,
|
|
pub mime_type: String,
|
|
pub size: u64,
|
|
/// Referenced from the body by `cid:` rather than listed as a chip.
|
|
pub inline: bool,
|
|
pub content_id: Option<String>,
|
|
/// Already in the local cache, so opening it costs nothing.
|
|
pub cached: bool,
|
|
}
|
|
|
|
/// A remote image that was removed before the body was rendered, and who it belonged to.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Tracker {
|
|
/// The vendor from the shipped list, or the bare host when it is not a known one.
|
|
pub vendor: String,
|
|
pub url: String,
|
|
}
|
|
|
|
/// `List-Unsubscribe`, in the three forms it arrives in.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Unsubscribe {
|
|
/// RFC 8058: the app POSTs and the sender is required to honour it without a round trip.
|
|
pub one_click: bool,
|
|
pub mailto: Option<String>,
|
|
pub url: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum InviteResponse {
|
|
Accepted,
|
|
Tentative,
|
|
Declined,
|
|
NeedsAction,
|
|
}
|
|
|
|
/// A `text/calendar` part with `METHOD:REQUEST`, rendered as a card.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Invite {
|
|
pub uid: String,
|
|
pub summary: String,
|
|
pub start_ms: i64,
|
|
pub end_ms: i64,
|
|
pub all_day: bool,
|
|
pub location: Option<String>,
|
|
pub organizer: Option<Person>,
|
|
pub description: Option<String>,
|
|
/// What this account has already answered, when it has.
|
|
pub my_response: InviteResponse,
|
|
/// A deep link into Margin Calendar, when the event can be addressed there.
|
|
pub calendar_link: Option<String>,
|
|
}
|
|
|
|
/// One message, sanitised and ready to render.
|
|
///
|
|
/// `html` is what goes into the iframe's `srcdoc`. It has been through the sanitiser, so `cid:`
|
|
/// images are already `data:` URIs and every remote image is either removed or, once the user has
|
|
/// asked for them, fetched by Rust and inlined the same way. The frontend never fetches anything.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MessageView {
|
|
/// The provider's message id. Every command argument named `message_id` is this one, never the
|
|
/// header below it: the RFC `Message-ID` is what the state database keys on and it never
|
|
/// crosses this boundary as an argument.
|
|
pub id: String,
|
|
/// The RFC `Message-ID`, which is what the state database keys on.
|
|
pub message_id: String,
|
|
pub thread_key: String,
|
|
pub from: Person,
|
|
pub to: Vec<Person>,
|
|
pub cc: Vec<Person>,
|
|
pub bcc: Vec<Person>,
|
|
pub reply_to: Vec<Person>,
|
|
pub date_ms: i64,
|
|
pub subject: String,
|
|
pub html: String,
|
|
/// True when this message's body has not been fetched yet, so `html` is empty because there is
|
|
/// nothing to show rather than because the message was.
|
|
///
|
|
/// Opening a thread never waits on the network. The rows come back from the mirror at once and
|
|
/// anything still missing a body arrives on a later `store-changed`, which is the difference
|
|
/// between a thread that opens and a thread that loads.
|
|
pub body_pending: bool,
|
|
/// The trailing quoted conversation, split off so it can sit behind a pill.
|
|
pub quoted_html: Option<String>,
|
|
/// True when the source was HTML. A plain text message has been converted, and the pane sets
|
|
/// it on a 46em measure in the text face rather than leaving it to the sender's markup.
|
|
pub is_html: bool,
|
|
/// Which surface the body reads on. Not the same question as `is_html`: what decides it is
|
|
/// whether the sender painted a page, and most HTML mail paints nothing.
|
|
pub surface: Surface,
|
|
pub attachments: Vec<Attachment>,
|
|
pub trackers: Vec<Tracker>,
|
|
/// Ordinary remote images that were blocked, which is a different count from the trackers.
|
|
pub blocked_images: u32,
|
|
#[serde(default)]
|
|
pub images_allowed: bool,
|
|
/// Whether the body currently rendered has remote images loaded.
|
|
pub images_loaded: bool,
|
|
pub seen: bool,
|
|
pub draft: bool,
|
|
pub sent_by_me: bool,
|
|
pub invite: Option<Invite>,
|
|
pub unsubscribe: Option<Unsubscribe>,
|
|
pub list_id: Option<String>,
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------
|
|
// The decisions: rules, piles, snoozes, notes, clips
|
|
// -------------------------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SenderRule {
|
|
pub account_id: String,
|
|
/// An address, or a domain when `is_domain`.
|
|
pub subject: String,
|
|
pub is_domain: bool,
|
|
pub destination: Destination,
|
|
pub decided_at_ms: i64,
|
|
/// The suggestion rule that fired when this was set automatically, for the contact card.
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
/// One waiting sender in the Screener.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ScreenerCard {
|
|
pub account_id: String,
|
|
pub sender: Person,
|
|
pub thread_key: String,
|
|
pub subject: String,
|
|
pub snippet: String,
|
|
pub date_ms: i64,
|
|
pub suggestion: Destination,
|
|
/// The sentence the card prints, which is the row of the rules table that matched.
|
|
pub reason: String,
|
|
/// How many messages this sender has waiting, when it is more than one.
|
|
pub waiting: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum SnoozeKind {
|
|
LaterToday,
|
|
Tomorrow,
|
|
Weekend,
|
|
NextWeek,
|
|
Date,
|
|
/// Comes back only if nobody but the account has written since.
|
|
IfNoReply,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Snooze {
|
|
pub thread_key: String,
|
|
pub return_at_ms: i64,
|
|
pub kind: SnoozeKind,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Note {
|
|
pub id: String,
|
|
pub thread_key: String,
|
|
pub body: String,
|
|
pub created_at_ms: i64,
|
|
/// The message that was latest when the note was written, so the pane can place it.
|
|
pub after_message_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Clip {
|
|
pub id: String,
|
|
pub account_id: String,
|
|
pub thread_key: String,
|
|
pub message_id: String,
|
|
pub text: String,
|
|
pub sender: Person,
|
|
pub subject: String,
|
|
pub created_at_ms: i64,
|
|
}
|
|
|
|
/// Everything the app knows about one correspondent, for the popover and the Contacts place.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ContactCard {
|
|
pub person: Person,
|
|
pub account_id: String,
|
|
pub destination: Destination,
|
|
/// The rule that decides them is on their whole domain rather than their address.
|
|
pub domain_rule: bool,
|
|
/// A consumer domain cannot carry a domain rule, so the toggle is not offered.
|
|
pub domain_rule_allowed: bool,
|
|
pub notify: bool,
|
|
pub screened_at_ms: Option<i64>,
|
|
pub note: Option<String>,
|
|
pub allow_remote_images: bool,
|
|
/// Trash this sender's Feed mail after so many days. None is never.
|
|
pub auto_trash_days: Option<u32>,
|
|
/// Bundle this sender's Paper Trail rows into one.
|
|
pub bundle: bool,
|
|
pub recent_threads: Vec<ThreadSummary>,
|
|
pub files: Vec<Attachment>,
|
|
pub unsubscribe: Option<Unsubscribe>,
|
|
}
|
|
|
|
/// The patch the contact card writes back. An absent field means unchanged.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ContactPatch {
|
|
#[serde(default)]
|
|
pub destination: Option<Destination>,
|
|
#[serde(default)]
|
|
pub domain_rule: Option<bool>,
|
|
#[serde(default)]
|
|
pub notify: Option<bool>,
|
|
#[serde(default)]
|
|
pub note: Option<String>,
|
|
#[serde(default)]
|
|
pub allow_remote_images: Option<bool>,
|
|
#[serde(default)]
|
|
pub auto_trash_days: Option<Option<u32>>,
|
|
#[serde(default)]
|
|
pub bundle: Option<bool>,
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------
|
|
// Writing
|
|
// -------------------------------------------------------------------------------------------
|
|
|
|
/// A file on the way out, before it has been encoded into a message.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DraftAttachment {
|
|
/// Absent for one that is already in the mirror, which is what a forward carries.
|
|
#[serde(default)]
|
|
pub path: Option<String>,
|
|
/// The mirror's attachment id, for a forward.
|
|
#[serde(default)]
|
|
pub attachment_id: Option<String>,
|
|
pub filename: String,
|
|
pub mime_type: String,
|
|
pub size: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Draft {
|
|
/// The app's own draft id. Absent on the first save.
|
|
#[serde(default)]
|
|
pub id: Option<String>,
|
|
pub account_id: String,
|
|
/// The thread being replied to, which is what sets the threading headers.
|
|
#[serde(default)]
|
|
pub thread_key: Option<String>,
|
|
/// The message being replied to or forwarded.
|
|
#[serde(default)]
|
|
pub in_reply_to: Option<String>,
|
|
/// A verified alias to send as, when it is not the account's own address.
|
|
#[serde(default)]
|
|
pub from_alias: Option<String>,
|
|
pub to: Vec<Person>,
|
|
#[serde(default)]
|
|
pub cc: Vec<Person>,
|
|
#[serde(default)]
|
|
pub bcc: Vec<Person>,
|
|
pub subject: String,
|
|
/// The editor's HTML. Rust inlines the stylesheet and builds the plain text alternative.
|
|
pub body_html: String,
|
|
#[serde(default)]
|
|
pub attachments: Vec<DraftAttachment>,
|
|
/// Set a reminder on the thread if nobody replies by then.
|
|
#[serde(default)]
|
|
pub remind_at_ms: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DraftSaved {
|
|
pub id: String,
|
|
pub updated_at_ms: i64,
|
|
/// The encoded size, so the composer can refuse an attachment before the send fails.
|
|
pub encoded_size: u64,
|
|
pub over_limit: bool,
|
|
}
|
|
|
|
/// A send that is waiting out its undo delay, or waiting for the network.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Outgoing {
|
|
pub id: String,
|
|
pub account_id: String,
|
|
pub thread_key: Option<String>,
|
|
/// Who the toast names.
|
|
pub to: Vec<Person>,
|
|
pub subject: String,
|
|
/// Epoch milliseconds. Until then the send can still be taken back.
|
|
pub hold_until_ms: i64,
|
|
pub attempts: u32,
|
|
pub last_error: Option<String>,
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------
|
|
// Undo
|
|
// -------------------------------------------------------------------------------------------
|
|
|
|
/// What a mutating command hands back so `z` can take it back.
|
|
///
|
|
/// The token is a handle on the state before the change, held in a bounded stack in Rust. It is
|
|
/// there rather than in the frontend because a bulk archive of forty threads with mixed prior
|
|
/// state cannot be reversed from what the frontend knew, and a reversal that guesses is worse than
|
|
/// no reversal at all.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Undo {
|
|
pub token: String,
|
|
/// What the toast says: "Archived 3 threads", "Sent to Ana".
|
|
pub label: String,
|
|
/// Zero for anything but a send. A send's toast counts down.
|
|
pub undo_ms: u32,
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------
|
|
// Search, labels, files
|
|
// -------------------------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SearchResult {
|
|
pub page: ThreadPage,
|
|
/// The query reached past the storage window, so the provider was asked as a second pass.
|
|
pub provider_searched: bool,
|
|
/// A note appended under the results when the two sources differ.
|
|
pub note: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct LabelInfo {
|
|
pub id: String,
|
|
pub account_id: String,
|
|
pub name: String,
|
|
/// system or user. The system ones are places already and are not offered for applying.
|
|
pub kind: String,
|
|
}
|
|
|
|
/// One card in the All files place.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct FileCard {
|
|
pub attachment: Attachment,
|
|
pub thread_key: String,
|
|
pub subject: String,
|
|
pub sender: Person,
|
|
pub date_ms: i64,
|
|
/// images, pdfs, documents, spreadsheets, presentations, invites, archives, other
|
|
pub category: String,
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------
|
|
// Settings
|
|
// -------------------------------------------------------------------------------------------
|
|
|
|
/// margin-shared's `FontRef`, which is either one of the six bundled faces or a family off the
|
|
/// machine. Stored rather than a bare family name, because a system font can be called "Literata"
|
|
/// and must not come back as the bundled one.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
|
pub enum FontRef {
|
|
#[serde(rename_all = "camelCase")]
|
|
Bundled { id: String },
|
|
#[serde(rename_all = "camelCase")]
|
|
System { family: String },
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AccountSettings {
|
|
pub account_id: String,
|
|
pub name: String,
|
|
pub color: String,
|
|
/// 30, 90, 180, 365, or 0 for everything.
|
|
pub window_days: u32,
|
|
pub signature: String,
|
|
pub aliases: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SnoozeTimes {
|
|
/// Hours from now for Later today.
|
|
pub later_today_hours: u32,
|
|
/// Local time of day, in minutes from midnight.
|
|
pub tomorrow_at: u32,
|
|
pub weekend_at: u32,
|
|
pub next_week_at: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct BackupSettings {
|
|
/// none, drive or r2
|
|
pub store: String,
|
|
pub configured: bool,
|
|
pub last_backup_ms: Option<i64>,
|
|
/// Shown once, at setup. Never returned again.
|
|
pub has_phrase: bool,
|
|
/// R2 only. The secret never crosses this boundary in the reading direction.
|
|
pub r2_bucket: Option<String>,
|
|
pub r2_endpoint: Option<String>,
|
|
}
|
|
|
|
/// Everything the settings screen edits. Device settings live in `settings.json` in the app data
|
|
/// directory, written atomically; the per account decisions that should roam live in the state
|
|
/// database and its journal instead, and are edited through the contact card and the account
|
|
/// sections rather than through this blob.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Settings {
|
|
/// light, dark or system
|
|
pub theme: String,
|
|
pub font_ui: FontRef,
|
|
pub font_text: FontRef,
|
|
pub text_size: u32,
|
|
pub reading_pane: bool,
|
|
/// comfortable or compact, on the phone
|
|
pub density: String,
|
|
|
|
pub accounts: Vec<AccountSettings>,
|
|
pub attachment_cache_mb: u32,
|
|
pub prefetch_bodies: bool,
|
|
|
|
/// never, ask or always. The per sender allowances are not here: they live on the contact,
|
|
/// because they are a decision about a person and they roam with the rest of those.
|
|
pub remote_images: String,
|
|
pub link_cleaning: bool,
|
|
#[serde(default)]
|
|
pub otp_autofill_enabled: bool,
|
|
#[serde(default)]
|
|
pub app_lock_enabled: bool,
|
|
#[serde(default = "on")]
|
|
pub spelling_enabled: bool,
|
|
#[serde(default)]
|
|
pub grammar_enabled: bool,
|
|
#[serde(default)]
|
|
pub writing_tools_enabled: bool,
|
|
|
|
pub screener_enabled: bool,
|
|
/// A reply to a thread you are in is never held. Turning this off holds it anyway.
|
|
pub hold_replies: bool,
|
|
pub suggestions: bool,
|
|
|
|
pub snooze_times: SnoozeTimes,
|
|
/// The pile a swipe reaches on the phone: reply-later, set-aside, archive, trash or none.
|
|
pub swipe_right: String,
|
|
pub swipe_left: String,
|
|
pub feed_auto_trash_days: u32,
|
|
|
|
pub undo_delay_secs: u32,
|
|
pub reply_all_default: bool,
|
|
pub instant_intro: String,
|
|
|
|
pub badge: bool,
|
|
/// The switch over everything below: off, and nothing is posted whatever a thread, a person or
|
|
/// a place says. On by default, and absent from files written before it existed.
|
|
#[serde(default = "on")]
|
|
pub notifications: bool,
|
|
/// The places that notify without being asked thread by thread. Empty is the default, which is
|
|
/// the product's position: nothing tells you anything until you say so.
|
|
pub notify_places: Vec<String>,
|
|
|
|
pub backup: BackupSettings,
|
|
}
|
|
|
|
fn on() -> bool {
|
|
true
|
|
}
|
|
|
|
/// Whether the system will show this app's notifications: what System Settings says on macOS, and
|
|
/// Prompt until the app has asked once. Everywhere else the answer is Granted.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum NotifyPermission {
|
|
Granted,
|
|
Denied,
|
|
Prompt,
|
|
}
|
|
|
|
/// Where a click on a notification goes: the account it was about, the place its thread shows in,
|
|
/// and the thread itself when the notification was about one. Several messages in one pass are
|
|
/// one notification, and a click on that goes to the place alone.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct NotifyTarget {
|
|
pub account_id: String,
|
|
pub place: Place,
|
|
pub thread_key: Option<String>,
|
|
}
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct StorageUsed {
|
|
pub account_id: String,
|
|
pub messages: u64,
|
|
pub threads: u64,
|
|
pub mirror_bytes: u64,
|
|
pub bodies_bytes: u64,
|
|
pub attachments_bytes: u64,
|
|
pub state_bytes: u64,
|
|
/// The date of the oldest message on the device, which is what the window setting shows.
|
|
pub oldest_ms: Option<i64>,
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------
|
|
// Sync, auth and events
|
|
// -------------------------------------------------------------------------------------------
|
|
|
|
/// Per account, because one account being signed out must not be reported as the app being broken.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SyncStatus {
|
|
pub account_id: String,
|
|
/// idle | syncing | hydrating | caching | backfilling | offline | error | paused
|
|
pub phase: String,
|
|
pub last_sync_ms: Option<i64>,
|
|
pub error: Option<String>,
|
|
pub pending_writes: u32,
|
|
/// The line the progress screen and the account chip show, when there is one.
|
|
pub message: Option<String>,
|
|
/// Whatever fill is running: the first sync's metadata crawl, and then the body cache behind
|
|
/// it. Both zero when there is nothing left to bring in.
|
|
pub hydrated: u32,
|
|
pub total: u32,
|
|
/// How far back the mirror actually reaches, which is not the same as the window setting until
|
|
/// the first sync has finished.
|
|
pub oldest_ms: Option<i64>,
|
|
}
|
|
|
|
impl SyncStatus {
|
|
pub fn idle(account_id: &str) -> Self {
|
|
SyncStatus {
|
|
account_id: account_id.to_string(),
|
|
phase: "idle".to_string(),
|
|
last_sync_ms: None,
|
|
error: None,
|
|
pending_writes: 0,
|
|
message: None,
|
|
hydrated: 0,
|
|
total: 0,
|
|
oldest_ms: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Payload of the `auth` event.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AuthEvent {
|
|
pub ok: bool,
|
|
pub error: Option<String>,
|
|
pub account_id: Option<String>,
|
|
pub email: Option<String>,
|
|
/// The user closed the consent browser rather than anything going wrong. Still not `ok`,
|
|
/// because no account arrived, but changing your mind is not a failure.
|
|
pub cancelled: bool,
|
|
/// What was granted, which may be less than what was asked for.
|
|
pub granted_scopes: Vec<String>,
|
|
/// Set when the account was refused because a scope the app cannot work without was withheld.
|
|
pub missing_required: Vec<String>,
|
|
}
|
|
|
|
/// The flags a triage action sets, and the only thing in this file the provider ever hears about.
|
|
/// An absent field means unchanged.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct FlagPatch {
|
|
#[serde(default)]
|
|
pub seen: Option<bool>,
|
|
#[serde(default)]
|
|
pub starred: Option<bool>,
|
|
#[serde(default)]
|
|
pub archived: Option<bool>,
|
|
#[serde(default)]
|
|
pub trashed: Option<bool>,
|
|
#[serde(default)]
|
|
pub spam: Option<bool>,
|
|
}
|