From 4f02c811a3be59b020a96234293ecc9d15d58281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asiel=20D=C3=ADaz=20Ben=C3=ADtez?= Date: Sat, 4 Jun 2022 12:12:38 -0400 Subject: [PATCH] update python API (#3394) --- CHANGELOG.md | 18 ++++ python/src/deltachat/account.py | 59 +++++++---- python/src/deltachat/chat.py | 102 +++++++++++++----- python/src/deltachat/message.py | 4 + python/src/deltachat/testplugin.py | 16 +-- python/tests/test_3_offline.py | 159 ++++++++++++++++++++++++++++- 6 files changed, 304 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 134abb5c8..a11eee687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,24 @@ - do not reset our database if imported backup cannot be decrypted #3397 - node: remove `npx` from build script, this broke flathub build #3396 +### API-Changes +- python: added optional `closed` parameter to `Account` constructor #3394 +- python: added optional `passphrase` parameter to `Account.export_all()` and `Account.import_all()` #3394 +- python: added `Account.open()` #3394 +- python: added `Chat.is_single()` #3394 +- python: added `Chat.is_mailinglist()` #3394 +- python: added `Chat.is_broadcast()` #3394 +- python: added `Chat.is_multiuser()` #3394 +- python: added `Chat.is_self_talk()` #3394 +- python: added `Chat.is_device_talk()` #3394 +- python: added `Chat.is_pinned()` #3394 +- python: added `Chat.pin()` #3394 +- python: added `Chat.unpin()` #3394 +- python: added `Chat.archive()` #3394 +- python: added `Chat.unarchive()` #3394 +- python: added `Message.get_summarytext()` #3394 +- python: added optional `closed` parameter to `ACFactory.get_unconfigured_account()` (pytest plugin) #3394 +- python: added optional `passphrase` parameter to `ACFactory.get_pseudo_configured_account()` (pytest plugin) #3394 ## 1.85.0 diff --git a/python/src/deltachat/account.py b/python/src/deltachat/account.py index d2db9439e..09242b6b3 100644 --- a/python/src/deltachat/account.py +++ b/python/src/deltachat/account.py @@ -62,12 +62,15 @@ class Account(object): MissingCredentials = MissingCredentials - def __init__(self, db_path, os_name=None, logging=True) -> None: + def __init__(self, db_path, os_name=None, logging=True, closed=False) -> None: """initialize account object. :param db_path: a path to the account database. The database will be created if it doesn't exist. - :param os_name: this will be put to the X-Mailer header in outgoing messages + :param os_name: [Deprecated] + :param logging: enable logging for this account + :param closed: set to True to avoid automatically opening the account + after creation. """ # initialize per-account plugin system self._pm = hookspec.PerAccount._make_plugin_manager() @@ -80,7 +83,7 @@ class Account(object): db_path = db_path.encode("utf8") self._dc_context = ffi.gc( - lib.dc_context_new(as_dc_charpointer(os_name), db_path, ffi.NULL), + lib.dc_context_new_closed(db_path) if closed else lib.dc_context_new(ffi.NULL, db_path, ffi.NULL), lib.dc_context_unref, ) if self._dc_context == ffi.NULL: @@ -92,6 +95,17 @@ class Account(object): hook = hookspec.Global._get_plugin_manager().hook hook.dc_account_init(account=self) + def open(self, passphrase: Optional[str] = None) -> bool: + """Open the account's database with the given passphrase. + This can only be used on a closed account. If the account is new, this + operation sets the database passphrase. For existing databases the passphrase + should be the one used to encrypt the database the first time. + + :returns: True if the database is opened with this passphrase, False if the + passphrase is incorrect or an error occurred. + """ + return bool(lib.dc_context_open(self._dc_context, as_dc_charpointer(passphrase))) + def disable_logging(self) -> None: """disable logging.""" self._logging = False @@ -209,13 +223,13 @@ class Account(object): :returns: True if account is configured. """ - return True if lib.dc_is_configured(self._dc_context) else False + return bool(lib.dc_is_configured(self._dc_context)) def is_open(self) -> bool: """Determine if account is open :returns True if account is open.""" - return True if lib.dc_context_is_open(self._dc_context) else False + return bool(lib.dc_context_is_open(self._dc_context)) def set_avatar(self, img_path: Optional[str]) -> None: """Set self avatar. @@ -461,21 +475,24 @@ class Account(object): """ return self._export(path, imex_cmd=const.DC_IMEX_EXPORT_SELF_KEYS) - def export_all(self, path): - """return new file containing a backup of all database state - (chats, contacts, keys, media, ...). The file is created in the - the `path` directory. + def export_all(self, path: str, passphrase: Optional[str] = None) -> str: + """Export a backup of all database state (chats, contacts, keys, media, ...). + + :param path: the directory where the backup will be stored. + :param passphrase: the backup will be encrypted with the passphrase, if it is + None or empty string, the backup is not encrypted. + :returns: path to the created backup. Note that the account has to be stopped; call stop_io() if necessary. """ - export_files = self._export(path, const.DC_IMEX_EXPORT_BACKUP) + export_files = self._export(path, const.DC_IMEX_EXPORT_BACKUP, passphrase) if len(export_files) != 1: raise RuntimeError("found more than one new file") return export_files[0] - def _export(self, path, imex_cmd): + def _export(self, path: str, imex_cmd: int, passphrase: Optional[str] = None) -> list: with self.temp_plugin(ImexTracker()) as imex_tracker: - self.imex(path, imex_cmd) + self.imex(path, imex_cmd, passphrase) return imex_tracker.wait_finish() def import_self_keys(self, path): @@ -487,21 +504,23 @@ class Account(object): """ self._import(path, imex_cmd=const.DC_IMEX_IMPORT_SELF_KEYS) - def import_all(self, path): - """import delta chat state from the specified backup `path` (a file). - + def import_all(self, path: str, passphrase: Optional[str] = None) -> None: + """Import Delta Chat state from the specified backup file. The account must be in unconfigured state for import to attempted. + + :param path: path to the backup file. + :param passphrase: if not None or empty, the backup will be opened with the passphrase. """ assert not self.is_configured(), "cannot import into configured account" - self._import(path, imex_cmd=const.DC_IMEX_IMPORT_BACKUP) + self._import(path, imex_cmd=const.DC_IMEX_IMPORT_BACKUP, passphrase=passphrase) - def _import(self, path, imex_cmd): + def _import(self, path: str, imex_cmd: int, passphrase: Optional[str] = None) -> None: with self.temp_plugin(ImexTracker()) as imex_tracker: - self.imex(path, imex_cmd) + self.imex(path, imex_cmd, passphrase) imex_tracker.wait_finish() - def imex(self, path, imex_cmd): - lib.dc_imex(self._dc_context, imex_cmd, as_dc_charpointer(path), ffi.NULL) + def imex(self, path: str, imex_cmd: int, passphrase: Optional[str] = None) -> None: + lib.dc_imex(self._dc_context, imex_cmd, as_dc_charpointer(path), as_dc_charpointer(passphrase)) def initiate_key_transfer(self) -> str: """return setup code after a Autocrypt setup message diff --git a/python/src/deltachat/chat.py b/python/src/deltachat/chat.py index 6e1c5fa50..effe73eb1 100644 --- a/python/src/deltachat/chat.py +++ b/python/src/deltachat/chat.py @@ -65,27 +65,65 @@ class Chat(object): # ------ chat status/metadata API ------------------------------ def is_group(self) -> bool: - """return true if this chat is a group chat. + """Return True if this chat is a group chat. - :returns: True if chat is a group-chat, false otherwise + :returns: True if chat is a group-chat, False otherwise """ return lib.dc_chat_get_type(self._dc_chat) == const.DC_CHAT_TYPE_GROUP + def is_single(self) -> bool: + """Return True if this chat is a single/direct chat, False otherwise""" + return lib.dc_chat_get_type(self._dc_chat) == const.DC_CHAT_TYPE_SINGLE + + def is_mailinglist(self) -> bool: + """Return True if this chat is a mailing list, False otherwise""" + return lib.dc_chat_get_type(self._dc_chat) == const.DC_CHAT_TYPE_MAILINGLIST + + def is_broadcast(self) -> bool: + """Return True if this chat is a broadcast list, False otherwise""" + return lib.dc_chat_get_type(self._dc_chat) == const.DC_CHAT_TYPE_BROADCAST + + def is_multiuser(self) -> bool: + """Return True if this chat is a multi-user chat (group, mailing list or broadcast), False otherwise""" + return lib.dc_chat_get_type(self._dc_chat) in ( + const.DC_CHAT_TYPE_GROUP, + const.DC_CHAT_TYPE_MAILINGLIST, + const.DC_CHAT_TYPE_BROADCAST, + ) + + def is_self_talk(self) -> bool: + """Return True if this chat is the self-chat (a.k.a. "Saved Messages"), False otherwise""" + return bool(lib.dc_chat_is_self_talk(self._dc_chat)) + + def is_device_talk(self) -> bool: + """Returns True if this chat is the "Device Messages" chat, False otherwise""" + return bool(lib.dc_chat_is_device_talk(self._dc_chat)) + def is_muted(self) -> bool: """return true if this chat is muted. :returns: True if chat is muted, False otherwise. """ - return lib.dc_chat_is_muted(self._dc_chat) + return bool(lib.dc_chat_is_muted(self._dc_chat)) - def is_contact_request(self): + def is_pinned(self) -> bool: + """Return True if this chat is pinned, False otherwise""" + return lib.dc_chat_get_visibility(self._dc_chat) == const.DC_CHAT_VISIBILITY_PINNED + + def is_archived(self) -> bool: + """Return True if this chat is archived, False otherwise. + :returns: True if archived, False otherwise + """ + return lib.dc_chat_get_visibility(self._dc_chat) == const.DC_CHAT_VISIBILITY_ARCHIVED + + def is_contact_request(self) -> bool: """return True if this chat is a contact request chat. :returns: True if chat is a contact request chat, False otherwise. """ - return lib.dc_chat_is_contact_request(self._dc_chat) + return bool(lib.dc_chat_is_contact_request(self._dc_chat)) - def is_promoted(self): + def is_promoted(self) -> bool: """return True if this chat is promoted, i.e. the member contacts are aware of their membership, have been sent messages. @@ -100,14 +138,14 @@ class Chat(object): :returns: True if the chat is writable, False otherwise """ - return lib.dc_chat_can_send(self._dc_chat) + return bool(lib.dc_chat_can_send(self._dc_chat)) def is_protected(self) -> bool: """return True if this chat is a protected chat. :returns: True if chat is protected, False otherwise. """ - return lib.dc_chat_is_protected(self._dc_chat) + return bool(lib.dc_chat_is_protected(self._dc_chat)) def get_name(self) -> Optional[str]: """return name of this chat. @@ -125,6 +163,18 @@ class Chat(object): name = as_dc_charpointer(name) return bool(lib.dc_set_chat_name(self.account._dc_context, self.id, name)) + def get_color(self): + """return the color of the chat. + :returns: color as 0x00rrggbb + """ + return lib.dc_chat_get_color(self._dc_chat) + + def get_summary(self): + """return dictionary with summary information.""" + dc_res = lib.dc_chat_get_info_json(self.account._dc_context, self.id) + s = from_dc_charpointer(dc_res) + return json.loads(s) + def mute(self, duration: Optional[int] = None) -> None: """mutes the chat @@ -148,6 +198,24 @@ class Chat(object): if not bool(ret): raise ValueError("Failed to unmute chat") + def pin(self) -> None: + """Pin the chat.""" + lib.dc_set_chat_visibility(self.account._dc_context, self.id, const.DC_CHAT_VISIBILITY_PINNED) + + def unpin(self) -> None: + """Unpin the chat.""" + if self.is_pinned(): + lib.dc_set_chat_visibility(self.account._dc_context, self.id, const.DC_CHAT_VISIBILITY_NORMAL) + + def archive(self) -> None: + """Archive the chat.""" + lib.dc_set_chat_visibility(self.account._dc_context, self.id, const.DC_CHAT_VISIBILITY_ARCHIVED) + + def unarchive(self) -> None: + """Unarchive the chat.""" + if self.is_archived(): + lib.dc_set_chat_visibility(self.account._dc_context, self.id, const.DC_CHAT_VISIBILITY_NORMAL) + def get_mute_duration(self) -> int: """Returns the number of seconds until the mute of this chat is lifted. @@ -364,12 +432,6 @@ class Chat(object): """ return lib.dc_marknoticed_chat(self.account._dc_context, self.id) - def get_summary(self): - """return dictionary with summary information.""" - dc_res = lib.dc_chat_get_info_json(self.account._dc_context, self.id) - s = from_dc_charpointer(dc_res) - return json.loads(s) - # ------ group management API ------------------------------ def add_contact(self, obj): @@ -460,12 +522,6 @@ class Chat(object): return None return from_dc_charpointer(dc_res) - def get_color(self): - """return the color of the chat. - :returns: color as 0x00rrggbb - """ - return lib.dc_chat_get_color(self._dc_chat) - # ------ location streaming API ------------------------------ def is_sending_locations(self): @@ -474,12 +530,6 @@ class Chat(object): """ return lib.dc_is_sending_locations_to_chat(self.account._dc_context, self.id) - def is_archived(self): - """return True if this chat is archived. - :returns: True if archived. - """ - return lib.dc_chat_get_visibility(self._dc_chat) == const.DC_CHAT_VISIBILITY_ARCHIVED - def enable_sending_locations(self, seconds): """enable sending locations for this chat. diff --git a/python/src/deltachat/message.py b/python/src/deltachat/message.py index 32075f4a3..597272614 100644 --- a/python/src/deltachat/message.py +++ b/python/src/deltachat/message.py @@ -159,6 +159,10 @@ class Message(object): """ return from_dc_charpointer(lib.dc_get_msg_info(self.account._dc_context, self.id)) + def get_summarytext(self, width: int) -> str: + """Get a message summary as a single line of text. Typically used for notifications.""" + return from_dc_charpointer(lib.dc_msg_get_summarytext(self._dc_msg, width)) + def continue_key_transfer(self, setup_code): """extract key and use it as primary key for this account.""" res = lib.dc_continue_key_transfer(self.account._dc_context, self.id, as_dc_charpointer(setup_code)) diff --git a/python/src/deltachat/testplugin.py b/python/src/deltachat/testplugin.py index 1dd7e46f9..0dc92188a 100644 --- a/python/src/deltachat/testplugin.py +++ b/python/src/deltachat/testplugin.py @@ -11,7 +11,7 @@ import threading import time import weakref from queue import Queue -from typing import Callable, List +from typing import Callable, List, Optional import pytest import requests @@ -429,16 +429,16 @@ class ACFactory: if addr in self.testprocess._addr2files: return self._getaccount(addr) - def get_unconfigured_account(self): - return self._getaccount() + def get_unconfigured_account(self, closed=False): + return self._getaccount(closed=closed) - def _getaccount(self, try_cache_addr=None): + def _getaccount(self, try_cache_addr=None, closed=False): logid = "ac{}".format(len(self._accounts) + 1) # we need to use fixed database basename for maybe_cache_* functions to work path = self.tmpdir.mkdir(logid).join("dc.db") if try_cache_addr: self.testprocess.cache_maybe_retrieve_configured_db_files(try_cache_addr, path) - ac = Account(path.strpath, logging=self._logging) + ac = Account(path.strpath, logging=self._logging, closed=closed) ac._logid = logid # later instantiated FFIEventLogger needs this ac._evtracker = ac.add_account_plugin(FFIEventTracker(ac)) if self.pytestconfig.getoption("--debug-setup"): @@ -467,9 +467,11 @@ class ACFactory: else: print("WARN: could not use preconfigured keys for {!r}".format(addr)) - def get_pseudo_configured_account(self): + def get_pseudo_configured_account(self, passphrase: Optional[str] = None) -> Account: # do a pseudo-configured account - ac = self.get_unconfigured_account() + ac = self.get_unconfigured_account(closed=bool(passphrase)) + if passphrase: + ac.open(passphrase) acname = ac._logid addr = "{}@offline.org".format(acname) ac.update_config( diff --git a/python/tests/test_3_offline.py b/python/tests/test_3_offline.py index f912690ea..feec34f7b 100644 --- a/python/tests/test_3_offline.py +++ b/python/tests/test_3_offline.py @@ -11,6 +11,7 @@ from deltachat.capi import ffi, lib from deltachat.cutil import iter_array from deltachat.hookspec import account_hookimpl from deltachat.message import Message +from deltachat.tracker import ImexFailed @pytest.mark.parametrize( @@ -496,7 +497,7 @@ class TestOfflineChat: with pytest.raises(ValueError): ac1.set_config("addr", "123@example.org") - def test_import_export_one_contact(self, acfactory, tmpdir): + def test_import_export_on_unencrypted_acct(self, acfactory, tmpdir): backupdir = tmpdir.mkdir("backup") ac1 = acfactory.get_pseudo_configured_account() chat = ac1.create_contact("some1 ").create_chat() @@ -525,6 +526,162 @@ class TestOfflineChat: assert messages[0].text == "msg1" assert os.path.exists(messages[1].filename) + def test_import_export_on_encrypted_acct(self, acfactory, tmpdir): + passphrase1 = "passphrase1" + passphrase2 = "passphrase2" + backupdir = tmpdir.mkdir("backup") + ac1 = acfactory.get_pseudo_configured_account(passphrase=passphrase1) + + chat = ac1.create_contact("some1 ").create_chat() + # send a text message + msg = chat.send_text("msg1") + # send a binary file + bin = tmpdir.join("some.bin") + with bin.open("w") as f: + f.write("\00123" * 10000) + msg = chat.send_file(bin.strpath) + contact = msg.get_sender_contact() + assert contact == ac1.get_self_contact() + + assert not backupdir.listdir() + ac1.stop_io() + + path = ac1.export_all(backupdir.strpath) + assert os.path.exists(path) + + ac2 = acfactory.get_unconfigured_account(closed=True) + ac2.open(passphrase2) + ac2.import_all(path) + + # check data integrity + contacts = ac2.get_contacts(query="some1") + assert len(contacts) == 1 + contact2 = contacts[0] + assert contact2.addr == "some1@example.org" + chat2 = contact2.create_chat() + messages = chat2.get_messages() + assert len(messages) == 2 + assert messages[0].text == "msg1" + assert os.path.exists(messages[1].filename) + + ac2.shutdown() + + # check that passphrase is not lost after import: + ac2 = Account(ac2.db_path, logging=ac2._logging, closed=True) + ac2.open(passphrase2) + + # check data integrity + contacts = ac2.get_contacts(query="some1") + assert len(contacts) == 1 + contact2 = contacts[0] + assert contact2.addr == "some1@example.org" + chat2 = contact2.create_chat() + messages = chat2.get_messages() + assert len(messages) == 2 + assert messages[0].text == "msg1" + assert os.path.exists(messages[1].filename) + + def test_import_export_with_passphrase(self, acfactory, tmpdir): + passphrase = "test_passphrase" + wrong_passphrase = "wrong_passprase" + backupdir = tmpdir.mkdir("backup") + ac1 = acfactory.get_pseudo_configured_account() + + chat = ac1.create_contact("some1 ").create_chat() + # send a text message + msg = chat.send_text("msg1") + # send a binary file + bin = tmpdir.join("some.bin") + with bin.open("w") as f: + f.write("\00123" * 10000) + msg = chat.send_file(bin.strpath) + contact = msg.get_sender_contact() + assert contact == ac1.get_self_contact() + + assert not backupdir.listdir() + ac1.stop_io() + + path = ac1.export_all(backupdir.strpath, passphrase) + assert os.path.exists(path) + + ac2 = acfactory.get_unconfigured_account() + with pytest.raises(ImexFailed): + ac2.import_all(path, wrong_passphrase) + ac2.import_all(path, passphrase) + + # check data integrity + contacts = ac2.get_contacts(query="some1") + assert len(contacts) == 1 + contact2 = contacts[0] + assert contact2.addr == "some1@example.org" + chat2 = contact2.create_chat() + messages = chat2.get_messages() + assert len(messages) == 2 + assert messages[0].text == "msg1" + assert os.path.exists(messages[1].filename) + + def test_import_encrypted_bak_into_encrypted_acct(self, acfactory, tmpdir): + """ + Test that account passphrase isn't lost if backup failed to be imported. + See https://github.com/deltachat/deltachat-core-rust/issues/3379 + """ + acct_passphrase = "passphrase1" + bak_passphrase = "passphrase2" + wrong_passphrase = "wrong_passprase" + backupdir = tmpdir.mkdir("backup") + + ac1 = acfactory.get_pseudo_configured_account() + chat = ac1.create_contact("some1 ").create_chat() + # send a text message + msg = chat.send_text("msg1") + # send a binary file + bin = tmpdir.join("some.bin") + with bin.open("w") as f: + f.write("\00123" * 10000) + msg = chat.send_file(bin.strpath) + contact = msg.get_sender_contact() + assert contact == ac1.get_self_contact() + + assert not backupdir.listdir() + ac1.stop_io() + + path = ac1.export_all(backupdir.strpath, bak_passphrase) + assert os.path.exists(path) + + ac2 = acfactory.get_unconfigured_account(closed=True) + ac2.open(acct_passphrase) + with pytest.raises(ImexFailed): + ac2.import_all(path, wrong_passphrase) + ac2.import_all(path, bak_passphrase) + + # check data integrity + contacts = ac2.get_contacts(query="some1") + assert len(contacts) == 1 + contact2 = contacts[0] + assert contact2.addr == "some1@example.org" + chat2 = contact2.create_chat() + messages = chat2.get_messages() + assert len(messages) == 2 + assert messages[0].text == "msg1" + assert os.path.exists(messages[1].filename) + + ac2.shutdown() + + # check that passphrase is not lost after import + ac2 = Account(ac2.db_path, logging=ac2._logging, closed=True) + ac2.open(acct_passphrase) + + # check data integrity + contacts = ac2.get_contacts(query="some1") + assert len(contacts) == 1 + contact2 = contacts[0] + assert contact2.addr == "some1@example.org" + chat2 = contact2.create_chat() + messages = chat2.get_messages() + assert len(messages) == 2 + assert messages[0].text == "msg1" + assert os.path.exists(messages[1].filename) + def test_set_get_draft(self, chat1): msg = Message.new_empty(chat1.account, "text") msg1 = chat1.prepare_message(msg)