Exemple #1
0
def resize_window(internal_id: str, width: int, height: int) -> None:
    window = ID_TO_WINDOW[internal_id]

    if window:
        window.resize(width, height)

    logger.warning(f'Tried to resize non-existant window: {internal_id}')
Exemple #2
0
def fix_missing_uids(expected_uid_count, uids):
    '''
    This fixes missing UIDs - when moving multiple emails into a new folder,
    the next search request sometimes returns just the latest UID. Because they
    are sequential we can infer the previous UIDs.

    When passed to fetch these then generally have to be remapped (see below)
    as Gmail will return incorrect UIDs for each message - but the messages
    *are* correct.
    '''

    uid_count = len(uids)

    if uid_count and uid_count < expected_uid_count:
        diff = expected_uid_count - uid_count
        lowest_uid = min(uids)

        for i in range(diff):
            uids.append(lowest_uid - (i + 1))

        logger.warning(
            f'Corrected {uid_count} missing UIDs {expected_uid_count} -> {uids}',
        )

    return uids
Exemple #3
0
def parse_bodystructure(bodystructure):
    try:
        items = _parse_bodystructure(bodystructure)
    except Exception as e:
        logger.warning(
            f'Could not parse bodystructure: {e} (struct={bodystructure})')

        raise

    # Attach shortcuts -> part IDs
    items['attachments'] = []

    for number, part in list(items.items()):
        if number == 'attachments':
            continue

        if part['type'].upper() == 'TEXT':
            subtype = part['subtype'].upper()

            if 'html' not in items and subtype == 'HTML':
                items['html'] = number
                continue

            if 'plain' not in items and subtype == 'PLAIN':
                items['plain'] = number
                continue

        items['attachments'].append(number)

    return items
Exemple #4
0
def minimize_window(internal_id: str) -> None:
    window = ID_TO_WINDOW.get(internal_id, None)

    if window:
        window.minimize()
        return

    logger.warning(f'Tried to minimize non-existant window: {internal_id}')
Exemple #5
0
def maximize_window(internal_id: str) -> None:
    window = ID_TO_WINDOW.get(internal_id, None)

    if window:
        window.toggle_fullscreen()
        return

    logger.warning(f'Tried to maximize non-existant window: {internal_id}')
Exemple #6
0
    def get_folders(account):
        folders = []

        try:
            folders = account.get_folders()
        except Exception as e:
            logger.warning(f'Failed to load folders for {account}: {e}')

        return account.name, folders
Exemple #7
0
def extract_excerpt(raw_body, raw_body_meta):
    try:
        return _extract_excerpt(
            raw_body,
            raw_body_meta,
        )

    except Exception as e:
        logger.warning(('Could not extract excerpt: '
                        f'{e} (data={raw_body}, meta={raw_body_meta})'))
Exemple #8
0
def fix_email_uids(email_uids, emails):
    '''
    After moving emails around in IMAP, Gmail sometimes returns stale/old UIDs
    for messages. This attempts to fix this by re-mapping any invalid returned
    UIDs to the correct UIDs.
    '''

    # First, get the list of returned UIDs
    returned_uids = []
    for uid, data in emails.items():
        returned_uids.append(uid)

    missing_uids = set(email_uids) - set(returned_uids)

    if missing_uids:
        error = ValueError((
            'Incorrect UIDs returned by server, '
            f'requested {len(email_uids)} but got {len(returned_uids)}, '
            f'missing={missing_uids} ({email_uids} - {returned_uids})'
        ))

        # If not the same length, we're probably missing some UIDs, this could
        # be due to another IMAP client or server issue. We can't attempt any
        # fix here, so return the partial response (unless debugging).
        if len(returned_uids) != len(email_uids):
            if DEBUG:
                raise error
            return emails

        # Build map of returned UID -> correct UID
        corrected_uid_map = {}

        # First pass - pull out any that exist in our wanted and returned
        for uid in email_uids:
            if uid in returned_uids:
                corrected_uid_map[uid] = uid
                email_uids.remove(uid)
                returned_uids.remove(uid)

        # Second pass - map any remaining ones in order
        email_uids = sorted(email_uids)
        returned_uids = sorted(returned_uids)

        for i, uid in enumerate(email_uids):
            corrected_uid_map[returned_uids[i]] = uid

        logger.warning(f'Corrected broken server UIDs: {corrected_uid_map}')

        # Overwrite our returned UID -> email map with our corrected UIDs
        emails = {
            corrected_uid_map[uid]: email
            for uid, email in emails.items()
        }

    return emails
