From 5d0519ed86cd7ac876f9f74e24921dad103ce4b9 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Mon, 6 Apr 2020 22:26:35 +0200 Subject: [PATCH] Add GET /api/v2/aliases --- README.md | 87 +++++++++++++++++++++++---------- app/api/serializer.py | 106 ++++++++++++++++++++++++++++++++++++++-- app/api/views/alias.py | 55 ++++++++++++++++++++- app/models.py | 11 +++++ tests/api/test_alias.py | 77 +++++++++++++++++++++++++++++ 5 files changed, 306 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 1c2337dd..8b409eda 100644 --- a/README.md +++ b/README.md @@ -831,7 +831,8 @@ Input: Output: always return 200, even if email doesn't exist. User need to enter correctly their email. -#### GET /api/aliases + +#### GET /api/v2/aliases Get user aliases. @@ -841,34 +842,70 @@ Input: - (Optional) query: included in request body. Some frameworks might prevent GET request having a non-empty body, in this case this endpoint also supports POST. Output: -If success, 200 with the list of aliases, for example: +If success, 200 with the list of aliases. Each alias has the following fields: + +- id +- email +- enabled +- creation_timestamp +- note +- nb_block +- nb_forward +- nb_reply +- (optional) latest_activity: + - action: forward|reply|block|bounced + - timestamp + - contact: + - email + - name + - reverse_alias + +Here's an example: ```json { - "aliases": [ - { - "creation_date": "2020-02-04 16:23:02+00:00", - "creation_timestamp": 1580833382, - "email": "e3@.alo@sl.local", - "id": 4, - "nb_block": 0, - "nb_forward": 0, - "nb_reply": 0, - "enabled": true, - "note": "This is a note" + "aliases": [ + { + "creation_date": "2020-04-06 17:57:14+00:00", + "creation_timestamp": 1586195834, + "email": "prefix1.cat@sl.local", + "enabled": true, + "id": 3, + "latest_activity": { + "action": "forward", + "contact": { + "email": "c1@example.com", + "name": null, + "reverse_alias": "\"c1 at example.com\" " }, - { - "creation_date": "2020-02-04 16:23:02+00:00", - "creation_timestamp": 1580833382, - "email": "e2@.meo@sl.local", - "id": 3, - "nb_block": 0, - "nb_forward": 0, - "nb_reply": 0, - "enabled": false, - "note": null - } - ] + "timestamp": 1586195834 + }, + "nb_block": 0, + "nb_forward": 1, + "nb_reply": 0, + "note": null + }, + { + "creation_date": "2020-04-06 17:57:14+00:00", + "creation_timestamp": 1586195834, + "email": "prefix0.hey@sl.local", + "enabled": true, + "id": 2, + "latest_activity": { + "action": "forward", + "contact": { + "email": "c0@example.com", + "name": null, + "reverse_alias": "\"c0 at example.com\" " + }, + "timestamp": 1586195834 + }, + "nb_block": 0, + "nb_forward": 1, + "nb_reply": 0, + "note": null + } + ] } ``` diff --git a/app/api/serializer.py b/app/api/serializer.py index b3af7333..94a874d3 100644 --- a/app/api/serializer.py +++ b/app/api/serializer.py @@ -1,12 +1,11 @@ from dataclasses import dataclass -from sqlalchemy import or_ -from sqlalchemy.orm import joinedload +from arrow import Arrow +from sqlalchemy import or_, func, case from app.config import PAGE_LIMIT from app.extensions import db - -from app.models import Alias, Mailbox, Contact, EmailLog +from app.models import Alias, Contact, EmailLog, Mailbox @dataclass @@ -17,6 +16,9 @@ class AliasInfo: nb_blocked: int nb_reply: int + latest_email_log: EmailLog = None + latest_contact: Contact = None + def serialize_alias_info(alias_info: AliasInfo) -> dict: return { @@ -34,6 +36,36 @@ def serialize_alias_info(alias_info: AliasInfo) -> dict: } +def serialize_alias_info_v2(alias_info: AliasInfo) -> dict: + res = { + # Alias field + "id": alias_info.alias.id, + "email": alias_info.alias.email, + "creation_date": alias_info.alias.created_at.format(), + "creation_timestamp": alias_info.alias.created_at.timestamp, + "enabled": alias_info.alias.enabled, + "note": alias_info.alias.note, + # activity + "nb_forward": alias_info.nb_forward, + "nb_block": alias_info.nb_blocked, + "nb_reply": alias_info.nb_reply, + } + if alias_info.latest_email_log: + email_log = alias_info.latest_email_log + contact = alias_info.latest_contact + # latest activity + res["latest_activity"] = { + "timestamp": email_log.created_at.timestamp, + "action": email_log.get_action(), + "contact": { + "email": contact.website_email, + "name": contact.name, + "reverse_alias": contact.website_send_to(), + }, + } + return res + + def serialize_contact(contact: Contact) -> dict: res = { "id": contact.id, @@ -74,6 +106,40 @@ def get_alias_infos_with_pagination(user, page_id=0, query=None) -> [AliasInfo]: return ret +def get_alias_infos_with_pagination_v2(user, page_id=0, query=None) -> [AliasInfo]: + ret = [] + latest_activity = func.max( + case( + [ + (Alias.created_at > EmailLog.created_at, Alias.created_at), + (Alias.created_at < EmailLog.created_at, EmailLog.created_at), + ], + else_=Alias.created_at, + ) + ).label("latest") + + q = ( + db.session.query(Alias, latest_activity) + .join(Contact, Alias.id == Contact.alias_id, isouter=True) + .join(EmailLog, Contact.id == EmailLog.contact_id, isouter=True) + .filter(Alias.user_id == user.id) + .group_by(Alias.id) + .order_by(latest_activity.desc()) + ) + + if query: + q = q.filter( + or_(Alias.email.ilike(f"%{query}%"), Alias.note.ilike(f"%{query}%")) + ) + + q = q.limit(PAGE_LIMIT).offset(page_id * PAGE_LIMIT) + + for alias, latest_activity in q: + ret.append(get_alias_info_v2(alias)) + + return ret + + def get_alias_info(alias: Alias) -> AliasInfo: q = ( db.session.query(Contact, EmailLog) @@ -94,6 +160,38 @@ def get_alias_info(alias: Alias) -> AliasInfo: return alias_info +def get_alias_info_v2(alias: Alias) -> AliasInfo: + q = ( + db.session.query(Contact, EmailLog) + .filter(Contact.alias_id == alias.id) + .filter(EmailLog.contact_id == Contact.id) + ) + + latest_activity: Arrow = alias.created_at + latest_email_log = None + latest_contact = None + + alias_info = AliasInfo(alias=alias, nb_blocked=0, nb_forward=0, nb_reply=0,) + + for contact, email_log in q: + if email_log.is_reply: + alias_info.nb_reply += 1 + elif email_log.blocked: + alias_info.nb_blocked += 1 + else: + alias_info.nb_forward += 1 + + if email_log.created_at > latest_activity: + latest_activity = email_log.created_at + latest_email_log = email_log + latest_contact = contact + + alias_info.latest_contact = latest_contact + alias_info.latest_email_log = latest_email_log + + return alias_info + + def get_alias_contacts(alias, page_id: int) -> [dict]: q = ( Contact.query.filter_by(alias_id=alias.id) diff --git a/app/api/views/alias.py b/app/api/views/alias.py index 623b6a3c..be2b79e5 100644 --- a/app/api/views/alias.py +++ b/app/api/views/alias.py @@ -11,6 +11,8 @@ from app.api.serializer import ( get_alias_infos_with_pagination, get_alias_info, get_alias_contacts, + get_alias_infos_with_pagination_v2, + serialize_alias_info_v2, ) from app.config import EMAIL_DOMAIN from app.dashboard.views.alias_log import get_alias_log @@ -64,6 +66,57 @@ def get_aliases(): ) +@api_bp.route("/v2/aliases", methods=["GET", "POST"]) +@cross_origin() +@verify_api_key +def get_aliases_v2(): + """ + Get aliases + Input: + page_id: in query + Output: + - aliases: list of alias: + - id + - email + - creation_date + - creation_timestamp + - nb_forward + - nb_block + - nb_reply + - note + - (optional) latest_activity: + - timestamp + - action: forward|reply|block|bounced + - contact: + - email + - name + - reverse_alias + + + """ + user = g.user + try: + page_id = int(request.args.get("page_id")) + except (ValueError, TypeError): + return jsonify(error="page_id must be provided in request query"), 400 + + query = None + data = request.get_json(silent=True) + if data: + query = data.get("query") + + alias_infos: [AliasInfo] = get_alias_infos_with_pagination_v2( + user, page_id=page_id, query=query + ) + + return ( + jsonify( + aliases=[serialize_alias_info_v2(alias_info) for alias_info in alias_infos] + ), + 200, + ) + + @api_bp.route("/aliases/", methods=["DELETE"]) @cross_origin() @verify_api_key @@ -127,7 +180,7 @@ def get_alias_activities(alias_id): - from - to - timestamp - - action: forward|reply|block + - action: forward|reply|block|bounced - reverse_alias """ diff --git a/app/models.py b/app/models.py index 2be7d095..1506c296 100644 --- a/app/models.py +++ b/app/models.py @@ -848,6 +848,17 @@ class EmailLog(db.Model, ModelMixin): contact = db.relationship(Contact) + def get_action(self) -> str: + """return the action name: forward|reply|block|bounced""" + if self.is_reply: + return "reply" + elif self.bounced: + return "bounced" + elif self.blocked: + return "blocked" + else: + return "forward" + class Subscription(db.Model, ModelMixin): # Come from Paddle diff --git a/tests/api/test_alias.py b/tests/api/test_alias.py index 354e6bb3..1b294619 100644 --- a/tests/api/test_alias.py +++ b/tests/api/test_alias.py @@ -1,3 +1,5 @@ +import json + from flask import url_for from flask import url_for @@ -101,6 +103,81 @@ def test_get_aliases_with_pagination(flask_client): assert len(r.json["aliases"]) == 1 +def test_get_aliases_v2(flask_client): + user = User.create( + email="a@b.c", password="password", name="Test User", activated=True + ) + db.session.commit() + + # create api_key + api_key = ApiKey.create(user.id, "for test") + db.session.commit() + + a0 = Alias.create_new(user, "prefix0") + a1 = Alias.create_new(user, "prefix1") + db.session.commit() + + # add activity for a0 + c0 = Contact.create( + user_id=user.id, + alias_id=a0.id, + website_email="c0@example.com", + reply_email="re0@SL", + ) + db.session.commit() + EmailLog.create(contact_id=c0.id, user_id=user.id) + db.session.commit() + + # a1 has more recent activity + c1 = Contact.create( + user_id=user.id, + alias_id=a1.id, + website_email="c1@example.com", + reply_email="re1@SL", + ) + db.session.commit() + EmailLog.create(contact_id=c1.id, user_id=user.id) + db.session.commit() + + # get aliases v2 + r = flask_client.get( + url_for("api.get_aliases_v2", page_id=0), + headers={"Authentication": api_key.code}, + ) + assert r.status_code == 200 + + # make sure a1 is returned before a0 + r0 = r.json["aliases"][0] + # r0 will have the following format + # { + # "creation_date": "2020-04-06 17:52:47+00:00", + # "creation_timestamp": 1586195567, + # "email": "prefix1.hey@sl.local", + # "enabled": true, + # "id": 3, + # "latest_activity": { + # "action": "forward", + # "contact": { + # "email": "c1@example.com", + # "name": null, + # "reverse_alias": "\"c1 at example.com\" " + # }, + # "timestamp": 1586195567 + # }, + # "nb_block": 0, + # "nb_forward": 1, + # "nb_reply": 0, + # "note": null + # } + assert r0["email"].startswith("prefix1") + assert r0["latest_activity"]["action"] == "forward" + assert "timestamp" in r0["latest_activity"] + + assert r0["latest_activity"]["contact"]["email"] == "c1@example.com" + assert "name" in r0["latest_activity"]["contact"] + assert "reverse_alias" in r0["latest_activity"]["contact"] + + def test_delete_alias(flask_client): user = User.create( email="a@b.c", password="password", name="Test User", activated=True