diff --git a/FlowCrypt/schemas/com.flowcrypt.email.database.FlowCryptRoomDatabase/29.json b/FlowCrypt/schemas/com.flowcrypt.email.database.FlowCryptRoomDatabase/29.json new file mode 100644 index 0000000000..651da25a6a --- /dev/null +++ b/FlowCrypt/schemas/com.flowcrypt.email.database.FlowCryptRoomDatabase/29.json @@ -0,0 +1,1019 @@ +{ + "formatVersion": 1, + "database": { + "version": 29, + "identityHash": "a92e84bda3f3e139a3c4e8a9a2c7684b", + "entities": [ + { + "tableName": "accounts_aliases", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `email` TEXT NOT NULL, `account_type` TEXT NOT NULL, `send_as_email` TEXT NOT NULL, `display_name` TEXT DEFAULT NULL, `is_default` INTEGER DEFAULT 0, `verification_status` TEXT NOT NULL, FOREIGN KEY(`email`, `account_type`) REFERENCES `accounts`(`email`, `account_type`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "account_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendAsEmail", + "columnName": "send_as_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "verificationStatus", + "columnName": "verification_status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "email_account_type_send_as_email_in_accounts_aliases", + "unique": true, + "columnNames": [ + "email", + "account_type", + "send_as_email" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `email_account_type_send_as_email_in_accounts_aliases` ON `${TABLE_NAME}` (`email`, `account_type`, `send_as_email`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email", + "account_type" + ], + "referencedColumns": [ + "email", + "account_type" + ] + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `email` TEXT NOT NULL, `account_type` TEXT DEFAULT NULL, `display_name` TEXT DEFAULT NULL, `given_name` TEXT DEFAULT NULL, `family_name` TEXT DEFAULT NULL, `photo_url` TEXT DEFAULT NULL, `is_enabled` INTEGER DEFAULT 1, `is_active` INTEGER DEFAULT 0, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `imap_server` TEXT NOT NULL, `imap_port` INTEGER DEFAULT 143, `imap_use_ssl_tls` INTEGER DEFAULT 0, `imap_use_starttls` INTEGER DEFAULT 0, `imap_auth_mechanisms` TEXT, `smtp_server` TEXT NOT NULL, `smtp_port` INTEGER DEFAULT 25, `smtp_use_ssl_tls` INTEGER DEFAULT 0, `smtp_use_starttls` INTEGER DEFAULT 0, `smtp_auth_mechanisms` TEXT, `smtp_use_custom_sign` INTEGER DEFAULT 0, `smtp_username` TEXT DEFAULT NULL, `smtp_password` TEXT DEFAULT NULL, `contacts_loaded` INTEGER DEFAULT 0, `show_only_encrypted` INTEGER DEFAULT 0, `uuid` TEXT DEFAULT NULL, `client_configuration` TEXT DEFAULT NULL, `use_api` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "account_type", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "givenName", + "columnName": "given_name", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "familyName", + "columnName": "family_name", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "photoUrl", + "columnName": "photo_url", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "1" + }, + { + "fieldPath": "isActive", + "columnName": "is_active", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imapServer", + "columnName": "imap_server", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imapPort", + "columnName": "imap_port", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "143" + }, + { + "fieldPath": "imapUseSslTls", + "columnName": "imap_use_ssl_tls", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "imapUseStarttls", + "columnName": "imap_use_starttls", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "imapAuthMechanisms", + "columnName": "imap_auth_mechanisms", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "smtpServer", + "columnName": "smtp_server", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smtpPort", + "columnName": "smtp_port", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "25" + }, + { + "fieldPath": "smtpUseSslTls", + "columnName": "smtp_use_ssl_tls", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "smtpUseStarttls", + "columnName": "smtp_use_starttls", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "smtpAuthMechanisms", + "columnName": "smtp_auth_mechanisms", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "smtpUseCustomSign", + "columnName": "smtp_use_custom_sign", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "smtpUsername", + "columnName": "smtp_username", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "smtpPassword", + "columnName": "smtp_password", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "contactsLoaded", + "columnName": "contacts_loaded", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "showOnlyEncrypted", + "columnName": "show_only_encrypted", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "clientConfiguration", + "columnName": "client_configuration", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "useAPI", + "columnName": "use_api", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "email_account_type_in_accounts", + "unique": true, + "columnNames": [ + "email", + "account_type" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `email_account_type_in_accounts` ON `${TABLE_NAME}` (`email`, `account_type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "action_queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `email` TEXT NOT NULL, `action_type` TEXT NOT NULL, `action_json` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionType", + "columnName": "action_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionJson", + "columnName": "action_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "attachment", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `email` TEXT NOT NULL, `folder` TEXT NOT NULL, `uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `encodedSize` INTEGER DEFAULT 0, `type` TEXT NOT NULL, `attachment_id` TEXT, `file_uri` TEXT, `forwarded_folder` TEXT, `forwarded_uid` INTEGER DEFAULT -1, `decrypt_when_forward` INTEGER NOT NULL DEFAULT 0, `path` TEXT NOT NULL, FOREIGN KEY(`email`, `folder`, `uid`) REFERENCES `messages`(`email`, `folder`, `uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "folder", + "columnName": "folder", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encodedSize", + "columnName": "encodedSize", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachment_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileUri", + "columnName": "file_uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "forwardedFolder", + "columnName": "forwarded_folder", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "forwardedUid", + "columnName": "forwarded_uid", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "-1" + }, + { + "fieldPath": "decryptWhenForward", + "columnName": "decrypt_when_forward", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "email_uid_folder_path_in_attachment", + "unique": true, + "columnNames": [ + "email", + "uid", + "folder", + "path" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `email_uid_folder_path_in_attachment` ON `${TABLE_NAME}` (`email`, `uid`, `folder`, `path`)" + }, + { + "name": "email_folder_uid_in_attachment", + "unique": false, + "columnNames": [ + "email", + "folder", + "uid" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `email_folder_uid_in_attachment` ON `${TABLE_NAME}` (`email`, `folder`, `uid`)" + } + ], + "foreignKeys": [ + { + "table": "messages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email", + "folder", + "uid" + ], + "referencedColumns": [ + "email", + "folder", + "uid" + ] + } + ] + }, + { + "tableName": "recipients", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `email` TEXT NOT NULL, `name` TEXT DEFAULT NULL, `last_use` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "lastUse", + "columnName": "last_use", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "name_in_recipients", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `name_in_recipients` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "last_use_in_recipients", + "unique": false, + "columnNames": [ + "last_use" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `last_use_in_recipients` ON `${TABLE_NAME}` (`last_use`)" + }, + { + "name": "email_in_recipients", + "unique": true, + "columnNames": [ + "email" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `email_in_recipients` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `fingerprint` TEXT NOT NULL, `account` TEXT NOT NULL, `account_type` TEXT DEFAULT NULL, `source` TEXT NOT NULL, `public_key` BLOB NOT NULL, `private_key` BLOB NOT NULL, `passphrase` TEXT DEFAULT NULL, `passphrase_type` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account`, `account_type`) REFERENCES `accounts`(`email`, `account_type`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "account_type", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "private_key", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "storedPassphrase", + "columnName": "passphrase", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "passphraseType", + "columnName": "passphrase_type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "fingerprint_account_account_type_in_keys", + "unique": true, + "columnNames": [ + "fingerprint", + "account", + "account_type" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `fingerprint_account_account_type_in_keys` ON `${TABLE_NAME}` (`fingerprint`, `account`, `account_type`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "account", + "account_type" + ], + "referencedColumns": [ + "email", + "account_type" + ] + } + ] + }, + { + "tableName": "labels", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `email` TEXT NOT NULL, `account_type` TEXT DEFAULT NULL, `name` TEXT NOT NULL, `alias` TEXT DEFAULT NULL, `is_custom` INTEGER NOT NULL DEFAULT 0, `messages_total` INTEGER NOT NULL DEFAULT 0, `message_unread` INTEGER NOT NULL DEFAULT 0, `attributes` TEXT DEFAULT NULL, `next_page_token` TEXT DEFAULT NULL, `history_id` TEXT DEFAULT NULL, FOREIGN KEY(`email`, `account_type`) REFERENCES `accounts`(`email`, `account_type`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "account_type", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "isCustom", + "columnName": "is_custom", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messagesTotal", + "columnName": "messages_total", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messagesUnread", + "columnName": "message_unread", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "nextPageToken", + "columnName": "next_page_token", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "historyId", + "columnName": "history_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "email_account_type_name_in_labels", + "unique": true, + "columnNames": [ + "email", + "account_type", + "name" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `email_account_type_name_in_labels` ON `${TABLE_NAME}` (`email`, `account_type`, `name`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email", + "account_type" + ], + "referencedColumns": [ + "email", + "account_type" + ] + } + ] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `email` TEXT NOT NULL, `folder` TEXT NOT NULL, `uid` INTEGER NOT NULL, `received_date` INTEGER DEFAULT NULL, `sent_date` INTEGER DEFAULT NULL, `from_address` TEXT DEFAULT NULL, `to_address` TEXT DEFAULT NULL, `cc_address` TEXT DEFAULT NULL, `subject` TEXT DEFAULT NULL, `flags` TEXT DEFAULT NULL, `raw_message_without_attachments` TEXT DEFAULT NULL, `is_message_has_attachments` INTEGER DEFAULT 0, `is_encrypted` INTEGER DEFAULT -1, `is_new` INTEGER DEFAULT -1, `state` INTEGER DEFAULT -1, `attachments_directory` TEXT, `error_msg` TEXT DEFAULT NULL, `reply_to` TEXT DEFAULT NULL, `thread_id` TEXT DEFAULT NULL, `history_id` TEXT DEFAULT NULL, `password` BLOB DEFAULT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "folder", + "columnName": "folder", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receivedDate", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "sentDate", + "columnName": "sent_date", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "fromAddress", + "columnName": "from_address", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "toAddress", + "columnName": "to_address", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "ccAddress", + "columnName": "cc_address", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "rawMessageWithoutAttachments", + "columnName": "raw_message_without_attachments", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "hasAttachments", + "columnName": "is_message_has_attachments", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "-1" + }, + { + "fieldPath": "isNew", + "columnName": "is_new", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "-1" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "-1" + }, + { + "fieldPath": "attachmentsDirectory", + "columnName": "attachments_directory", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "errorMsg", + "columnName": "error_msg", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "replyTo", + "columnName": "reply_to", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "threadId", + "columnName": "thread_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "historyId", + "columnName": "history_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "BLOB", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "email_in_messages", + "unique": false, + "columnNames": [ + "email" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `email_in_messages` ON `${TABLE_NAME}` (`email`)" + }, + { + "name": "email_uid_folder_in_messages", + "unique": true, + "columnNames": [ + "email", + "uid", + "folder" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `email_uid_folder_in_messages` ON `${TABLE_NAME}` (`email`, `uid`, `folder`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `recipient` TEXT NOT NULL, `fingerprint` TEXT NOT NULL, `public_key` BLOB NOT NULL, FOREIGN KEY(`recipient`) REFERENCES `recipients`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "recipient_fingerprint_in_public_keys", + "unique": true, + "columnNames": [ + "recipient", + "fingerprint" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `recipient_fingerprint_in_public_keys` ON `${TABLE_NAME}` (`recipient`, `fingerprint`)" + }, + { + "name": "recipient_in_public_keys", + "unique": false, + "columnNames": [ + "recipient" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `recipient_in_public_keys` ON `${TABLE_NAME}` (`recipient`)" + }, + { + "name": "fingerprint_in_public_keys", + "unique": false, + "columnNames": [ + "fingerprint" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `fingerprint_in_public_keys` ON `${TABLE_NAME}` (`fingerprint`)" + } + ], + "foreignKeys": [ + { + "table": "recipients", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "recipient" + ], + "referencedColumns": [ + "email" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a92e84bda3f3e139a3c4e8a9a2c7684b')" + ] + } +} \ No newline at end of file diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/database/MigrationTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/database/MigrationTest.kt index 53dc1c07d9..7e48756a46 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/database/MigrationTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/database/MigrationTest.kt @@ -38,7 +38,8 @@ class MigrationTest { FlowCryptRoomDatabase.MIGRATION_24_25, FlowCryptRoomDatabase.MIGRATION_25_26, FlowCryptRoomDatabase.MIGRATION_26_27, - FlowCryptRoomDatabase.MIGRATION_27_28 + FlowCryptRoomDatabase.MIGRATION_27_28, + FlowCryptRoomDatabase.MIGRATION_28_29 ) @get:Rule diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/EmailUtil.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/EmailUtil.kt index f8820e34cf..a906257551 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/EmailUtil.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/EmailUtil.kt @@ -19,11 +19,16 @@ import com.flowcrypt.email.BuildConfig import com.flowcrypt.email.Constants import com.flowcrypt.email.R import com.flowcrypt.email.api.email.gmail.GmailApiHelper +import com.flowcrypt.email.api.email.javamail.AttachmentInfoDataSource +import com.flowcrypt.email.api.email.javamail.ForwardedAttachmentInfoDataSource import com.flowcrypt.email.api.email.model.AttachmentInfo import com.flowcrypt.email.api.email.model.IncomingMessageInfo import com.flowcrypt.email.api.email.model.LocalFolder import com.flowcrypt.email.api.email.model.OutgoingMessageInfo import com.flowcrypt.email.database.entity.AccountEntity +import com.flowcrypt.email.database.entity.AttachmentEntity +import com.flowcrypt.email.database.entity.MessageEntity +import com.flowcrypt.email.extensions.kotlin.toInputStream import com.flowcrypt.email.model.MessageEncryptionType import com.flowcrypt.email.model.MessageType import com.flowcrypt.email.security.KeyStoreCryptoManager @@ -54,8 +59,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.apache.commons.io.FilenameUtils import org.apache.commons.io.IOUtils +import org.bouncycastle.openpgp.PGPSecretKeyRingCollection import org.pgpainless.key.protection.SecretKeyRingProtector -import java.io.ByteArrayInputStream import java.io.IOException import java.nio.charset.StandardCharsets import java.text.SimpleDateFormat @@ -474,42 +479,6 @@ class EmailUtil { } } - /** - * Get updated information about messages in the local database. - * - * @param folder The folder which contains messages. - * @param loadedMsgsCount The count of already loaded messages. - * @param newMsgsCount The count of new messages (offset value). - * @return A list of messages which already exist in the local database. - * @throws MessagingException for other failures. - */ - fun getUpdatedMsgs( - folder: IMAPFolder, - loadedMsgsCount: Int, - newMsgsCount: Int - ): Array { - val end = folder.messageCount - newMsgsCount - var start = end - loadedMsgsCount + 1 - - if (end < 1) { - return arrayOf() - } else { - if (start < 1) { - start = 1 - } - - val msgs = folder.getMessages(start, end) - - if (msgs.isNotEmpty()) { - val fetchProfile = FetchProfile() - fetchProfile.add(FetchProfile.Item.FLAGS) - fetchProfile.add(UIDFolder.FetchProfileItem.UID) - folder.fetch(msgs, fetchProfile) - } - return msgs - } - } - /** * Get updated information about messages in the local database using UIDs. * @@ -758,25 +727,6 @@ class EmailUtil { } } - /** - * Get only headers from the raw MIME - */ - fun getHeadersFromRawMIME(rawMime: String?): String { - // we don't know if the message is \n or \r\n delimited - if (rawMime == null) { - return "" - } - - val headersByDoubleNl = rawMime.trim().substringBefore("\n\n") - val headersByDoubleCrNl = rawMime.trim().substringBefore("\r\n\r\n") - - return if (headersByDoubleCrNl.length < headersByDoubleNl.length) { // therefore we choose smaller result - headersByDoubleCrNl - } else { - headersByDoubleNl - } - } - /** * Get information about attachments from the given [Part] * @@ -1031,9 +981,7 @@ class EmailUtil { msg.setRecipients(Message.RecipientType.CC, info.ccRecipients?.toTypedArray()) msg.setRecipients(Message.RecipientType.BCC, info.bccRecipients?.toTypedArray()) msg.setContent(MimeMultipart().apply { - addBodyPart(MimeBodyPart().apply { - setText(prepareMsgContent(info, pubKeys, prvKeys, protector)) - }) + addBodyPart(prepareBodyPart(info, pubKeys, prvKeys, protector)) }) return msg } @@ -1048,9 +996,7 @@ class EmailUtil { val reply = replyToMsg.reply(false)//we use replyToAll == false to use the own logic reply.setFrom(InternetAddress(info.from)) reply.setContent(MimeMultipart().apply { - addBodyPart(MimeBodyPart().apply { - setText(prepareMsgContent(info, pubKeys, prvKeys, protector)) - }) + addBodyPart(prepareBodyPart(info, pubKeys, prvKeys, protector)) }) reply.setRecipients(Message.RecipientType.TO, info.toRecipients.toTypedArray()) reply.setRecipients(Message.RecipientType.CC, info.ccRecipients?.toTypedArray()) @@ -1071,6 +1017,83 @@ class EmailUtil { ) } + suspend fun createMimeMsg( + context: Context, + sess: Session?, + account: AccountEntity, + msgEntity: MessageEntity, + atts: List + ): MimeMessage = withContext(Dispatchers.IO) { + val mimeMsg = MimeMessage(sess, msgEntity.rawMessageWithoutAttachments?.toInputStream()) + + //https://tools.ietf.org/html/draft-melnikov-email-user-agent-00#:~:text=User%2DAgent%20and%20X%2DMailer%20are%20common%20Email%20header%20fields,use%20of%20different%20email%20clients. + mimeMsg.addHeader("User-Agent", "FlowCrypt_Android_" + BuildConfig.VERSION_NAME) + + if (mimeMsg.content is MimeMultipart && atts.isNotEmpty()) { + val mimeMultipart = mimeMsg.content as MimeMultipart + val keysStorage = KeysStorageImpl.getInstance(context) + val secretKeys = PGPSecretKeyRingCollection(keysStorage.getPGPSecretKeyRings()) + val ringProtector = keysStorage.getSecretKeyRingProtector() + + val publicKeys = mutableListOf() + val senderEmail = getFirstAddressString(msgEntity.from) + val recipients = msgEntity.allRecipients.toMutableList() + publicKeys.addAll( + SecurityUtils.getRecipientsUsablePubKeys(context, recipients) + ) + publicKeys.addAll( + SecurityUtils.getSenderPgpKeyDetailsList(context, account, senderEmail) + .map { it.publicKey }) + + for (att in atts) { + val attBodyPart = genBodyPartWithAtt( + context = context, + att = att, + shouldBeEncrypted = msgEntity.isEncrypted ?: false, + publicKeys = publicKeys, + secretKeys = secretKeys, + ringProtector = ringProtector + ) + mimeMultipart.addBodyPart(attBodyPart) + } + + mimeMsg.setContent(mimeMultipart) + mimeMsg.saveChanges() + } + + return@withContext mimeMsg + } + + private fun genBodyPartWithAtt( + context: Context, + att: AttachmentEntity, + shouldBeEncrypted: Boolean, + publicKeys: List?, + secretKeys: PGPSecretKeyRingCollection, + ringProtector: SecretKeyRingProtector + ): BodyPart { + val attBodyPart = MimeBodyPart() + val attInfo = att.toAttInfo() + attBodyPart.dataHandler = if (attInfo.isForwarded) { + DataHandler( + ForwardedAttachmentInfoDataSource( + context, + attInfo, + shouldBeEncrypted, + publicKeys, + secretKeys, + ringProtector + ) + ) + } else { + DataHandler(AttachmentInfoDataSource(context, attInfo)) + } + attBodyPart.fileName = attInfo.getSafeName() + attBodyPart.contentID = attInfo.id + + return attBodyPart + } + private fun generateNonGmailSearchTerm(localFolder: LocalFolder): SearchTerm { return OrTerm( arrayOf( @@ -1094,44 +1117,58 @@ class EmailUtil { ): Message { val replyToMessageEntity = info.replyToMsgEntity ?: throw IllegalArgumentException("Empty replyTo MessageEntity") - var msg: MimeMessage - if (replyToMessageEntity.rawMessageWithoutAttachments.isNullOrEmpty()) { + val msg = if (replyToMessageEntity.rawMessageWithoutAttachments.isNullOrEmpty()) { val snapshot = MsgsCacheManager.getMsgSnapshot(replyToMessageEntity.id.toString()) ?: throw IllegalArgumentException("Snapshot of replyTo message not found") val uri = snapshot.getUri(0) ?: throw IllegalArgumentException("Uri not found") val input = context.contentResolver?.openInputStream(uri) ?: throw IllegalArgumentException("InputStream not found") - msg = FlowCryptMimeMessage(session, KeyStoreCryptoManager.getCipherInputStream(input)) + FlowCryptMimeMessage(session, KeyStoreCryptoManager.getCipherInputStream(input)) } else { - val input = ByteArrayInputStream( - replyToMessageEntity.rawMessageWithoutAttachments.toByteArray() - ) + val input = replyToMessageEntity.rawMessageWithoutAttachments.toInputStream() try { - msg = FlowCryptMimeMessage(session, KeyStoreCryptoManager.getCipherInputStream(input)) + FlowCryptMimeMessage(session, KeyStoreCryptoManager.getCipherInputStream(input)) } catch (e: Exception) { //added for compatibility to previous versions - msg = FlowCryptMimeMessage(session, input) + FlowCryptMimeMessage(session, input) } } return genReplyMessage(msg, info, pubKeys, prvKeys, protector) } - private fun prepareMsgContent( - info: OutgoingMessageInfo, pubKeys: List? = null, + private fun prepareBodyPart( + info: OutgoingMessageInfo, + pubKeys: List? = null, prvKeys: List? = null, protector: SecretKeyRingProtector? = null - ): String { + ): BodyPart { return if (info.encryptionType == MessageEncryptionType.ENCRYPTED) { - PgpEncryptAndOrSign.encryptAndOrSignMsg( + val encryptedContent = PgpEncryptAndOrSign.encryptAndOrSignMsg( msg = info.msg ?: "", pubKeys = pubKeys ?: emptyList(), prvKeys = prvKeys, secretKeyRingProtector = protector ) + + if (info.isPasswordProtected == true) { + MimeBodyPart().apply { + val dataSource = ByteArrayDataSource(encryptedContent, "application/pgp-encrypted") + dataHandler = DataHandler(dataSource) + description = "OpenPGP encrypted message" + disposition = Part.INLINE + fileName = "message.asc" + } + } else { + MimeBodyPart().apply { + setText(encryptedContent) + } + } } else { - info.msg ?: "" + MimeBodyPart().apply { + setText(info.msg ?: "") + } } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/javamail/AttachmentInfoDataSource.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/javamail/AttachmentInfoDataSource.kt new file mode 100644 index 0000000000..ab1345a088 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/javamail/AttachmentInfoDataSource.kt @@ -0,0 +1,45 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.api.email.javamail + +import android.content.Context +import android.net.Uri +import android.text.TextUtils +import com.flowcrypt.email.Constants +import com.flowcrypt.email.api.email.model.AttachmentInfo +import java.io.BufferedInputStream +import java.io.InputStream +import java.io.OutputStream +import javax.activation.DataSource + +/** + * The [DataSource] realization for a file which received from [Uri] + * + * @author Denis Bondarenko + * Date: 12/29/21 + * Time: 5:27 PM + * E-mail: DenBond7@gmail.com + */ +open class AttachmentInfoDataSource(private val context: Context, val att: AttachmentInfo) : + DataSource { + + override fun getInputStream(): InputStream? { + return att.uri?.let { uri -> + context.contentResolver.openInputStream(uri)?.let { stream -> BufferedInputStream(stream) } + } ?: att.rawData?.inputStream() + } + + override fun getOutputStream(): OutputStream? = null + + /** + * If a content type is unknown we return "application/octet-stream". + * http://www.rfc-editor.org/rfc/rfc2046.txt (section 4.5.1. Octet-Stream Subtype) + */ + override fun getContentType(): String = + if (TextUtils.isEmpty(att.type)) Constants.MIME_TYPE_BINARY_DATA else att.type + + override fun getName(): String = att.getSafeName() +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/javamail/ForwardedAttachmentInfoDataSource.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/javamail/ForwardedAttachmentInfoDataSource.kt new file mode 100644 index 0000000000..c66b28cb40 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/javamail/ForwardedAttachmentInfoDataSource.kt @@ -0,0 +1,54 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.api.email.javamail + +import android.content.Context +import com.flowcrypt.email.api.email.model.AttachmentInfo +import com.flowcrypt.email.security.pgp.PgpDecryptAndOrVerify +import com.flowcrypt.email.security.pgp.PgpEncryptAndOrSign +import org.bouncycastle.openpgp.PGPSecretKeyRingCollection +import org.pgpainless.key.protection.SecretKeyRingProtector +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/** + * @author Denis Bondarenko + * Date: 12/29/21 + * Time: 5:28 PM + * E-mail: DenBond7@gmail.com + */ +class ForwardedAttachmentInfoDataSource( + context: Context, + att: AttachmentInfo, + private val shouldBeEncrypted: Boolean, + private val publicKeys: List? = null, + private val secretKeys: PGPSecretKeyRingCollection, + private val protector: SecretKeyRingProtector +) : AttachmentInfoDataSource(context, att) { + override fun getInputStream(): InputStream? { + val inputStream = super.getInputStream() ?: return null + val srcInputStream = if (att.decryptWhenForward) PgpDecryptAndOrVerify.genDecryptionStream( + srcInputStream = inputStream, + secretKeys = secretKeys, + protector = protector + ) else inputStream + + return if (shouldBeEncrypted) { + //here we use [ByteArrayOutputStream] as a temp destination of encrypted data. + //todo-denbond7 it should be improved in the future for better performance + val tempByteArrayOutputStream = ByteArrayOutputStream() + PgpEncryptAndOrSign.encryptAndOrSign( + srcInputStream = srcInputStream, + destOutputStream = tempByteArrayOutputStream, + pubKeys = requireNotNull(publicKeys) + ) + + tempByteArrayOutputStream.toByteArray().inputStream() + } else { + srcInputStream + } + } +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/model/OutgoingMessageInfo.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/model/OutgoingMessageInfo.kt index 6e52a55287..620b8461e6 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/model/OutgoingMessageInfo.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/model/OutgoingMessageInfo.kt @@ -33,9 +33,12 @@ data class OutgoingMessageInfo constructor( val encryptionType: MessageEncryptionType, val messageType: MessageType, val replyToMsgEntity: MessageEntity? = null, - val uid: Long = 0 + val uid: Long = 0, + val password: CharArray? = null ) : Parcelable { + val isPasswordProtected = password?.isNotEmpty() + /** * Generate a list of the all recipients. * @@ -63,7 +66,8 @@ data class OutgoingMessageInfo constructor( parcel.readParcelable(MessageEncryptionType::class.java.classLoader)!!, parcel.readParcelable(MessageType::class.java.classLoader)!!, parcel.readParcelable(MessageEntity::class.java.classLoader), - parcel.readLong() + parcel.readLong(), + parcel.createCharArray() ) override fun describeContents(): Int { @@ -85,9 +89,55 @@ data class OutgoingMessageInfo constructor( writeParcelable(messageType, flags) writeParcelable(replyToMsgEntity, flags) writeLong(uid) + writeCharArray(password) } } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OutgoingMessageInfo + + if (account != other.account) return false + if (subject != other.subject) return false + if (msg != other.msg) return false + if (toRecipients != other.toRecipients) return false + if (ccRecipients != other.ccRecipients) return false + if (bccRecipients != other.bccRecipients) return false + if (from != other.from) return false + if (atts != other.atts) return false + if (forwardedAtts != other.forwardedAtts) return false + if (encryptionType != other.encryptionType) return false + if (messageType != other.messageType) return false + if (replyToMsgEntity != other.replyToMsgEntity) return false + if (uid != other.uid) return false + if (password != null) { + if (other.password == null) return false + if (!password.contentEquals(other.password)) return false + } else if (other.password != null) return false + + return true + } + + override fun hashCode(): Int { + var result = account.hashCode() + result = 31 * result + subject.hashCode() + result = 31 * result + (msg?.hashCode() ?: 0) + result = 31 * result + toRecipients.hashCode() + result = 31 * result + (ccRecipients?.hashCode() ?: 0) + result = 31 * result + (bccRecipients?.hashCode() ?: 0) + result = 31 * result + from.hashCode() + result = 31 * result + (atts?.hashCode() ?: 0) + result = 31 * result + (forwardedAtts?.hashCode() ?: 0) + result = 31 * result + encryptionType.hashCode() + result = 31 * result + messageType.hashCode() + result = 31 * result + (replyToMsgEntity?.hashCode() ?: 0) + result = 31 * result + uid.hashCode() + result = 31 * result + (password?.contentHashCode() ?: 0) + return result + } + companion object { @JvmField @Suppress("unused") diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/base/Result.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/base/Result.kt index f2aa254d03..34902793e0 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/base/Result.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/base/Result.kt @@ -5,6 +5,7 @@ package com.flowcrypt.email.api.retrofit.response.base +import com.flowcrypt.email.util.exception.ApiException import java.io.Serializable /** @@ -77,5 +78,15 @@ data class Result( progress = progress ) } + + fun throwExceptionIfNotSuccess(result: Result) { + when (result.status) { + Status.EXCEPTION -> result.exception?.let { throw it } + Status.ERROR -> result.data?.apiError?.let { throw ApiException(it) } + else -> { + //do nothing + } + } + } } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/database/FlowCryptRoomDatabase.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/database/FlowCryptRoomDatabase.kt index dafbe9968b..def323f3fd 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/database/FlowCryptRoomDatabase.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/database/FlowCryptRoomDatabase.kt @@ -90,7 +90,7 @@ abstract class FlowCryptRoomDatabase : RoomDatabase() { companion object { const val DB_NAME = "flowcrypt.db" - const val DB_VERSION = 28 + const val DB_VERSION = 29 private val MIGRATION_1_3 = object : FlowCryptMigration(1, 3) { override fun doMigration(database: SupportSQLiteDatabase) { @@ -552,6 +552,13 @@ abstract class FlowCryptRoomDatabase : RoomDatabase() { } } + @VisibleForTesting + val MIGRATION_28_29 = object : FlowCryptMigration(28, 29) { + override fun doMigration(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE messages ADD COLUMN password BLOB DEFAULT NULL;") + } + } + // Singleton prevents multiple instances of database opening at the same time. @Volatile private var INSTANCE: FlowCryptRoomDatabase? = null @@ -593,7 +600,8 @@ abstract class FlowCryptRoomDatabase : RoomDatabase() { MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, - MIGRATION_27_28 + MIGRATION_27_28, + MIGRATION_28_29 ).build() INSTANCE = instance return instance diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/database/MessageState.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/database/MessageState.kt index f6dc636cd8..1a0355b2cc 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/database/MessageState.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/database/MessageState.kt @@ -41,7 +41,9 @@ enum class MessageState constructor(val value: Int) : Parcelable { ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER(19), QUEUED_MAKE_COPY_IN_SENT_FOLDER(20), PENDING_DELETING_PERMANENTLY(21), - PENDING_EMPTY_TRASH(22); + PENDING_EMPTY_TRASH(22), + NEW_PASSWORD_PROTECTED(23), + ERROR_PASSWORD_PROTECTED(24); override fun describeContents(): Int { return 0 diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/MessageDao.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/MessageDao.kt index 17f677c646..d76dd29c2a 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/MessageDao.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/MessageDao.kt @@ -157,7 +157,8 @@ abstract class MessageDao : BaseDao { MessageState.ERROR_ORIGINAL_ATTACHMENT_NOT_FOUND.value, MessageState.ERROR_SENDING_FAILED.value, MessageState.ERROR_PRIVATE_KEY_NOT_FOUND.value, - MessageState.ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER.value + MessageState.ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER.value, + MessageState.ERROR_PASSWORD_PROTECTED.value ) ): Int @@ -172,8 +173,8 @@ abstract class MessageDao : BaseDao { MessageState.ERROR_ORIGINAL_ATTACHMENT_NOT_FOUND.value, MessageState.ERROR_SENDING_FAILED.value, MessageState.ERROR_PRIVATE_KEY_NOT_FOUND.value, - MessageState - .ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER.value + MessageState.ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER.value, + MessageState.ERROR_PASSWORD_PROTECTED.value ) ): Int? @@ -365,9 +366,9 @@ abstract class MessageDao : BaseDao { val msgEntity = getMsgSuspend(account = email, folder = label, uid = uid) ?: return@withContext val modifiedMsgEntity = if (flags.contains(Flags.Flag.SEEN)) { - msgEntity.copy(flags = flags.toString().toUpperCase(Locale.getDefault()), isNew = false) + msgEntity.copy(flags = flags.toString().uppercase(Locale.getDefault()), isNew = false) } else { - msgEntity.copy(flags = flags.toString().toUpperCase(Locale.getDefault())) + msgEntity.copy(flags = flags.toString().uppercase(Locale.getDefault())) } updateSuspend(modifiedMsgEntity) } @@ -452,9 +453,9 @@ abstract class MessageDao : BaseDao { val flags = flagsMap[msgEntity.uid] flags?.let { val modifiedMsgEntity = if (it.contains(Flags.Flag.SEEN)) { - msgEntity.copy(flags = it.toString().toUpperCase(Locale.getDefault()), isNew = false) + msgEntity.copy(flags = it.toString().uppercase(Locale.getDefault()), isNew = false) } else { - msgEntity.copy(flags = it.toString().toUpperCase(Locale.getDefault())) + msgEntity.copy(flags = it.toString().uppercase(Locale.getDefault())) } modifiedMsgEntities.add(modifiedMsgEntity) } @@ -474,9 +475,9 @@ abstract class MessageDao : BaseDao { val flags = flagsMap[msgEntity.uid] flags?.let { val modifiedMsgEntity = if (it.contains(Flags.Flag.SEEN)) { - msgEntity.copy(flags = it.toString().toUpperCase(Locale.getDefault()), isNew = false) + msgEntity.copy(flags = it.toString().uppercase(Locale.getDefault()), isNew = false) } else { - msgEntity.copy(flags = it.toString().toUpperCase(Locale.getDefault())) + msgEntity.copy(flags = it.toString().uppercase(Locale.getDefault())) } modifiedMsgEntities.add(modifiedMsgEntity) } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/RecipientDao.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/RecipientDao.kt index d87665da61..be9586c452 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/RecipientDao.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/RecipientDao.kt @@ -53,6 +53,10 @@ interface RecipientDao : BaseDao { @Query("SELECT * FROM recipients WHERE email IN (:emails)") fun getRecipientsWithPubKeysByEmails(emails: Collection): List + @Transaction + @Query("SELECT * FROM recipients WHERE email IN (:emails)") + suspend fun getRecipientsWithPubKeysByEmailsSuspend(emails: Collection): List + @Query("SELECT * FROM recipients WHERE email LIKE :searchPattern ORDER BY last_use DESC") fun getFilteredCursor(searchPattern: String): Cursor? diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/database/entity/MessageEntity.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/database/entity/MessageEntity.kt index f466940aeb..446e4de91b 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/database/entity/MessageEntity.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/database/entity/MessageEntity.kt @@ -31,7 +31,6 @@ import com.flowcrypt.email.util.SharedPreferencesHelper import com.google.android.gms.common.util.CollectionUtils import com.sun.mail.imap.IMAPFolder import java.util.ArrayList -import java.util.Locale import java.util.Properties import javax.mail.Flags import javax.mail.Message @@ -82,7 +81,8 @@ data class MessageEntity( @ColumnInfo(name = "error_msg", defaultValue = "NULL") val errorMsg: String? = null, @ColumnInfo(name = "reply_to", defaultValue = "NULL") val replyTo: String? = null, @ColumnInfo(name = "thread_id", defaultValue = "NULL") val threadId: String? = null, - @ColumnInfo(name = "history_id", defaultValue = "NULL") val historyId: String? = null + @ColumnInfo(name = "history_id", defaultValue = "NULL") val historyId: String? = null, + @ColumnInfo(name = "password", defaultValue = "NULL") val password: ByteArray? = null, ) : Parcelable { @Ignore @@ -106,6 +106,9 @@ data class MessageEntity( @Ignore val uidAsHEX: String = uid.toHex() + @Ignore + val isPasswordProtected = password?.isNotEmpty() ?: false + /** * Generate a list of the all recipients. * @@ -147,7 +150,8 @@ data class MessageEntity( parcel.readString(), parcel.readString(), parcel.readString(), - parcel.readString() + parcel.readString(), + parcel.createByteArray() ) override fun writeToParcel(parcel: Parcel, flags: Int) { @@ -172,6 +176,7 @@ data class MessageEntity( parcel.writeString(replyTo) parcel.writeString(threadId) parcel.writeString(historyId) + parcel.writeByteArray(password) } override fun describeContents(): Int { @@ -182,6 +187,81 @@ data class MessageEntity( return JavaEmailConstants.FOLDER_OUTBOX.equals(folder, ignoreCase = true) } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MessageEntity + + if (id != other.id) return false + if (email != other.email) return false + if (folder != other.folder) return false + if (uid != other.uid) return false + if (receivedDate != other.receivedDate) return false + if (sentDate != other.sentDate) return false + if (fromAddress != other.fromAddress) return false + if (toAddress != other.toAddress) return false + if (ccAddress != other.ccAddress) return false + if (subject != other.subject) return false + if (flags != other.flags) return false + if (rawMessageWithoutAttachments != other.rawMessageWithoutAttachments) return false + if (hasAttachments != other.hasAttachments) return false + if (isEncrypted != other.isEncrypted) return false + if (isNew != other.isNew) return false + if (state != other.state) return false + if (attachmentsDirectory != other.attachmentsDirectory) return false + if (errorMsg != other.errorMsg) return false + if (replyTo != other.replyTo) return false + if (threadId != other.threadId) return false + if (historyId != other.historyId) return false + if (password != null) { + if (other.password == null) return false + if (!password.contentEquals(other.password)) return false + } else if (other.password != null) return false + if (from != other.from) return false + if (replyToAddress != other.replyToAddress) return false + if (to != other.to) return false + if (cc != other.cc) return false + if (msgState != other.msgState) return false + if (isSeen != other.isSeen) return false + if (uidAsHEX != other.uidAsHEX) return false + + return true + } + + override fun hashCode(): Int { + var result = id?.hashCode() ?: 0 + result = 31 * result + email.hashCode() + result = 31 * result + folder.hashCode() + result = 31 * result + uid.hashCode() + result = 31 * result + (receivedDate?.hashCode() ?: 0) + result = 31 * result + (sentDate?.hashCode() ?: 0) + result = 31 * result + (fromAddress?.hashCode() ?: 0) + result = 31 * result + (toAddress?.hashCode() ?: 0) + result = 31 * result + (ccAddress?.hashCode() ?: 0) + result = 31 * result + (subject?.hashCode() ?: 0) + result = 31 * result + (flags?.hashCode() ?: 0) + result = 31 * result + (rawMessageWithoutAttachments?.hashCode() ?: 0) + result = 31 * result + (hasAttachments?.hashCode() ?: 0) + result = 31 * result + (isEncrypted?.hashCode() ?: 0) + result = 31 * result + (isNew?.hashCode() ?: 0) + result = 31 * result + (state ?: 0) + result = 31 * result + (attachmentsDirectory?.hashCode() ?: 0) + result = 31 * result + (errorMsg?.hashCode() ?: 0) + result = 31 * result + (replyTo?.hashCode() ?: 0) + result = 31 * result + (threadId?.hashCode() ?: 0) + result = 31 * result + (historyId?.hashCode() ?: 0) + result = 31 * result + (password?.contentHashCode() ?: 0) + result = 31 * result + from.hashCode() + result = 31 * result + replyToAddress.hashCode() + result = 31 * result + to.hashCode() + result = 31 * result + cc.hashCode() + result = 31 * result + msgState.hashCode() + result = 31 * result + isSeen.hashCode() + result = 31 * result + uidAsHEX.hashCode() + return result + } + companion object CREATOR : Parcelable.Creator { const val TABLE_NAME = "messages" @@ -226,7 +306,7 @@ data class MessageEntity( val isMsgEncrypted: Boolean? = if (areAllMsgsEncrypted) { true } else { - msgsEncryptionStates.get(folder.getUID(msg)) + msgsEncryptionStates[folder.getUID(msg)] } isMsgEncrypted?.let { @@ -344,7 +424,7 @@ data class MessageEntity( toAddress = InternetAddress.toString(msg.getRecipients(Message.RecipientType.TO)), ccAddress = InternetAddress.toString(msg.getRecipients(Message.RecipientType.CC)), subject = msg.subject, - flags = msg.flags.toString().toUpperCase(Locale.getDefault()), + flags = msg.flags.toString().uppercase(), hasAttachments = hasAttachments?.let { hasAttachments } ?: EmailUtil.hasAtt(msg), isNew = if (!msg.flags.contains(Flags.Flag.SEEN)) { isNew diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/javax/mail/internet/MimeMessageExt.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/javax/mail/internet/MimeMessageExt.kt new file mode 100644 index 0000000000..6a2cbaf85c --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/javax/mail/internet/MimeMessageExt.kt @@ -0,0 +1,36 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.extensions.javax.mail.internet + +import javax.mail.Address +import javax.mail.Message +import javax.mail.internet.InternetAddress +import javax.mail.internet.MimeMessage + +/** + * @author Denis Bondarenko + * Date: 12/30/21 + * Time: 1:42 PM + * E-mail: DenBond7@gmail.com + */ +fun MimeMessage.getAddresses(type: Message.RecipientType): List { + return getRecipients(type) + ?.mapNotNull { (it as? InternetAddress)?.address?.lowercase() } ?: emptyList() +} + +fun MimeMessage.getFromAddress(): String { + return (from.first() as? InternetAddress)?.address?.lowercase() + ?: throw IllegalStateException("'from' address is undefined") +} + +fun MimeMessage.getMatchingRecipients( + type: Message.RecipientType, + list: Iterable +): Array
{ + return getRecipients(type)?.filter { + (it as? InternetAddress)?.address?.lowercase() in list + }?.toTypedArray() ?: emptyArray() +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/ForwardedAttachmentsDownloaderWorker.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/ForwardedAttachmentsDownloaderWorker.kt index 1c3a603f1a..d0dda98fcf 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/ForwardedAttachmentsDownloaderWorker.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/ForwardedAttachmentsDownloaderWorker.kt @@ -26,7 +26,6 @@ import com.flowcrypt.email.database.entity.AttachmentEntity import com.flowcrypt.email.database.entity.MessageEntity import com.flowcrypt.email.extensions.kotlin.toHex import com.flowcrypt.email.jetpack.viewmodel.AccountViewModel -import com.flowcrypt.email.ui.notifications.ErrorNotificationManager import com.flowcrypt.email.util.FileAndDirectoryUtils import com.flowcrypt.email.util.GeneralUtil import com.flowcrypt.email.util.LogsUtil @@ -144,21 +143,30 @@ class ForwardedAttachmentsDownloaderWorker(context: Context, params: WorkerParam val msgState = getNewMsgState(account, msgEntity, msgAttsDir, atts, store) - val updateResult = - roomDatabase.msgDao().updateSuspend(msgEntity.copy(state = msgState.value)) + val updateResult = roomDatabase.msgDao().updateSuspend( + msgEntity.copy( + state = if ( + msgState == MessageState.QUEUED + && msgEntity.isEncrypted == true + && msgEntity.isPasswordProtected + ) { + MessageState.NEW_PASSWORD_PROTECTED.value + } else { + msgState.value + } + ) + ) + if (updateResult > 0) { if (msgState != MessageState.QUEUED) { - val failedOutgoingMsgsCount = roomDatabase.msgDao() - .getFailedOutgoingMsgsCountSuspend(account.email) ?: 0 - if (failedOutgoingMsgsCount > 0) { - ErrorNotificationManager(applicationContext).notifyUserAboutProblemWithOutgoingMsg( - account, - failedOutgoingMsgsCount - ) - } + GeneralUtil.notifyUserAboutProblemWithOutgoingMsgs(applicationContext, account) } - MessagesSenderWorker.enqueue(applicationContext) + if (msgEntity.isEncrypted == true && msgEntity.isPasswordProtected) { + HandlePasswordProtectedMsgWorker.enqueue(applicationContext) + } else { + MessagesSenderWorker.enqueue(applicationContext) + } } } catch (e: Exception) { e.printStackTrace() diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/HandlePasswordProtectedMsgWorker.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/HandlePasswordProtectedMsgWorker.kt new file mode 100644 index 0000000000..a1d6288609 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/HandlePasswordProtectedMsgWorker.kt @@ -0,0 +1,420 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.jetpack.workmanager + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.flowcrypt.email.R +import com.flowcrypt.email.api.email.EmailUtil +import com.flowcrypt.email.api.email.JavaEmailConstants +import com.flowcrypt.email.api.retrofit.FlowcryptApiRepository +import com.flowcrypt.email.api.retrofit.request.model.MessageUploadRequest +import com.flowcrypt.email.database.MessageState +import com.flowcrypt.email.database.entity.AccountEntity +import com.flowcrypt.email.database.entity.AttachmentEntity +import com.flowcrypt.email.database.entity.MessageEntity +import com.flowcrypt.email.extensions.javax.mail.internet.getAddresses +import com.flowcrypt.email.extensions.javax.mail.internet.getFromAddress +import com.flowcrypt.email.extensions.javax.mail.internet.getMatchingRecipients +import com.flowcrypt.email.extensions.kotlin.toInputStream +import com.flowcrypt.email.jetpack.viewmodel.AccountViewModel +import com.flowcrypt.email.jetpack.workmanager.base.BaseMsgWorker +import com.flowcrypt.email.security.KeyStoreCryptoManager +import com.flowcrypt.email.security.KeysStorageImpl +import com.flowcrypt.email.security.pgp.PgpDecryptAndOrVerify +import com.flowcrypt.email.security.pgp.PgpEncryptAndOrSign +import com.flowcrypt.email.util.GeneralUtil +import com.flowcrypt.email.util.LogsUtil +import com.flowcrypt.email.util.exception.ExceptionUtil +import com.flowcrypt.email.util.google.GoogleApiClientHelper +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.gson.GsonBuilder +import com.sun.mail.util.MailConnectException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection +import org.bouncycastle.openpgp.PGPSecretKeyRingCollection +import org.pgpainless.util.Passphrase +import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException +import java.io.InputStream +import java.net.SocketException +import java.util.Base64 +import java.util.Properties +import javax.mail.Message +import javax.mail.MessagingException +import javax.mail.Multipart +import javax.mail.Session +import javax.mail.internet.InternetAddress +import javax.mail.internet.MimeBodyPart +import javax.mail.internet.MimeMessage +import javax.net.ssl.SSLException +import kotlin.random.Random + +/** + * @author Denis Bondarenko + * Date: 12/29/21 + * Time: 9:30 AM + * E-mail: DenBond7@gmail.com + */ +class HandlePasswordProtectedMsgWorker(context: Context, params: WorkerParameters) : + BaseMsgWorker(context, params) { + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + LogsUtil.d(TAG, "doWork") + + if (isStopped) { + return@withContext Result.success() + } + + try { + val account = AccountViewModel.getAccountEntityWithDecryptedInfoSuspend( + roomDatabase.accountDao().getActiveAccountSuspend() + ) ?: return@withContext Result.success() + + val passwordProtectedCandidates = roomDatabase.msgDao().getOutboxMsgsByStatesSuspend( + account = account.email, + msgStates = listOf(MessageState.NEW_PASSWORD_PROTECTED.value) + ) + + if (passwordProtectedCandidates.isNotEmpty()) { + prepareAndUploadPasswordProtectedMsgsToFES(account) + } + + return@withContext rescheduleIfActiveAccountWasChanged(account) + } catch (e: Exception) { + e.printStackTrace() + return@withContext Result.failure() + } finally { + LogsUtil.d(TAG, "work was finished") + } + } + + private suspend fun prepareAndUploadPasswordProtectedMsgsToFES(account: AccountEntity) = + withContext(Dispatchers.IO) { + val apiRepository = FlowcryptApiRepository() + val keysStorage = KeysStorageImpl.getInstance(applicationContext) + val accountSecretKeys = PGPSecretKeyRingCollection(keysStorage.getPGPSecretKeyRings()) + + var list: List + var lastMsgUID = 0L + while (true) { + list = roomDatabase.msgDao().getOutboxMsgsByStatesSuspend( + account = account.email, + msgStates = listOf(MessageState.NEW_PASSWORD_PROTECTED.value) + ) + + if (list.isEmpty()) break + val msgEntity = list.firstOrNull { it.uid > lastMsgUID } ?: list[0] + + lastMsgUID = msgEntity.uid + + try { + if (msgEntity.isEncrypted == true && msgEntity.isPasswordProtected) { + //get msg attachments(including forwarded that can be encrypted) + val attachments = getAttachments(msgEntity) + + //create MimeMessage from content + attachments + val plainMimeMsgWithAttachments = getMimeMessage(account, msgEntity, attachments) + + //get recipients that will be used to create password-encrypted msg + val toCandidates = getRecipients(plainMimeMsgWithAttachments, Message.RecipientType.TO) + val ccCandidates = getRecipients(plainMimeMsgWithAttachments, Message.RecipientType.CC) + val bccCandidates = + getRecipients(plainMimeMsgWithAttachments, Message.RecipientType.BCC) + + if (toCandidates.isEmpty() && ccCandidates.isEmpty() && bccCandidates.isEmpty()) { + throw IllegalStateException("Wrong password-protected implementation") + } + + //start of creating and uploading a password-protected msg to FES + val fromAddress = plainMimeMsgWithAttachments.getFromAddress() + val domain = EmailUtil.getDomain(fromAddress) + val idToken = getGoogleIdToken() + val replyToken = fetchReplyToken(apiRepository, domain, idToken) + val messageUploadRequest = MessageUploadRequest( + associateReplyToken = replyToken, + from = fromAddress, + to = toCandidates.map { (it as InternetAddress).address }, + cc = ccCandidates.map { (it as InternetAddress).address }, + bcc = bccCandidates.map { (it as InternetAddress).address } + ) + + //prepare bodyWithReplyToken + val replyInfo = Base64.getEncoder().encodeToString( + GsonBuilder().create().toJson(messageUploadRequest).toByteArray() + ) + + val infoDiv = genInfoDiv(replyInfo) + val originalText = getDecryptedContentFromMessage( + mimeMsgWithAttachments = plainMimeMsgWithAttachments, + accountSecretKeys = accountSecretKeys, + keysStorage = keysStorage + ) + val bodyWithReplyToken = originalText + "\n\n" + infoDiv + + val rawMimeMsg = createRawPlainMimeMsgWithAttachments( + plainMimeMsgWithAttachments = plainMimeMsgWithAttachments, + bodyWithReplyToken = bodyWithReplyToken + ) + + //encrypt the raw MIME message ONLY FOR THE MESSAGE PASSWORD + val pwdEncryptedWithAttachments = PgpEncryptAndOrSign.encryptAndOrSignMsg( + msg = rawMimeMsg, + pubKeys = emptyList(), + prvKeys = emptyList(), + passphrase = Passphrase.fromPassword( + KeyStoreCryptoManager.decryptSuspend(String(requireNotNull(msgEntity.password))) + ) + ) + + //upload resulting data to FES + val fesUrl = uploadMsgToFESAndReturnUrl( + apiRepository = apiRepository, + domain = domain, + idToken = idToken, + messageUploadRequest = messageUploadRequest, + pwdEncryptedWithAttachments = pwdEncryptedWithAttachments + ) + + updateExistingMimeMsgWithUrl(msgEntity, fesUrl) + } else { + roomDatabase.msgDao() + .updateSuspend(msgEntity.copy(state = MessageState.QUEUED.value)) + } + MessagesSenderWorker.enqueue(applicationContext) + } catch (e: Exception) { + e.printStackTrace() + handleExceptionsForMessage(e, msgEntity, account) + } + } + } + + private suspend fun handleExceptionsForMessage( + e: Exception, + msgEntity: MessageEntity, + account: AccountEntity + ) = withContext(Dispatchers.IO) { + ExceptionUtil.handleError(e) + + if (!GeneralUtil.isConnected(applicationContext)) { + if (msgEntity.msgState !== MessageState.SENT) { + roomDatabase.msgDao().updateSuspend( + msgEntity.copy(state = MessageState.NEW_PASSWORD_PROTECTED.value) + ) + } + + throw e + } else { + val newMsgState = when (e) { + is MailConnectException -> { + MessageState.NEW_PASSWORD_PROTECTED + } + + is MessagingException -> { + if (e.cause is SSLException || e.cause is SocketException) { + MessageState.NEW_PASSWORD_PROTECTED + } else { + MessageState.ERROR_PASSWORD_PROTECTED + } + } + + else -> { + when (e.cause) { + is FileNotFoundException -> MessageState.ERROR_CACHE_PROBLEM + + else -> MessageState.ERROR_PASSWORD_PROTECTED + } + } + } + + roomDatabase.msgDao() + .updateSuspend(msgEntity.copy(state = newMsgState.value, errorMsg = e.message)) + + if (newMsgState == MessageState.ERROR_PASSWORD_PROTECTED) { + GeneralUtil.notifyUserAboutProblemWithOutgoingMsgs(applicationContext, account) + } + } + } + + private fun createRawPlainMimeMsgWithAttachments( + plainMimeMsgWithAttachments: MimeMessage, + bodyWithReplyToken: String + ): String { + // construct a regular plain mime message using bodyWithReplyToken + attachments + val multipart = plainMimeMsgWithAttachments.content as Multipart + //need to remove 'encrypted.asc' from the existing MIME + multipart.removeBodyPart(0) + multipart.addBodyPart(MimeBodyPart().apply { setText(bodyWithReplyToken) }, 0) + plainMimeMsgWithAttachments.saveChanges() + + //prepare raw MIME + val rawMimeMsg = String(ByteArrayOutputStream().apply { + plainMimeMsgWithAttachments.writeTo(this) + }.toByteArray()) + return rawMimeMsg + } + + private suspend fun getMimeMessage( + account: AccountEntity, + msgEntity: MessageEntity, + attachments: List + ) = EmailUtil.createMimeMsg( + context = applicationContext, + sess = Session.getDefaultInstance(Properties()), + account = account, + msgEntity = msgEntity.copy(isEncrypted = false), + atts = attachments + ) + + private suspend fun getAttachments(msgEntity: MessageEntity) = + roomDatabase.attachmentDao().getAttachmentsSuspend( + account = msgEntity.email, + label = JavaEmailConstants.FOLDER_OUTBOX, + uid = msgEntity.uid + ).map { + it.copy( + forwardedFolder = "Outbox", + forwardedUid = Random.nextLong(), + decryptWhenForward = true + ) + } + + private suspend fun getRecipients( + mimeMsgWithAttachments: MimeMessage, + type: Message.RecipientType + ) = withContext(Dispatchers.IO) { + mimeMsgWithAttachments.getMatchingRecipients( + type = type, + list = GeneralUtil.getRecipientsWithoutUsablePubKeys( + context = applicationContext, + emails = mimeMsgWithAttachments.getAddresses(type) + ) + ) + } + + private suspend fun updateExistingMimeMsgWithUrl( + msgEntity: MessageEntity, + url: String + ) = withContext(Dispatchers.IO) { + val mimeMsgWithoutAttachments = MimeMessage( + Session.getDefaultInstance(Properties()), + msgEntity.rawMessageWithoutAttachments?.toInputStream() + ) + val fromAddress = (mimeMsgWithoutAttachments.from.first() as InternetAddress).address + val multipart = mimeMsgWithoutAttachments.content as Multipart + multipart.addBodyPart(MimeBodyPart().apply { + setText(applicationContext.getString(R.string.password_protected_msg_promo, fromAddress, url)) + }, 0) + //todo-denbond7 need to add HTML version + mimeMsgWithoutAttachments.saveChanges() + + val out = ByteArrayOutputStream() + mimeMsgWithoutAttachments.writeTo(out) + + roomDatabase.msgDao().updateSuspend( + msgEntity.copy( + rawMessageWithoutAttachments = String(out.toByteArray()), + state = MessageState.QUEUED.value + ) + ) + } + + private suspend fun uploadMsgToFESAndReturnUrl( + apiRepository: FlowcryptApiRepository, + domain: String, + idToken: String, + messageUploadRequest: MessageUploadRequest, + pwdEncryptedWithAttachments: String + ): String = withContext(Dispatchers.IO) { + val messageUploadResponseResult = apiRepository.uploadPasswordProtectedMsgToWebPortal( + context = applicationContext, + domain = domain, + idToken = idToken, + messageUploadRequest = messageUploadRequest, + msg = pwdEncryptedWithAttachments + ) + + com.flowcrypt.email.api.retrofit.response.base.Result.throwExceptionIfNotSuccess( + messageUploadResponseResult + ) + return@withContext requireNotNull(messageUploadResponseResult.data?.url) + } + + private suspend fun getDecryptedContentFromMessage( + mimeMsgWithAttachments: MimeMessage, + accountSecretKeys: PGPSecretKeyRingCollection, + keysStorage: KeysStorageImpl + ): String = withContext(Dispatchers.IO) { + val decryptionResult = PgpDecryptAndOrVerify.decryptAndOrVerifyWithResult( + srcInputStream = (mimeMsgWithAttachments.content as Multipart).getBodyPart(0).content as InputStream, + publicKeys = PGPPublicKeyRingCollection(emptyList()), + secretKeys = accountSecretKeys, + protector = keysStorage.getSecretKeyRingProtector() + ) + + return@withContext String(decryptionResult.content?.toByteArray() ?: byteArrayOf()) + } + + private suspend fun getGoogleIdToken(): String = withContext(Dispatchers.IO) { + val googleSignInClient = GoogleSignIn.getClient( + applicationContext, + GoogleApiClientHelper.generateGoogleSignInOptions() + ) + val silentSignIn = googleSignInClient.silentSignIn() + if (!silentSignIn.isSuccessful) { + throw IllegalStateException("Could not receive idToken") + } + return@withContext requireNotNull(silentSignIn.result.idToken) + } + + private suspend fun fetchReplyToken( + apiRepository: FlowcryptApiRepository, + domain: String, + idToken: String + ): String = withContext(Dispatchers.IO) { + val messageReplyTokenResponseResult = + apiRepository.getReplyTokenForPasswordProtectedMsg( + context = applicationContext, + domain = domain, + idToken = idToken + ) + + com.flowcrypt.email.api.retrofit.response.base.Result.throwExceptionIfNotSuccess( + messageReplyTokenResponseResult + ) + + return@withContext requireNotNull(messageReplyTokenResponseResult.data?.replyToken) + } + + companion object { + private val TAG = HandlePasswordProtectedMsgWorker::class.java.simpleName + val NAME = HandlePasswordProtectedMsgWorker::class.java.simpleName + + private fun genInfoDiv(replyInfo: String?) = + "
" + + fun enqueue(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + WorkManager + .getInstance(context.applicationContext) + .enqueueUniqueWork( + NAME, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() + ) + } + } +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/MessagesSenderWorker.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/MessagesSenderWorker.kt index d596ad7c3c..5ae341823d 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/MessagesSenderWorker.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/MessagesSenderWorker.kt @@ -7,8 +7,6 @@ package com.flowcrypt.email.jetpack.workmanager import android.accounts.AuthenticatorException import android.content.Context -import android.net.Uri -import android.text.TextUtils import androidx.core.app.NotificationCompat import androidx.work.Constraints import androidx.work.ExistingWorkPolicy @@ -17,15 +15,12 @@ import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters -import com.flowcrypt.email.BuildConfig import com.flowcrypt.email.Constants import com.flowcrypt.email.R import com.flowcrypt.email.api.email.EmailUtil import com.flowcrypt.email.api.email.FoldersManager import com.flowcrypt.email.api.email.JavaEmailConstants import com.flowcrypt.email.api.email.gmail.GmailApiHelper -import com.flowcrypt.email.api.email.model.AttachmentInfo -import com.flowcrypt.email.api.email.model.GeneralMessageDetails import com.flowcrypt.email.api.email.protocol.OpenStoreHelper import com.flowcrypt.email.api.email.protocol.SmtpProtocolUtil import com.flowcrypt.email.database.FlowCryptRoomDatabase @@ -34,10 +29,7 @@ import com.flowcrypt.email.database.entity.AccountEntity import com.flowcrypt.email.database.entity.AttachmentEntity import com.flowcrypt.email.database.entity.MessageEntity import com.flowcrypt.email.jetpack.viewmodel.AccountViewModel -import com.flowcrypt.email.security.KeysStorageImpl -import com.flowcrypt.email.security.SecurityUtils -import com.flowcrypt.email.security.pgp.PgpDecryptAndOrVerify -import com.flowcrypt.email.security.pgp.PgpEncryptAndOrSign +import com.flowcrypt.email.jetpack.workmanager.base.BaseMsgWorker import com.flowcrypt.email.ui.notifications.NotificationChannelManager import com.flowcrypt.email.util.FileAndDirectoryUtils import com.flowcrypt.email.util.GeneralUtil @@ -55,34 +47,19 @@ import com.sun.mail.util.MailConnectException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext -import org.apache.commons.io.IOUtils -import org.bouncycastle.openpgp.PGPSecretKeyRingCollection -import org.pgpainless.key.protection.SecretKeyRingProtector -import java.io.BufferedInputStream -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream import java.io.File import java.io.FileNotFoundException import java.io.FileOutputStream -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream import java.net.SocketException -import java.nio.charset.StandardCharsets import java.util.* -import javax.activation.DataHandler -import javax.activation.DataSource import javax.mail.AuthenticationFailedException -import javax.mail.BodyPart import javax.mail.Flags import javax.mail.Folder import javax.mail.Message import javax.mail.MessagingException import javax.mail.Session import javax.mail.Store -import javax.mail.internet.MimeBodyPart import javax.mail.internet.MimeMessage -import javax.mail.internet.MimeMultipart import javax.net.ssl.SSLException /** @@ -93,7 +70,7 @@ import javax.net.ssl.SSLException * E-mail: DenBond7@gmail.com */ class MessagesSenderWorker(context: Context, params: WorkerParameters) : - BaseWorker(context, params) { + BaseMsgWorker(context, params) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) { @@ -157,7 +134,7 @@ class MessagesSenderWorker(context: Context, params: WorkerParameters) : e.printStackTrace() when (e) { is UserRecoverableAuthException, is UserRecoverableAuthIOException, is AuthenticatorException, is AuthenticationFailedException -> { - markMsgsWithAuthFailureState(roomDatabase) + markMsgsWithAuthFailureState(roomDatabase, MessageState.QUEUED) } else -> { @@ -180,16 +157,6 @@ class MessagesSenderWorker(context: Context, params: WorkerParameters) : } } - private suspend fun markMsgsWithAuthFailureState(roomDatabase: FlowCryptRoomDatabase) { - val account = roomDatabase.accountDao().getActiveAccountSuspend() - roomDatabase.msgDao().changeMsgsStateSuspend( - account = account?.email, - label = JavaEmailConstants.FOLDER_OUTBOX, - oldValue = MessageState.QUEUED.value, - newValues = MessageState.AUTH_FAILURE.value - ) - } - private fun genForegroundInfo(account: AccountEntity): ForegroundInfo { val title = applicationContext.getString(R.string.sending_email) val notification = NotificationCompat.Builder( @@ -344,7 +311,8 @@ class MessagesSenderWorker(context: Context, params: WorkerParameters) : val attachments = roomDatabase.attachmentDao() .getAttachmentsSuspend(email, JavaEmailConstants.FOLDER_OUTBOX, msgEntity.uid) - val mimeMsg = createMimeMsg(sess, account, msgEntity, attachments) + val mimeMsg = + EmailUtil.createMimeMsg(applicationContext, sess, account, msgEntity, attachments) roomDatabase.msgDao().resetMsgsWithSendingStateSuspend(account.email) roomDatabase.msgDao().updateSuspend(msgEntity.copy(state = MessageState.SENDING.value)) @@ -424,7 +392,7 @@ class MessagesSenderWorker(context: Context, params: WorkerParameters) : atts: List, sess: Session?, store: Store? ): Boolean = withContext(Dispatchers.IO) { - val mimeMsg = createMimeMsg(sess, account, msgEntity, atts) + val mimeMsg = EmailUtil.createMimeMsg(applicationContext, sess, account, msgEntity, atts) val roomDatabase = FlowCryptRoomDatabase.getDatabase(applicationContext) when (account.accountType) { @@ -508,98 +476,6 @@ class MessagesSenderWorker(context: Context, params: WorkerParameters) : return@withContext true } - /** - * Create [MimeMessage] from the given [GeneralMessageDetails]. - * - * @param sess Will be used to create [MimeMessage] - * @throws IOException - * @throws MessagingException - */ - private suspend fun createMimeMsg( - sess: Session?, - account: AccountEntity, - msgEntity: MessageEntity, - atts: List - ): MimeMessage = - withContext(Dispatchers.IO) { - val stream = - IOUtils.toInputStream(msgEntity.rawMessageWithoutAttachments, StandardCharsets.UTF_8) - val mimeMsg = MimeMessage(sess, stream) - - //https://tools.ietf.org/html/draft-melnikov-email-user-agent-00#:~:text=User%2DAgent%20and%20X%2DMailer%20are%20common%20Email%20header%20fields,use%20of%20different%20email%20clients. - mimeMsg.addHeader("User-Agent", "FlowCrypt_Android_" + BuildConfig.VERSION_NAME) - - if (mimeMsg.content is MimeMultipart && atts.isNotEmpty()) { - val mimeMultipart = mimeMsg.content as MimeMultipart - val keysStorage = KeysStorageImpl.getInstance(applicationContext) - val secretKeys = PGPSecretKeyRingCollection(keysStorage.getPGPSecretKeyRings()) - val ringProtector = keysStorage.getSecretKeyRingProtector() - - val publicKeys = mutableListOf() - val senderEmail = EmailUtil.getFirstAddressString(msgEntity.from) - val recipients = msgEntity.allRecipients.toMutableList() - publicKeys.addAll( - SecurityUtils.getRecipientsUsablePubKeys(applicationContext, recipients) - ) - publicKeys.addAll( - SecurityUtils.getSenderPgpKeyDetailsList(applicationContext, account, senderEmail) - .map { it.publicKey }) - - for (att in atts) { - val attBodyPart = genBodyPartWithAtt( - att = att, - shouldBeEncrypted = msgEntity.isEncrypted ?: false, - publicKeys = publicKeys, - secretKeys = secretKeys, - ringProtector = ringProtector - ) - mimeMultipart.addBodyPart(attBodyPart) - } - - mimeMsg.setContent(mimeMultipart) - mimeMsg.saveChanges() - } - - return@withContext mimeMsg - } - - /** - * Generate a [BodyPart] with an attachment. - * - * @param att The [AttachmentInfo] object, which contains general information about the - * attachment. - * @return Generated [MimeBodyPart] with the attachment. - * @throws MessagingException - */ - private fun genBodyPartWithAtt( - att: AttachmentEntity, - shouldBeEncrypted: Boolean, - publicKeys: List?, - secretKeys: PGPSecretKeyRingCollection, - ringProtector: SecretKeyRingProtector - ): BodyPart { - val attBodyPart = MimeBodyPart() - val attInfo = att.toAttInfo() - attBodyPart.dataHandler = if (attInfo.isForwarded) { - DataHandler( - ForwardedAttachmentInfoDataSource( - applicationContext, - attInfo, - shouldBeEncrypted, - publicKeys, - secretKeys, - ringProtector - ) - ) - } else { - DataHandler(AttachmentInfoDataSource(applicationContext, attInfo)) - } - attBodyPart.fileName = attInfo.getSafeName() - attBodyPart.contentID = attInfo.id - - return attBodyPart - } - /** * Save a copy of the sent message to the account SENT folder. * @@ -635,75 +511,6 @@ class MessagesSenderWorker(context: Context, params: WorkerParameters) : ) } - /** - * The [DataSource] realization for a file which received from [Uri] - */ - private open class AttachmentInfoDataSource( - private val context: Context, - protected val att: AttachmentInfo - ) : DataSource { - - override fun getInputStream(): InputStream? { - val inputStream: InputStream? = if (att.uri == null) { - val rawData = att.rawData ?: return null - ByteArrayInputStream(rawData) - } else { - att.uri?.let { context.contentResolver.openInputStream(it) } - } - - return if (inputStream == null) null else BufferedInputStream(inputStream) - } - - override fun getOutputStream(): OutputStream? { - return null - } - - /** - * If a content type is unknown we return "application/octet-stream". - * http://www.rfc-editor.org/rfc/rfc2046.txt (section 4.5.1. Octet-Stream Subtype) - */ - override fun getContentType(): String { - return if (TextUtils.isEmpty(att.type)) Constants.MIME_TYPE_BINARY_DATA else att.type - } - - override fun getName(): String { - return att.getSafeName() - } - } - - private class ForwardedAttachmentInfoDataSource( - context: Context, - att: AttachmentInfo, - private val shouldBeEncrypted: Boolean, - private val publicKeys: List? = null, - private val secretKeys: PGPSecretKeyRingCollection, - private val protector: SecretKeyRingProtector - ) : AttachmentInfoDataSource(context, att) { - override fun getInputStream(): InputStream? { - val inputStream = super.getInputStream() ?: return null - val srcInputStream = if (att.decryptWhenForward) PgpDecryptAndOrVerify.genDecryptionStream( - srcInputStream = inputStream, - secretKeys = secretKeys, - protector = protector - ) else inputStream - - return if (shouldBeEncrypted) { - //here we use [ByteArrayOutputStream] as a temp destination of encrypted data. - //it should be improved in the future for better performance - val tempByteArrayOutputStream = ByteArrayOutputStream() - PgpEncryptAndOrSign.encryptAndOrSign( - srcInputStream = srcInputStream, - destOutputStream = tempByteArrayOutputStream, - pubKeys = requireNotNull(publicKeys) - ) - - return ByteArrayInputStream(tempByteArrayOutputStream.toByteArray()) - } else { - srcInputStream - } - } - } - companion object { private val TAG = MessagesSenderWorker::class.java.simpleName private const val NOTIFICATION_ID = -10000 diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/base/BaseMsgWorker.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/base/BaseMsgWorker.kt new file mode 100644 index 0000000000..96a855571d --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/workmanager/base/BaseMsgWorker.kt @@ -0,0 +1,36 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.jetpack.workmanager.base + +import android.content.Context +import androidx.work.WorkerParameters +import com.flowcrypt.email.api.email.JavaEmailConstants +import com.flowcrypt.email.database.FlowCryptRoomDatabase +import com.flowcrypt.email.database.MessageState +import com.flowcrypt.email.jetpack.workmanager.BaseWorker + +/** + * @author Denis Bondarenko + * Date: 12/29/21 + * Time: 4:56 PM + * E-mail: DenBond7@gmail.com + */ +abstract class BaseMsgWorker(context: Context, params: WorkerParameters) : + BaseWorker(context, params) { + + protected suspend fun markMsgsWithAuthFailureState( + roomDatabase: FlowCryptRoomDatabase, + oldMessageState: MessageState + ) { + val account = roomDatabase.accountDao().getActiveAccountSuspend() + roomDatabase.msgDao().changeMsgsStateSuspend( + account = account?.email, + label = JavaEmailConstants.FOLDER_OUTBOX, + oldValue = oldMessageState.value, + newValues = MessageState.AUTH_FAILURE.value + ) + } +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpEncryptAndOrSign.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpEncryptAndOrSign.kt index 8db3fe3159..443c1d07a4 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpEncryptAndOrSign.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpEncryptAndOrSign.kt @@ -15,6 +15,7 @@ import org.pgpainless.encryption_signing.EncryptionStream import org.pgpainless.encryption_signing.ProducerOptions import org.pgpainless.encryption_signing.SigningOptions import org.pgpainless.key.protection.SecretKeyRingProtector +import org.pgpainless.util.Passphrase import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.IOException @@ -33,7 +34,8 @@ object PgpEncryptAndOrSign { msg: String, pubKeys: List, prvKeys: List? = null, - secretKeyRingProtector: SecretKeyRingProtector? = null + secretKeyRingProtector: SecretKeyRingProtector? = null, + passphrase: Passphrase? = null ): String { val outputStreamForEncryptedSource = ByteArrayOutputStream() encryptAndOrSign( @@ -42,7 +44,8 @@ object PgpEncryptAndOrSign { pubKeys = pubKeys, prvKeys = prvKeys, secretKeyRingProtector = secretKeyRingProtector, - doArmor = true + doArmor = true, + passphrase = passphrase ) return String(outputStreamForEncryptedSource.toByteArray()) } @@ -53,7 +56,8 @@ object PgpEncryptAndOrSign { pubKeys: List, prvKeys: List? = null, secretKeyRingProtector: SecretKeyRingProtector? = null, - doArmor: Boolean = false + doArmor: Boolean = false, + passphrase: Passphrase? = null ) { val pubKeysStream = ByteArrayInputStream(pubKeys.joinToString(separator = "\n").toByteArray()) val pgpPublicKeyRingCollection = pubKeysStream.use { @@ -79,7 +83,8 @@ object PgpEncryptAndOrSign { pgpPublicKeyRingCollection = pgpPublicKeyRingCollection, pgpSecretKeyRingCollection = pgpSecretKeyRingCollection, secretKeyRingProtector = secretKeyRingProtector, - doArmor = doArmor + doArmor = doArmor, + passphrase = passphrase ) } @@ -89,7 +94,8 @@ object PgpEncryptAndOrSign { pgpPublicKeyRingCollection: PGPPublicKeyRingCollection, pgpSecretKeyRingCollection: PGPSecretKeyRingCollection? = null, secretKeyRingProtector: SecretKeyRingProtector? = null, - doArmor: Boolean = false + doArmor: Boolean = false, + passphrase: Passphrase? = null ) { srcInputStream.use { srcStream -> genEncryptionStream( @@ -97,7 +103,8 @@ object PgpEncryptAndOrSign { pgpPublicKeyRingCollection = pgpPublicKeyRingCollection, pgpSecretKeyRingCollection = pgpSecretKeyRingCollection, secretKeyRingProtector = secretKeyRingProtector, - doArmor = doArmor + doArmor = doArmor, + passphrase = passphrase ).use { encryptionStream -> srcStream.copyTo(encryptionStream) } @@ -109,16 +116,18 @@ object PgpEncryptAndOrSign { pgpPublicKeyRingCollection: PGPPublicKeyRingCollection, pgpSecretKeyRingCollection: PGPSecretKeyRingCollection?, secretKeyRingProtector: SecretKeyRingProtector?, - doArmor: Boolean + doArmor: Boolean, + passphrase: Passphrase? = null ): EncryptionStream { val encOpt = EncryptionOptions().apply { + passphrase?.let { addPassphrase(passphrase) } pgpPublicKeyRingCollection.forEach { addRecipient(it) } } val producerOptions: ProducerOptions = - if (pgpSecretKeyRingCollection?.keyRings?.hasNext() == true) { + if (passphrase == null && pgpSecretKeyRingCollection?.any() == true) { ProducerOptions.signAndEncrypt(encOpt, SigningOptions().apply { pgpSecretKeyRingCollection.forEach { addInlineSignature( diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/service/PrepareOutgoingMessagesJobIntentService.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/service/PrepareOutgoingMessagesJobIntentService.kt index a56e9bb53c..510a33c91a 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/service/PrepareOutgoingMessagesJobIntentService.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/service/PrepareOutgoingMessagesJobIntentService.kt @@ -23,10 +23,12 @@ import com.flowcrypt.email.database.entity.AttachmentEntity import com.flowcrypt.email.database.entity.MessageEntity import com.flowcrypt.email.database.entity.RecipientEntity import com.flowcrypt.email.jetpack.workmanager.ForwardedAttachmentsDownloaderWorker +import com.flowcrypt.email.jetpack.workmanager.HandlePasswordProtectedMsgWorker import com.flowcrypt.email.jetpack.workmanager.MessagesSenderWorker import com.flowcrypt.email.jobscheduler.JobIdManager import com.flowcrypt.email.model.MessageEncryptionType import com.flowcrypt.email.model.MessageType +import com.flowcrypt.email.security.KeyStoreCryptoManager import com.flowcrypt.email.security.SecurityUtils import com.flowcrypt.email.security.pgp.PgpEncryptAndOrSign import com.flowcrypt.email.ui.notifications.ErrorNotificationManager @@ -144,8 +146,16 @@ class PrepareOutgoingMessagesJobIntentService : JobIntentService() { msgEntity.email, msgEntity.folder, msgEntity.uid ) insertedMsgEntity?.let { - roomDatabase.msgDao().update(it.copy(state = MessageState.QUEUED.value)) - MessagesSenderWorker.enqueue(applicationContext) + if (outgoingMsgInfo.encryptionType == MessageEncryptionType.ENCRYPTED + && outgoingMsgInfo.isPasswordProtected == true + ) { + roomDatabase.msgDao() + .update(it.copy(state = MessageState.NEW_PASSWORD_PROTECTED.value)) + HandlePasswordProtectedMsgWorker.enqueue(applicationContext) + } else { + roomDatabase.msgDao().update(it.copy(state = MessageState.QUEUED.value)) + MessagesSenderWorker.enqueue(applicationContext) + } } } else { ForwardedAttachmentsDownloaderWorker.enqueue(applicationContext) @@ -190,7 +200,7 @@ class PrepareOutgoingMessagesJobIntentService : JobIntentService() { val failedOutgoingMsgsCount = roomDatabase.msgDao().getFailedOutgoingMsgsCount(accountEntity.email) if (failedOutgoingMsgsCount > 0) { - ErrorNotificationManager(applicationContext).notifyUserAboutProblemWithOutgoingMsg( + ErrorNotificationManager(applicationContext).notifyUserAboutProblemWithOutgoingMsgs( accountEntity, failedOutgoingMsgsCount ) @@ -247,7 +257,8 @@ class PrepareOutgoingMessagesJobIntentService : JobIntentService() { flags = MessageFlag.SEEN.value, isEncrypted = isEncrypted, state = msgStateValue, - attachmentsDirectory = attsCacheDir.name + attachmentsDirectory = attsCacheDir.name, + password = msgInfo.password?.let { KeyStoreCryptoManager.encrypt(String(it)).toByteArray() } ) } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/base/BaseEmailListActivity.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/base/BaseEmailListActivity.kt index f734929df0..47752b4667 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/base/BaseEmailListActivity.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/base/BaseEmailListActivity.kt @@ -8,6 +8,7 @@ package com.flowcrypt.email.ui.activity.base import com.flowcrypt.email.R import com.flowcrypt.email.api.email.JavaEmailConstants import com.flowcrypt.email.jetpack.workmanager.ForwardedAttachmentsDownloaderWorker +import com.flowcrypt.email.jetpack.workmanager.HandlePasswordProtectedMsgWorker import com.flowcrypt.email.jetpack.workmanager.MessagesSenderWorker import com.flowcrypt.email.ui.activity.fragment.EmailListFragment @@ -37,6 +38,7 @@ abstract class BaseEmailListActivity : BaseSyncActivity(), val isOutbox = JavaEmailConstants.FOLDER_OUTBOX.equals(currentFolder!!.fullName, ignoreCase = true) if (currentFolder != null && isOutbox) { + HandlePasswordProtectedMsgWorker.enqueue(applicationContext) ForwardedAttachmentsDownloaderWorker.enqueue(applicationContext) MessagesSenderWorker.enqueue(applicationContext) } else { diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/EmailListFragment.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/EmailListFragment.kt index 06ad3ace2e..b2d2fefff1 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/EmailListFragment.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/EmailListFragment.kt @@ -46,6 +46,7 @@ import com.flowcrypt.email.extensions.showTwoWayDialog import com.flowcrypt.email.extensions.toast import com.flowcrypt.email.jetpack.viewmodel.LabelsViewModel import com.flowcrypt.email.jetpack.viewmodel.MessagesViewModel +import com.flowcrypt.email.jetpack.workmanager.HandlePasswordProtectedMsgWorker import com.flowcrypt.email.jetpack.workmanager.MessagesSenderWorker import com.flowcrypt.email.ui.activity.MessageDetailsActivity import com.flowcrypt.email.ui.activity.base.BaseSyncActivity @@ -189,13 +190,19 @@ class EmailListFragment : BaseFragment(), ListProgressBehaviour, when (requestCode) { REQUEST_CODE_RETRY_TO_SEND_MESSAGES -> when (resultCode) { TwoWayDialogFragment.RESULT_OK -> listener?.currentFolder?.let { - val newMsgState = when (activeMsgEntity?.msgState) { + val oldState = activeMsgEntity?.msgState + val newMsgState = when (oldState) { MessageState.ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER -> MessageState.QUEUED_MAKE_COPY_IN_SENT_FOLDER + MessageState.ERROR_PASSWORD_PROTECTED -> MessageState.NEW_PASSWORD_PROTECTED else -> MessageState.QUEUED } msgsViewModel.changeMsgsState(listOf(activeMsgEntity?.id ?: -1), it, newMsgState) - MessagesSenderWorker.enqueue(requireContext()) + if (oldState == MessageState.ERROR_PASSWORD_PROTECTED) { + HandlePasswordProtectedMsgWorker.enqueue(requireContext()) + } else { + MessagesSenderWorker.enqueue(requireContext()) + } } } @@ -355,7 +362,8 @@ class EmailListFragment : BaseFragment(), ListProgressBehaviour, MessageState.ERROR_DURING_CREATION, MessageState.ERROR_SENDING_FAILED, MessageState.ERROR_PRIVATE_KEY_NOT_FOUND, - MessageState.ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER -> handleOutgoingMsgWhichHasSomeError( + MessageState.ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER, + MessageState.ERROR_PASSWORD_PROTECTED -> handleOutgoingMsgWhichHasSomeError( msgEntity ) else -> { @@ -485,7 +493,9 @@ class EmailListFragment : BaseFragment(), ListProgressBehaviour, } } - MessageState.ERROR_SENDING_FAILED, MessageState.ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER -> { + MessageState.ERROR_SENDING_FAILED, + MessageState.ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER, + MessageState.ERROR_PASSWORD_PROTECTED -> { val twoWayDialogFragment = TwoWayDialogFragment.newInstance( dialogTitle = "", dialogMsg = getString(R.string.message_failed_to_send, message), diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/MessageDetailsFragment.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/MessageDetailsFragment.kt index 696bb680d7..0f8d8d6238 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/MessageDetailsFragment.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/MessageDetailsFragment.kt @@ -644,7 +644,9 @@ class MessageDetailsFragment : BaseFragment(), ProgressBehaviour, View.OnClickLi if (JavaEmailConstants.FOLDER_OUTBOX.equals(messageEntity.folder, ignoreCase = true)) { actionBarTitle = getString(R.string.outgoing) actionBarSubTitle = when (messageEntity.msgState) { - MessageState.NEW, MessageState.NEW_FORWARDED -> getString(R.string.preparing) + MessageState.NEW, + MessageState.NEW_FORWARDED, + MessageState.NEW_PASSWORD_PROTECTED -> getString(R.string.preparing) MessageState.QUEUED, MessageState.QUEUED_MAKE_COPY_IN_SENT_FOLDER -> getString(R.string.queued) @@ -658,7 +660,8 @@ class MessageDetailsFragment : BaseFragment(), ProgressBehaviour, View.OnClickLi MessageState.ERROR_ORIGINAL_ATTACHMENT_NOT_FOUND, MessageState.ERROR_SENDING_FAILED, MessageState.ERROR_PRIVATE_KEY_NOT_FOUND, - MessageState.ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER -> getString(R.string.an_error_has_occurred) + MessageState.ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER, + MessageState.ERROR_PASSWORD_PROTECTED -> getString(R.string.an_error_has_occurred) else -> null } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MsgsPagedListAdapter.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MsgsPagedListAdapter.kt index 8ecda4dfe8..d3d14b7902 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MsgsPagedListAdapter.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MsgsPagedListAdapter.kt @@ -319,7 +319,7 @@ class MsgsPagedListAdapter(private val onMessageClickListener: OnMessageClickLis var stateTextColor = ContextCompat.getColor(context, R.color.red) when (messageState) { - MessageState.NEW, MessageState.NEW_FORWARDED -> { + MessageState.NEW, MessageState.NEW_FORWARDED, MessageState.NEW_PASSWORD_PROTECTED -> { state = context.getString(R.string.preparing) stateTextColor = ContextCompat.getColor(context, R.color.colorAccent) } @@ -341,7 +341,8 @@ class MsgsPagedListAdapter(private val onMessageClickListener: OnMessageClickLis MessageState.ERROR_SENDING_FAILED, MessageState.ERROR_PRIVATE_KEY_NOT_FOUND, MessageState.ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER, - MessageState.AUTH_FAILURE -> { + MessageState.AUTH_FAILURE, + MessageState.ERROR_PASSWORD_PROTECTED -> { stateTextColor = ContextCompat.getColor(context, R.color.red) when (messageState) { @@ -367,6 +368,9 @@ class MsgsPagedListAdapter(private val onMessageClickListener: OnMessageClickLis MessageState.AUTH_FAILURE -> state = context.getString(R.string.can_not_send_due_to_auth_failure) + MessageState.ERROR_PASSWORD_PROTECTED -> + state = context.getString(R.string.can_not_send_password_protected) + else -> { } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/notifications/ErrorNotificationManager.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/notifications/ErrorNotificationManager.kt index 01be4c8e42..8071ae390d 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/notifications/ErrorNotificationManager.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/notifications/ErrorNotificationManager.kt @@ -31,7 +31,7 @@ class ErrorNotificationManager(context: Context) : CustomNotificationManager(con override val groupName: String = GROUP_NAME_ERRORS override val groupId: Int = NOTIFICATIONS_GROUP_ERROR - fun notifyUserAboutProblemWithOutgoingMsg(account: AccountEntity, messageCount: Int) { + fun notifyUserAboutProblemWithOutgoingMsgs(account: AccountEntity, messageCount: Int) { val intent = Intent(context, EmailManagerActivity::class.java).apply { action = EmailManagerActivity.ACTION_OPEN_OUTBOX_FOLDER } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/util/GeneralUtil.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/util/GeneralUtil.kt index 7491bf5eb4..69f2c5aac2 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/util/GeneralUtil.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/util/GeneralUtil.kt @@ -33,6 +33,10 @@ import com.flowcrypt.email.Constants import com.flowcrypt.email.R import com.flowcrypt.email.api.email.EmailUtil import com.flowcrypt.email.api.retrofit.ApiService +import com.flowcrypt.email.database.FlowCryptRoomDatabase +import com.flowcrypt.email.database.entity.AccountEntity +import com.flowcrypt.email.security.pgp.PgpKey +import com.flowcrypt.email.ui.notifications.ErrorNotificationManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -411,5 +415,45 @@ class GeneralUtil { fun generateFesUrl(domain: String): String { return "https://fes.$domain/api/v1/client-configuration?domain=$domain" } + + /** + * Get recipients without usable public keys; + * + * @param context Interface to global information about an application environment. + * @param emails A list which contains recipients + */ + suspend fun getRecipientsWithoutUsablePubKeys( + context: Context, + emails: List + ): List = withContext(Dispatchers.IO) { + val mapOfRecipients = mutableMapOf(*emails.map { Pair(it, false) }.toTypedArray()) + val recipientsWithPubKeys = FlowCryptRoomDatabase.getDatabase(context).recipientDao() + .getRecipientsWithPubKeysByEmailsSuspend(emails) + + for (recipientWithPubKeys in recipientsWithPubKeys) { + for (publicKeyEntity in recipientWithPubKeys.publicKeys) { + val pgpKeyDetailsList = PgpKey.parseKeys(publicKeyEntity.publicKey).pgpKeyDetailsList + for (pgpKeyDetails in pgpKeyDetailsList) { + if (!pgpKeyDetails.isExpired && !pgpKeyDetails.isRevoked) { + mapOfRecipients[recipientWithPubKeys.recipient.email] = true + } + } + } + } + + return@withContext mapOfRecipients.filter { entry -> !entry.value }.keys.toList() + } + + suspend fun notifyUserAboutProblemWithOutgoingMsgs(context: Context, account: AccountEntity) = + withContext(Dispatchers.IO) { + val failedOutgoingMsgsCount = FlowCryptRoomDatabase.getDatabase(context).msgDao() + .getFailedOutgoingMsgsCountSuspend(account.email) ?: 0 + if (failedOutgoingMsgsCount > 0) { + ErrorNotificationManager(context).notifyUserAboutProblemWithOutgoingMsgs( + account, + failedOutgoingMsgsCount + ) + } + } } } diff --git a/FlowCrypt/src/main/res/values/strings.xml b/FlowCrypt/src/main/res/values/strings.xml index e3199a6c1e..e5e3662285 100644 --- a/FlowCrypt/src/main/res/values/strings.xml +++ b/FlowCrypt/src/main/res/values/strings.xml @@ -556,4 +556,6 @@ verifying signature… not signed mixed signed + %1$s has sent you a password-encrypted email. Follow this link to open it: %2$s + Can\'t send message: password-protected