Exemple #9
0
def error_network_exception(e) -> Response:
    error_name = e.__class__.__name__
    message = f'{e} (account={e.account})'
    trace = traceback.format_exc().strip()
    logger.warning(f'Network error in view: {message}: {trace}')
    return make_response(
        jsonify(
            status_code=503,
            error_name=error_name,
            error_message=message,
        ), 503)
Exemple #10
0
def read_license_file_data() -> dict:
    if not path.exists(LICENSE_FILE):
        return {}

    try:
        with open(LICENSE_FILE, 'r+') as f:  # license file must be writeable
            return json.load(f)
    except Exception as e:
        logger.warning(f'Could not load license file: {e}')

    return {}
Exemple #11
0
def destroy_window(internal_id: str) -> None:
    window = ID_TO_WINDOW.pop(internal_id, None)

    if window:
        try:
            window.destroy()
            return
        except KeyError:
            pass

    logger.warning(f'Tried to destroy non-existant window: {internal_id}')
Exemple #12
0
def destroy_window(internal_id: str) -> None:
    window = ID_TO_WINDOW.pop(internal_id, None)

    if window:
        try:
            window.destroy()
        except KeyError:  # happens if the window has already been destroyed (user close)
            pass
        else:
            return

    logger.warning(f'Tried to destroy non-existant window: {internal_id}')
Exemple #13
0
def main():
    logger.info(f'\n#\n# Booting Kanmail {get_version()}\n#')

    init_window_hacks()
    boot()

    server_thread = Thread(name='Server', target=run_server)
    server_thread.daemon = True
    server_thread.start()

    run_thread(validate_or_remove_license)
    run_thread(run_cache_cleanup_later)

    # Ensure the webserver is up & running by polling it
    waits = 0
    while waits < 10:
        try:
            response = requests.get(
                f'http://{SERVER_HOST}:{server.get_port()}/ping')
            response.raise_for_status()
        except requests.RequestException as e:
            logger.warning(f'Waiting for main window: {e}')
            sleep(0.1 * waits)
            waits += 1
        else:
            break
    else:
        logger.critical('Webserver did not start properly!')
        sys.exit(2)

    create_window(
        unique_key='main',
        **get_window_settings(),
    )

    # Let's hope this thread doesn't fail!
    monitor_thread = Thread(
        name='Thread monitor',
        target=monitor_threads,
        args=(server_thread, ),
    )
    monitor_thread.daemon = True
    monitor_thread.start()

    if DEBUG:
        sleep(1)  # give webpack a second to start listening

    # Start the GUI - this will block until the main window is destroyed
    webview.start(gui=GUI_LIB, debug=DEBUG)

    # Main window closed, cleanup/exit
    sys.exit()
Exemple #14
0
def get_ispdb_confg(domain: str) -> [dict, dict]:
    if domain in COMMON_ISPDB_DOMAINS:
        logger.debug(f'Got hardcoded autoconfig for {domain}')
        return (
            COMMON_ISPDB_DOMAINS[domain]['imap_connection'],
            COMMON_ISPDB_DOMAINS[domain]['smtp_connection'],
        )

    logger.debug(f'Looking up thunderbird autoconfig for {domain}')

    ispdb_url = ISPDB_URL_FORMATTER.format(domain=domain)

    try:
        response = requests.get(ispdb_url)
    except requests.RequestException as e:
        logger.warning(
            f'Failed to fetch ISPDB settings for domain: {domain}: {e}')
        return

    if response.status_code == 200:
        imap_settings = {}
        smtp_settings = {}

        # Parse the XML
        et = parse_xml(response.content)
        provider = et.find('emailProvider')

        for incoming in provider.findall('incomingServer'):
            if incoming.get('type') != 'imap':
                continue

            imap_settings['host'] = incoming.find('hostname').text
            imap_settings['port'] = int(incoming.find('port').text)
            imap_settings['ssl'] = incoming.find('socketType').text == 'SSL'
            break

        for outgoing in provider.findall('outgoingServer'):
            if outgoing.get('type') != 'smtp':
                continue

            smtp_settings['host'] = outgoing.find('hostname').text
            smtp_settings['port'] = int(outgoing.find('port').text)

            socket_type = outgoing.find('socketType').text
            smtp_settings['ssl'] = socket_type == 'SSL'
            smtp_settings['tls'] = socket_type == 'STARTTLS'
            break

        logger.debug((f'Autoconf settings for {domain}: '
                      f'imap={imap_settings}, smtp={smtp_settings}'))
        return imap_settings, smtp_settings
