diff --git a/mailosaur/mailosaur_client.py b/mailosaur/mailosaur_client.py index 36979af..2c8959a 100644 --- a/mailosaur/mailosaur_client.py +++ b/mailosaur/mailosaur_client.py @@ -16,8 +16,10 @@ from .operations.analysis_operations import AnalysisOperations from .operations.files_operations import FilesOperations from .operations.usage_operations import UsageOperations +from .operations.devices_operations import DevicesOperations from .models.mailosaur_exception import MailosaurException + class MailosaurClient(object): """ Main class to access Mailosaur.com api. """ @@ -30,20 +32,30 @@ def __init__(self, api_key, base_url="https://mailosaur.com/"): if base_url is None: base_url = "https://mailosaur.com/" - self.servers = ServersOperations(session, base_url, self.handle_http_error) - self.messages = MessagesOperations(session, base_url, self.handle_http_error) - self.analysis = AnalysisOperations(session, base_url, self.handle_http_error) + self.servers = ServersOperations( + session, base_url, self.handle_http_error) + self.messages = MessagesOperations( + session, base_url, self.handle_http_error) + self.analysis = AnalysisOperations( + session, base_url, self.handle_http_error) self.files = FilesOperations(session, base_url, self.handle_http_error) self.usage = UsageOperations(session, base_url, self.handle_http_error) + self.devices = DevicesOperations( + session, base_url, self.handle_http_error) def handle_http_error(self, response): if response.status_code == 400: - raise MailosaurException("Request had one or more invalid parameters.", "invalid_request", response.status_code, response.text) + raise MailosaurException("Request had one or more invalid parameters.", + "invalid_request", response.status_code, response.text) elif response.status_code == 401: - raise MailosaurException("Authentication failed, check your API key.", "authentication_error", response.status_code, response.text) + raise MailosaurException("Authentication failed, check your API key.", + "authentication_error", response.status_code, response.text) elif response.status_code == 403: - raise MailosaurException("Insufficient permission to perform that task.", "permission_error", response.status_code, response.text) + raise MailosaurException("Insufficient permission to perform that task.", + "permission_error", response.status_code, response.text) elif response.status_code == 404: - raise MailosaurException("Request did not find any matching resources.", "invalid_request", response.status_code, response.text) + raise MailosaurException("Request did not find any matching resources.", + "invalid_request", response.status_code, response.text) else: - raise MailosaurException("An API error occurred, see httpResponse for further information.", "api_error", response.status_code, response.text) \ No newline at end of file + raise MailosaurException("An API error occurred, see httpResponse for further information.", + "api_error", response.status_code, response.text) diff --git a/mailosaur/models/__init__.py b/mailosaur/models/__init__.py index 5e2485e..b9af74a 100644 --- a/mailosaur/models/__init__.py +++ b/mailosaur/models/__init__.py @@ -24,6 +24,10 @@ from .usage_account_limit import UsageAccountLimit from .usage_transaction_list_result import UsageTransactionListResult from .usage_transaction import UsageTransaction +from .device import Device +from .device_list_result import DeviceListResult +from .device_create_options import DeviceCreateOptions +from .otp_result import OtpResult __all__ = [ 'SpamAssassinRule', @@ -51,5 +55,9 @@ 'UsageAccountLimits', 'UsageAccountLimit', 'UsageTransactionListResult', - 'UsageTransaction' + 'UsageTransaction', + 'Device', + 'DeviceListResult', + 'DeviceCreateOptions', + 'OtpResult' ] diff --git a/mailosaur/models/device.py b/mailosaur/models/device.py new file mode 100644 index 0000000..be726d8 --- /dev/null +++ b/mailosaur/models/device.py @@ -0,0 +1,15 @@ +class Device(object): + """Device. + + :param id: Unique identifier for the device. + :type id: str + :param name: The name of the device. + :type name: str + """ + + def __init__(self, data=None): + if data is None: + data = {} + + self.id = data.get('id', None) + self.name = data.get('name', None) diff --git a/mailosaur/models/device_create_options.py b/mailosaur/models/device_create_options.py new file mode 100644 index 0000000..7bb2a2e --- /dev/null +++ b/mailosaur/models/device_create_options.py @@ -0,0 +1,21 @@ +class DeviceCreateOptions(object): + """DeviceCreateOptions. + + :param name: A name used to identify the device. + :type name: str + :param shared_secret: The base32-encoded shared secret for this device. + :type shared_secret: str + """ + + def __init__(self, data=None): + if data is None: + data = {} + + self.name = data.get('name', None) + self.shared_secret = data.get('shared_secret', None) + + def to_json(self): + return { + 'name': self.name, + 'sharedSecret': self.shared_secret + } diff --git a/mailosaur/models/device_list_result.py b/mailosaur/models/device_list_result.py new file mode 100644 index 0000000..6945af2 --- /dev/null +++ b/mailosaur/models/device_list_result.py @@ -0,0 +1,12 @@ +class DeviceListResult(object): + """The result of a device listing request. + + :param items: The individual devices forming the result. + :type items: list[~mailosaur.models.Device] + """ + + def __init__(self, data=None): + if data is None: + data = {} + + self.items = data.get('items', None) diff --git a/mailosaur/models/otp_result.py b/mailosaur/models/otp_result.py new file mode 100644 index 0000000..31f6e5b --- /dev/null +++ b/mailosaur/models/otp_result.py @@ -0,0 +1,18 @@ +import dateutil.parser + + +class OtpResult(object): + """OtpResult. + + :param code: The current one-time password. + :type code: str + :param expires: The expiry date/time of the current one-time password. + :type expires: datetime + """ + + def __init__(self, data=None): + if data is None: + data = {} + + self.code = data.get('code', None) + self.expires = dateutil.parser.parse(data.get('expires', None)) diff --git a/mailosaur/operations/__init__.py b/mailosaur/operations/__init__.py index 22551dd..7fedc28 100644 --- a/mailosaur/operations/__init__.py +++ b/mailosaur/operations/__init__.py @@ -2,10 +2,12 @@ from mailosaur.operations.files_operations import FilesOperations from mailosaur.operations.messages_operations import MessagesOperations from mailosaur.operations.servers_operations import ServersOperations +from mailosaur.operations.devices_operations import DevicesOperations __all__ = [ 'AnalysisOperations', 'FilesOperations', 'MessagesOperations', 'ServersOperations', + 'DevicesOperations', ] diff --git a/mailosaur/operations/devices_operations.py b/mailosaur/operations/devices_operations.py new file mode 100644 index 0000000..07c375e --- /dev/null +++ b/mailosaur/operations/devices_operations.py @@ -0,0 +1,125 @@ +import os +import random +import string + +from mailosaur.models.otp_result import OtpResult +from ..models import DeviceListResult +from ..models import Device +from ..models import OtpResult +from ..models import MailosaurException + + +class DevicesOperations(object): + """DevicesOperations operations. + """ + + def __init__(self, session, base_url, handle_http_error): + self.session = session + self.base_url = base_url + self.handle_http_error = handle_http_error + + def generate_email_address(self, server): + host = os.getenv('MAILOSAUR_SMTP_HOST', 'mailosaur.net') + randomString = ''.join(random.choice( + string.ascii_uppercase + string.digits) for _ in range(10)) + return "%s@%s.%s" % (randomString, server, host) + + def list(self): + """List all devices. + + Returns a list of your virtual security devices. + + :return: DeviceListResult + :rtype: ~mailosaur.models.DeviceListResult + :raises: + :class:`MailosaurException` + """ + url = "%sapi/devices" % (self.base_url) + response = self.session.get(url) + + if response.status_code not in [200]: + self.handle_http_error(response) + return + + data = response.json() + + return DeviceListResult(data) + + def create(self, device_create_options): + """Create a device. + + Creates a new virtual security device and returns it. + + :param device_create_options: + :type device_create_options: ~mailosaur.models.DeviceCreateOptions + :return: Device + :rtype: ~mailosaur.models.Device + :raises: + :class:`MailosaurException` + """ + url = "%sapi/devices" % (self.base_url) + response = self.session.post(url, json=device_create_options.to_json()) + + if response.status_code not in [200]: + self.handle_http_error(response) + return + + data = response.json() + + return Device(data) + + def otp(self, query): + """Retrieves the current one-time password for a saved device, or given base32-encoded shared secret. + + Retrieves the detail for a single server. Simply supply the unique + identifier for the required server. + + :param query: Either the unique identifier of the device, or a base32-encoded shared secret. + :type query: str + :return: OtpResult + :rtype: ~mailosaur.models.OtpResult + :raises: + :class:`MailosaurException` + """ + if "-" in query: + url = "%sapi/devices/%s/otp" % (self.base_url, query) + response = self.session.get(url) + + if response.status_code not in [200]: + self.handle_http_error(response) + return + + data = response.json() + + return OtpResult(data) + + url = "%sapi/devices/otp" % (self.base_url) + response = self.session.post(url, json={'sharedSecret': query}) + + if response.status_code not in [200]: + self.handle_http_error(response) + return + + data = response.json() + + return OtpResult(data) + + def delete( + self, id): + """Delete a device. + + Permanently delete a virtual security device. This operation cannot be undone. + + :param id: The identifier of the device to be deleted. + :type id: str + :return: None + :rtype: None + :raises: + :class:`MailosaurException` + """ + url = "%sapi/devices/%s" % (self.base_url, id) + response = self.session.delete(url) + + if response.status_code not in [204]: + self.handle_http_error(response) + return diff --git a/tests/devices_test.py b/tests/devices_test.py new file mode 100644 index 0000000..a5d570b --- /dev/null +++ b/tests/devices_test.py @@ -0,0 +1,47 @@ +import os +import numbers +from unittest import TestCase +from mailosaur import MailosaurClient +from mailosaur.models import DeviceCreateOptions, MailosaurException + + +class DevicesTest(TestCase): + @classmethod + def setUpClass(self): + api_key = os.getenv('MAILOSAUR_API_KEY') + base_url = os.getenv('MAILOSAUR_BASE_URL') + + if api_key is None: + raise Exception( + "Missing necessary environment variables - refer to README.md") + + self.client = MailosaurClient(api_key, base_url) + + def test_crud(self): + device_name = "My test" + shared_secret = "ONSWG4TFOQYTEMY=" + + # Create a new device + options = DeviceCreateOptions() + options.name = device_name + options.shared_secret = shared_secret + + created_device = self.client.devices.create(options) + self.assertIsNotNone(created_device.id) + self.assertEqual(device_name, created_device.name) + + # Retrieve an otp via device ID + otp_result = self.client.devices.otp(created_device.id) + self.assertEqual(6, len(otp_result.code)) + + list_result = self.client.devices.list() + self.assertEqual(1, len(list_result.items)) + self.client.devices.delete(created_device.id) + list_result = self.client.devices.list() + self.assertEqual(0, len(list_result.items)) + + def test_otp_via_shared_secret(self): + shared_secret = "ONSWG4TFOQYTEMY=" + + otp_result = self.client.devices.otp(shared_secret) + self.assertEqual(6, len(otp_result.code))