Exemple #15
0
def get_icon_for_email(email):
    email = email.lower().strip()

    hasher = md5()
    hasher.update(email.encode())
    email_hash = hasher.hexdigest()

    cached_icon_filename = path.join(ICON_CACHE_DIR, f'{email_hash}.json')
    if path.exists(cached_icon_filename):
        with open(cached_icon_filename, 'r') as f:
            base64_data, mimetype = json.load(f)
            return b64decode(base64_data), mimetype

    requests_to_attempt = [
        (f'https://www.gravatar.com/avatar/{email_hash}', {
            'd': '404'
        }),
    ]

    if '@' in email:
        email_domain = email.rsplit('@', 1)[1]
        email_domain_parts = list(reversed(email_domain.split('.')))
        email_domains = [
            '.'.join(reversed(email_domain_parts[:i + 1]))
            for i in range(len(email_domain_parts))
        ]
        for domain in reversed(email_domains[1:]):
            requests_to_attempt.append(
                f'https://icons.duckduckgo.com/ip3/{domain}.ico')

    for url in requests_to_attempt:
        params = None
        if isinstance(url, tuple):
            url, params = url

        try:
            response = requests.get(url, params=params)
        except requests.RequestException as e:
            logger.warning(f'Could not fetch icon: {e}')
        else:
            if response.status_code == 200:
                data, mimetype = response.content, response.headers.get(
                    'Content-Type')
                with open(cached_icon_filename, 'w') as f:
                    json.dump([b64encode(data).decode(), mimetype], f)
                return data, mimetype

    return DEFAULT_ICON_DATA, DEFAULT_ICON_MIMETYPE
Exemple #16
0
def update_device(update) -> None:
    '''
    Checks for and downloads any updates for Kanmail - after this it tells the
    frontend to render a restart icon.
    '''

    update = update or check_device_update()

    if not update:
        return

    if not FROZEN:
        logger.warning('App not frozen, not fetching update')
        return

    logger.debug(f'Downloading update: {update.version}')
    update.download()

    logger.debug('Download complete, extracting & overwriting')
    update.extract_overwrite()
Exemple #17
0
def create_window(
    endpoint: str = '/',
    unique_key: Optional[str] = None,
    **kwargs,
) -> Union[str, bool]:
    if not IS_APP:
        logger.warning('Cannot open window in server mode!')
        return False

    internal_id = str(uuid4())
    link = (f'http://{SERVER_HOST}:{server.get_port()}{endpoint}'
            f'?window_id={internal_id}'
            f'&Kanmail-Session-Token={SESSION_TOKEN}')

    logger.debug(
        f'Opening window ({internal_id}) '
        f'url={endpoint} kwargs={kwargs}', )

    # Nuke any existing unique window
    if unique_key and unique_key in UNIQUE_NAME_TO_ID:
        old_window_id = UNIQUE_NAME_TO_ID.get(unique_key)
        if old_window_id:
            destroy_window(old_window_id)

    window = webview.create_window(
        'Kanmail',
        link,
        frameless=FRAMELESS,
        easy_drag=False,
        text_select=True,
        localization=UNIQUE_KEY_TO_LOCALIZATION.get(unique_key),
        **kwargs,
    )

    ID_TO_WINDOW[internal_id] = window

    if unique_key:
        UNIQUE_NAME_TO_ID[unique_key] = internal_id

    return internal_id
Exemple #18
0
def get_window_settings() -> dict:
    settings = {
        'width': DEFAULT_WINDOW_WIDTH,
        'height': DEFAULT_WINDOW_HEIGHT,
        'x': DEFAULT_WINDOW_LEFT,
        'y': DEFAULT_WINDOW_TOP,
    }

    if path.exists(WINDOW_CACHE_FILE):
        with open(WINDOW_CACHE_FILE, 'rb') as f:
            data = pickle.loads(f.read())
        logger.debug(f'Loaded window settings: {data}')

        for key, value in data.items():
            if key.startswith('WINDOW_'):  # COMPAT w/old style
                key = key.split('_')[1].lower()
                logger.warning(
                    f'Updated old window setting: WINDOW_{key} -> {key}')

            if key in settings:
                settings[key] = value

    return settings
Exemple #19
0
def create_window(
    endpoint: str = '/',
    unique_key: Optional[str] = None,
    **kwargs,
) -> Union[str, bool]:
    if not IS_APP:
        logger.warning('Cannot open window in server mode!')
        return False

    internal_id = str(uuid4())
    link = f'http://localhost:{SERVER_PORT}{endpoint}?window_id={internal_id}'

    logger.debug(
        f'Opening window (#{internal_id}) '
        f'url={endpoint} kwargs={kwargs}', )

    # Nuke any existing unique window
    if unique_key and unique_key in UNIQUE_NAME_TO_ID:
        old_window_id = UNIQUE_NAME_TO_ID.get(unique_key)
        if old_window_id:
            destroy_window(old_window_id)

    window = webview.create_window(
        'Kanmail',
        link,
        frameless=FRAMELESS,
        easy_drag=False,
        text_select=True,
        **kwargs,
    )

    ID_TO_WINDOW[internal_id] = window

    if unique_key:
        UNIQUE_NAME_TO_ID[unique_key] = internal_id

    return internal_id
Exemple #20
0
def api_update_window_settings() -> Response:
    if not IS_APP:
        return jsonify(
            saved=False)  # success response, but not saved (browser mode)

    window_settings = get_main_window_size_position()
    js_window_settings = request.get_json()

    if DEBUG:
        for key in ('left', 'top', 'width', 'height'):
            if window_settings[key] != js_window_settings[key]:
                logger.warning(
                    (f'Mismatched Python <> JS window setting for {key}, '
                     f'py={window_settings[key]}, js={js_window_settings[key]}'
                     ))

    # Cap the width + height to max size of screen
    window_settings['height'] = min(window_settings['height'],
                                    js_window_settings['height'])
    window_settings['width'] = min(window_settings['width'],
                                   js_window_settings['width'])

    set_window_settings(**window_settings)
    return jsonify(saved=True)
Exemple #21
0
def validate_or_remove_license() -> None:
    '''
    A "hard" check for the presence and validity of a license key. Will also remove
    any invalid license file/keyring item.

    This function is executed in a separate thread on app start.
    '''

    license_email = check_get_license_email()
    if not license_email:
        return

    logger.debug(f'Validating license for {license_email}...')

    combined_token = get_password('license', LICENSE_SERVER_APP_TOKEN,
                                  license_email)
    if not combined_token:
        return

    token, device_token = combined_token.split(':')

    try:
        response = requests.get(
            f'{LICENSE_SERVER_URL}/api/check',
            json={
                'app_token': LICENSE_SERVER_APP_TOKEN,
                'memo': license_email,
                'token': token,
                'device_token': device_token,
            },
        )
    except ConnectionError:
        logger.warning(
            'Could not connect to license server, please try again later!')
        return

    if response.status_code == 404:
        logger.warning('Valid license NOT found, removing!')
        remove_license()
        return

    if response.status_code == 201:
        logger.debug('Licence validated!')
        return

    try:
        response.raise_for_status()
    except HTTPError as e:
        logger.warning(
            f'Unexpected status from license server: {e} ({response.text})')
Exemple #22
0
def bust_all_caches():
    logger.warning('Busting all cache items!')
    FolderCacheItem.query.delete()
    db.session.commit()
Exemple #23
0
def invalidate_access_token(refresh_token):
    tokens = CURRENT_OAUTH_TOKENS.pop(refresh_token, None)

    if tokens is None:
        logger.warning('Invalidated non-existent refresh token')